diff --git a/Makefile b/Makefile index 7c4c90b8..76af6fb9 100644 --- a/Makefile +++ b/Makefile @@ -15,10 +15,10 @@ install: ## Install dependencies and pre-commit hooks uv run pre-commit install test: ## Run tests - uv run pytest tests/ -v -n auto + uv run --all-extras pytest tests/ -v -n auto coverage: ## Run tests with coverage report (terminal + HTML) - uv run pytest tests/ -n auto --cov --cov-report=term-missing --cov-report=html + uv run --all-extras pytest tests/ -n auto --cov --cov-report=term-missing --cov-report=html @echo "HTML report: htmlcov/index.html" lint: ## Run linters diff --git a/pyproject.toml b/pyproject.toml index 60a707d3..86830057 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,22 @@ http = [ "starlette>=0.39.0", "uvicorn[standard]>=0.30.0", ] +a2a = [ + "a2a-sdk[http-server,signing]>=1.0.2,<2", + "cryptography>=42.0", + "starlette>=0.39.0", + "uvicorn[standard]>=0.30.0", +] +a2a-signing = [ + "a2a-sdk[signing]>=1.0.2,<2", +] +a2a-grpc = [ + "grpcio>=1.60.0", + "grpcio-status>=1.60.0", +] +a2a-redis = [ + "redis>=5.0.0", +] [dependency-groups] dev = [ @@ -45,6 +61,7 @@ dev = [ "pytest-cov>=5.0", "setuptools>=68.0", "wheel", + "tomli>=2.0; python_version<\"3.11\"", ] [build-system] diff --git a/src/iac_code/__init__.py b/src/iac_code/__init__.py index 749db218..000326c9 100644 --- a/src/iac_code/__init__.py +++ b/src/iac_code/__init__.py @@ -1,2 +1,2 @@ __version__ = "0.1.2" -__release_date__ = "" +__release_date__ = "2026-05-18" diff --git a/src/iac_code/a2a/__init__.py b/src/iac_code/a2a/__init__.py new file mode 100644 index 00000000..4c74f061 --- /dev/null +++ b/src/iac_code/a2a/__init__.py @@ -0,0 +1 @@ +"""A2A protocol server support for iac-code.""" diff --git a/src/iac_code/a2a/agent_card.py b/src/iac_code/a2a/agent_card.py new file mode 100644 index 00000000..521e3715 --- /dev/null +++ b/src/iac_code/a2a/agent_card.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +from typing import Any + +from a2a.server.request_handlers.response_helpers import agent_card_to_dict +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentCardSignature, + AgentExtension, + AgentInterface, + AgentProvider, + AgentSkill, + APIKeySecurityScheme, + HTTPAuthSecurityScheme, + SecurityRequirement, +) +from google.protobuf.json_format import ParseDict + +from iac_code import __version__ +from iac_code.a2a.parts import supported_input_mime_types +from iac_code.a2a.signing import sign_agent_card_dict + +IAC_CODE_ARTIFACT_METADATA_EXTENSION_URI = "urn:iac-code:a2a:artifact-metadata:v1" + + +def _base_url(host: str, port: int) -> str: + return f"http://{host}:{port}/" + + +def agent_card_to_client_dict(card: AgentCard) -> dict[str, Any]: + data = agent_card_to_dict(card) + if not card.supported_interfaces: + return data + + primary_interface = card.supported_interfaces[0] + data.setdefault("url", primary_interface.url) + data.setdefault("preferredTransport", primary_interface.protocol_binding) + data.setdefault("protocolVersion", primary_interface.protocol_version) + + additional_interfaces = [ + {"url": interface.url, "transport": interface.protocol_binding} for interface in card.supported_interfaces[1:] + ] + if additional_interfaces: + data.setdefault("additionalInterfaces", additional_interfaces) + + return data + + +def _add_security_requirement(card: AgentCard, scheme_name: str) -> None: + requirement = SecurityRequirement() + requirement.schemes[scheme_name].list.append("") + card.security_requirements.append(requirement) + + +def build_agent_card( + *, + host: str, + port: int, + token_enabled: bool, + basic_enabled: bool = False, + api_key_enabled: bool = False, + api_key_header: str = "X-API-Key", + signing_secret: str | None = None, + signing_key_id: str = "default", + push_notifications: bool = False, + supported_interfaces: list[dict[str, str]] | None = None, + agent_extensions: Any = None, +) -> AgentCard: + url = _base_url(host, port) + description = "AI-powered Infrastructure as Code assistant for Alibaba Cloud ROS and Terraform workflows." + if not token_enabled and not basic_enabled and not api_key_enabled: + description += " Unauthenticated A2A server mode is intended for trusted local environments." + if push_notifications: + description += ( + " Experimental terminal-state webhooks can be enabled locally, but the standard A2A push config API is not" + " advertised." + ) + + interfaces = ( + [ + AgentInterface( + url=item["url"], + protocol_binding=item["protocolBinding"], + protocol_version=item.get("protocolVersion", "1.0"), + ) + for item in supported_interfaces + ] + if supported_interfaces + else [ + AgentInterface(url=url, protocol_binding="JSONRPC", protocol_version="1.0"), + ] + ) + input_modes = supported_input_mime_types() + + card = AgentCard( + name="iac-code", + description=description, + supported_interfaces=interfaces, + provider=AgentProvider(organization="iac-code"), + version=__version__, + capabilities=AgentCapabilities( + streaming=True, + push_notifications=push_notifications, + extended_agent_card=True, + ), + default_input_modes=input_modes, + default_output_modes=["text/plain"], + skills=[ + AgentSkill( + id="iac_generation", + name="IaC Generation", + description="Generate Alibaba Cloud ROS and Terraform templates from natural language.", + tags=["iac", "ros", "terraform", "alibaba-cloud"], + examples=["Create a VPC with two vSwitches in cn-hangzhou."], + input_modes=input_modes, + output_modes=["text/plain"], + ), + AgentSkill( + id="iac_review", + name="IaC Review", + description="Inspect IaC templates and suggest fixes.", + tags=["iac", "review", "validation"], + examples=["Review this ROS template for missing parameters."], + input_modes=input_modes, + output_modes=["text/plain"], + ), + AgentSkill( + id="aliyun_ros_operations", + name="Alibaba Cloud ROS Operations", + description="Assist with ROS stack workflows using iac-code tools.", + tags=["aliyun", "ros", "stack"], + examples=["Check why this ROS stack update failed."], + input_modes=input_modes, + output_modes=["text/plain"], + ), + AgentSkill( + id="terraform_ros_conversion", + name="Terraform To ROS Conversion", + description="Assist Terraform-to-ROS conversion using bundled iac-code skill resources.", + tags=["terraform", "ros", "conversion"], + examples=["Convert this Terraform VPC module to ROS YAML."], + input_modes=input_modes, + output_modes=["text/plain"], + ), + ], + ) + card.capabilities.extensions.append( + AgentExtension( + uri=IAC_CODE_ARTIFACT_METADATA_EXTENSION_URI, + description="Optional iac-code metadata namespace for tool status and stored local artifact metadata.", + required=False, + ) + ) + for item in _iter_agent_extensions(agent_extensions): + card.capabilities.extensions.append(_agent_extension_from_dict(item)) + + if token_enabled: + card.security_schemes["bearerAuth"].http_auth_security_scheme.CopyFrom(HTTPAuthSecurityScheme(scheme="bearer")) + _add_security_requirement(card, "bearerAuth") + + if basic_enabled: + card.security_schemes["basicAuth"].http_auth_security_scheme.CopyFrom(HTTPAuthSecurityScheme(scheme="basic")) + _add_security_requirement(card, "basicAuth") + + if api_key_enabled: + card.security_schemes["apiKeyAuth"].api_key_security_scheme.CopyFrom( + APIKeySecurityScheme(location="header", name=api_key_header) + ) + _add_security_requirement(card, "apiKeyAuth") + + if signing_secret: + signed_data = sign_agent_card_dict(agent_card_to_dict(card), secret=signing_secret, key_id=signing_key_id) + signatures = signed_data.get("signatures") + signature = signatures[0] if isinstance(signatures, list) and signatures else None + if isinstance(signature, dict): + header = signature.get("header") + header_dict = dict(header) if isinstance(header, dict) else {} + card_signature = AgentCardSignature( + protected=str(signature.get("protected") or ""), + signature=str(signature.get("signature") or ""), + header=header_dict, + ) + card.signatures.append(card_signature) + + return card + + +def build_extended_agent_card(card: AgentCard) -> AgentCard: + extended = AgentCard() + extended.CopyFrom(card) + extended.skills.append( + AgentSkill( + id="iac_code_runtime_details", + name="iac-code Runtime Details", + description="Authenticated details for task management, push configuration, and local runtime behavior.", + tags=["iac-code", "runtime", "a2a"], + examples=["List my current A2A tasks."], + input_modes=supported_input_mime_types(), + output_modes=["text/plain"], + ) + ) + return extended + + +def _agent_extension_from_dict(item: dict[str, Any]) -> AgentExtension: + extension = AgentExtension( + uri=str(item["uri"]), + description=str(item.get("description") or ""), + required=bool(item.get("required", False)), + ) + params = item.get("params") + if isinstance(params, dict): + ParseDict(params, extension.params) + return extension + + +def _iter_agent_extensions(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict) and isinstance(item.get("uri"), str)] diff --git a/src/iac_code/a2a/app.py b/src/iac_code/a2a/app.py new file mode 100644 index 00000000..2c2dc23e --- /dev/null +++ b/src/iac_code/a2a/app.py @@ -0,0 +1,527 @@ +from __future__ import annotations + +import asyncio +import base64 +import binascii +import hashlib +import hmac +import json +import os +from contextlib import asynccontextmanager, suppress +from email.utils import formatdate +from pathlib import Path +from time import time +from typing import Awaitable, Callable + +from a2a.server.routes import create_jsonrpc_routes, create_rest_routes +from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH +from starlette.applications import Starlette +from starlette.authentication import AuthCredentials, SimpleUser +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import BaseRoute, Route + +from iac_code.a2a.agent_card import agent_card_to_client_dict + +_V03_JSONRPC_METHODS = frozenset( + { + "message/send", + "message/stream", + "tasks/get", + "tasks/cancel", + "tasks/pushNotificationConfig/set", + "tasks/pushNotificationConfig/get", + "tasks/pushNotificationConfig/list", + "tasks/pushNotificationConfig/delete", + "tasks/resubscribe", + "agent/getAuthenticatedExtendedCard", + } +) + + +def resolve_token(cli_token: str | None) -> str | None: + return cli_token or os.environ.get("IACCODE_A2A_HTTP_TOKEN") + + +def resolve_basic_credentials(cli_username: str | None, cli_password: str | None) -> tuple[str, str] | None: + username = cli_username or os.environ.get("IACCODE_A2A_BASIC_USERNAME") + password = cli_password or os.environ.get("IACCODE_A2A_BASIC_PASSWORD") + if username and password: + return username, password + return None + + +def resolve_api_key(cli_api_key: str | None) -> str | None: + return cli_api_key or os.environ.get("IACCODE_A2A_API_KEY") + + +def resolve_api_key_header(cli_api_key_header: str | None) -> str: + return cli_api_key_header or os.environ.get("IACCODE_A2A_API_KEY_HEADER") or "X-API-Key" + + +class A2AAuthMiddleware(BaseHTTPMiddleware): + def __init__( + self, + app, + *, + token: str | None, + basic_username: str | None, + basic_password: str | None, + api_key: str | None, + api_key_header: str, + ) -> None: + super().__init__(app) + self._token = token + self._basic_username = basic_username + self._basic_password = basic_password + self._api_key = api_key + self._api_key_header = api_key_header + + @property + def _auth_enabled(self) -> bool: + return bool(self._token or (self._basic_username and self._basic_password) or self._api_key) + + async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: + principal = self._authorized_principal(request) + if self._auth_enabled and principal is None: + return JSONResponse({"error": "Unauthorized"}, status_code=401) + if principal is not None: + request.scope["auth"] = AuthCredentials([principal.partition(":")[0]]) + request.scope["user"] = SimpleUser(principal) + return await call_next(request) + + def _authorized_principal(self, request: Request) -> str | None: + auth = request.headers.get("authorization", "") + if self._token and auth.startswith("Bearer ") and hmac.compare_digest(auth[7:], self._token): + return "bearer" + if self._basic_username and self._basic_password and self._valid_basic_auth(auth): + return f"basic:{self._basic_username}" + api_key = request.headers.get(self._api_key_header) + if self._api_key and api_key and hmac.compare_digest(api_key, self._api_key): + return f"api-key:{self._api_key_header}" + if not self._auth_enabled: + return None + return None + + def _valid_basic_auth(self, auth: str) -> bool: + if not auth.startswith("Basic "): + return False + try: + decoded = base64.b64decode(auth[6:], validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError): + return False + username, separator, password = decoded.partition(":") + if not separator: + return False + if not username or not password: + return False + return hmac.compare_digest(username, self._basic_username or "") and hmac.compare_digest( + password, self._basic_password or "" + ) + + +async def health(request: Request) -> JSONResponse: + return JSONResponse({"status": "healthy"}) + + +async def normalize_v03_jsonrpc_version(request: Request) -> None: + try: + body = await request.json() + except Exception: + return + if not isinstance(body, dict) or body.get("method") not in _V03_JSONRPC_METHODS: + return + + headers = [(name, value) for name, value in request.scope["headers"] if name.lower() != b"a2a-version"] + headers.append((b"a2a-version", b"0.3")) + request.scope["headers"] = headers + if hasattr(request, "_headers"): + delattr(request, "_headers") + + +def create_app( + *, + host: str, + port: int, + token: str | None, + model: str, + basic_username: str | None = None, + basic_password: str | None = None, + api_key: str | None = None, + api_key_header: str = "X-API-Key", + persistence_dir: str | Path | None = None, + artifact_dir: str | Path | None = None, + signing_secret: str | None = None, + signing_key_id: str = "default", + push_notifications: bool = False, + push_queue: str = "local-file", + push_redis_url: str | None = None, + push_stream: str = "iac-code:a2a:push", + push_retry_key: str = "iac-code:a2a:push:retry", + push_dead_stream: str = "iac-code:a2a:push:dead", + push_consumer_group: str = "iac-code-push", + push_consumer_name: str | None = None, + push_lease_timeout_ms: int = 300_000, + supported_interfaces: list[dict[str, str]] | None = None, + agent_extensions: object | None = None, + auto_approve_permissions: bool = False, +) -> Starlette: + from iac_code.a2a.transports.dispatcher import create_runtime_components + + components = create_runtime_components( + model=model, + host=host, + port=port, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + persistence_dir=persistence_dir, + artifact_dir=artifact_dir, + signing_secret=signing_secret, + signing_key_id=signing_key_id, + push_notifications=push_notifications, + push_queue=push_queue, + push_redis_url=push_redis_url, + push_stream=push_stream, + push_retry_key=push_retry_key, + push_dead_stream=push_dead_stream, + push_consumer_group=push_consumer_group, + push_consumer_name=push_consumer_name, + push_lease_timeout_ms=push_lease_timeout_ms, + supported_interfaces=supported_interfaces, + agent_extensions=agent_extensions, + auto_approve_permissions=auto_approve_permissions, + ) + + @asynccontextmanager + async def lifespan(app: Starlette): + await components.task_store.start_cleanup_loop() + push_worker_task: asyncio.Task[None] | None = None + if components.push_worker is not None: + push_worker_task = asyncio.create_task(components.push_worker.serve_forever()) + try: + yield + finally: + if push_worker_task is not None: + push_worker_task.cancel() + with suppress(asyncio.CancelledError): + await push_worker_task + await components.aclose() + + card_data = agent_card_to_client_dict(components.card) + card_etag = _agent_card_etag(card_data) + card_last_modified = formatdate(time(), usegmt=True) + card_cache_headers = { + "Cache-Control": "public, max-age=60", + "ETag": card_etag, + "Last-Modified": card_last_modified, + } + + async def get_agent_card(request: Request) -> Response: + if request.headers.get("if-none-match") == card_etag: + return Response(status_code=304, headers=card_cache_headers) + return JSONResponse(card_data, headers=card_cache_headers) + + routes: list[BaseRoute] = [ + Route("/health", health, methods=["GET"]), + Route(AGENT_CARD_WELL_KNOWN_PATH, get_agent_card, methods=["GET"]), + ] + jsonrpc_endpoint = create_jsonrpc_routes(components.handler, rpc_url="/", enable_v0_3_compat=True)[0].endpoint + + async def handle_jsonrpc(request: Request) -> Response: + await normalize_v03_jsonrpc_version(request) + return await jsonrpc_endpoint(request) + + routes.append(Route("/", handle_jsonrpc, methods=["POST"])) + routes.extend(create_rest_routes(components.handler, enable_v0_3_compat=True)) + app = Starlette(routes=routes, lifespan=lifespan) + app.add_middleware( + A2AAuthMiddleware, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ) + return app + + +def _agent_card_etag(card: dict[str, object]) -> str: + body = json.dumps(card, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return f'"sha256-{hashlib.sha256(body).hexdigest()}"' + + +def run_server( + *, + host: str, + port: int, + token: str | None, + model: str, + basic_username: str | None, + basic_password: str | None, + api_key: str | None, + api_key_header: str, + persistence_dir: str | Path | None = None, + artifact_dir: str | Path | None = None, + signing_secret: str | None = None, + signing_key_id: str = "default", + push_notifications: bool = False, + transport: str = "http", + socket_path: str | None = None, + ws_path: str = "/a2a", + grpc_host: str | None = None, + grpc_port: int | None = None, + redis_url: str | None = None, + request_stream: str = "iac-code:a2a:requests", + response_stream: str = "iac-code:a2a:responses", + consumer_group: str = "iac-code", + push_queue: str = "local-file", + push_redis_url: str | None = None, + push_stream: str = "iac-code:a2a:push", + push_retry_key: str = "iac-code:a2a:push:retry", + push_dead_stream: str = "iac-code:a2a:push:dead", + push_consumer_group: str = "iac-code-push", + push_consumer_name: str | None = None, + push_lease_timeout_ms: int = 300_000, + auto_approve_permissions: bool = False, +) -> None: + from iac_code.a2a.transports.base import normalize_transport_name + + normalized_transport = normalize_transport_name(transport) + if persistence_dir is None: + from iac_code.config import get_config_dir + + persistence_dir = get_config_dir() / "a2a" + if artifact_dir is None: + artifact_dir = Path(persistence_dir) / "artifacts" + + if normalized_transport == "unix" and not socket_path: + raise RuntimeError("--socket-path is required for --transport unix.") + if normalized_transport == "redis-streams" and not redis_url: + raise RuntimeError("--redis-url is required for --transport redis-streams.") + if push_queue == "redis-streams" and not push_redis_url: + raise RuntimeError("--push-redis-url is required for --push-queue redis-streams.") + + supported_interfaces = _supported_interfaces( + transport=normalized_transport, + host=host, + port=port, + socket_path=socket_path, + ws_path=ws_path, + grpc_host=grpc_host, + grpc_port=grpc_port, + redis_url=redis_url, + request_stream=request_stream, + response_stream=response_stream, + consumer_group=consumer_group, + ) + + from iac_code.a2a.transports.dispatcher import create_runtime_components + + common_kwargs = { + "model": model, + "host": host, + "port": port, + "token": token, + "basic_username": basic_username, + "basic_password": basic_password, + "api_key": api_key, + "api_key_header": api_key_header, + "persistence_dir": persistence_dir, + "artifact_dir": artifact_dir, + "signing_secret": signing_secret, + "signing_key_id": signing_key_id, + "push_notifications": push_notifications, + "push_queue": push_queue, + "push_redis_url": push_redis_url, + "push_stream": push_stream, + "push_retry_key": push_retry_key, + "push_dead_stream": push_dead_stream, + "push_consumer_group": push_consumer_group, + "push_consumer_name": push_consumer_name, + "push_lease_timeout_ms": push_lease_timeout_ms, + "supported_interfaces": supported_interfaces, + "auto_approve_permissions": auto_approve_permissions, + } + + if normalized_transport == "stdio": + from iac_code.a2a.transports.stdio import StdioA2AServer + + components = create_runtime_components(**common_kwargs) + asyncio.run(_serve_async_transport(StdioA2AServer(components=components), components=components)) + return + + if normalized_transport == "unix": + from iac_code.a2a.transports.unix import UnixA2AServer + + components = create_runtime_components(**common_kwargs) + asyncio.run( + _serve_async_transport( + UnixA2AServer(components=components, socket_path=socket_path or ""), + components=components, + ) + ) + return + + if normalized_transport == "grpc": + from iac_code.a2a.transports.grpc import GrpcA2AServer + + components = create_runtime_components(**common_kwargs) + resolved_grpc_port = port if grpc_port is None else grpc_port + asyncio.run( + _serve_async_transport( + GrpcA2AServer(components=components, host=grpc_host or host, port=resolved_grpc_port), + components=components, + ) + ) + return + + if normalized_transport == "grpc-jsonrpc": + from iac_code.a2a.transports.grpc_jsonrpc import GrpcJsonRpcA2AServer + + components = create_runtime_components(**common_kwargs) + resolved_grpc_port = port if grpc_port is None else grpc_port + asyncio.run( + _serve_async_transport( + GrpcJsonRpcA2AServer(components=components, host=grpc_host or host, port=resolved_grpc_port), + components=components, + ) + ) + return + + if normalized_transport == "redis-streams": + from iac_code.a2a.transports.redis_streams import RedisStreamsA2AServer, require_redis + + redis_module = require_redis() + components = create_runtime_components(**common_kwargs) + redis = redis_module.from_url(redis_url) + asyncio.run( + _serve_async_transport( + RedisStreamsA2AServer( + redis=redis, + components=components, + request_stream=request_stream, + response_stream=response_stream, + consumer_group=consumer_group, + ), + components=components, + ) + ) + return + + if normalized_transport == "websocket": + from iac_code.a2a.transports.websocket import WebSocketA2AServerApp + + components = create_runtime_components(**common_kwargs) + app = WebSocketA2AServerApp(components=components, path=ws_path).create_app() + else: + app = create_app( + host=host, + port=port, + token=token, + model=model, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + persistence_dir=persistence_dir, + artifact_dir=artifact_dir, + signing_secret=signing_secret, + signing_key_id=signing_key_id, + push_notifications=push_notifications, + push_queue=push_queue, + push_redis_url=push_redis_url, + push_stream=push_stream, + push_retry_key=push_retry_key, + push_dead_stream=push_dead_stream, + push_consumer_group=push_consumer_group, + push_consumer_name=push_consumer_name, + push_lease_timeout_ms=push_lease_timeout_ms, + supported_interfaces=supported_interfaces, + auto_approve_permissions=auto_approve_permissions, + ) + + try: + import uvicorn + except ImportError as exc: + raise RuntimeError("A2A server dependencies are missing. Install iac-code with the 'a2a' extra.") from exc + + uvicorn.run( + app, + host=host, + port=port, + ) + + +async def _serve_async_transport(server, *, components) -> None: + await components.task_store.start_cleanup_loop() + push_worker_task: asyncio.Task[None] | None = None + if components.push_worker is not None: + push_worker_task = asyncio.create_task(components.push_worker.serve_forever()) + await asyncio.sleep(0) + try: + await server.serve() + finally: + if push_worker_task is not None: + push_worker_task.cancel() + with suppress(asyncio.CancelledError): + await push_worker_task + try: + await server.aclose() + finally: + await components.aclose() + + +def _supported_interfaces( + *, + transport: str, + host: str, + port: int, + socket_path: str | None, + ws_path: str, + grpc_host: str | None, + grpc_port: int | None, + redis_url: str | None, + request_stream: str, + response_stream: str, + consumer_group: str, +) -> list[dict[str, str]] | None: + if transport == "http": + return [ + {"url": f"http://{host}:{port}/", "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}, + {"url": f"http://{host}:{port}", "protocolBinding": "HTTP+JSON", "protocolVersion": "1.0"}, + ] + if transport == "stdio": + return [{"url": "stdio://iac-code", "protocolBinding": "stdio", "protocolVersion": "1.0"}] + if transport == "unix" and socket_path: + return [{"url": f"unix://{socket_path}", "protocolBinding": "unix", "protocolVersion": "1.0"}] + if transport == "websocket": + return [{"url": f"ws://{host}:{port}{ws_path}", "protocolBinding": "websocket", "protocolVersion": "1.0"}] + if transport == "grpc": + return [ + { + "url": f"grpc://{grpc_host or host}:{port if grpc_port is None else grpc_port}", + "protocolBinding": "grpc", + "protocolVersion": "1.0", + } + ] + if transport == "grpc-jsonrpc": + return [ + { + "url": f"grpc-jsonrpc://{grpc_host or host}:{port if grpc_port is None else grpc_port}", + "protocolBinding": "grpc-jsonrpc", + "protocolVersion": "1.0", + } + ] + if transport == "redis-streams" and redis_url: + return [ + { + "url": f"redis-streams://{redis_url}/{request_stream}/{response_stream}/{consumer_group}", + "protocolBinding": "redis-streams", + "protocolVersion": "1.0", + } + ] + return None diff --git a/src/iac_code/a2a/artifacts.py b/src/iac_code/a2a/artifacts.py new file mode 100644 index 00000000..62acb43e --- /dev/null +++ b/src/iac_code/a2a/artifacts.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import base64 +import hashlib +import os +import uuid +from dataclasses import asdict, dataclass +from pathlib import Path + + +class UnsafeArtifactNameError(ValueError): + """Raised when an artifact filename would escape the artifact directory.""" + + +@dataclass(frozen=True) +class A2AArtifactMetadata: + artifact_id: str + filename: str + media_type: str + byte_size: int + sha256: str + uri: str + + def to_dict(self) -> dict[str, object]: + data = asdict(self) + return { + "artifactId": data["artifact_id"], + "filename": data["filename"], + "mediaType": data["media_type"], + "byteSize": data["byte_size"], + "sha256": data["sha256"], + "uri": data["uri"], + } + + +class A2AArtifactStore: + def __init__(self, root: str | Path) -> None: + self.root = Path(root) + + def save_text(self, *, filename: str, content: str, media_type: str) -> A2AArtifactMetadata: + encoded = content.encode("utf-8") + return self.save_bytes(filename=filename, content=encoded, media_type=media_type) + + def save_base64(self, *, filename: str, content: str, media_type: str) -> A2AArtifactMetadata: + decoded = base64.b64decode(content.encode("ascii"), validate=True) + return self.save_bytes(filename=filename, content=decoded, media_type=media_type) + + def save_bytes(self, *, filename: str, content: bytes, media_type: str) -> A2AArtifactMetadata: + safe_name = self._safe_filename(filename) + artifact_id = str(uuid.uuid4()) + artifact_dir = self.root / artifact_id + artifact_dir.mkdir(parents=True, exist_ok=False) + path = artifact_dir / safe_name + path.write_bytes(content) + return A2AArtifactMetadata( + artifact_id=artifact_id, + filename=safe_name, + media_type=media_type, + byte_size=len(content), + sha256=hashlib.sha256(content).hexdigest(), + uri=path.resolve().as_uri(), + ) + + def path_for(self, artifact_id: str) -> Path: + candidates = list((self.root / artifact_id).iterdir()) + if not candidates: + raise FileNotFoundError(artifact_id) + return candidates[0] + + @staticmethod + def _safe_filename(filename: str) -> str: + if not filename or filename != os.path.basename(filename) or filename in {".", ".."}: + raise UnsafeArtifactNameError("Unsafe artifact filename") + return filename diff --git a/src/iac_code/a2a/client.py b/src/iac_code/a2a/client.py new file mode 100644 index 00000000..e3ff9911 --- /dev/null +++ b/src/iac_code/a2a/client.py @@ -0,0 +1,361 @@ +from __future__ import annotations + +import uuid +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from time import monotonic +from typing import Any, AsyncIterator + +import httpx + +from iac_code.a2a.signing import AgentCardSignature, agent_card_signature_jwks_url, verify_agent_card_dict +from iac_code.a2a.transport import A2AAuthConfig, A2ATransportBinding, UnsupportedA2ATransportError, headers_for_auth +from iac_code.a2a.transports.base import A2ATransportClient, TransportClientOptions, binding_from_url +from iac_code.a2a.transports.http import HttpA2AClient + + +class A2ACardVerificationError(ValueError): + """Raised when a discovered Agent Card fails configured verification.""" + + +TransportClientFactory = Callable[[TransportClientOptions], A2ATransportClient] + + +@dataclass(frozen=True) +class A2AClientResponse: + payload: dict[str, Any] + + @property + def text(self) -> str: + result = self.payload.get("result") + if not isinstance(result, dict): + return "" + text = result.get("text") + if isinstance(text, str): + return text + status = result.get("status") + if not isinstance(status, dict): + return "" + message = status.get("message") + if not isinstance(message, dict): + return "" + parts = message.get("parts") + if not isinstance(parts, list) or not parts or not isinstance(parts[0], dict): + return "" + value = parts[0].get("text") + return value if isinstance(value, str) else "" + + +class A2AClient: + def __init__( + self, + *, + http_client: Any | None = None, + auth: A2AAuthConfig | None = None, + verification_secret: str | None = None, + verification_secrets: Mapping[str, str] | None = None, + verification_jwks: Mapping[str, Any] | None = None, + verification_jwks_url: str | None = None, + require_card_signature: bool = False, + transport_client_factory: TransportClientFactory | None = None, + timeout_seconds: float | None = None, + jwks_cache_ttl_seconds: float = 3600.0, + clock: Callable[[], float] = monotonic, + ) -> None: + self._owns_http_client = http_client is None + self._http_client = http_client or ( + httpx.AsyncClient(timeout=timeout_seconds) if timeout_seconds is not None else httpx.AsyncClient() + ) + self._auth = auth + self._verification_secret = verification_secret + self._verification_secrets = verification_secrets + self._verification_jwks = verification_jwks + self._verification_jwks_url = verification_jwks_url + self._remote_jwks_cache: dict[str, tuple[Mapping[str, Any], float]] = {} + self._jwks_cache_ttl_seconds = max(0.0, jwks_cache_ttl_seconds) + self._clock = clock + self._require_card_signature = require_card_signature + self._transport_client_factory = transport_client_factory + + async def discover(self, base_url: str) -> dict[str, Any]: + url = base_url.rstrip("/") + "/.well-known/agent-card.json" + response = await self._http_client.get(url, headers=headers_for_auth(self._auth)) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + raise ValueError("A2A Agent Card response must be a JSON object") + if self._should_verify_agent_card(data): + result = await self._verify_agent_card(data) + if not result.valid: + raise A2ACardVerificationError(f"A2A Agent Card verification failed: {result.message}") + return data + + async def send_message( + self, + url: str, + prompt: str, + *, + cwd: str, + context_id: str | None = None, + ) -> A2AClientResponse: + payload = self._message_payload(method="SendMessage", prompt=prompt, cwd=cwd, context_id=context_id) + transport = self._make_transport_client(url) + response = await transport.send(payload) + return A2AClientResponse(payload=response) + + async def stream_message( + self, + url: str, + prompt: str, + *, + cwd: str, + context_id: str | None = None, + ) -> AsyncIterator[dict[str, Any]]: + payload = self._message_payload(method="SendStreamingMessage", prompt=prompt, cwd=cwd, context_id=context_id) + transport = self._make_transport_client(url) + async for event in transport.stream(payload): + yield event + + async def get_task(self, url: str, task_id: str, *, history_length: int | None = None) -> dict[str, Any]: + params: dict[str, Any] = {"id": task_id} + if history_length is not None: + params["historyLength"] = history_length + return await self._send_jsonrpc(url, method="GetTask", params=params) + + async def list_tasks( + self, + url: str, + *, + context_id: str | None = None, + status: str | None = None, + page_size: int | None = None, + page_token: str | None = None, + include_artifacts: bool | None = None, + ) -> dict[str, Any]: + params = _without_none( + { + "contextId": context_id, + "status": status, + "pageSize": page_size, + "pageToken": page_token, + "includeArtifacts": include_artifacts, + } + ) + return await self._send_jsonrpc(url, method="ListTasks", params=params) + + async def cancel_task(self, url: str, task_id: str) -> dict[str, Any]: + return await self._send_jsonrpc(url, method="CancelTask", params={"id": task_id}) + + async def subscribe_task(self, url: str, task_id: str) -> AsyncIterator[dict[str, Any]]: + payload = self._jsonrpc_payload(method="SubscribeToTask", params={"id": task_id}) + transport = self._make_transport_client(url) + async for event in transport.stream(payload): + yield event + + async def create_push_notification_config( + self, + endpoint_url: str, + *, + task_id: str, + config_id: str, + url: str, + token: str | None = None, + authentication: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + params = _without_none( + { + "taskId": task_id, + "id": config_id, + "url": url, + "token": token, + "authentication": dict(authentication) if authentication is not None else None, + } + ) + return await self._send_jsonrpc(endpoint_url, method="CreateTaskPushNotificationConfig", params=params) + + async def get_push_notification_config(self, url: str, *, task_id: str, config_id: str) -> dict[str, Any]: + return await self._send_jsonrpc( + url, + method="GetTaskPushNotificationConfig", + params={"taskId": task_id, "id": config_id}, + ) + + async def list_push_notification_configs( + self, + url: str, + *, + task_id: str, + page_size: int | None = None, + page_token: str | None = None, + ) -> dict[str, Any]: + return await self._send_jsonrpc( + url, + method="ListTaskPushNotificationConfigs", + params=_without_none({"taskId": task_id, "pageSize": page_size, "pageToken": page_token}), + ) + + async def delete_push_notification_config(self, url: str, *, task_id: str, config_id: str) -> dict[str, Any]: + return await self._send_jsonrpc( + url, + method="DeleteTaskPushNotificationConfig", + params={"taskId": task_id, "id": config_id}, + ) + + async def get_extended_agent_card(self, url: str) -> dict[str, Any]: + return await self._send_jsonrpc(url, method="GetExtendedAgentCard", params={}) + + async def aclose(self) -> None: + if not self._owns_http_client: + return + close = getattr(self._http_client, "aclose", None) + if close is not None: + await close() + + def _should_verify_agent_card(self, card: Mapping[str, Any]) -> bool: + return bool( + self._require_card_signature + or self._verification_secret + or self._verification_secrets + or self._verification_jwks + or self._verification_jwks_url + ) + + async def _verify_agent_card(self, card: dict[str, Any]) -> AgentCardSignature: + remote_jwks_url = self._verification_jwks_url or agent_card_signature_jwks_url(card) + remote_jwks = await self._remote_jwks(remote_jwks_url, force_refresh=False) if remote_jwks_url else None + jwks = _merge_jwks(remote_jwks, self._verification_jwks) + result = verify_agent_card_dict( + card, + secret=self._verification_secret, + secrets=self._verification_secrets, + jwks=jwks, + require_signature=self._require_card_signature, + ) + if result.valid or remote_jwks_url is None or result.reason not in {"signature-mismatch", "unknown-key"}: + return result + + refreshed_jwks = await self._remote_jwks(remote_jwks_url, force_refresh=True) + return verify_agent_card_dict( + card, + secret=self._verification_secret, + secrets=self._verification_secrets, + jwks=_merge_jwks(refreshed_jwks, self._verification_jwks), + require_signature=self._require_card_signature, + ) + + async def _remote_jwks(self, url: str, *, force_refresh: bool) -> Mapping[str, Any]: + cached = self._remote_jwks_cache.get(url) + if not force_refresh and cached is not None: + data, expires_at = cached + if self._clock() < expires_at: + return data + + response = await self._http_client.get(url) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + raise ValueError("A2A JWKS response must be a JSON object") + self._remote_jwks_cache[url] = (data, self._clock() + self._jwks_cache_ttl_seconds) + return data + + def _jsonrpc_headers(self) -> dict[str, str]: + return {"A2A-Version": "1.0", **headers_for_auth(self._auth)} + + @staticmethod + def select_endpoint_url(card: Mapping[str, Any], *, fallback_url: str) -> str: + interfaces = card.get("supportedInterfaces") + if isinstance(interfaces, Sequence) and not isinstance(interfaces, (str, bytes)): + for item in interfaces: + if not isinstance(item, Mapping): + continue + url = item.get("url") + if isinstance(url, str) and url: + return url + + url = card.get("url") + if isinstance(url, str) and url: + return url + return fallback_url + + def _make_transport_client(self, url: str) -> A2ATransportClient: + binding = binding_from_url(url) + if binding.transport == "http": + return _BoundHttpA2AClient(HttpA2AClient(http_client=self._http_client, auth=self._auth), url) + if self._transport_client_factory is None: + raise UnsupportedA2ATransportError( + f"A2A transport {binding.transport!r} requires a transport client factory." + ) + return self._transport_client_factory(self._transport_options(binding)) + + def _transport_options(self, binding: A2ATransportBinding) -> TransportClientOptions: + auth = self._auth or A2AAuthConfig() + return TransportClientOptions( + binding=binding, + token=auth.bearer_token, + basic_username=auth.basic_username, + basic_password=auth.basic_password, + api_key=auth.api_key, + api_key_header=auth.api_key_header, + ) + + def _message_payload(self, *, method: str, prompt: str, cwd: str, context_id: str | None) -> dict[str, Any]: + message: dict[str, Any] = { + "messageId": str(uuid.uuid4()), + "role": "ROLE_USER", + "parts": [{"kind": "text", "text": prompt}], + "metadata": {"iac_code": {"cwd": cwd}}, + } + if context_id: + message["contextId"] = context_id + return self._jsonrpc_payload( + method=method, + params={ + "message": message, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + ) + + async def _send_jsonrpc(self, url: str, *, method: str, params: dict[str, Any]) -> dict[str, Any]: + payload = self._jsonrpc_payload(method=method, params=params) + transport = self._make_transport_client(url) + return await transport.send(payload) + + @staticmethod + def _jsonrpc_payload(*, method: str, params: dict[str, Any]) -> dict[str, Any]: + return { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": method, + "params": params, + } + + +def _without_none(values: Mapping[str, Any]) -> dict[str, Any]: + return {key: value for key, value in values.items() if value is not None} + + +def _merge_jwks(*jwks_values: Mapping[str, Any] | None) -> dict[str, Any] | None: + keys: list[Any] = [] + for jwks in jwks_values: + if not jwks: + continue + jwks_keys = jwks.get("keys") + if isinstance(jwks_keys, list): + keys.extend(jwks_keys) + return {"keys": keys} if keys else None + + +class _BoundHttpA2AClient: + def __init__(self, client: HttpA2AClient, url: str) -> None: + self._client = client + self._url = url + + async def send(self, payload: dict[str, Any]) -> dict[str, Any]: + return await self._client.send(self._url, payload) + + async def stream(self, payload: dict[str, Any]) -> AsyncIterator[dict[str, Any]]: + async for event in self._client.stream(self._url, payload): + yield event + + async def aclose(self) -> None: + return None diff --git a/src/iac_code/a2a/events.py b/src/iac_code/a2a/events.py new file mode 100644 index 00000000..769c9b3b --- /dev/null +++ b/src/iac_code/a2a/events.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import inspect +import logging +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any, TypeAlias + +from a2a.types import ( + Artifact, + Message, + Part, + Role, + TaskArtifactUpdateEvent, + TaskState, + TaskStatus, + TaskStatusUpdateEvent, +) +from google.protobuf.json_format import ParseDict + +from iac_code.types.stream_events import ( + ErrorEvent, + MessageEndEvent, + PermissionRequestEvent, + TextDeltaEvent, + ThinkingDeltaEvent, + ToolInputDeltaEvent, + ToolResultEvent, + ToolUseEndEvent, + ToolUseStartEvent, +) + +_METADATA_MAX_CHARS = 4000 +_METADATA_MAX_DEPTH = 32 +logger = logging.getLogger(__name__) +A2APermissionResolver: TypeAlias = Callable[[PermissionRequestEvent], "bool | Awaitable[bool]"] + + +def _truncate(value: Any, *, _depth: int = 0) -> Any: + if _depth >= _METADATA_MAX_DEPTH: + return "[truncated-depth]" + if isinstance(value, str): + return value[:_METADATA_MAX_CHARS] + if isinstance(value, dict): + return {str(k): _truncate(v, _depth=_depth + 1) for k, v in value.items()} + if isinstance(value, list): + return [_truncate(v, _depth=_depth + 1) for v in value] + return value + + +def make_text_part(text: str) -> Part: + return Part(text=text) + + +def _extract_artifact_metadata(result: Any, artifact_store: Any | None) -> dict[str, Any] | None: + if artifact_store is None or not isinstance(result, dict): + return None + raw = result.get("artifact") + if not isinstance(raw, dict): + return None + filename = raw.get("filename") + media_type = raw.get("mediaType") or raw.get("media_type") or "application/octet-stream" + if not isinstance(filename, str): + return None + content = raw.get("content") + if isinstance(content, str): + metadata = artifact_store.save_text(filename=filename, content=content, media_type=str(media_type)) + return metadata.to_dict() + encoded = raw.get("bytes") or raw.get("base64") + if isinstance(encoded, str): + metadata = artifact_store.save_base64(filename=filename, content=encoded, media_type=str(media_type)) + return metadata.to_dict() + source_path = raw.get("path") + if isinstance(source_path, str): + path = Path(source_path) + if not path.is_file(): + return None + metadata = artifact_store.save_bytes(filename=filename, content=path.read_bytes(), media_type=str(media_type)) + return metadata.to_dict() + raw_bytes = raw.get("raw") + if isinstance(raw_bytes, bytes): + metadata = artifact_store.save_bytes(filename=filename, content=raw_bytes, media_type=str(media_type)) + return metadata.to_dict() + return None + + +def _tool_result_metadata(result: Any) -> Any: + if not isinstance(result, dict): + return _truncate(result) + data = dict(result) + raw_artifact = data.get("artifact") + if isinstance(raw_artifact, dict) and any( + key in raw_artifact for key in ("content", "bytes", "base64", "raw", "path") + ): + artifact = dict(raw_artifact) + artifact.pop("content", None) + artifact.pop("bytes", None) + artifact.pop("base64", None) + artifact.pop("raw", None) + artifact.pop("path", None) + data["artifact"] = artifact + return _truncate(data) + + +def _artifact_update_event(*, task_id: str, context_id: str, metadata: dict[str, Any]) -> TaskArtifactUpdateEvent: + artifact_metadata = { + "uri": metadata["uri"], + "mediaType": metadata["mediaType"], + "byteSize": metadata["byteSize"], + "sha256": metadata["sha256"], + } + artifact = Artifact( + artifact_id=str(metadata["artifactId"]), + name=str(metadata["filename"]), + parts=[ + Part( + url=str(metadata["uri"]), + filename=str(metadata["filename"]), + media_type=str(metadata["mediaType"]), + ) + ], + ) + ParseDict(artifact_metadata, artifact.metadata) + ParseDict(artifact_metadata, artifact.parts[0].metadata) + return TaskArtifactUpdateEvent( + task_id=task_id, + context_id=context_id, + artifact=artifact, + append=False, + last_chunk=True, + ) + + +def _agent_text_message(*, task_id: str, context_id: str, text: str) -> Message: + return Message( + message_id=f"{task_id}-message", + task_id=task_id, + context_id=context_id, + role=Role.ROLE_AGENT, + parts=[make_text_part(text)], + ) + + +async def _enqueue_status( + event_queue: Any, + *, + task_id: str, + context_id: str, + state: int, + message: Message | None = None, + metadata: dict[str, Any] | None = None, +) -> None: + update = TaskStatusUpdateEvent( + task_id=task_id, + context_id=context_id, + status=TaskStatus(state=TaskState.Name(state), message=message), + ) + if metadata is not None: + ParseDict(metadata, update.metadata) + await event_queue.enqueue_event(update) + + +async def publish_stream_event( + event_queue: Any, + *, + task_id: str, + context_id: str, + event: Any, + artifact_store: Any | None = None, + permission_resolver: A2APermissionResolver | None = None, + auto_approve_permissions: bool = False, +) -> str | None: + if isinstance(event, TextDeltaEvent): + if not event.text: + return None + await _enqueue_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_WORKING, + message=_agent_text_message(task_id=task_id, context_id=context_id, text=event.text), + ) + return event.text + + if isinstance(event, ThinkingDeltaEvent): + return None + + if isinstance(event, ToolUseStartEvent): + await _enqueue_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_WORKING, + metadata={"iac_code": {"tool": {"status": "started", "toolUseId": event.tool_use_id, "name": event.name}}}, + ) + return None + + if isinstance(event, ToolInputDeltaEvent): + await _enqueue_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_WORKING, + metadata={ + "iac_code": { + "tool": { + "status": "input_delta", + "toolUseId": event.tool_use_id, + "partialJson": _truncate(event.partial_json), + } + } + }, + ) + return None + + if isinstance(event, ToolUseEndEvent): + await _enqueue_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_WORKING, + metadata={ + "iac_code": { + "tool": { + "status": "input_complete", + "toolUseId": event.tool_use_id, + "name": event.name, + "input": _truncate(event.input), + } + } + }, + ) + return None + + if isinstance(event, ToolResultEvent): + artifact_metadata = _extract_artifact_metadata(event.result, artifact_store) + tool_metadata = { + "status": "failed" if event.is_error else "completed", + "toolUseId": event.tool_use_id, + "name": event.tool_name, + "result": _tool_result_metadata(event.result), + } + if artifact_metadata is not None: + tool_metadata["artifact"] = artifact_metadata + await event_queue.enqueue_event( + _artifact_update_event(task_id=task_id, context_id=context_id, metadata=artifact_metadata) + ) + await _enqueue_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_WORKING, + metadata={"iac_code": {"tool": tool_metadata}}, + ) + return None + + if isinstance(event, PermissionRequestEvent): + approved = auto_approve_permissions + if permission_resolver is not None: + decision = permission_resolver(event) + approved = bool(await decision) if inspect.isawaitable(decision) else bool(decision) + if event.response_future is not None and not event.response_future.done(): + event.response_future.set_result(approved) + await _enqueue_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_WORKING, + metadata={ + "iac_code": { + "permission": { + "autoApproved": approved, + "toolName": event.tool_name, + "toolUseId": event.tool_use_id, + "toolInput": _truncate(event.tool_input), + } + } + }, + ) + return None + + if isinstance(event, MessageEndEvent): + await _enqueue_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_WORKING, + metadata={ + "iac_code": { + "usage": { + "inputTokens": event.usage.input_tokens, + "outputTokens": event.usage.output_tokens, + "totalTokens": event.usage.total_tokens, + } + } + }, + ) + return None + + if isinstance(event, ErrorEvent): + await _enqueue_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_INPUT_REQUIRED if event.is_retryable else TaskState.TASK_STATE_FAILED, + message=_agent_text_message( + task_id=task_id, + context_id=context_id, + text="A temporary error occurred. Please retry." + if event.is_retryable + else "An internal error occurred.", + ), + ) + return None + + logger.debug("Skipping unmapped A2A stream event: %s", type(event).__name__) + return None diff --git a/src/iac_code/a2a/executor.py b/src/iac_code/a2a/executor.py new file mode 100644 index 00000000..cbb0233a --- /dev/null +++ b/src/iac_code/a2a/executor.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import asyncio +import logging +import os +import uuid +from collections.abc import Awaitable, Callable, Mapping +from pathlib import Path +from typing import Any, TypeAlias + +import httpx +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.types import Message, Role, Task, TaskState, TaskStatus, TaskStatusUpdateEvent +from google.protobuf.json_format import MessageToDict + +from iac_code.a2a.events import make_text_part, publish_stream_event +from iac_code.a2a.metrics import A2AMetrics, NoOpA2AMetrics +from iac_code.a2a.parts import allowed_cwd_roots, is_relative_to, parts_to_prompt +from iac_code.a2a.task_store import A2ATaskStore +from iac_code.a2a.types import ( + TASK_STATE_CANCELED, + TASK_STATE_FAILED, + TASK_STATE_INPUT_REQUIRED, + TASK_STATE_WORKING, +) +from iac_code.services.agent_factory import AgentFactoryOptions, create_agent_runtime + +logger = logging.getLogger(__name__) +_CONTEXT_LOCK_ACQUIRE_TIMEOUT_SECONDS = 1 +A2APermissionResolver: TypeAlias = Callable[[Any], "bool | Awaitable[bool]"] + + +def _allowed_cwd_roots() -> list[Path]: + return allowed_cwd_roots() + + +def _is_relative_to(path: Path, root: Path) -> bool: + return is_relative_to(path, root) + + +class IacCodeA2AExecutor(AgentExecutor): + def __init__( + self, + *, + task_store: A2ATaskStore, + model: str, + metrics: A2AMetrics | None = None, + artifact_store: Any | None = None, + push_notifier: Any | None = None, + permission_resolver: A2APermissionResolver | None = None, + auto_approve_permissions: bool = False, + ) -> None: + self._task_store = task_store + self._model = model + self._metrics = metrics or NoOpA2AMetrics() + self._artifact_store = artifact_store + self._push_notifier = push_notifier + self._permission_resolver = permission_resolver + self._auto_approve_permissions = auto_approve_permissions + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + task_id = context.task_id or "task-" + uuid.uuid4().hex[:12] + context_id = context.context_id or "ctx-" + uuid.uuid4().hex[:12] + task = None + try: + task = await self._task_store.get_or_create_task(task_id=task_id, context_id=context_id) + if not isinstance(getattr(context, "current_task", None), Task): + await self._publish_initial_task(event_queue, task_id=task_id, context_id=context_id, context=context) + await self._task_store.ensure_task_not_expired(task.task_id) + metadata = getattr(context, "metadata", None) or getattr( + getattr(context, "message", None), "metadata", None + ) + cwd = self._resolve_cwd(metadata) + prompt = self._prompt_from_context(context, cwd=cwd) + except Exception as exc: + if _is_retryable_executor_error(exc): + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_INPUT_REQUIRED, + text="A temporary error occurred. Please retry.", + ) + if task is not None: + task.state = TASK_STATE_INPUT_REQUIRED + self._task_store.mirror_task(task) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_executor_error() + return + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_FAILED, + text=str(exc), + ) + if task is not None: + task.state = TASK_STATE_FAILED + self._task_store.mirror_task(task) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_task_failed() + return + + if not prompt.strip(): + task.state = TASK_STATE_FAILED + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_FAILED, + text="A2A server currently accepts text input only.", + ) + self._task_store.mirror_task(task) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_task_failed() + return + + def runtime_factory(session_id: str) -> Any: + return create_agent_runtime(AgentFactoryOptions(model=self._model, session_id=session_id, cwd=cwd)) + + try: + ctx = await self._task_store.get_or_create_context( + context_id=context_id, + cwd=cwd, + runtime_factory=runtime_factory, + ) + except Exception as exc: + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_FAILED, + text=self._sanitize_error(exc), + ) + task.state = TASK_STATE_FAILED + self._task_store.mirror_task(task) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_executor_error() + self._metrics.record_task_failed() + return + + if ctx.lock is None: + ctx.lock = asyncio.Lock() + if ctx.active_task_id is not None: + task.state = TASK_STATE_FAILED + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_FAILED, + text="Task is already working.", + ) + self._task_store.mirror_task(task) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_task_failed() + return + + lock = ctx.lock + try: + await asyncio.wait_for(lock.acquire(), timeout=_CONTEXT_LOCK_ACQUIRE_TIMEOUT_SECONDS) + except TimeoutError: + task.state = TASK_STATE_FAILED + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_FAILED, + text="Task is already working.", + ) + self._task_store.mirror_task(task) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_task_failed() + return + + try: + ctx.active_task_id = task.task_id + task.state = TASK_STATE_WORKING + task.active_task = asyncio.current_task() + self._task_store.mirror_task(task) + self._task_store.mirror_context(ctx) + try: + runtime = ctx.runtime + if runtime is None: + raise RuntimeError("A2A context runtime missing") + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_SUBMITTED, + ) + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_WORKING, + ) + async for event in runtime.agent_loop.run_streaming(prompt): + text_chunk = await publish_stream_event( + event_queue, + task_id=task_id, + context_id=context_id, + event=event, + artifact_store=self._artifact_store, + permission_resolver=self._permission_resolver, + auto_approve_permissions=self._auto_approve_permissions, + ) + if text_chunk: + task.output_text.append(text_chunk) + task.state = TASK_STATE_INPUT_REQUIRED + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_INPUT_REQUIRED, + ) + self._task_store.mirror_task(task) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_turn_completed() + except asyncio.CancelledError: + task.state = TASK_STATE_CANCELED + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_CANCELED, + text="Task canceled.", + ) + self._task_store.mirror_task(task) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_task_canceled() + except Exception as exc: + if _is_retryable_executor_error(exc): + task.state = TASK_STATE_INPUT_REQUIRED + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_INPUT_REQUIRED, + text="A temporary error occurred. Please retry.", + ) + self._task_store.mirror_task(task) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_executor_error() + else: + task.state = TASK_STATE_FAILED + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_FAILED, + text=self._sanitize_error(exc), + ) + self._task_store.mirror_task(task) + await self._notify_terminal_task(task_id=task.task_id, context_id=task.context_id, state=task.state) + self._metrics.record_executor_error() + self._metrics.record_task_failed() + finally: + task.active_task = None + ctx.active_task_id = None + ctx.touch() + task.touch() + self._task_store.mirror_context(ctx) + finally: + lock.release() + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + task_id = context.task_id + context_id = context.context_id or "unknown" + if task_id and await self._task_store.cancel_task(task_id): + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_CANCELED, + text="Task cancellation requested.", + ) + self._metrics.record_task_canceled() + return + if task_id: + await self._publish_status( + event_queue, + task_id=task_id, + context_id=context_id, + state=TaskState.TASK_STATE_FAILED, + text="Task not running.", + ) + + def _resolve_cwd(self, metadata: Any | None) -> str: + cwd = os.getcwd() + if metadata is not None and hasattr(metadata, "DESCRIPTOR"): + metadata = MessageToDict(metadata, preserving_proto_field_name=False) + if metadata: + raw_iac_meta = metadata.get("iac_code") if isinstance(metadata, Mapping) else None + if isinstance(raw_iac_meta, Mapping): + raw_cwd = raw_iac_meta.get("cwd") + if isinstance(raw_cwd, str): + cwd = raw_cwd + if not isinstance(cwd, str) or not Path(cwd).is_absolute() or not Path(cwd).is_dir(): + raise ValueError("Invalid A2A workspace metadata.") + resolved_cwd = Path(cwd).resolve() + if not any(_is_relative_to(resolved_cwd, root) for root in _allowed_cwd_roots()): + raise ValueError("Invalid A2A workspace metadata.") + return str(resolved_cwd) + + def _prompt_from_context(self, context: RequestContext, *, cwd: str) -> str: + message = getattr(context, "message", None) + if not isinstance(message, Message): + return context.get_user_input() + return parts_to_prompt(message.parts, cwd=cwd) + + def _sanitize_error(self, exc: Exception) -> str: + if isinstance(exc, ValueError): + msg = str(exc).lower() + if "provider" in msg or "configure" in msg or "/auth" in msg: + return "Authentication required. Please configure your API credentials." + if type(exc).__name__ == "AuthenticationError": + return "Authentication required. Please configure your API credentials." + status = getattr(exc, "status_code", None) or getattr(exc, "status", None) + if status == 401: + return "Authentication required. Please configure your API credentials." + logger.exception("Unhandled A2A executor error") + return "An internal error occurred." + + async def _publish_status( + self, + event_queue: EventQueue, + *, + task_id: str, + context_id: str, + state: int, + text: str | None = None, + ) -> None: + message = None + if text: + message = Message( + message_id=f"{task_id}-{state}", + task_id=task_id, + context_id=context_id, + role=Role.ROLE_AGENT, + parts=[make_text_part(text)], + ) + status = TaskStatus(state=TaskState.Name(state), message=message) + status.timestamp.GetCurrentTime() + await event_queue.enqueue_event(TaskStatusUpdateEvent(task_id=task_id, context_id=context_id, status=status)) + + async def _publish_initial_task( + self, + event_queue: EventQueue, + *, + task_id: str, + context_id: str, + context: RequestContext, + ) -> None: + task = Task( + id=task_id, + context_id=context_id, + status=TaskStatus(state=TaskState.Name(TaskState.TASK_STATE_SUBMITTED)), + ) + message = getattr(context, "message", None) + if isinstance(message, Message): + task.history.append(message) + await event_queue.enqueue_event(task) + + async def _notify_terminal_task(self, *, task_id: str, context_id: str, state: str) -> None: + if self._push_notifier is None: + return + try: + await self._push_notifier.notify_task_state(task_id=task_id, context_id=context_id, state=state) + except Exception: + logger.warning("A2A push notification failed", exc_info=True) + + +def _is_retryable_executor_error(exc: Exception) -> bool: + return isinstance(exc, (TimeoutError, httpx.TimeoutException, httpx.TransportError, ConnectionError)) diff --git a/src/iac_code/a2a/metrics.py b/src/iac_code/a2a/metrics.py new file mode 100644 index 00000000..896cc084 --- /dev/null +++ b/src/iac_code/a2a/metrics.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import logging +from typing import Protocol + +logger = logging.getLogger(__name__) + + +class A2AMetrics(Protocol): + def record_task_created(self) -> None: + """Record creation of a protocol task record.""" + ... + + def record_turn_completed(self) -> None: + """Record successful completion of one agent turn.""" + ... + + def record_task_canceled(self) -> None: + """Record cancellation of an active task.""" + ... + + def record_task_failed(self) -> None: + """Record a task reaching a failed terminal state.""" + ... + + def record_context_evicted(self) -> None: + """Record cleanup of an idle A2A context.""" + ... + + def record_executor_error(self) -> None: + """Record an executor-level error while handling a task.""" + ... + + def record_push_enqueued(self) -> None: + """Record enqueueing of a push notification job.""" + ... + + def record_push_delivered(self, *, duration_ms: float) -> None: + """Record successful push delivery latency.""" + ... + + def record_push_retry_scheduled(self) -> None: + """Record scheduling of a retry for a transient push failure.""" + ... + + def record_push_dead_lettered(self) -> None: + """Record a push job moved to the dead-letter queue.""" + ... + + def record_push_permanent_failure(self) -> None: + """Record a non-retryable push delivery failure.""" + ... + + def record_push_transient_failure(self) -> None: + """Record a retryable push delivery failure.""" + ... + + def record_push_queue_depth(self, depth: int) -> None: + """Record the current push queue depth.""" + ... + + +class NoOpA2AMetrics: + def record_task_created(self) -> None: + logger.debug("a2a task created") + + def record_turn_completed(self) -> None: + logger.debug("a2a turn completed") + + def record_task_canceled(self) -> None: + logger.debug("a2a task canceled") + + def record_task_failed(self) -> None: + logger.debug("a2a task failed") + + def record_context_evicted(self) -> None: + logger.debug("a2a context evicted") + + def record_executor_error(self) -> None: + logger.debug("a2a executor error") + + def record_push_enqueued(self) -> None: + logger.debug("a2a push enqueued") + + def record_push_delivered(self, *, duration_ms: float) -> None: + logger.debug("a2a push delivered in %.2f ms", duration_ms) + + def record_push_retry_scheduled(self) -> None: + logger.debug("a2a push retry scheduled") + + def record_push_dead_lettered(self) -> None: + logger.debug("a2a push dead lettered") + + def record_push_permanent_failure(self) -> None: + logger.debug("a2a push permanent failure") + + def record_push_transient_failure(self) -> None: + logger.debug("a2a push transient failure") + + def record_push_queue_depth(self, depth: int) -> None: + logger.debug("a2a push queue depth=%d", depth) diff --git a/src/iac_code/a2a/parts.py b/src/iac_code/a2a/parts.py new file mode 100644 index 00000000..4390d541 --- /dev/null +++ b/src/iac_code/a2a/parts.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import tempfile +from collections.abc import Iterable +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlparse + +from google.protobuf.json_format import MessageToDict + +MAX_INLINE_BYTES = 1024 * 1024 +MAX_FILE_BYTES = 1024 * 1024 +MAX_BINARY_INLINE_BYTES = 5 * 1024 * 1024 +MAX_BINARY_FILE_BYTES = 25 * 1024 * 1024 + +DEFAULT_TEXT_LIKE_MIME_TYPES = ( + "text/plain", + "application/json", + "text/markdown", + "text/yaml", + "application/yaml", + "application/x-yaml", +) +DEFAULT_MULTIMODAL_MIME_TYPES = ( + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "audio/mpeg", + "audio/wav", + "audio/ogg", + "application/octet-stream", +) +TEXT_LIKE_MIME_TYPES = frozenset(DEFAULT_TEXT_LIKE_MIME_TYPES) +MULTIMODAL_MIME_TYPES = frozenset(DEFAULT_MULTIMODAL_MIME_TYPES) +SUPPORTED_INPUT_MIME_TYPES = [*DEFAULT_TEXT_LIKE_MIME_TYPES, *DEFAULT_MULTIMODAL_MIME_TYPES] + + +def supported_input_mime_types() -> list[str]: + values = [ + *DEFAULT_TEXT_LIKE_MIME_TYPES, + *DEFAULT_MULTIMODAL_MIME_TYPES, + *sorted(_extra_mime_types("IACCODE_A2A_TEXT_MIME_TYPES")), + *sorted(_extra_mime_types("IACCODE_A2A_MULTIMODAL_MIME_TYPES")), + ] + return list(dict.fromkeys(values)) + + +def text_like_mime_types() -> frozenset[str]: + return TEXT_LIKE_MIME_TYPES | _extra_mime_types("IACCODE_A2A_TEXT_MIME_TYPES") + + +def multimodal_mime_types() -> frozenset[str]: + return MULTIMODAL_MIME_TYPES | _extra_mime_types("IACCODE_A2A_MULTIMODAL_MIME_TYPES") + + +def _extra_mime_types(env_name: str) -> frozenset[str]: + raw = os.environ.get(env_name, "") + return frozenset(item.strip().lower() for item in raw.replace(";", ",").split(",") if item.strip()) + + +def allowed_cwd_roots() -> list[Path]: + raw = os.environ.get("IACCODE_A2A_ALLOWED_CWDS") + if raw: + candidates = [Path(item) for item in raw.split(os.pathsep) if item] + else: + candidates = [Path.cwd(), Path(tempfile.gettempdir())] + return [path.resolve() for path in candidates if path.exists() and path.is_dir()] + + +def is_relative_to(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + except ValueError: + return False + return True + + +def parts_to_prompt(message_parts: Iterable[Any], *, cwd: str | Path) -> str: + values = [part_to_prompt(part, cwd=cwd) for part in message_parts] + return "\n".join(value for value in values if value) + + +def part_to_prompt(part: Any, *, cwd: str | Path) -> str: + media_type = _media_type(part) + if _has_field(part, "text"): + _ensure_text_like(media_type) + return str(part.text) + if _has_field(part, "data"): + if _is_multimodal(media_type): + return _binary_data_part_to_manifest(part, media_type=media_type) + if media_type != "application/json": + raise ValueError("A2A data parts must use application/json media type.") + data = MessageToDict(part.data, preserving_proto_field_name=False) + serialized = json.dumps(data, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + _ensure_size(serialized.encode("utf-8"), limit=MAX_INLINE_BYTES, label="A2A data part") + return serialized + if _has_field(part, "raw"): + raw = bytes(part.raw) + if _is_multimodal(media_type): + _ensure_size(raw, limit=MAX_BINARY_INLINE_BYTES, label="A2A binary raw part") + return _multimodal_manifest( + filename=_filename(part) or "inline", + media_type=media_type, + content=raw, + source="inline", + ) + _ensure_text_like(media_type) + _ensure_size(raw, limit=MAX_INLINE_BYTES, label="A2A raw part") + try: + return raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError("A2A raw parts must contain valid UTF-8.") from exc + if _has_field(part, "url"): + if _is_multimodal(media_type): + return _file_url_part_to_manifest(str(part.url), media_type=media_type, cwd=Path(cwd)) + _ensure_text_like(media_type) + return _read_file_url_part(str(part.url), cwd=Path(cwd)) + raise ValueError("A2A server supports text, JSON data, raw text, or workspace file URL parts only.") + + +def _read_file_url_part(url: str, *, cwd: Path) -> str: + parsed = urlparse(url) + if parsed.scheme != "file" or parsed.netloc: + raise ValueError("A2A file URL parts must use local file:// URLs.") + + cwd_path = cwd.resolve() + path = Path(unquote(parsed.path)).resolve() + if not is_relative_to(path, cwd_path) or not any(is_relative_to(path, root) for root in allowed_cwd_roots()): + raise ValueError("A2A file URL part is outside the allowed workspace.") + if not path.is_file(): + raise ValueError("A2A file URL part must reference an existing file.") + if path.stat().st_size > MAX_FILE_BYTES: + raise ValueError("A2A file URL part content is too large.") + try: + return path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise ValueError("A2A file URL parts must contain valid UTF-8.") from exc + + +def _media_type(part: Any) -> str: + return str(getattr(part, "media_type", "") or "text/plain").lower() + + +def _ensure_text_like(media_type: str) -> None: + if media_type not in text_like_mime_types(): + raise ValueError("A2A part has unsupported media type.") + + +def _ensure_size(content: bytes, *, limit: int, label: str) -> None: + if len(content) > limit: + raise ValueError(f"{label} content is too large.") + + +def _is_multimodal(media_type: str) -> bool: + return media_type in multimodal_mime_types() + + +def _filename(part: Any) -> str: + return os.path.basename(str(getattr(part, "filename", "") or "")) + + +def _binary_data_part_to_manifest(part: Any, *, media_type: str) -> str: + data = MessageToDict(part.data, preserving_proto_field_name=False) + if not isinstance(data, dict): + raise ValueError("A2A binary data parts must contain an object.") + encoded = data.get("bytes") or data.get("base64") + if not isinstance(encoded, str): + raise ValueError("A2A binary data parts must include base64 bytes.") + try: + content = base64.b64decode(encoded.encode("ascii"), validate=True) + except (ValueError, UnicodeEncodeError) as exc: + raise ValueError("A2A binary data part bytes must be valid base64.") from exc + _ensure_size(content, limit=MAX_BINARY_INLINE_BYTES, label="A2A binary data part") + filename = str(data.get("filename") or _filename(part) or "inline") + return _multimodal_manifest( + filename=os.path.basename(filename), + media_type=media_type, + content=content, + source="data", + ) + + +def _file_url_part_to_manifest(url: str, *, media_type: str, cwd: Path) -> str: + path = _safe_file_url_path(url, cwd=cwd) + if path.stat().st_size > MAX_BINARY_FILE_BYTES: + raise ValueError("A2A binary file URL part content is too large.") + content = path.read_bytes() + return _multimodal_manifest(filename=path.name, media_type=media_type, content=content, source=path.as_uri()) + + +def _safe_file_url_path(url: str, *, cwd: Path) -> Path: + parsed = urlparse(url) + if parsed.scheme != "file" or parsed.netloc: + raise ValueError("A2A file URL parts must use local file:// URLs.") + + cwd_path = cwd.resolve() + path = Path(unquote(parsed.path)).resolve() + if not is_relative_to(path, cwd_path) or not any(is_relative_to(path, root) for root in allowed_cwd_roots()): + raise ValueError("A2A file URL part is outside the allowed workspace.") + if not path.is_file(): + raise ValueError("A2A file URL part must reference an existing file.") + return path + + +def _multimodal_manifest(*, filename: str, media_type: str, content: bytes, source: str) -> str: + safe_filename = filename if filename and filename == os.path.basename(filename) else "attachment" + return "\n".join( + [ + "A2A multimodal attachment:", + f"- filename={safe_filename}", + f"- mediaType={media_type}", + f"- byteSize={len(content)}", + f"- sha256={hashlib.sha256(content).hexdigest()}", + f"- source={source}", + ] + ) + + +def _has_field(message: Any, field: str) -> bool: + try: + return bool(message.HasField(field)) + except ValueError: + return False diff --git a/src/iac_code/a2a/persistence.py b/src/iac_code/a2a/persistence.py new file mode 100644 index 00000000..b8b56c29 --- /dev/null +++ b/src/iac_code/a2a/persistence.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import json +import time +from collections.abc import Mapping +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import cast + +from iac_code.a2a.types import validate_protocol_id + +_INTERRUPTED_RESTORE_STATES = {"submitted", "working", "auth-required"} + + +@dataclass(frozen=True) +class A2ATaskSnapshot: + task_id: str + context_id: str + state: str + output_text: list[str] = field(default_factory=list) + status_message: str = "" + updated_at: float = field(default_factory=time.time) + + +@dataclass(frozen=True) +class A2AContextSnapshot: + context_id: str + session_id: str + cwd: str + active_task_id: str | None = None + updated_at: float = field(default_factory=time.time) + + +@dataclass(frozen=True) +class A2ARouteSnapshot: + name: str + url: str + skills: list[str] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + + +class A2APersistenceStore: + def __init__(self, root: str | Path) -> None: + self.root = Path(root) + self.tasks_dir = self.root / "tasks" + self.contexts_dir = self.root / "contexts" + self.routes_path = self.root / "routes.json" + + def save_task(self, snapshot: A2ATaskSnapshot) -> None: + task_id = validate_protocol_id(snapshot.task_id) + self.tasks_dir.mkdir(parents=True, exist_ok=True) + self._write_json(self.tasks_dir / f"{task_id}.json", asdict(snapshot)) + + def load_task(self, task_id: str) -> A2ATaskSnapshot | None: + data = self._read_json(self.tasks_dir / f"{validate_protocol_id(task_id)}.json") + if data is None: + return None + return self._task_from_dict(data) + + def restore_task(self, task_id: str) -> A2ATaskSnapshot | None: + snapshot = self.load_task(task_id) + if snapshot is None: + return None + if snapshot.state in _INTERRUPTED_RESTORE_STATES: + interrupted = A2ATaskSnapshot( + task_id=snapshot.task_id, + context_id=snapshot.context_id, + state="interrupted", + output_text=snapshot.output_text, + status_message="Task was interrupted by process exit and cannot be revived automatically.", + ) + self.save_task(interrupted) + return interrupted + return snapshot + + def list_tasks(self) -> list[A2ATaskSnapshot]: + if not self.tasks_dir.exists(): + return [] + snapshots: list[A2ATaskSnapshot] = [] + for path in sorted(self.tasks_dir.glob("*.json")): + data = self._read_json(path) + if data is None: + continue + snapshot = self._task_from_dict(data) + if snapshot is not None: + snapshots.append(snapshot) + return snapshots + + def save_context(self, snapshot: A2AContextSnapshot) -> None: + context_id = validate_protocol_id(snapshot.context_id) + self.contexts_dir.mkdir(parents=True, exist_ok=True) + self._write_json(self.contexts_dir / f"{context_id}.json", asdict(snapshot)) + + def load_context(self, context_id: str) -> A2AContextSnapshot | None: + data = self._read_json(self.contexts_dir / f"{validate_protocol_id(context_id)}.json") + if data is None: + return None + return self._context_from_dict(data) + + def save_routes(self, routes: list[A2ARouteSnapshot]) -> None: + """Persist a cold-start cache of route metadata. + + Explicit CLI route options and future settings-file configuration are + the source of truth; this cache is only used when a caller asks to save + or reload recently discovered/configured routes. + """ + self.root.mkdir(parents=True, exist_ok=True) + self._write_json(self.routes_path, {"routes": [asdict(route) for route in routes]}) + + def load_routes(self) -> list[A2ARouteSnapshot]: + data = self._read_json(self.routes_path) + raw_routes = data.get("routes") if isinstance(data, dict) else None + if not isinstance(raw_routes, list): + return [] + routes: list[A2ARouteSnapshot] = [] + for raw in raw_routes: + route = self._route_from_dict(raw) + if route is not None: + routes.append(route) + return routes + + @staticmethod + def _task_from_dict(data: dict[str, object]) -> A2ATaskSnapshot | None: + task_id = data.get("task_id") + context_id = data.get("context_id") + state = data.get("state") + if not isinstance(task_id, str) or not isinstance(context_id, str) or not isinstance(state, str): + return None + raw_output_text = data.get("output_text") + output_text = ( + [item for item in raw_output_text if isinstance(item, str)] if isinstance(raw_output_text, list) else [] + ) + status_message = data.get("status_message") + updated_at = data.get("updated_at") + return A2ATaskSnapshot( + task_id=task_id, + context_id=context_id, + state=state, + output_text=output_text, + status_message=status_message if isinstance(status_message, str) else "", + updated_at=float(updated_at) if isinstance(updated_at, (int, float)) else time.time(), + ) + + @staticmethod + def _context_from_dict(data: dict[str, object]) -> A2AContextSnapshot | None: + context_id = data.get("context_id") + session_id = data.get("session_id") + cwd = data.get("cwd") + if not isinstance(context_id, str) or not isinstance(session_id, str) or not isinstance(cwd, str): + return None + active_task_id = data.get("active_task_id") + updated_at = data.get("updated_at") + return A2AContextSnapshot( + context_id=context_id, + session_id=session_id, + cwd=cwd, + active_task_id=active_task_id if isinstance(active_task_id, str) else None, + updated_at=float(updated_at) if isinstance(updated_at, (int, float)) else time.time(), + ) + + @staticmethod + def _route_from_dict(data: object) -> A2ARouteSnapshot | None: + if not isinstance(data, Mapping): + return None + data = cast("Mapping[str, object]", data) + name = data.get("name") + url = data.get("url") + if not isinstance(name, str) or not isinstance(url, str): + return None + raw_skills = data.get("skills") + raw_tags = data.get("tags") + skills = [item for item in raw_skills if isinstance(item, str)] if isinstance(raw_skills, list) else [] + tags = [item for item in raw_tags if isinstance(item, str)] if isinstance(raw_tags, list) else [] + return A2ARouteSnapshot(name=name, url=url, skills=skills, tags=tags) + + @staticmethod + def _write_json(path: Path, data: dict[str, object]) -> None: + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, sort_keys=True), encoding="utf-8") + tmp.replace(path) + + @staticmethod + def _read_json(path: Path) -> dict[str, object] | None: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None diff --git a/src/iac_code/a2a/push.py b/src/iac_code/a2a/push.py new file mode 100644 index 00000000..0b25e9b7 --- /dev/null +++ b/src/iac_code/a2a/push.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import os +import uuid +from dataclasses import asdict, dataclass +from ipaddress import ip_address +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import httpx +from a2a.server.context import ServerCallContext +from a2a.server.owner_resolver import resolve_user_scope +from a2a.server.tasks.push_notification_config_store import PushNotificationConfigStore +from a2a.server.tasks.push_notification_sender import PushNotificationEvent, PushNotificationSender +from a2a.types import TaskPushNotificationConfig +from a2a.utils.proto_utils import to_stream_response +from google.protobuf.json_format import MessageToDict, ParseDict + +from iac_code.a2a.metrics import A2AMetrics, NoOpA2AMetrics +from iac_code.a2a.persistence import A2APersistenceStore +from iac_code.a2a.push_queue import A2APushJob, A2APushQueue, LocalFileA2APushQueue +from iac_code.a2a.push_secrets import A2APushSecretKeyring +from iac_code.a2a.types import validate_protocol_id + + +class InvalidPushNotificationConfigError(ValueError): + pass + + +@dataclass(frozen=True) +class A2APushConfig: + task_id: str + callback_url: str + + def __post_init__(self) -> None: + validate_push_callback_url(self.callback_url) + + +def validate_push_callback_url(url: str) -> str: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise InvalidPushNotificationConfigError("A2A push callback URL must be http or https") + host = parsed.hostname.lower() + if host == "localhost" or host.endswith(".localhost"): + raise InvalidPushNotificationConfigError("A2A push callback URL must not target private or local hosts") + try: + address = ip_address(host) + except ValueError: + return url + if ( + address.is_private + or address.is_loopback + or address.is_link_local + or address.is_multicast + or address.is_reserved + or address.is_unspecified + ): + raise InvalidPushNotificationConfigError("A2A push callback URL must not target private or local hosts") + return url + + +class A2APushConfigStore(PushNotificationConfigStore): + def __init__( + self, + *, + persistence: A2APersistenceStore, + secret_keyring: A2APushSecretKeyring | None = None, + ) -> None: + self._root = Path(persistence.root) / "push_configs" + self._secret_keyring = secret_keyring or A2APushSecretKeyring(Path(persistence.root) / "push_keys.json") + self._root.mkdir(parents=True, exist_ok=True) + _chmod_private(self._root, directory=True) + + async def set_info( + self, + task_id: str, + notification_config: TaskPushNotificationConfig, + context: ServerCallContext, + ) -> None: + task_id = validate_protocol_id(task_id) + if not notification_config.url: + return + validate_push_callback_url(notification_config.url) + config = TaskPushNotificationConfig() + config.CopyFrom(notification_config) + if not config.id: + config.id = task_id + config.id = validate_protocol_id(config.id) + config.task_id = task_id + + path = self._config_path(_owner(context), task_id, config.id) + path.parent.mkdir(parents=True, exist_ok=True) + _chmod_private(path.parent, directory=True) + data = self._config_to_storage(config) + _write_json_atomic(path, data) + + async def get_info(self, task_id: str, context: ServerCallContext) -> list[TaskPushNotificationConfig]: + return self._load_configs_for_owner(_owner(context), validate_protocol_id(task_id)) + + async def get_info_for_dispatch(self, task_id: str) -> list[TaskPushNotificationConfig]: + task_id = validate_protocol_id(task_id) + configs: list[TaskPushNotificationConfig] = [] + if not self._root.exists(): + return configs + for owner_dir in self._root.iterdir(): + if owner_dir.is_dir(): + configs.extend(self._load_configs_for_owner(owner_dir.name, task_id, owner_is_hashed=True)) + return configs + + async def resolve_headers_for_dispatch(self, task_id: str, config_id: str) -> dict[str, str]: + task_id = validate_protocol_id(task_id) + config_id = validate_protocol_id(config_id) + for config in await self.get_info_for_dispatch(task_id): + if config.id == config_id: + return _notification_headers(config) + return {} + + async def delete_info(self, task_id: str, context: ServerCallContext, config_id: str | None = None) -> None: + owner = _owner(context) + task_id = validate_protocol_id(task_id) + if config_id: + path = self._config_path(owner, task_id, validate_protocol_id(config_id)) + path.unlink(missing_ok=True) + return + task_dir = self._owner_dir(owner) / task_id + if not task_dir.exists(): + return + for path in task_dir.glob("*.json"): + path.unlink(missing_ok=True) + + def _load_configs_for_owner( + self, owner: str, task_id: str, *, owner_is_hashed: bool = False + ) -> list[TaskPushNotificationConfig]: + owner_dir = self._root / owner if owner_is_hashed else self._owner_dir(owner) + task_dir = owner_dir / task_id + if not task_dir.exists(): + return [] + configs: list[TaskPushNotificationConfig] = [] + for path in sorted(task_dir.glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + config = TaskPushNotificationConfig() + ParseDict(self._config_from_storage(data), config, ignore_unknown_fields=True) + except (OSError, json.JSONDecodeError, TypeError, ValueError): + continue + configs.append(config) + return configs + + def _config_path(self, owner: str, task_id: str, config_id: str) -> Path: + return self._owner_dir(owner) / task_id / f"{config_id}.json" + + def _owner_dir(self, owner: str) -> Path: + return self._root / hashlib.sha256(owner.encode("utf-8")).hexdigest() + + def _config_to_storage(self, config: TaskPushNotificationConfig) -> dict[str, Any]: + data = MessageToDict(config) + encrypted_fields: dict[str, dict[str, str]] = {} + token = data.pop("token", "") + if token: + encrypted_fields["token"] = self._secret_keyring.encrypt(str(token)) + authentication = data.get("authentication") + if isinstance(authentication, dict): + credentials = authentication.pop("credentials", "") + if credentials: + encrypted_fields["authentication.credentials"] = self._secret_keyring.encrypt(str(credentials)) + if encrypted_fields: + data["iacCodeEncryptedFields"] = {"version": 1, "fields": encrypted_fields} + return data + + def _config_from_storage(self, data: dict[str, Any]) -> dict[str, Any]: + data = dict(data) + encrypted = data.pop("iacCodeEncryptedFields", None) + if not isinstance(encrypted, dict): + return data + fields = encrypted.get("fields") + if not isinstance(fields, dict): + return data + token = fields.get("token") + if isinstance(token, dict): + data["token"] = self._secret_keyring.decrypt(token) + credentials = fields.get("authentication.credentials") + if isinstance(credentials, dict): + authentication = dict(data.get("authentication") or {}) + authentication["credentials"] = self._secret_keyring.decrypt(credentials) + data["authentication"] = authentication + return data + + +class A2APushSender(PushNotificationSender): + def __init__( + self, + *, + config_store: PushNotificationConfigStore, + queue: A2APushQueue | None = None, + metrics: A2AMetrics | None = None, + persistence: A2APersistenceStore | None = None, + **_: Any, + ) -> None: + self._config_store = config_store + if queue is None: + if persistence is None: + raise ValueError("A2APushSender requires a push queue.") + queue = LocalFileA2APushQueue( + Path(persistence.root) / "push_queue", + secret_keyring=getattr(config_store, "_secret_keyring", None), + ) + self._queue = queue + self._metrics = metrics or NoOpA2AMetrics() + + async def send_notification(self, task_id: str, event: PushNotificationEvent) -> None: + configs = await self._config_store.get_info_for_dispatch(validate_protocol_id(task_id)) + payload = MessageToDict(to_stream_response(event), preserving_proto_field_name=False) + for config in configs: + await self._queue.enqueue( + A2APushJob( + task_id=task_id, + config_id=config.id or task_id, + url=validate_push_callback_url(config.url), + payload=payload, + ) + ) + self._metrics.record_push_enqueued() + + async def aclose(self) -> None: + return None + + +class A2APushNotifier: + def __init__( + self, + *, + persistence: A2APersistenceStore, + http_client: Any | None = None, + max_attempts: int = 3, + retry_delay_seconds: float = 0.25, + ) -> None: + self._persistence = persistence + self._owns_http_client = http_client is None + self._http_client = http_client or httpx.AsyncClient() + self._push_dir = Path(persistence.root) / "push" + self._max_attempts = max(1, max_attempts) + self._retry_delay_seconds = max(0.0, retry_delay_seconds) + + def save_config(self, config: A2APushConfig) -> None: + self._push_dir.mkdir(parents=True, exist_ok=True) + _write_json_atomic(self._push_dir / f"{config.task_id}.json", asdict(config)) + + def load_config(self, task_id: str) -> A2APushConfig | None: + path = self._push_dir / f"{task_id}.json" + try: + data = json.loads(path.read_text(encoding="utf-8")) + return A2APushConfig(**data) + except (OSError, json.JSONDecodeError, TypeError, ValueError): + return None + + async def notify_task_state(self, *, task_id: str, context_id: str, state: str) -> bool: + config = self.load_config(task_id) + if config is None: + return False + payload = {"taskId": task_id, "contextId": context_id, "state": state} + for attempt in range(self._max_attempts): + try: + response = await self._http_client.post(config.callback_url, json=payload, timeout=5.0) + response.raise_for_status() + return True + except Exception: + if attempt == self._max_attempts - 1: + raise + if self._retry_delay_seconds: + await asyncio.sleep(self._retry_delay_seconds) + return False + + async def aclose(self) -> None: + if not self._owns_http_client: + return + close = getattr(self._http_client, "aclose", None) + if close is not None: + await close() + + +def _notification_headers(config: TaskPushNotificationConfig) -> dict[str, str]: + headers: dict[str, str] = {} + if config.token: + headers["X-A2A-Notification-Token"] = config.token + if config.HasField("authentication"): + scheme = config.authentication.scheme.lower() + credentials = config.authentication.credentials + if scheme == "bearer" and credentials: + headers["Authorization"] = f"Bearer {credentials}" + elif scheme == "basic" and credentials: + encoded = base64.b64encode(credentials.encode("utf-8")).decode("ascii") + headers["Authorization"] = f"Basic {encoded}" + elif scheme and credentials: + headers["Authorization"] = f"{config.authentication.scheme} {credentials}" + return headers + + +def _owner(context: ServerCallContext) -> str: + return resolve_user_scope(context) + + +def _chmod_private(path: Path, *, directory: bool) -> None: + try: + os.chmod(path, 0o700 if directory else 0o600) + except OSError: + return + + +def _write_json_atomic(path: Path, data: dict[str, Any]) -> None: + tmp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + try: + tmp_path.write_text(json.dumps(data, ensure_ascii=False, sort_keys=True), encoding="utf-8") + _chmod_private(tmp_path, directory=False) + os.replace(tmp_path, path) + _chmod_private(path, directory=False) + finally: + tmp_path.unlink(missing_ok=True) diff --git a/src/iac_code/a2a/push_queue.py b/src/iac_code/a2a/push_queue.py new file mode 100644 index 00000000..58d586d3 --- /dev/null +++ b/src/iac_code/a2a/push_queue.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import inspect +import json +import os +import random +import socket +import time +import uuid +from dataclasses import dataclass, field, replace +from importlib import import_module +from pathlib import Path +from typing import Any, Protocol + +from iac_code.a2a.push_secrets import A2APushSecretKeyring + +_REDACTED_HEADERS = {"authorization", "x-a2a-notification-token", "x-api-key", "api-key"} +_ENCRYPTED_JOB_FIELD = "iacCodeEncryptedPushJob" + + +@dataclass(frozen=True) +class A2APushJob: + task_id: str + config_id: str + url: str + payload: dict[str, Any] + headers: dict[str, str] = field(default_factory=dict) + job_id: str = "" + attempt: int = 0 + next_attempt_at: float = 0.0 + last_error: str = "" + + def __post_init__(self) -> None: + if not self.job_id: + object.__setattr__(self, "job_id", uuid.uuid4().hex) + + def with_attempt(self, *, attempt: int, next_attempt_at: float | None = None, last_error: str = "") -> A2APushJob: + return replace( + self, + attempt=attempt, + next_attempt_at=self.next_attempt_at if next_attempt_at is None else next_attempt_at, + last_error=last_error, + ) + + def to_dict(self) -> dict[str, Any]: + return { + "jobId": self.job_id, + "taskId": self.task_id, + "configId": self.config_id, + "url": self.url, + "payload": self.payload, + "attempt": self.attempt, + "nextAttemptAt": self.next_attempt_at, + "lastError": self.last_error, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> A2APushJob: + return cls( + job_id=str(data["jobId"]), + task_id=str(data["taskId"]), + config_id=str(data["configId"]), + url=str(data["url"]), + payload=dict(data["payload"]), + headers={str(key): str(value) for key, value in dict(data.get("headers") or {}).items()}, + attempt=int(data.get("attempt") or 0), + next_attempt_at=float(data.get("nextAttemptAt") or 0.0), + last_error=str(data.get("lastError") or ""), + ) + + +class A2APushQueue(Protocol): + async def enqueue(self, job: A2APushJob) -> None: ... + + async def claim(self, *, now: float | None = None) -> A2APushJob | None: ... + + async def ack(self, job_id: str) -> None: ... + + async def retry(self, job: A2APushJob) -> None: ... + + async def dead_letter(self, job: A2APushJob) -> None: ... + + +@dataclass(frozen=True) +class A2APushRetryPolicy: + initial_delay_seconds: float = 1.0 + max_delay_seconds: float = 60.0 + jitter_ratio: float = 0.2 + max_attempts: int = 5 + + def delay_for_attempt(self, attempt: int) -> float: + base = min(self.max_delay_seconds, self.initial_delay_seconds * (2 ** max(0, attempt - 1))) + if self.jitter_ratio <= 0: + return base + jitter = base * self.jitter_ratio + return max(0.0, base + random.uniform(-jitter, jitter)) + + +def default_redis_push_consumer_name() -> str: + return f"{socket.gethostname()}-{os.getpid()}-{uuid.uuid4().hex[:12]}" + + +def require_redis_asyncio() -> Any: + try: + return import_module("redis.asyncio") + except ModuleNotFoundError as exc: + from iac_code.a2a.transports.base import A2ATransportDependencyError + + raise A2ATransportDependencyError( + "Redis-backed A2A push delivery requires optional dependencies. Install iac-code[a2a-redis]." + ) from exc + + +class LocalFileA2APushQueue: + def __init__( + self, + root: str | Path, + *, + inflight_timeout_seconds: float = 300.0, + secret_keyring: A2APushSecretKeyring | None = None, + ) -> None: + self.root = Path(root) + self._inflight_timeout_seconds = inflight_timeout_seconds + self._secret_keyring = secret_keyring + self.pending_dir = self.root / "pending" + self.inflight_dir = self.root / "inflight" + self.dead_dir = self.root / "dead" + for path in (self.pending_dir, self.inflight_dir, self.dead_dir): + path.mkdir(parents=True, exist_ok=True) + self._chmod_private(path, directory=True) + + async def enqueue(self, job: A2APushJob) -> None: + self._write(self.pending_dir / f"{job.job_id}.json", job) + + async def claim(self, *, now: float | None = None) -> A2APushJob | None: + current = time.time() if now is None else now + self._recover_expired_inflight(current) + for path in sorted(self.pending_dir.glob("*.json")): + job = self._read(path) + if job.next_attempt_at > current: + continue + target = self.inflight_dir / path.name + leased = job.with_attempt( + attempt=job.attempt, + next_attempt_at=current + self._inflight_timeout_seconds, + last_error=job.last_error, + ) + path.replace(target) + self._write(target, leased) + return self._read(target) + return None + + async def ack(self, job_id: str) -> None: + (self.inflight_dir / f"{job_id}.json").unlink(missing_ok=True) + + async def retry(self, job: A2APushJob) -> None: + (self.inflight_dir / f"{job.job_id}.json").unlink(missing_ok=True) + self._write(self.pending_dir / f"{job.job_id}.json", job) + + async def dead_letter(self, job: A2APushJob) -> None: + (self.inflight_dir / f"{job.job_id}.json").unlink(missing_ok=True) + self._write(self.dead_dir / f"{job.job_id}.json", job) + + def _write(self, path: Path, job: A2APushJob) -> None: + path.write_text(_serialize_push_job(job, secret_keyring=self._secret_keyring), encoding="utf-8") + self._chmod_private(path, directory=False) + + def _read(self, path: Path) -> A2APushJob: + return _deserialize_push_job(path.read_text(encoding="utf-8"), secret_keyring=self._secret_keyring) + + def _recover_expired_inflight(self, now: float) -> None: + for path in sorted(self.inflight_dir.glob("*.json")): + job = self._read(path) + if job.next_attempt_at > now: + continue + target = self.pending_dir / path.name + path.replace(target) + self._write( + target, + job.with_attempt(attempt=job.attempt, next_attempt_at=now, last_error="Delivery lease expired."), + ) + + def _chmod_private(self, path: Path, *, directory: bool) -> None: + try: + os.chmod(path, 0o700 if directory else 0o600) + except OSError: + return + + +class RedisStreamsA2APushQueue: + def __init__( + self, + *, + redis: Any, + stream: str = "iac-code:a2a:push", + retry_key: str = "iac-code:a2a:push:retry", + dead_stream: str = "iac-code:a2a:push:dead", + consumer_group: str = "iac-code-push", + consumer_name: str = "", + lease_timeout_ms: int = 300_000, + owns_redis: bool = False, + secret_keyring: A2APushSecretKeyring | None = None, + ) -> None: + self._redis = redis + self._stream = stream + self._retry_key = retry_key + self._dead_stream = dead_stream + self._consumer_group = consumer_group + self._consumer_name = consumer_name or default_redis_push_consumer_name() + self._lease_timeout_ms = lease_timeout_ms + self._owns_redis = owns_redis + self._secret_keyring = secret_keyring + self._group_ready = False + self._claimed_entries: dict[str, str] = {} + + async def enqueue(self, job: A2APushJob) -> None: + await self._ensure_group() + await self._redis.xadd(self._stream, {"job": self._serialize(job)}) + + async def claim(self, *, now: float | None = None) -> A2APushJob | None: + await self._ensure_group() + current = time.time() if now is None else now + await self._promote_due_retries(current) + reclaimed = await self._claim_expired() + if reclaimed is not None: + return reclaimed + rows = await self._redis.xreadgroup( + self._consumer_group, + self._consumer_name, + {self._stream: ">"}, + count=1, + block=0, + ) + return self._job_from_rows(rows) + + async def ack(self, job_id: str) -> None: + entry_id = self._claimed_entries.pop(job_id, None) + if entry_id is not None: + await self._redis.xack(self._stream, self._consumer_group, entry_id) + + async def retry(self, job: A2APushJob) -> None: + await self._redis.zadd(self._retry_key, {self._serialize(job): job.next_attempt_at}) + await self.ack(job.job_id) + + async def dead_letter(self, job: A2APushJob) -> None: + await self._redis.xadd(self._dead_stream, {"job": self._serialize(job)}) + await self.ack(job.job_id) + + async def aclose(self) -> None: + if not self._owns_redis: + return + close = getattr(self._redis, "aclose", None) + if close is None: + return + result = close() + if inspect.isawaitable(result): + await result + + async def _ensure_group(self) -> None: + if self._group_ready: + return + try: + await self._redis.xgroup_create(self._stream, self._consumer_group, id="0-0", mkstream=True) + except Exception as exc: + if "BUSYGROUP" not in str(exc): + raise + self._group_ready = True + + async def _promote_due_retries(self, now: float) -> None: + members = await self._redis.zrangebyscore(self._retry_key, "-inf", now, start=0, num=10) + for member in members: + encoded = _decode_redis_field(member) + await self._redis.xadd(self._stream, {"job": encoded}) + await self._redis.zrem(self._retry_key, member) + + async def _claim_expired(self) -> A2APushJob | None: + xautoclaim = getattr(self._redis, "xautoclaim", None) + if xautoclaim is None: + return None + result = await xautoclaim( + self._stream, + self._consumer_group, + self._consumer_name, + min_idle_time=self._lease_timeout_ms, + start_id="0-0", + count=1, + ) + entries = result[1] if isinstance(result, (list, tuple)) and len(result) >= 2 else [] + return self._job_from_entries(entries) + + def _job_from_rows(self, rows: Any) -> A2APushJob | None: + for _stream, entries in rows or []: + job = self._job_from_entries(entries) + if job is not None: + return job + return None + + def _job_from_entries(self, entries: Any) -> A2APushJob | None: + for entry_id, fields in entries or []: + encoded = _field_value(fields, "job") + if encoded is None: + continue + job = _deserialize_push_job(_decode_redis_field(encoded), secret_keyring=self._secret_keyring) + self._claimed_entries[job.job_id] = _decode_redis_field(entry_id) + return job + return None + + def _serialize(self, job: A2APushJob) -> str: + return _serialize_push_job(job, secret_keyring=self._secret_keyring) + + +def redact_push_headers(headers: dict[str, str]) -> dict[str, str]: + return {key: "[redacted]" if key.lower() in _REDACTED_HEADERS else value for key, value in headers.items()} + + +def _decode_redis_field(value: Any) -> str: + if isinstance(value, bytes): + return value.decode("utf-8") + return str(value) + + +def _field_value(fields: Any, name: str) -> Any: + return fields.get(name, fields.get(name.encode("utf-8"))) if isinstance(fields, dict) else None + + +def _serialize_push_job(job: A2APushJob, *, secret_keyring: A2APushSecretKeyring | None) -> str: + payload = json.dumps(job.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + if secret_keyring is None: + return payload + return json.dumps( + {_ENCRYPTED_JOB_FIELD: {"version": 1, **secret_keyring.encrypt(payload)}}, + sort_keys=True, + separators=(",", ":"), + ) + + +def _deserialize_push_job(value: str, *, secret_keyring: A2APushSecretKeyring | None) -> A2APushJob: + data = json.loads(value) + encrypted = data.get(_ENCRYPTED_JOB_FIELD) if isinstance(data, dict) else None + if encrypted is None: + return A2APushJob.from_dict(data) + if secret_keyring is None: + raise ValueError("Encrypted A2A push job requires a configured secret keyring") + decrypted = secret_keyring.decrypt(dict(encrypted)) + return A2APushJob.from_dict(json.loads(decrypted)) diff --git a/src/iac_code/a2a/push_secrets.py b/src/iac_code/a2a/push_secrets.py new file mode 100644 index 00000000..06072291 --- /dev/null +++ b/src/iac_code/a2a/push_secrets.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json +import os +import time +import uuid +from pathlib import Path +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken + + +class A2APushSecretError(ValueError): + pass + + +class A2APushSecretKeyring: + def __init__(self, path: str | Path) -> None: + self._path = Path(path) + self._loaded = False + self._env_managed = False + self._active_key_id = "" + self._keys: dict[str, str] = {} + + @property + def active_key_id(self) -> str: + self._ensure_loaded() + return self._active_key_id + + def encrypt(self, value: str) -> dict[str, str]: + self._ensure_loaded() + key_id = self._active_key_id + token = Fernet(self._keys[key_id].encode("ascii")).encrypt(value.encode("utf-8")).decode("ascii") + return {"keyId": key_id, "ciphertext": token} + + def decrypt(self, envelope: dict[str, Any]) -> str: + self._ensure_loaded() + key_id = str(envelope.get("keyId") or "") + ciphertext = str(envelope.get("ciphertext") or "") + key = self._keys.get(key_id) + if not key: + raise A2APushSecretError(f"A2A push secret encryption key is not available: {key_id}") + try: + return Fernet(key.encode("ascii")).decrypt(ciphertext.encode("ascii")).decode("utf-8") + except (InvalidToken, UnicodeDecodeError) as exc: + raise A2APushSecretError("A2A push secret ciphertext could not be decrypted") from exc + + def rotate(self, key_id: str | None = None) -> str: + self._ensure_loaded() + if self._env_managed: + raise A2APushSecretError("A2A push secret keyring is environment-managed and cannot be rotated locally") + key_id = key_id or _new_key_id() + if key_id in self._keys: + raise A2APushSecretError(f"A2A push secret encryption key already exists: {key_id}") + self._keys[key_id] = Fernet.generate_key().decode("ascii") + self._active_key_id = key_id + self._write() + return key_id + + def _ensure_loaded(self) -> None: + if self._loaded: + return + env_keyring = os.environ.get("IAC_CODE_A2A_PUSH_KEYRING") + if env_keyring: + self._load_data(json.loads(env_keyring)) + self._env_managed = True + self._loaded = True + return + if self._path.exists(): + self._load_data(json.loads(self._path.read_text(encoding="utf-8"))) + self._loaded = True + return + self._active_key_id = _new_key_id() + self._keys = {self._active_key_id: Fernet.generate_key().decode("ascii")} + self._loaded = True + self._write() + + def _load_data(self, data: dict[str, Any]) -> None: + keys = data.get("keys") + if not isinstance(keys, list): + raise A2APushSecretError("A2A push secret keyring is malformed") + self._keys = { + str(item["id"]): str(item["fernetKey"]) + for item in keys + if isinstance(item, dict) and item.get("id") and item.get("fernetKey") + } + self._active_key_id = str(data.get("activeKeyId") or "") + if not self._active_key_id or self._active_key_id not in self._keys: + raise A2APushSecretError("A2A push secret keyring does not contain its active key") + + def _write(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + _chmod_private(self._path.parent, directory=True) + data = { + "activeKeyId": self._active_key_id, + "keys": [ + {"id": key_id, "fernetKey": key, "createdAt": int(time.time())} + for key_id, key in sorted(self._keys.items()) + ], + } + self._path.write_text(json.dumps(data, sort_keys=True), encoding="utf-8") + _chmod_private(self._path, directory=False) + + +def _new_key_id() -> str: + return f"push-{int(time.time())}-{uuid.uuid4().hex[:12]}" + + +def _chmod_private(path: Path, *, directory: bool) -> None: + try: + os.chmod(path, 0o700 if directory else 0o600) + except OSError: + return diff --git a/src/iac_code/a2a/push_worker.py b/src/iac_code/a2a/push_worker.py new file mode 100644 index 00000000..1a09f7c1 --- /dev/null +++ b/src/iac_code/a2a/push_worker.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import asyncio +import inspect +import logging +import socket +import time +from collections.abc import Awaitable +from ipaddress import ip_address +from typing import Any, Callable, Protocol, TypeAlias, cast +from urllib.parse import urlparse, urlunparse + +import httpx + +from iac_code.a2a.metrics import A2AMetrics, NoOpA2AMetrics +from iac_code.a2a.push import InvalidPushNotificationConfigError, validate_push_callback_url +from iac_code.a2a.push_queue import A2APushJob, A2APushQueue, A2APushRetryPolicy, redact_push_headers + +logger = logging.getLogger(__name__) + + +class A2APushAlertSink(Protocol): + async def dead_lettered(self, job: A2APushJob) -> None: ... + + +class A2APushCallbackConnector(Protocol): + async def post(self, url: str, *, json: dict[str, Any], headers: dict[str, str], timeout: float) -> Any: ... + + +A2APushHeaderResolver: TypeAlias = Callable[[str, str], "dict[str, str] | Awaitable[dict[str, str]]"] + + +class LoggingA2APushAlertSink: + async def dead_lettered(self, job: A2APushJob) -> None: + logger.error( + "A2A push notification dead-lettered", + extra={ + "task_id": job.task_id, + "config_id": job.config_id, + "url": job.url, + "attempt": job.attempt, + "last_error": job.last_error, + "headers": redact_push_headers(job.headers), + }, + ) + + +class DefaultA2APushCallbackConnector: + def __init__(self, *, http_client: Any) -> None: + self._http_client = http_client + + async def post(self, url: str, *, json: dict[str, Any], headers: dict[str, str], timeout: float) -> Any: + validate_push_callback_url(url) + validation = _validate_resolved_callback_host(url) + addresses = await validation if inspect.isawaitable(validation) else validation + if addresses is None: + raise InvalidPushNotificationConfigError("A2A push callback delivery requires verified callback addresses") + if not addresses: + raise InvalidPushNotificationConfigError("A2A push callback URL host did not resolve to any addresses") + pinned_url, pinned_headers, extensions = _pinned_callback_request(url, addresses[0], headers) + async with httpx.AsyncClient(limits=httpx.Limits(max_keepalive_connections=0)) as client: + return await client.post( + pinned_url, + json=json, + headers=pinned_headers, + timeout=timeout, + extensions=extensions, + ) + + +class A2APushDeliveryWorker: + def __init__( + self, + *, + queue: A2APushQueue, + http_client: Any | None = None, + connector: A2APushCallbackConnector | None = None, + metrics: A2AMetrics | None = None, + retry_policy: A2APushRetryPolicy | None = None, + alert_sink: A2APushAlertSink | None = None, + header_resolver: A2APushHeaderResolver | None = None, + clock: Callable[[], float] = time.time, + timeout_seconds: float = 5.0, + ) -> None: + self._queue = queue + self._owns_http_client = http_client is None and connector is None + self._http_client = http_client or (httpx.AsyncClient() if connector is None else None) + self._connector = connector or DefaultA2APushCallbackConnector(http_client=self._http_client) + self._metrics = metrics or NoOpA2AMetrics() + self._retry_policy = retry_policy or A2APushRetryPolicy() + self._alert_sink = alert_sink or LoggingA2APushAlertSink() + self._header_resolver = header_resolver + self._clock = clock + self._timeout_seconds = timeout_seconds + + async def run_once(self) -> bool: + job = await self._queue.claim(now=self._clock()) + if job is None: + return False + + started = self._clock() + try: + headers = await self._resolve_headers(job) + response = await self._connector.post( + job.url, + json=job.payload, + headers=headers, + timeout=self._timeout_seconds, + ) + if 200 <= response.status_code < 300: + try: + await self._queue.ack(job.job_id) + except Exception: + logger.exception( + "A2A push notification delivered but queue ack failed; lease recovery will retry ownership", + extra={"task_id": job.task_id, "config_id": job.config_id, "job_id": job.job_id}, + ) + return False + self._metrics.record_push_delivered(duration_ms=(self._clock() - started) * 1000) + return True + raise RuntimeError(f"HTTP {response.status_code}") + except Exception as exc: + await self._handle_failure(job, exc) + return False + + async def serve_forever(self, *, idle_sleep_seconds: float = 0.25) -> None: + while True: + processed = await self.run_once() + if not processed: + await asyncio.sleep(idle_sleep_seconds) + + async def aclose(self) -> None: + if not self._owns_http_client: + return + close = getattr(self._http_client, "aclose", None) + if close is not None: + await close() + + async def _handle_failure(self, job: A2APushJob, exc: Exception) -> None: + next_attempt = job.attempt + 1 + transient = _is_transient_delivery_error(exc) + if transient: + self._metrics.record_push_transient_failure() + else: + self._metrics.record_push_permanent_failure() + + if transient and next_attempt < self._retry_policy.max_attempts: + delay = self._retry_policy.delay_for_attempt(next_attempt) + await self._queue.retry( + job.with_attempt(attempt=next_attempt, next_attempt_at=self._clock() + delay, last_error=str(exc)) + ) + self._metrics.record_push_retry_scheduled() + return + + dead = job.with_attempt(attempt=next_attempt, last_error=str(exc)) + await self._queue.dead_letter(dead) + self._metrics.record_push_dead_lettered() + await self._alert_sink.dead_lettered(dead) + + async def _resolve_headers(self, job: A2APushJob) -> dict[str, str]: + if self._header_resolver is None: + return dict(job.headers) + resolved = self._header_resolver(job.task_id, job.config_id) + if inspect.isawaitable(resolved): + resolved = await resolved + resolved = cast(dict[str, str], resolved) + return dict(resolved) + + +def _is_transient_delivery_error(exc: Exception) -> bool: + text = str(exc) + if isinstance(exc, (httpx.TimeoutException, httpx.TransportError, TimeoutError, OSError)): + return True + return any(f"HTTP {code}" in text for code in (408, 409, 425, 429, 500, 502, 503, 504)) + + +async def _validate_resolved_callback_host(url: str) -> list[str]: + parsed = urlparse(url) + if parsed.hostname is None: + raise InvalidPushNotificationConfigError("A2A push callback URL must include a host") + addresses = await asyncio.to_thread(socket.getaddrinfo, parsed.hostname, parsed.port or 443) + verified: list[str] = [] + for _, _, _, _, sockaddr in addresses: + host = str(sockaddr[0]) + address = ip_address(host) + if ( + address.is_private + or address.is_loopback + or address.is_link_local + or address.is_multicast + or address.is_reserved + or address.is_unspecified + ): + raise InvalidPushNotificationConfigError("A2A push callback URL must not resolve to private or local hosts") + verified.append(host) + if not verified: + raise InvalidPushNotificationConfigError("A2A push callback URL host did not resolve to any addresses") + return verified + + +def _pinned_callback_request( + url: str, + address: str, + headers: dict[str, str], +) -> tuple[str, dict[str, str], dict[str, str]]: + parsed = urlparse(url) + if parsed.hostname is None: + raise InvalidPushNotificationConfigError("A2A push callback URL must include a host") + + host_header = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname + if parsed.port is not None: + host_header = f"{host_header}:{parsed.port}" + + pinned_host = f"[{address}]" if ":" in address else address + netloc = pinned_host + if parsed.port is not None: + netloc = f"{netloc}:{parsed.port}" + + pinned_headers = dict(headers) + pinned_headers["Host"] = host_header + pinned_url = urlunparse(parsed._replace(netloc=netloc)) + return pinned_url, pinned_headers, {"sni_hostname": parsed.hostname} diff --git a/src/iac_code/a2a/router.py b/src/iac_code/a2a/router.py new file mode 100644 index 00000000..33fdd5bd --- /dev/null +++ b/src/iac_code/a2a/router.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field + + +class MissingA2ARouteError(ValueError): + pass + + +class AmbiguousA2ARouteError(ValueError): + pass + + +@dataclass(frozen=True) +class A2ARoute: + name: str + url: str + skills: list[str] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + + +RouteMatcher = Callable[[str, A2ARoute], bool] + + +@dataclass(frozen=True) +class _RoutePromptTerms: + route: A2ARoute + tags: frozenset[str] + names: frozenset[str] + + +def _prompt_words(prompt: str) -> set[str]: + return set(prompt.lower().replace(",", " ").replace(".", " ").split()) + + +def _default_prompt_matcher(prompt: str, route: A2ARoute) -> bool: + """Default prompt matcher for callers that use the standalone function. + + This is a simple keyword overlap strategy — it does NOT perform semantic + or fuzzy matching. For more advanced routing, supply a custom *match_fn* + to :meth:`A2ARouter.resolve`. + """ + prompt_words = _prompt_words(prompt) + tag_match = prompt_words.intersection({tag.lower() for tag in route.tags}) + name_match = prompt_words.intersection({route.name.lower()}) + return bool(tag_match or name_match) + + +class A2ARouter: + def __init__(self, routes: list[A2ARoute]) -> None: + self._routes = routes + self._prompt_terms = [ + _RoutePromptTerms( + route=route, + tags=frozenset(tag.lower() for tag in route.tags), + names=frozenset({route.name.lower()}), + ) + for route in routes + ] + + @property + def route_names(self) -> list[str]: + return [route.name for route in self._routes] + + def resolve( + self, + *, + name: str | None = None, + skill: str | None = None, + prompt: str | None = None, + match_fn: RouteMatcher | None = None, + ) -> A2ARoute: + """Resolve a route by exact name, skill id, or prompt matching. + + Args: + name: Exact route name lookup (highest priority). + skill: Match routes containing this skill id. + prompt: Match routes via keyword overlap or custom *match_fn*. + match_fn: Optional custom matcher ``(prompt, route) -> bool``. + Falls back to simple keyword intersection when not provided. + """ + if name: + for route in self._routes: + if route.name == name: + return route + raise MissingA2ARouteError(f"Unknown A2A route {name!r}. Known routes: {', '.join(self.route_names)}") + + matches: list[A2ARoute] = [] + if skill: + matches = [route for route in self._routes if skill in route.skills] + if not matches and prompt: + if match_fn is None: + prompt_words = _prompt_words(prompt) + matches = [ + terms.route + for terms in self._prompt_terms + if prompt_words.intersection(terms.tags) or prompt_words.intersection(terms.names) + ] + else: + matches = [route for route in self._routes if match_fn(prompt, route)] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise AmbiguousA2ARouteError( + f"Ambiguous A2A route. Candidates: {', '.join(route.name for route in matches)}" + ) + raise MissingA2ARouteError(f"No A2A route matched. Known routes: {', '.join(self.route_names)}") diff --git a/src/iac_code/a2a/signing.py b/src/iac_code/a2a/signing.py new file mode 100644 index 00000000..d7a67763 --- /dev/null +++ b/src/iac_code/a2a/signing.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import base64 +import binascii +import copy +import json +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from a2a.types import AgentCard +from google.protobuf.json_format import MessageToDict, ParseDict, ParseError + +SIGNATURE_ALGORITHM = "HS256" +ASYMMETRIC_SIGNATURE_ALGORITHM = "RS256" +SUPPORTED_SIGNATURE_ALGORITHMS = (SIGNATURE_ALGORITHM, ASYMMETRIC_SIGNATURE_ALGORITHM) + + +@dataclass(frozen=True) +class AgentCardSignature: + valid: bool + reason: str + key_id: str | None = None + detail: str = "" + + @property + def message(self) -> str: + if self.detail: + return f"{self.reason}: {self.detail}" + if self.key_id and self.reason in {"unknown-key", "signature-mismatch"}: + return f"{self.reason}: kid={self.key_id}" + return self.reason + + +def _without_signature(card: dict[str, Any]) -> dict[str, Any]: + data = copy.deepcopy(card) + data.pop("signatures", None) + metadata = data.get("metadata") + if isinstance(metadata, dict): + metadata.pop("iac_code_signature", None) + if not metadata: + data.pop("metadata", None) + return data + + +def canonicalize_agent_card(card: dict[str, Any]) -> bytes: + data = _without_signature(card) + return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +def sign_agent_card_dict(card: dict[str, Any], *, secret: str, key_id: str = "default") -> dict[str, Any]: + from a2a.utils.signing import ProtectedHeader, create_agent_card_signer + + agent_card = _agent_card_from_dict(card) + protected_header: ProtectedHeader = {"alg": SIGNATURE_ALGORITHM, "typ": "JOSE", "kid": key_id, "jku": None} + signer = create_agent_card_signer( + signing_key=secret, + protected_header=protected_header, + ) + signed = signer(agent_card) + return _agent_card_to_dict(signed) + + +def verify_agent_card_dict( + card: dict[str, Any], + *, + secret: str | None = None, + secrets: Mapping[str, str] | None = None, + jwks: Mapping[str, Any] | None = None, + require_signature: bool = False, +) -> AgentCardSignature: + signatures = card.get("signatures") + signature_data = signatures[0] if isinstance(signatures, list) and signatures else None + if not isinstance(signature_data, dict): + reason = "missing-signature" if require_signature else "unsigned" + return AgentCardSignature(valid=not require_signature, reason=reason) + + protected_header = _decode_protected_header(signature_data.get("protected")) + if protected_header is None: + return AgentCardSignature(valid=False, reason="malformed-signature") + algorithm = protected_header.get("alg") + if algorithm not in SUPPORTED_SIGNATURE_ALGORITHMS: + detail = f"alg={algorithm}" if isinstance(algorithm, str) else "alg=" + return AgentCardSignature(valid=False, reason="unsupported-algorithm", detail=detail) + if not isinstance(signature_data.get("signature"), str): + return AgentCardSignature(valid=False, reason="malformed-signature") + + raw_key_id = protected_header.get("kid") + key_id = raw_key_id if isinstance(raw_key_id, str) else None + verification_key = _select_verification_key( + secret=secret, + secrets=secrets, + jwks=jwks, + key_id=key_id, + algorithm=algorithm, + ) + if isinstance(verification_key, AgentCardSignature): + return verification_key + + from a2a.utils.signing import InvalidSignaturesError, create_signature_verifier + + verifier = create_signature_verifier( + key_provider=lambda _kid, _jku: verification_key, + algorithms=[algorithm], + ) + try: + verifier(_agent_card_from_dict(card)) + except InvalidSignaturesError: + return AgentCardSignature(valid=False, reason="signature-mismatch", key_id=key_id) + except (ParseError, TypeError, ValueError): + return AgentCardSignature(valid=False, reason="malformed-signature", key_id=key_id) + return AgentCardSignature(valid=True, reason="valid", key_id=key_id) + + +def agent_card_signature_jwks_url(card: dict[str, Any]) -> str | None: + signatures = card.get("signatures") + signature_data = signatures[0] if isinstance(signatures, list) and signatures else None + if not isinstance(signature_data, dict): + return None + protected_header = _decode_protected_header(signature_data.get("protected")) + if protected_header is None: + return None + jku = protected_header.get("jku") + return jku if isinstance(jku, str) and jku else None + + +def _select_verification_key( + *, + secret: str | None, + secrets: Mapping[str, str] | None, + jwks: Mapping[str, Any] | None, + key_id: str | None, + algorithm: str, +) -> Any | AgentCardSignature: + key_map: dict[str, Any] = {} + if secrets: + key_map.update({str(kid): value for kid, value in secrets.items()}) + key_map.update(_jwks_verification_keys(jwks, algorithm=algorithm)) + + if not key_map: + if secret is None: + return AgentCardSignature(valid=False, reason="missing-key", key_id=key_id) + return secret + + if key_id: + selected = key_map.get(key_id) + if selected is None: + return AgentCardSignature(valid=False, reason="unknown-key", key_id=key_id) + return selected + + if len(key_map) == 1: + return next(iter(key_map.values())) + return AgentCardSignature(valid=False, reason="ambiguous-key", key_id=key_id) + + +def _jwks_verification_keys(jwks: Mapping[str, Any] | None, *, algorithm: str) -> dict[str, Any]: + if not jwks: + return {} + keys = jwks.get("keys") + if not isinstance(keys, list): + return {} + + decoded: dict[str, Any] = {} + for item in keys: + if not isinstance(item, Mapping): + continue + kid = item.get("kid") + if not isinstance(kid, str): + continue + jwk_alg = item.get("alg") + if isinstance(jwk_alg, str) and jwk_alg != algorithm: + continue + if item.get("kty") == "oct": + if algorithm != SIGNATURE_ALGORITHM: + continue + key = item.get("k") + if not isinstance(key, str): + continue + try: + padding = "=" * (-len(key) % 4) + decoded[kid] = base64.urlsafe_b64decode(f"{key}{padding}".encode("ascii")).decode("utf-8") + except (binascii.Error, UnicodeDecodeError, ValueError): + continue + continue + if algorithm == SIGNATURE_ALGORITHM: + continue + try: + from jwt import PyJWK + from jwt.exceptions import PyJWKError + + decoded[kid] = PyJWK.from_dict(dict(item)) + except (PyJWKError, TypeError, ValueError): + continue + return decoded + + +def _agent_card_from_dict(card: dict[str, Any]) -> AgentCard: + agent_card = AgentCard() + ParseDict(card, agent_card, ignore_unknown_fields=True) + return agent_card + + +def _agent_card_to_dict(card: AgentCard) -> dict[str, Any]: + data = MessageToDict(card, preserving_proto_field_name=False) + if not isinstance(data, dict): + raise ValueError("A2A Agent Card must serialize to a JSON object") + return data + + +def _decode_protected_header(value: Any) -> dict[str, Any] | None: + if not isinstance(value, str) or not value: + return None + try: + from jwt.utils import base64url_decode + + header = json.loads(base64url_decode(value.encode("utf-8")).decode("utf-8")) + except (binascii.Error, json.JSONDecodeError, UnicodeDecodeError, ValueError): + return None + return header if isinstance(header, dict) else None diff --git a/src/iac_code/a2a/task_store.py b/src/iac_code/a2a/task_store.py new file mode 100644 index 00000000..d99e7647 --- /dev/null +++ b/src/iac_code/a2a/task_store.py @@ -0,0 +1,310 @@ +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from collections.abc import Callable +from typing import Any + +from a2a.server.context import ServerCallContext +from a2a.server.tasks import TaskStore +from a2a.server.tasks.inmemory_task_store import DEFAULT_LIST_TASKS_PAGE_SIZE, decode_page_token, encode_page_token +from a2a.server.tasks.inmemory_task_store import resolve_user_scope as default_owner_resolver +from a2a.types import ListTasksRequest, ListTasksResponse, Task +from a2a.utils.errors import InvalidParamsError + +from iac_code.a2a.metrics import A2AMetrics, NoOpA2AMetrics +from iac_code.a2a.persistence import A2AContextSnapshot, A2APersistenceStore, A2ATaskSnapshot +from iac_code.a2a.types import A2AContextRecord, A2ATaskRecord, validate_protocol_id + +logger = logging.getLogger(__name__) + + +class A2ATaskStore(TaskStore): + def __init__( + self, + *, + metrics: A2AMetrics | None = None, + idle_timeout_seconds: float = 3600, + cleanup_interval_seconds: float = 300, + persistence: A2APersistenceStore | None = None, + owner_resolver: Callable[[ServerCallContext], str] = default_owner_resolver, + ) -> None: + self._sdk_tasks: dict[str, dict[str, Task]] = {} + self._sdk_tasks_by_context: dict[str, dict[str, set[str]]] = {} + self._tasks: dict[str, A2ATaskRecord] = {} + self._contexts: dict[str, A2AContextRecord] = {} + self._expired_task_tombstones: dict[str, float] = {} + self._metrics = metrics or NoOpA2AMetrics() + self._persistence = persistence + self._idle_timeout_seconds = idle_timeout_seconds + self._cleanup_interval_seconds = cleanup_interval_seconds + self._cleanup_task: asyncio.Task[None] | None = None + self._mutation_lock = asyncio.Lock() + self._owner_resolver = owner_resolver + + async def get(self, task_id: str, context: ServerCallContext | None = None) -> Task | None: + task = self._owner_tasks(context).get(validate_protocol_id(task_id)) + return _copy_task(task) if task is not None else None + + async def save(self, task: Task, context: ServerCallContext | None = None) -> None: + owner = self._owner(context) + task_id = validate_protocol_id(task.id) + owner_tasks = self._sdk_tasks.setdefault(owner, {}) + previous = owner_tasks.get(task_id) + if previous is not None: + self._remove_sdk_task_from_index(owner, task_id, previous.context_id) + owner_tasks[task_id] = _copy_task(task) + self._sdk_tasks_by_context.setdefault(owner, {}).setdefault(task.context_id, set()).add(task_id) + + async def delete(self, task_id: str, context: ServerCallContext | None = None) -> None: + owner = self._owner(context) + task_id = validate_protocol_id(task_id) + async with self._mutation_lock: + owner_tasks = self._owner_tasks(context) + existing = owner_tasks.get(task_id) + if existing is not None: + self._remove_sdk_task_from_index(owner, task_id, existing.context_id) + owner_tasks.pop(task_id, None) + self._tasks.pop(task_id, None) + self._expired_task_tombstones.pop(task_id, None) + + async def list(self, params: ListTasksRequest, context: ServerCallContext | None = None) -> ListTasksResponse: + owner = self._owner(context) + owner_tasks = self._sdk_tasks.get(owner, {}) + if params.context_id: + task_ids = self._sdk_tasks_by_context.get(owner, {}).get(params.context_id, set()) + tasks = [owner_tasks[task_id] for task_id in task_ids if task_id in owner_tasks] + else: + tasks = list(owner_tasks.values()) + + if params.status: + tasks = [task for task in tasks if task.status.state == params.status] + if params.HasField("status_timestamp_after"): + after = params.status_timestamp_after.ToJsonString() + tasks = [ + task + for task in tasks + if task.HasField("status") + and task.status.HasField("timestamp") + and task.status.timestamp.ToJsonString() >= after + ] + + tasks.sort( + key=lambda task: ( + task.status.HasField("timestamp") if task.HasField("status") else False, + task.status.timestamp.ToJsonString() + if task.HasField("status") and task.status.HasField("timestamp") + else "", + task.id, + ), + reverse=True, + ) + + total_size = len(tasks) + start_idx = 0 + if params.page_token: + start_task_id = decode_page_token(params.page_token) + for idx, task in enumerate(tasks): + if task.id == start_task_id: + start_idx = idx + break + else: + raise InvalidParamsError(f"Invalid page token: {params.page_token}") + + page_size = params.page_size or DEFAULT_LIST_TASKS_PAGE_SIZE + end_idx = start_idx + page_size + next_page_token = encode_page_token(tasks[end_idx].id) if end_idx < total_size else None + page = [_project_task(task, include_artifacts=params.include_artifacts) for task in tasks[start_idx:end_idx]] + return ListTasksResponse( + tasks=page, + next_page_token=next_page_token, + page_size=page_size, + total_size=total_size, + ) + + async def get_or_create_task(self, *, task_id: str | None, context_id: str) -> A2ATaskRecord: + context_id = validate_protocol_id(context_id) + task_id = validate_protocol_id(task_id or str(uuid.uuid4())) + async with self._mutation_lock: + if task_id in self._expired_task_tombstones: + raise ValueError("A2A task expired") + record = self._tasks.get(task_id) + if record is None: + record = A2ATaskRecord(task_id=task_id, context_id=context_id) + self._tasks[task_id] = record + self._metrics.record_task_created() + elif record.context_id != context_id: + raise ValueError("Task belongs to a different context") + record.touch() + self._mirror_task(record) + return record + + async def get_or_create_context( + self, + *, + context_id: str, + cwd: str, + runtime_factory: Callable[[str], Any], + ) -> A2AContextRecord: + context_id = validate_protocol_id(context_id) + async with self._mutation_lock: + if context_id in self._contexts: + record = self._contexts[context_id] + if record.expired: + raise ValueError("A2A context expired") + if record.cwd != cwd: + raise ValueError("A2A context belongs to a different workspace") + record.touch() + self._mirror_context(record) + return record + + session_id = str(uuid.uuid4()) + record = A2AContextRecord( + context_id=context_id, + session_id=session_id, + cwd=cwd, + runtime=runtime_factory(session_id), + lock=asyncio.Lock(), + ) + self._contexts[context_id] = record + self._mirror_context(record) + return record + + async def ensure_task_not_expired(self, task_id: str) -> None: + async with self._mutation_lock: + if validate_protocol_id(task_id) in self._expired_task_tombstones: + raise ValueError("A2A task expired") + + async def cancel_task(self, task_id: str) -> bool: + async with self._mutation_lock: + record = self._tasks.get(validate_protocol_id(task_id)) + if record is None or record.active_task is None or record.active_task.done(): + return False + record.active_task.cancel() + return True + + async def is_task_active(self, task_id: str) -> bool: + async with self._mutation_lock: + record = self._tasks.get(validate_protocol_id(task_id)) + return bool(record is not None and record.active_task is not None and not record.active_task.done()) + + def mirror_task(self, record: A2ATaskRecord) -> None: + self._mirror_task(record) + + def mirror_context(self, record: A2AContextRecord) -> None: + self._mirror_context(record) + + async def cleanup_once(self, *, now_offset_seconds: float = 0) -> None: + now = time.monotonic() + now_offset_seconds + async with self._mutation_lock: + expired_context_ids = [ + context_id + for context_id, context in self._contexts.items() + if context.active_task_id is None and now - context.last_active > self._idle_timeout_seconds + ] + for context_id in expired_context_ids: + self._contexts.pop(context_id, None) + for task_id, task in list(self._tasks.items()): + if task.context_id == context_id: + task.expired = True + self._expired_task_tombstones[task_id] = now + self._metrics.record_context_evicted() + + for task_id, expired_at in list(self._expired_task_tombstones.items()): + if now - expired_at > self._cleanup_interval_seconds: + self._expired_task_tombstones.pop(task_id, None) + self._tasks.pop(task_id, None) + for owner, owner_tasks in list(self._sdk_tasks.items()): + existing = owner_tasks.pop(task_id, None) + if existing is not None: + self._remove_sdk_task_from_index(owner, task_id, existing.context_id) + if not owner_tasks: + self._sdk_tasks.pop(owner, None) + + async def start_cleanup_loop(self) -> None: + if self._cleanup_task is not None: + return + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + + async def stop_cleanup_loop(self) -> None: + if self._cleanup_task is None: + return + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + self._cleanup_task = None + + async def _cleanup_loop(self) -> None: + while True: + await asyncio.sleep(self._cleanup_interval_seconds) + try: + await self.cleanup_once() + except Exception: + logger.exception("A2A cleanup loop failed") + + def _mirror_task(self, record: A2ATaskRecord) -> None: + if self._persistence is None: + return + try: + self._persistence.save_task( + A2ATaskSnapshot( + task_id=record.task_id, + context_id=record.context_id, + state=record.state, + output_text=list(record.output_text), + ) + ) + except Exception: + logger.exception("Failed to persist A2A task %s", record.task_id) + + def _mirror_context(self, record: A2AContextRecord) -> None: + if self._persistence is None: + return + try: + self._persistence.save_context( + A2AContextSnapshot( + context_id=record.context_id, + session_id=record.session_id, + cwd=record.cwd, + active_task_id=record.active_task_id, + ) + ) + except Exception: + logger.exception("Failed to persist A2A context %s", record.context_id) + + def _owner(self, context: ServerCallContext | None) -> str: + if context is None: + return "" + return self._owner_resolver(context) + + def _owner_tasks(self, context: ServerCallContext | None) -> dict[str, Task]: + return self._sdk_tasks.get(self._owner(context), {}) + + def _remove_sdk_task_from_index(self, owner: str, task_id: str, context_id: str) -> None: + task_ids = self._sdk_tasks_by_context.get(owner, {}).get(context_id) + if task_ids is None: + return + task_ids.discard(task_id) + if not task_ids: + owner_contexts = self._sdk_tasks_by_context.get(owner) + if owner_contexts is not None: + owner_contexts.pop(context_id, None) + if not owner_contexts: + self._sdk_tasks_by_context.pop(owner, None) + + +def _copy_task(task: Task) -> Task: + copied = Task() + copied.CopyFrom(task) + return copied + + +def _project_task(task: Task, *, include_artifacts: bool) -> Task: + projected = _copy_task(task) + if not include_artifacts: + projected.ClearField("artifacts") + return projected diff --git a/src/iac_code/a2a/transport.py b/src/iac_code/a2a/transport.py new file mode 100644 index 00000000..b8aa96ae --- /dev/null +++ b/src/iac_code/a2a/transport.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import base64 +from dataclasses import dataclass + + +class UnsupportedA2ATransportError(ValueError): + """Raised when a discovered protocol binding is not runnable by this client.""" + + +@dataclass(frozen=True) +class A2ATransportBinding: + url: str + protocol_binding: str + protocol_version: str | None = None + + @property + def transport(self) -> str: + from iac_code.a2a.transports.base import normalize_transport_name + + return normalize_transport_name(self.protocol_binding) + + +@dataclass(frozen=True) +class A2AAuthConfig: + bearer_token: str | None = None + api_key: str | None = None + api_key_header: str = "X-API-Key" + basic_username: str | None = None + basic_password: str | None = None + + +def normalize_protocol_binding(value: str | None) -> str: + from iac_code.a2a.transports.base import normalize_transport_name + + normalized = normalize_transport_name(value) + return "jsonrpc" if normalized == "http" else normalized + + +def ensure_supported_transport(binding: A2ATransportBinding) -> A2ATransportBinding: + from iac_code.a2a.transports.base import is_runnable_binding + + if is_runnable_binding(binding): + return binding + raise UnsupportedA2ATransportError( + f"A2A protocol binding {binding.protocol_binding!r} at {binding.url!r} is not runnable." + ) + + +def headers_for_auth(config: A2AAuthConfig | None) -> dict[str, str]: + if config is None: + return {} + headers: dict[str, str] = {} + if config.bearer_token: + headers["Authorization"] = f"Bearer {config.bearer_token}" + elif config.basic_username and config.basic_password: + raw = f"{config.basic_username}:{config.basic_password}".encode("utf-8") + headers["Authorization"] = "Basic " + base64.b64encode(raw).decode("ascii") + if config.api_key: + headers[config.api_key_header or "X-API-Key"] = config.api_key + return headers diff --git a/src/iac_code/a2a/transports/__init__.py b/src/iac_code/a2a/transports/__init__.py new file mode 100644 index 00000000..5658f153 --- /dev/null +++ b/src/iac_code/a2a/transports/__init__.py @@ -0,0 +1,29 @@ +from iac_code.a2a.transports.base import ( + A2AFrameError, + A2ARuntimeTransport, + A2ATransportClient, + A2ATransportConfigError, + A2ATransportDependencyError, + A2ATransportServer, + TransportClientOptions, + TransportServerOptions, + TransportStreamEvent, + binding_from_url, + normalize_transport_name, + select_binding, +) + +__all__ = [ + "A2AFrameError", + "A2ARuntimeTransport", + "A2ATransportClient", + "A2ATransportConfigError", + "A2ATransportDependencyError", + "A2ATransportServer", + "TransportClientOptions", + "TransportServerOptions", + "TransportStreamEvent", + "binding_from_url", + "normalize_transport_name", + "select_binding", +] diff --git a/src/iac_code/a2a/transports/base.py b/src/iac_code/a2a/transports/base.py new file mode 100644 index 00000000..b9cb8c40 --- /dev/null +++ b/src/iac_code/a2a/transports/base.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Sequence +from dataclasses import dataclass +from typing import Any, Protocol +from urllib.parse import urlparse + +from iac_code.a2a.transport import A2ATransportBinding, UnsupportedA2ATransportError + +RUNNABLE_TRANSPORTS = frozenset({"http", "stdio", "unix", "websocket", "grpc", "grpc-jsonrpc", "redis-streams"}) + + +class A2ATransportConfigError(ValueError): + """Raised when a runnable transport is missing required runtime configuration.""" + + +class A2ATransportDependencyError(RuntimeError): + """Raised when an optional transport dependency is not installed.""" + + +class A2AFrameError(ValueError): + """Raised when a transport frame cannot be decoded as an A2A JSON-RPC message.""" + + +@dataclass(frozen=True) +class TransportStreamEvent: + request_id: str | int | None + payload: dict[str, Any] + final: bool = False + + +@dataclass(frozen=True) +class TransportServerOptions: + transport: str + model: str + host: str = "127.0.0.1" + port: int = 41242 + token: str | None = None + basic_username: str | None = None + basic_password: str | None = None + api_key: str | None = None + api_key_header: str = "X-API-Key" + persistence_dir: str | None = None + artifact_dir: str | None = None + signing_secret: str | None = None + signing_key_id: str = "default" + push_notifications: bool = False + socket_path: str | None = None + ws_path: str = "/a2a" + grpc_host: str | None = None + grpc_port: int | None = None + redis_url: str | None = None + request_stream: str = "iac-code:a2a:requests" + response_stream: str = "iac-code:a2a:responses" + consumer_group: str = "iac-code" + + +@dataclass(frozen=True) +class TransportClientOptions: + binding: A2ATransportBinding + token: str | None = None + basic_username: str | None = None + basic_password: str | None = None + api_key: str | None = None + api_key_header: str = "X-API-Key" + command: list[str] | None = None + redis_url: str | None = None + request_stream: str = "iac-code:a2a:requests" + response_stream: str = "iac-code:a2a:responses" + timeout_seconds: float = 30.0 + + +class A2ATransportServer(Protocol): + async def serve(self) -> None: + """Run the transport server until cancelled or shut down.""" + + async def aclose(self) -> None: + """Close listener resources.""" + + +class A2ATransportClient(Protocol): + async def send(self, payload: dict[str, Any]) -> dict[str, Any]: + """Send one unary JSON-RPC request.""" + + def stream(self, payload: dict[str, Any]) -> AsyncIterator[dict[str, Any]]: + """Send one streaming JSON-RPC request and yield response/event payloads.""" + + async def aclose(self) -> None: + """Close client resources.""" + + +class A2ARuntimeTransport(Protocol): + name: str + + def create_server(self, options: TransportServerOptions) -> A2ATransportServer: + """Create a server for this transport.""" + + def create_client(self, options: TransportClientOptions) -> A2ATransportClient: + """Create a client for this transport.""" + + +def normalize_transport_name(value: str | None) -> str: + normalized = (value or "http").strip().lower().replace("_", "-") + aliases = { + "jsonrpc": "http", + "json-rpc": "http", + "http+jsonrpc": "http", + "http-jsonrpc": "http", + "https": "http", + "grpcs": "grpc", + "grpc-jsonrpc": "grpc-jsonrpc", + "grpc+jsonrpc": "grpc-jsonrpc", + "ws": "websocket", + "wss": "websocket", + "redis": "redis-streams", + "redis-stream": "redis-streams", + } + return aliases.get(normalized, normalized) + + +def binding_from_url(url: str, *, protocol_version: str | None = None) -> A2ATransportBinding: + parsed = urlparse(url) + scheme = parsed.scheme or "http" + transport = normalize_transport_name(scheme) + return A2ATransportBinding(url=url, protocol_binding=transport, protocol_version=protocol_version) + + +def is_runnable_binding(binding: A2ATransportBinding) -> bool: + transport = normalize_transport_name(binding.protocol_binding) + if transport == "http": + return binding.url.startswith(("http://", "https://")) + if transport == "websocket": + return binding.url.startswith(("ws://", "wss://")) + if transport == "grpc": + return binding.url.startswith(("grpc://", "grpcs://")) + if transport == "grpc-jsonrpc": + return binding.url.startswith(("grpc-jsonrpc://", "grpc+jsonrpc://")) + if transport == "redis-streams": + return binding.url.startswith("redis-streams://") + if transport == "unix": + return binding.url.startswith("unix://") + if transport == "stdio": + return binding.url.startswith("stdio://") + return False + + +def select_binding(bindings: Sequence[A2ATransportBinding]) -> A2ATransportBinding: + for binding in bindings: + if is_runnable_binding(binding): + transport = normalize_transport_name(binding.protocol_binding) + return A2ATransportBinding( + url=binding.url, + protocol_binding=transport, + protocol_version=binding.protocol_version, + ) + names = ", ".join(binding.protocol_binding for binding in bindings) or "none" + raise UnsupportedA2ATransportError(f"No runnable A2A transport found. Candidate bindings: {names}") diff --git a/src/iac_code/a2a/transports/dispatcher.py b/src/iac_code/a2a/transports/dispatcher.py new file mode 100644 index 00000000..a3b7a5f5 --- /dev/null +++ b/src/iac_code/a2a/transports/dispatcher.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +import inspect +import json +from contextlib import AsyncExitStack +from dataclasses import dataclass +from pathlib import Path +from typing import Any, AsyncIterator + +import httpx +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_jsonrpc_routes +from a2a.server.tasks.inmemory_task_store import DEFAULT_LIST_TASKS_PAGE_SIZE, decode_page_token, encode_page_token +from a2a.types import ( + CancelTaskRequest, + DeleteTaskPushNotificationConfigRequest, + GetTaskPushNotificationConfigRequest, + GetTaskRequest, + ListTaskPushNotificationConfigsRequest, + ListTaskPushNotificationConfigsResponse, + ListTasksRequest, + SendMessageRequest, + SubscribeToTaskRequest, + Task, + TaskPushNotificationConfig, +) +from a2a.utils.errors import ( + ExtensionSupportRequiredError, + InvalidParamsError, + TaskNotCancelableError, + TaskNotFoundError, +) +from starlette.applications import Starlette +from starlette.routing import Route + +from iac_code.a2a.agent_card import build_agent_card, build_extended_agent_card +from iac_code.a2a.app import normalize_v03_jsonrpc_version +from iac_code.a2a.artifacts import A2AArtifactStore +from iac_code.a2a.executor import IacCodeA2AExecutor +from iac_code.a2a.metrics import NoOpA2AMetrics +from iac_code.a2a.persistence import A2APersistenceStore +from iac_code.a2a.push import ( + A2APushConfigStore, + A2APushSender, + InvalidPushNotificationConfigError, + validate_push_callback_url, +) +from iac_code.a2a.push_queue import LocalFileA2APushQueue, RedisStreamsA2APushQueue, require_redis_asyncio +from iac_code.a2a.push_secrets import A2APushSecretKeyring +from iac_code.a2a.push_worker import A2APushDeliveryWorker +from iac_code.a2a.task_store import A2ATaskStore + + +@dataclass +class A2ARuntimeComponents: + handler: DefaultRequestHandler + task_store: A2ATaskStore + card: Any + app: Starlette + _exit_stack: AsyncExitStack + push_worker: Any | None = None + push_queue: Any | None = None + + async def aclose(self) -> None: + await self.task_store.stop_cleanup_loop() + executor = getattr(self.handler, "agent_executor", None) + if executor is not None: + artifact_store = getattr(executor, "artifact_store", None) + if artifact_store is not None: + close = getattr(artifact_store, "aclose", None) + if close is not None: + await close() + push_sender = getattr(self.handler, "_push_sender", None) + if push_sender is not None: + close = getattr(push_sender, "aclose", None) + if close is not None: + await close() + if self.push_worker is not None: + close = getattr(self.push_worker, "aclose", None) + if close is not None: + await close() + if self.push_queue is not None: + close = getattr(self.push_queue, "aclose", None) + if close is not None: + result = close() + if inspect.isawaitable(result): + await result + await self._exit_stack.aclose() + + +def create_runtime_components( + *, + model: str, + host: str, + port: int, + token: str | None = None, + basic_username: str | None = None, + basic_password: str | None = None, + api_key: str | None = None, + api_key_header: str = "X-API-Key", + persistence_dir: str | Path | None = None, + artifact_dir: str | Path | None = None, + signing_secret: str | None = None, + signing_key_id: str = "default", + push_notifications: bool = False, + push_queue: str = "local-file", + push_redis_url: str | None = None, + push_stream: str = "iac-code:a2a:push", + push_retry_key: str = "iac-code:a2a:push:retry", + push_dead_stream: str = "iac-code:a2a:push:dead", + push_consumer_group: str = "iac-code-push", + push_consumer_name: str | None = None, + push_lease_timeout_ms: int = 300_000, + supported_interfaces: list[dict[str, str]] | None = None, + agent_extensions: object | None = None, + auto_approve_permissions: bool = False, +) -> A2ARuntimeComponents: + metrics = NoOpA2AMetrics() + persistence = A2APersistenceStore(persistence_dir) if persistence_dir is not None else None + artifact_store = A2AArtifactStore(artifact_dir) if artifact_dir is not None else None + push_config_store = None + push_sender = None + push_worker = None + push_queue_instance = None + push_secret_keyring = None + if push_notifications: + if persistence is None: + from iac_code.config import get_config_dir + + persistence = A2APersistenceStore(get_config_dir() / "a2a") + push_secret_keyring = A2APushSecretKeyring(Path(persistence.root) / "push_keys.json") + push_config_store = A2APushConfigStore(persistence=persistence, secret_keyring=push_secret_keyring) + if push_queue == "redis-streams": + if not push_redis_url: + raise RuntimeError("--push-redis-url is required for --push-queue redis-streams.") + redis_module = require_redis_asyncio() + redis_client = redis_module.from_url(push_redis_url) + push_queue_instance = RedisStreamsA2APushQueue( + redis=redis_client, + stream=push_stream, + retry_key=push_retry_key, + dead_stream=push_dead_stream, + consumer_group=push_consumer_group, + consumer_name=push_consumer_name or "", + lease_timeout_ms=push_lease_timeout_ms, + owns_redis=True, + secret_keyring=push_secret_keyring, + ) + elif push_queue == "local-file": + push_queue_instance = LocalFileA2APushQueue( + Path(persistence.root) / "push_queue", + secret_keyring=push_secret_keyring, + ) + else: + raise RuntimeError("--push-queue must be local-file or redis-streams.") + push_sender = A2APushSender(config_store=push_config_store, queue=push_queue_instance, metrics=metrics) + push_worker = A2APushDeliveryWorker( + queue=push_queue_instance, + metrics=metrics, + header_resolver=push_config_store.resolve_headers_for_dispatch, + ) + task_store = A2ATaskStore(metrics=metrics, persistence=persistence) + executor = IacCodeA2AExecutor( + task_store=task_store, + model=model, + metrics=metrics, + artifact_store=artifact_store, + auto_approve_permissions=auto_approve_permissions, + ) + card = build_agent_card( + host=host, + port=port, + token_enabled=bool(token), + basic_enabled=bool(basic_username and basic_password), + api_key_enabled=bool(api_key), + api_key_header=api_key_header, + signing_secret=signing_secret, + signing_key_id=signing_key_id, + push_notifications=push_notifications, + supported_interfaces=supported_interfaces, + agent_extensions=agent_extensions, + ) + handler = IacCodeRequestHandler( + agent_executor=executor, + task_store=task_store, + agent_card=card, + push_config_store=push_config_store, + push_sender=push_sender, + extended_agent_card=build_extended_agent_card(card), + ) + return A2ARuntimeComponents( + handler=handler, + task_store=task_store, + card=card, + app=_create_dispatch_app(handler), + _exit_stack=AsyncExitStack(), + push_worker=push_worker, + push_queue=push_queue_instance, + ) + + +class IacCodeRequestHandler(DefaultRequestHandler): + async def on_get_task(self, params: GetTaskRequest, context): + self._validate_extensions(context) + return await super().on_get_task(params, context) + + async def on_list_tasks(self, params: ListTasksRequest, context): + self._validate_extensions(context) + return await super().on_list_tasks(params, context) + + async def on_message_send(self, params: SendMessageRequest, context): + self._validate_extensions(context) + return await super().on_message_send(params, context) + + async def on_message_send_stream(self, params: SendMessageRequest, context): + self._validate_extensions(context) + async for event in super().on_message_send_stream(params, context): + yield event + + async def on_cancel_task(self, params: CancelTaskRequest, context) -> Task | None: + self._validate_extensions(context) + task = await self.task_store.get(params.id, context) + if task is None: + raise TaskNotFoundError(f"Task {params.id} not found") + if isinstance(self.task_store, A2ATaskStore) and not await self.task_store.is_task_active(params.id): + raise TaskNotCancelableError + return await super().on_cancel_task(params, context) + + async def on_subscribe_to_task(self, params: SubscribeToTaskRequest, context): + self._validate_extensions(context) + task = await self.task_store.get(params.id, context) + if task is None: + raise TaskNotFoundError(f"Task {params.id} not found") + if isinstance(self.task_store, A2ATaskStore) and not await self.task_store.is_task_active(params.id): + raise TaskNotFoundError(f"Task {params.id} is not active") + async for event in super().on_subscribe_to_task(params, context): + yield event + + async def on_create_task_push_notification_config( + self, params: TaskPushNotificationConfig, context + ) -> TaskPushNotificationConfig: + self._validate_extensions(context) + try: + validate_push_callback_url(params.url) + except InvalidPushNotificationConfigError as exc: + raise InvalidParamsError(str(exc)) from exc + return await super().on_create_task_push_notification_config(params, context) + + async def on_get_task_push_notification_config(self, params: GetTaskPushNotificationConfigRequest, context): + self._validate_extensions(context) + return await super().on_get_task_push_notification_config(params, context) + + async def on_list_task_push_notification_configs( + self, params: ListTaskPushNotificationConfigsRequest, context + ) -> ListTaskPushNotificationConfigsResponse: + self._validate_extensions(context) + task = await self.task_store.get(params.task_id, context) + if task is None: + raise TaskNotFoundError(f"Task {params.task_id} not found") + if self._push_config_store is None: + return await super().on_list_task_push_notification_configs(params, context) + configs = await self._push_config_store.get_info(params.task_id, context) + configs.sort(key=lambda config: config.id) + start_idx = 0 + if params.page_token: + start_config_id = decode_page_token(params.page_token) + for idx, config in enumerate(configs): + if config.id == start_config_id: + start_idx = idx + break + else: + raise InvalidParamsError(f"Invalid page token: {params.page_token}") + page_size = params.page_size or DEFAULT_LIST_TASKS_PAGE_SIZE + end_idx = start_idx + page_size + next_page_token = encode_page_token(configs[end_idx].id) if end_idx < len(configs) else None + return ListTaskPushNotificationConfigsResponse( + configs=configs[start_idx:end_idx], + next_page_token=next_page_token or "", + ) + + async def on_delete_task_push_notification_config( + self, params: DeleteTaskPushNotificationConfigRequest, context + ) -> None: + self._validate_extensions(context) + await super().on_delete_task_push_notification_config(params, context) + + def _validate_extensions(self, context) -> None: + requested = set(getattr(context, "requested_extensions", set()) or set()) + required = sorted(extension.uri for extension in self._agent_card.capabilities.extensions if extension.required) + missing = [uri for uri in required if uri not in requested] + if missing: + raise ExtensionSupportRequiredError(f"Required A2A extensions were not requested: {', '.join(missing)}") + + +def _create_dispatch_app(handler: DefaultRequestHandler) -> Starlette: + jsonrpc_endpoint = create_jsonrpc_routes(handler, rpc_url="/", enable_v0_3_compat=True)[0].endpoint + + async def handle_jsonrpc(request): + await normalize_v03_jsonrpc_version(request) + return await jsonrpc_endpoint(request) + + return Starlette(routes=[Route("/", handle_jsonrpc, methods=["POST"])]) + + +class A2AJsonRpcDispatcher: + def __init__(self, components: A2ARuntimeComponents) -> None: + self._components = components + self._http_client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=self._components.app), + base_url="http://transport.local", + ) + + async def dispatch(self, payload: dict[str, Any]) -> dict[str, Any]: + response = await self._http_client.post("/", json=payload, headers={"A2A-Version": "1.0"}) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + raise ValueError("A2A dispatcher response must be a JSON object") + return data + + async def dispatch_stream(self, payload: dict[str, Any]) -> AsyncIterator[dict[str, Any]]: + async with self._http_client.stream("POST", "/", json=payload, headers={"A2A-Version": "1.0"}) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if line.startswith("data:"): + yield json.loads(line.removeprefix("data:").strip()) + + async def aclose(self) -> None: + await self._http_client.aclose() diff --git a/src/iac_code/a2a/transports/grpc.py b/src/iac_code/a2a/transports/grpc.py new file mode 100644 index 00000000..e26f7f06 --- /dev/null +++ b/src/iac_code/a2a/transports/grpc.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Any + +from iac_code.a2a.transports.base import A2ATransportDependencyError +from iac_code.a2a.transports.dispatcher import A2ARuntimeComponents + + +def require_grpc() -> Any: + try: + import grpc # type: ignore[import-not-found] + except ImportError as exc: + raise A2ATransportDependencyError( + "gRPC A2A transport requires optional dependencies. Install iac-code[a2a-grpc]." + ) from exc + return grpc + + +class GrpcA2AServer: + def __init__(self, *, components: A2ARuntimeComponents | None, host: str, port: int) -> None: + if not host or port < 0: + raise ValueError("gRPC host and port are required.") + self._components = components + self._host = host + self._port = port + self._server: Any | None = None + + async def serve(self) -> None: + grpc = require_grpc() + try: + from a2a.server.request_handlers.grpc_handler import GrpcHandler + from a2a.types import a2a_pb2_grpc + except ImportError as exc: + raise A2ATransportDependencyError( + "Official gRPC A2A transport requires optional dependencies. Install iac-code[a2a-grpc]." + ) from exc + + if self._components is None: + raise ValueError("gRPC server requires runtime components.") + + self._server = grpc.aio.server() + servicer = GrpcHandler(self._components.handler) + a2a_pb2_grpc.add_A2AServiceServicer_to_server(servicer, self._server) + self._server.add_insecure_port(f"{self._host}:{self._port}") + await self._server.start() + await self._server.wait_for_termination() + + async def aclose(self) -> None: + if self._server is not None: + await self._server.stop(grace=1) + if self._components is not None: + await self._components.aclose() diff --git a/src/iac_code/a2a/transports/grpc_jsonrpc.py b/src/iac_code/a2a/transports/grpc_jsonrpc.py new file mode 100644 index 00000000..644a3c38 --- /dev/null +++ b/src/iac_code/a2a/transports/grpc_jsonrpc.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import asyncio +import inspect +import json +from collections.abc import AsyncIterator +from dataclasses import dataclass +from importlib import import_module +from typing import Any + +from iac_code.a2a.transports.base import A2ATransportDependencyError +from iac_code.a2a.transports.dispatcher import A2ARuntimeComponents + + +@dataclass(frozen=True) +class JsonRpcEnvelope: + payload: bytes + final: bool = False + + +def require_grpc() -> Any: + try: + import grpc # type: ignore[import-not-found] + except ImportError as exc: + raise A2ATransportDependencyError( + "gRPC A2A transport requires optional dependencies. Install iac-code[a2a-grpc]." + ) from exc + return grpc + + +class GrpcA2AServer: + def __init__(self, *, components: A2ARuntimeComponents | None, host: str, port: int) -> None: + if not host or port < 0: + raise ValueError("gRPC host and port are required.") + self._components = components + self._host = host + self._port = port + self._server: Any | None = None + self._servicer: _JsonRpcServicer | None = None + + async def serve(self) -> None: + grpc = require_grpc() + + try: + pb2_grpc = import_module("iac_code.a2a.transports.proto.a2a_jsonrpc_pb2_grpc") + except ModuleNotFoundError as exc: + raise A2ATransportDependencyError( + "gRPC JSON-RPC A2A compatibility transport requires generated protobuf bindings." + ) from exc + + if self._components is None: + raise ValueError("gRPC server requires runtime components.") + + self._server = grpc.aio.server() + self._servicer = _JsonRpcServicer(self._components) + pb2_grpc.add_A2AJsonRpcServicer_to_server(self._servicer, self._server) + self._server.add_insecure_port(f"{self._host}:{self._port}") + await self._server.start() + await self._server.wait_for_termination() + + async def aclose(self) -> None: + if self._server is not None: + await self._server.stop(grace=1) + if self._servicer is not None: + await self._servicer.aclose() + if self._components is not None: + await self._components.aclose() + + +class _JsonRpcServicer: + def __init__(self, components: A2ARuntimeComponents) -> None: + from iac_code.a2a.transports.dispatcher import A2AJsonRpcDispatcher + + self._dispatcher = A2AJsonRpcDispatcher(components) + + async def Send(self, request: JsonRpcEnvelope, context: Any) -> JsonRpcEnvelope: # noqa: N802 + response = await self._dispatcher.dispatch(_from_envelope(request)) + return _to_envelope(response) + + async def Stream( # noqa: N802 + self, + request: JsonRpcEnvelope, + context: Any, + ) -> AsyncIterator[JsonRpcEnvelope]: + try: + async for event in self._dispatcher.dispatch_stream(_from_envelope(request)): + yield _to_envelope(event) + except asyncio.CancelledError: + if _context_cancelled(context): + return + raise + + async def aclose(self) -> None: + await self._dispatcher.aclose() + + +class GrpcA2AClient: + def __init__(self, *, stub: Any) -> None: + self._stub = stub + + async def send(self, payload: dict[str, Any]) -> dict[str, Any]: + response = await self._stub.Send(_to_envelope(payload)) + return _from_envelope(response) + + async def stream(self, payload: dict[str, Any]) -> AsyncIterator[dict[str, Any]]: + async for response in self._stub.Stream(_to_envelope(payload)): + data = _from_envelope(response) + if getattr(response, "final", False): + data["final"] = True + yield data + + async def aclose(self) -> None: + close = getattr(self._stub, "close", None) + if close is None: + return + result = close() + if inspect.isawaitable(result): + await result + + +GrpcJsonRpcA2AServer = GrpcA2AServer +GrpcJsonRpcA2AClient = GrpcA2AClient + + +def _to_envelope(payload: dict[str, Any], *, envelope_cls: Any = JsonRpcEnvelope) -> Any: + return envelope_cls(payload=json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + + +def _from_envelope(envelope: JsonRpcEnvelope) -> dict[str, Any]: + data = json.loads(envelope.payload.decode("utf-8")) + if not isinstance(data, dict): + raise ValueError("gRPC A2A envelope must contain a JSON object") + return data + + +def _context_cancelled(context: Any) -> bool: + cancelled = getattr(context, "cancelled", None) + if cancelled is None: + return False + result = cancelled() + return bool(result) diff --git a/src/iac_code/a2a/transports/http.py b/src/iac_code/a2a/transports/http.py new file mode 100644 index 00000000..ab1279d3 --- /dev/null +++ b/src/iac_code/a2a/transports/http.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import json +from typing import Any, AsyncIterator, cast + +import httpx + +from iac_code.a2a.transport import A2AAuthConfig, headers_for_auth + + +class HttpA2AClient: + def __init__(self, *, http_client: Any | None = None, auth: A2AAuthConfig | None = None) -> None: + self._owns_http_client = http_client is None + self._http_client = http_client or httpx.AsyncClient() + self._auth = auth + + async def send(self, url: str, payload: dict[str, Any]) -> dict[str, Any]: + response = await self._http_client.post(url, json=payload, headers=self._headers()) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + raise ValueError("A2A HTTP response must be a JSON object") + return data + + async def stream(self, url: str, payload: dict[str, Any]) -> AsyncIterator[dict[str, Any]]: + async with self._http_client.stream("POST", url, json=payload, headers=self._headers()) as response: + response.raise_for_status() + lines = cast(AsyncIterator[str | bytes], response.iter_lines()) + async for raw_line in lines: + line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line + if line.startswith("data:"): + yield json.loads(line.removeprefix("data:").strip()) + + async def aclose(self) -> None: + if not self._owns_http_client: + return + close = getattr(self._http_client, "aclose", None) + if close is not None: + await close() + + def _headers(self) -> dict[str, str]: + return {"A2A-Version": "1.0", **headers_for_auth(self._auth)} diff --git a/src/iac_code/a2a/transports/redis_streams.py b/src/iac_code/a2a/transports/redis_streams.py new file mode 100644 index 00000000..1e512472 --- /dev/null +++ b/src/iac_code/a2a/transports/redis_streams.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import asyncio +import inspect +import json +import uuid +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping +from dataclasses import dataclass +from importlib import import_module +from typing import Any + +from iac_code.a2a.transports.base import A2ATransportDependencyError +from iac_code.a2a.transports.dispatcher import A2AJsonRpcDispatcher, A2ARuntimeComponents +from iac_code.a2a.transports.stdio import is_streaming_request + + +@dataclass(frozen=True) +class RedisStreamsMessage: + entry_id: str + correlation_id: str + payload: dict[str, Any] + final: bool + + +def require_redis() -> Any: + try: + return import_module("redis.asyncio") + except ModuleNotFoundError as exc: + raise A2ATransportDependencyError( + "Redis Streams A2A transport requires optional dependencies. Install iac-code[a2a-redis]." + ) from exc + + +def parse_redis_entry(entry_id: str, fields: Mapping[Any, Any]) -> RedisStreamsMessage: + payload = _field_value(fields, "payload") + correlation_id = _field_value(fields, "correlation_id") + data = json.loads(_decode_field(payload)) + if not isinstance(data, dict): + raise ValueError("Redis Streams A2A payload must be a JSON object") + + return RedisStreamsMessage( + entry_id=entry_id, + correlation_id=_decode_field(correlation_id), + payload=data, + final=_field_value(fields, "final") in {True, "true", "1", b"true", b"1"}, + ) + + +class RedisStreamsA2AClient: + def __init__( + self, + *, + redis: Any, + request_stream: str, + response_stream: str, + timeout_seconds: float, + redis_factory: Callable[[], Any | Awaitable[Any]] | None = None, + ) -> None: + self._redis = redis + self._redis_factory = redis_factory + self._request_stream = request_stream + self._response_stream = response_stream + self._timeout_seconds = timeout_seconds + + async def send(self, payload: dict[str, Any], *, correlation_id: str | None = None) -> dict[str, Any]: + correlation_id = correlation_id or str(uuid.uuid4()) + await self._write_request(payload, correlation_id=correlation_id) + async for item in self._read_responses(correlation_id): + return item.payload + raise TimeoutError("Redis Streams A2A request timed out") + + async def stream(self, payload: dict[str, Any]) -> AsyncIterator[dict[str, Any]]: + correlation_id = str(uuid.uuid4()) + await self._write_request(payload, correlation_id=correlation_id) + async for item in self._read_responses(correlation_id): + yield item.payload + if item.final: + break + + async def aclose(self) -> None: + close = getattr(self._redis, "aclose", None) + if close is None: + return + result = close() + if inspect.isawaitable(result): + await result + + async def _write_request(self, payload: dict[str, Any], *, correlation_id: str) -> None: + await self._redis.xadd( + self._request_stream, + { + "correlation_id": correlation_id, + "reply_stream": self._response_stream, + "payload": json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + }, + ) + + async def _read_responses(self, correlation_id: str) -> AsyncIterator[RedisStreamsMessage]: + deadline = asyncio.get_running_loop().time() + self._timeout_seconds + consecutive_errors = 0 + max_consecutive_errors = 3 + while asyncio.get_running_loop().time() < deadline: + try: + remaining = deadline - asyncio.get_running_loop().time() + rows = await asyncio.wait_for( + self._redis.xread({self._response_stream: "$"}, count=1, block=100), + timeout=max(0.001, min(0.2, remaining)), + ) + consecutive_errors = 0 + except Exception as exc: + consecutive_errors += 1 + if self._redis_factory is not None: + await self._reconnect() + consecutive_errors = 0 + continue + if consecutive_errors >= max_consecutive_errors: + raise TimeoutError( + f"Redis Streams connection failed after {max_consecutive_errors} consecutive errors" + ) from exc + await asyncio.sleep(min(1.0, 0.5 * consecutive_errors)) + continue + for _stream, entries in rows: + for entry_id, fields in entries: + message = parse_redis_entry(entry_id, fields) + if message.correlation_id == correlation_id: + yield message + if message.final: + return + await asyncio.sleep(0) + + async def _reconnect(self) -> None: + close = getattr(self._redis, "aclose", None) + if close is not None: + result = close() + if inspect.isawaitable(result): + await result + if self._redis_factory is None: + return + redis = self._redis_factory() + if inspect.isawaitable(redis): + redis = await redis + self._redis = redis + + +class RedisStreamsA2AServer: + def __init__( + self, + *, + redis: Any, + components: A2ARuntimeComponents, + request_stream: str, + response_stream: str, + consumer_group: str, + consumer_name: str = "", + ) -> None: + self._redis = redis + self._components = components + self._dispatcher = A2AJsonRpcDispatcher(components) + self._request_stream = request_stream + self._response_stream = response_stream + self._consumer_group = consumer_group + self._consumer_name = consumer_name or f"consumer-{uuid.uuid4().hex[:12]}" + self._group_ready = False + self._closed = False + + async def serve(self) -> None: + while not self._closed: + processed = await self.serve_once() + if not processed: + await asyncio.sleep(0) + + async def serve_once(self) -> bool: + await self._ensure_group() + rows = await self._redis.xreadgroup( + self._consumer_group, + self._consumer_name, + {self._request_stream: ">"}, + count=1, + block=100, + ) + if not rows: + return False + + processed = False + for _stream, entries in rows: + for entry_id, fields in entries: + await self._process_entry(entry_id, fields) + processed = True + return processed + + async def aclose(self) -> None: + self._closed = True + dispatcher_close = getattr(self._dispatcher, "aclose", None) + if dispatcher_close is not None: + await dispatcher_close() + close = getattr(self._redis, "aclose", None) + if close is not None: + result = close() + if inspect.isawaitable(result): + await result + await self._components.aclose() + + async def _process_entry(self, entry_id: str, fields: Mapping[Any, Any]) -> None: + try: + message = parse_redis_entry(entry_id, fields) + reply_stream = self._reply_stream(fields) + + if is_streaming_request(message.payload): + async for event in self._dispatcher.dispatch_stream(message.payload): + await self._write_response( + reply_stream, + correlation_id=message.correlation_id, + payload=event, + final=False, + ) + await self._write_response( + reply_stream, + correlation_id=message.correlation_id, + payload={"jsonrpc": "2.0", "id": message.payload.get("id")}, + final=True, + ) + else: + response = await self._dispatcher.dispatch(message.payload) + await self._write_response( + reply_stream, + correlation_id=message.correlation_id, + payload=response, + final=True, + ) + finally: + await self._ack(entry_id) + + def _reply_stream(self, fields: Mapping[Any, Any]) -> str: + value = _field_value(fields, "reply_stream") + return self._response_stream if value is None else _decode_field(value) + + async def _write_response( + self, + stream: str, + *, + correlation_id: str, + payload: dict[str, Any], + final: bool, + ) -> None: + await self._redis.xadd( + stream, + { + "correlation_id": correlation_id, + "payload": json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + "final": "true" if final else "false", + }, + ) + + async def _ack(self, entry_id: str) -> None: + xack = getattr(self._redis, "xack", None) + if xack is None: + return + result = xack(self._request_stream, self._consumer_group, entry_id) + if inspect.isawaitable(result): + await result + + async def _ensure_group(self) -> None: + if self._group_ready: + return + try: + await self._redis.xgroup_create(self._request_stream, self._consumer_group, id="0-0", mkstream=True) + except Exception as exc: + if "BUSYGROUP" not in str(exc): + raise + self._group_ready = True + + +def _decode_field(value: Any) -> str: + if isinstance(value, bytes): + return value.decode("utf-8") + return str(value) + + +def _field_value(fields: Mapping[Any, Any], name: str) -> Any: + return fields.get(name, fields.get(name.encode("utf-8"))) diff --git a/src/iac_code/a2a/transports/stdio.py b/src/iac_code/a2a/transports/stdio.py new file mode 100644 index 00000000..12bfb5e5 --- /dev/null +++ b/src/iac_code/a2a/transports/stdio.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import asyncio +import json +import sys +from collections.abc import AsyncIterator +from typing import Any + +from iac_code.a2a.transports.base import A2AFrameError +from iac_code.a2a.transports.dispatcher import A2AJsonRpcDispatcher, A2ARuntimeComponents + + +def encode_frame(payload: dict[str, Any]) -> bytes: + return (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8") + + +def decode_frame(line: bytes | str) -> dict[str, Any]: + text = line.decode("utf-8") if isinstance(line, bytes) else line + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise A2AFrameError(f"Invalid JSON-RPC frame: {exc.msg}") from exc + if not isinstance(payload, dict): + raise A2AFrameError("A2A frame must decode to a JSON object") + return payload + + +def is_streaming_request(payload: dict[str, Any]) -> bool: + return payload.get("method") in {"message/stream", "StreamMessage"} + + +class StdioA2AServer: + def __init__( + self, + *, + components: A2ARuntimeComponents, + reader: asyncio.StreamReader | None = None, + writer: Any | None = None, + ) -> None: + self._components = components + self._dispatcher = A2AJsonRpcDispatcher(components) + self._reader = reader + self._writer = writer + self._closed = False + + async def serve(self) -> None: + reader = self._reader + writer = self._writer + if reader is None or writer is None: + reader, writer = await open_stdio_streams() + while not self._closed: + line = await reader.readline() + if not line: + break + try: + payload = decode_frame(line) + if is_streaming_request(payload): + async for event in self._dispatcher.dispatch_stream(payload): + writer.write(encode_frame(event)) + await writer.drain() + else: + writer.write(encode_frame(await self._dispatcher.dispatch(payload))) + await writer.drain() + except Exception as exc: + writer.write(encode_frame(_error_response(None, str(exc)))) + await writer.drain() + + async def aclose(self) -> None: + self._closed = True + await self._components.aclose() + + +class StdioA2AClient: + def __init__(self, *, reader: asyncio.StreamReader, writer: Any) -> None: + self._reader = reader + self._writer = writer + + async def send(self, payload: dict[str, Any]) -> dict[str, Any]: + self._writer.write(encode_frame(payload)) + await self._writer.drain() + return decode_frame(await self._reader.readline()) + + async def stream(self, payload: dict[str, Any]) -> AsyncIterator[dict[str, Any]]: + self._writer.write(encode_frame(payload)) + await self._writer.drain() + while True: + response = decode_frame(await self._reader.readline()) + yield response + if response.get("final") is True or response.get("result", {}).get("final") is True: + break + + async def aclose(self) -> None: + close = getattr(self._writer, "close", None) + if close is not None: + close() + wait_closed = getattr(self._writer, "wait_closed", None) + if wait_closed is not None: + await wait_closed() + + +def _error_response(request_id: str | int | None, message: str) -> dict[str, Any]: + return {"jsonrpc": "2.0", "id": request_id, "error": {"code": -32603, "message": message}} + + +async def open_stdio_streams() -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + reader = asyncio.StreamReader() + read_protocol = asyncio.StreamReaderProtocol(reader) + loop = asyncio.get_running_loop() + await loop.connect_read_pipe(lambda: read_protocol, sys.stdin.buffer) + write_transport, write_protocol = await loop.connect_write_pipe( + asyncio.streams.FlowControlMixin, + sys.stdout.buffer, + ) + writer = asyncio.StreamWriter(write_transport, write_protocol, reader, loop) + return reader, writer diff --git a/src/iac_code/a2a/transports/unix.py b/src/iac_code/a2a/transports/unix.py new file mode 100644 index 00000000..652f0a1b --- /dev/null +++ b/src/iac_code/a2a/transports/unix.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any + +from iac_code.a2a.transports.dispatcher import A2ARuntimeComponents +from iac_code.a2a.transports.stdio import StdioA2AClient, StdioA2AServer + + +def validate_socket_path(socket_path: str) -> Path: + path = Path(socket_path) + if not path.parent.exists(): + raise ValueError(f"Unix socket parent does not exist: {path.parent}") + return path + + +class UnixA2AServer: + def __init__(self, *, components: A2ARuntimeComponents, socket_path: str) -> None: + self._components = components + self._socket_path = validate_socket_path(socket_path) + self._server: asyncio.AbstractServer | None = None + + async def serve(self) -> None: + if self._socket_path.exists(): + self._socket_path.unlink() + self._server = await asyncio.start_unix_server(self._handle_client, path=str(self._socket_path)) + async with self._server: + await self._server.serve_forever() + + async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + server = StdioA2AServer(components=self._components, reader=reader, writer=writer) + try: + await server.serve() + finally: + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + + async def aclose(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + with contextlib.suppress(FileNotFoundError): + self._socket_path.unlink() + await self._components.aclose() + + +class UnixA2AClient: + def __init__(self, *, socket_path: str) -> None: + self._socket_path = validate_socket_path(socket_path) + self._client: StdioA2AClient | None = None + + async def _connect(self) -> StdioA2AClient: + if self._client is None: + reader, writer = await asyncio.open_unix_connection(str(self._socket_path)) + self._client = StdioA2AClient(reader=reader, writer=writer) + return self._client + + async def send(self, payload: dict[str, Any]) -> dict[str, Any]: + return await (await self._connect()).send(payload) + + async def stream(self, payload: dict[str, Any]) -> AsyncIterator[dict[str, Any]]: + async for item in (await self._connect()).stream(payload): + yield item + + async def aclose(self) -> None: + if self._client is not None: + await self._client.aclose() diff --git a/src/iac_code/a2a/transports/websocket.py b/src/iac_code/a2a/transports/websocket.py new file mode 100644 index 00000000..97a5ffc1 --- /dev/null +++ b/src/iac_code/a2a/transports/websocket.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from contextlib import asynccontextmanager, suppress +from typing import Any + +from starlette.applications import Starlette +from starlette.endpoints import WebSocketEndpoint +from starlette.routing import WebSocketRoute +from starlette.websockets import WebSocket, WebSocketDisconnect + +from iac_code.a2a.transports.dispatcher import A2AJsonRpcDispatcher, A2ARuntimeComponents +from iac_code.a2a.transports.stdio import is_streaming_request + +logger = logging.getLogger(__name__) + + +def websocket_event_frame(payload: dict[str, Any], *, final: bool) -> dict[str, Any]: + return {"id": payload.get("id"), "payload": payload, "final": final} + + +def websocket_error_frame(request_id: Any, *, code: int, message: str) -> dict[str, Any]: + payload = {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}} + return websocket_event_frame(payload, final=True) + + +async def _send_json(websocket: WebSocket, payload: dict[str, Any]) -> bool: + try: + await websocket.send_json(payload) + except (RuntimeError, WebSocketDisconnect): + logger.debug("A2A WebSocket send failed; client disconnected", exc_info=True) + return False + return True + + +class WebSocketA2AServerApp: + def __init__(self, *, components: A2ARuntimeComponents, path: str = "/a2a") -> None: + self._components = components + self._path = path + + def create_app(self) -> Starlette: + components = self._components + + class A2AEndpoint(WebSocketEndpoint): + encoding = "text" + + async def on_connect(self, websocket: WebSocket) -> None: + await websocket.accept() + self.dispatcher = A2AJsonRpcDispatcher(components) + + async def on_disconnect(self, websocket: WebSocket, close_code: int) -> None: + await self.dispatcher.aclose() + + async def on_receive(self, websocket: WebSocket, data: str) -> None: + try: + payload = json.loads(data) + except json.JSONDecodeError: + await _send_json(websocket, websocket_error_frame(None, code=-32700, message="Parse error")) + return + if not isinstance(payload, dict): + await _send_json(websocket, websocket_error_frame(None, code=-32600, message="Invalid Request")) + return + if is_streaming_request(payload): + async for event in self.dispatcher.dispatch_stream(payload): + if not await _send_json(websocket, websocket_event_frame(event, final=False)): + return + final_payload = {"jsonrpc": "2.0", "id": payload.get("id")} + await _send_json(websocket, websocket_event_frame(final_payload, final=True)) + return + + response = await self.dispatcher.dispatch(payload) + await _send_json(websocket, websocket_event_frame(response, final=True)) + + @asynccontextmanager + async def lifespan(app: Starlette): + await components.task_store.start_cleanup_loop() + push_worker_task: asyncio.Task[None] | None = None + if components.push_worker is not None: + push_worker_task = asyncio.create_task(components.push_worker.serve_forever()) + try: + yield + finally: + if push_worker_task is not None: + push_worker_task.cancel() + with suppress(asyncio.CancelledError): + await push_worker_task + await components.aclose() + + return Starlette(routes=[WebSocketRoute(self._path, A2AEndpoint)], lifespan=lifespan) diff --git a/src/iac_code/a2a/types.py b/src/iac_code/a2a/types.py new file mode 100644 index 00000000..47c4fa13 --- /dev/null +++ b/src/iac_code/a2a/types.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import asyncio +import re +import time +from dataclasses import dataclass, field +from typing import Any + +A2A_ID_MAX_LENGTH = 128 +TASK_STATE_CANCELED = "canceled" +TASK_STATE_FAILED = "failed" +TASK_STATE_INPUT_REQUIRED = "input-required" +TASK_STATE_SUBMITTED = "submitted" +TASK_STATE_WORKING = "working" +_A2A_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.:-]+$") + + +def validate_protocol_id(value: str) -> str: + if not isinstance(value, str) or not value or len(value) > A2A_ID_MAX_LENGTH: + raise ValueError("Invalid A2A id") + if _A2A_ID_PATTERN.fullmatch(value) is None: + raise ValueError("Invalid A2A id") + return value + + +@dataclass +class A2ATaskRecord: + task_id: str + context_id: str + state: str = TASK_STATE_SUBMITTED + output_text: list[str] = field(default_factory=list) + active_task: asyncio.Task[Any] | None = None + expired: bool = False + created_at: float = field(default_factory=time.monotonic) + last_active: float = field(default_factory=time.monotonic) + + def touch(self) -> None: + self.last_active = time.monotonic() + + +@dataclass +class A2AContextRecord: + context_id: str + session_id: str + cwd: str + runtime: Any | None = None + lock: asyncio.Lock | None = None + active_task_id: str | None = None + expired: bool = False + created_at: float = field(default_factory=time.monotonic) + last_active: float = field(default_factory=time.monotonic) + + def touch(self) -> None: + self.last_active = time.monotonic() diff --git a/src/iac_code/cli/main.py b/src/iac_code/cli/main.py index a8d848d1..7a254f19 100644 --- a/src/iac_code/cli/main.py +++ b/src/iac_code/cli/main.py @@ -1,11 +1,18 @@ """CLI entry point for iac-code.""" import asyncio +import json import os +import shlex import sys import uuid +from collections.abc import Callable +from dataclasses import asdict +from pathlib import Path +from typing import Any import typer +from typer._completion_classes import completion_init from typer.completion import install_callback, show_callback from iac_code import __release_date__, __version__ @@ -14,6 +21,8 @@ from iac_code.services.qwenpaw_source import QwenPawError as _QwenPawError from iac_code.utils.log import setup_logging +completion_init() + # Initialize i18n. Thanks to `gettext.bindtextdomain` inside setup_i18n(), # this works regardless of where it's called relative to `import typer` / click. setup_i18n() @@ -27,6 +36,33 @@ context_settings={"help_option_names": ["-h", "--help"]}, ) +a2a_client_app = typer.Typer( + help=_("Use iac-code as an A2A client."), + context_settings={"help_option_names": ["-h", "--help"]}, +) +app.add_typer(a2a_client_app, name="a2a-client") + + +@a2a_client_app.callback() +def a2a_client( + ctx: typer.Context, + config: str = typer.Option("", "--config", help=_("YAML config file containing A2A client options")), +) -> None: + """Use iac-code as an A2A client.""" + try: + import iac_code.a2a.client # noqa: F401 + except ImportError: + typer.echo( + _("A2A client dependencies are missing. Install with: pip install 'iac-code[a2a]'"), + err=True, + ) + raise typer.Exit(1) + try: + ctx.obj = {"a2a_client_config": _load_a2a_config(config) if config else {}} + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + @app.callback(invoke_without_command=True) def main( @@ -285,5 +321,1365 @@ def acp( acp_main(debug=debug) +def _load_a2a_config(path: str) -> dict[str, Any]: + import yaml + + data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + if data is None: + return {} + if not isinstance(data, dict): + raise typer.BadParameter("A2A config file must contain a YAML mapping.") + return _normalize_a2a_config_mapping(data) + + +def _normalize_a2a_config_mapping(data: dict[Any, Any]) -> dict[str, Any]: + return {str(key).replace("-", "_"): _normalize_a2a_config_value(value) for key, value in data.items()} + + +def _normalize_a2a_config_value(value: Any) -> Any: + if isinstance(value, dict): + return _normalize_a2a_config_mapping(value) + if isinstance(value, list): + return [_normalize_a2a_config_value(item) for item in value] + return value + + +def _a2a_config_value(ctx: typer.Context, config: dict[str, Any], name: str, current: Any) -> Any: + if name not in config: + return current + source = getattr(ctx, "get_parameter_source", lambda _name: None)(name) + if source is not None and getattr(source, "name", "") != "DEFAULT": + return current + return config[name] + + +def _a2a_client_config(ctx: typer.Context) -> dict[str, Any]: + current: typer.Context | None = ctx + while current is not None: + obj = getattr(current, "obj", None) + if isinstance(obj, dict) and isinstance(obj.get("a2a_client_config"), dict): + return obj["a2a_client_config"] + current = getattr(current, "parent", None) + return {} + + +def _a2a_client_auth_options( + ctx: typer.Context, + config: dict[str, Any], + *, + token: str, + basic_username: str, + basic_password: str, + api_key: str, + api_key_header: str, +) -> dict[str, str]: + return { + "token": _a2a_config_value(ctx, config, "token", token), + "basic_username": _a2a_config_value(ctx, config, "basic_username", basic_username), + "basic_password": _a2a_config_value(ctx, config, "basic_password", basic_password), + "api_key": _a2a_config_value(ctx, config, "api_key", api_key), + "api_key_header": _a2a_config_value(ctx, config, "api_key_header", api_key_header), + } + + +def _a2a_client_card_verification_options( + ctx: typer.Context, + config: dict[str, Any], + *, + verify_card_secret: str, + verify_card_jwks_url: str, + require_card_signature: bool, +) -> dict[str, Any]: + return { + "verify_card_secret": _a2a_config_value(ctx, config, "verify_card_secret", verify_card_secret), + "verify_card_jwks_url": _a2a_config_value(ctx, config, "verify_card_jwks_url", verify_card_jwks_url), + "require_card_signature": _a2a_config_value( + ctx, + config, + "require_card_signature", + require_card_signature, + ), + } + + +def _require_a2a_client_value(value: str, *, option_name: str, config_name: str | None = None) -> str: + if value: + return value + config_name = config_name or option_name.removeprefix("--") + raise ValueError(f"{config_name} is required. Provide {option_name} or {config_name} in --config.") + + +def _a2a_client_route_specs(ctx: typer.Context, config: dict[str, Any], route: list[str]) -> list[str]: + source = getattr(ctx, "get_parameter_source", lambda _name: None)("route") + if source is not None and getattr(source, "name", "") != "DEFAULT": + return route + route_config = config.get("routes", config.get("route", route)) + if isinstance(route_config, str): + return [route_config] + if not isinstance(route_config, list): + return route + return [_format_a2a_route_config(item) for item in route_config] + + +def _format_a2a_route_config(item: Any) -> str: + if isinstance(item, str): + return item + if not isinstance(item, dict): + raise ValueError("A2A client routes config entries must be strings or mappings.") + name = str(item.get("name", "")) + url = str(item.get("url", "")) + if not name or not url: + raise ValueError("A2A client route config entries require name and url.") + parts = [f"{name}={url}"] + for key in ("skills", "tags"): + value = item.get(key) + if value: + if isinstance(value, str): + parts.append(f"{key}={value}") + elif isinstance(value, list): + parts.append(f"{key}={','.join(str(entry) for entry in value)}") + else: + raise ValueError(f"A2A client route {key} must be a string or list.") + return ";".join(parts) + + +@app.command(help=_("Run iac-code as an A2A 1.0 server.")) +def a2a( + ctx: typer.Context, + config_path: str = typer.Option("", "--config", help=_("YAML config file for A2A server options")), + host: str = typer.Option("127.0.0.1", help=_("HTTP server host")), + port: int = typer.Option( + 41242, + help=_("HTTP server port. 41242 is the iac-code default inspired by Gemini CLI, not a registered A2A port."), + ), + transport: str = typer.Option( + "http", + "--transport", + help=_("A2A transport: http, stdio, unix, websocket, grpc, grpc-jsonrpc, or redis-streams"), + ), + debug: bool = typer.Option(False, "--debug", "-d", help=_("Enable debug logging")), +) -> None: + """Run iac-code as an A2A 1.0 server.""" + config = _load_a2a_config(config_path) if config_path else {} + host = _a2a_config_value(ctx, config, "host", host) + port = _a2a_config_value(ctx, config, "port", port) + transport = _a2a_config_value(ctx, config, "transport", transport) + socket_path = config.get("socket_path", "") + ws_path = config.get("ws_path", "/a2a") + grpc_host = config.get("grpc_host", "") + grpc_port = config.get("grpc_port") + redis_url = config.get("redis_url", "") + request_stream = config.get("request_stream", "iac-code:a2a:requests") + response_stream = config.get("response_stream", "iac-code:a2a:responses") + consumer_group = config.get("consumer_group", "iac-code") + token = config.get("token", "") + basic_username = config.get("basic_username", "") + basic_password = config.get("basic_password", "") + api_key = config.get("api_key", "") + api_key_header = config.get("api_key_header", "") + persistence_dir = config.get("persistence_dir", "") + artifact_dir = config.get("artifact_dir", "") + signing_secret = config.get("signing_secret", "") + push_notifications = config.get("push_notifications", False) + push_queue = config.get("push_queue", "local-file") + push_redis_url = config.get("push_redis_url", "") + push_stream = config.get("push_stream", "iac-code:a2a:push") + push_retry_key = config.get("push_retry_key", "iac-code:a2a:push:retry") + push_dead_stream = config.get("push_dead_stream", "iac-code:a2a:push:dead") + push_consumer_group = config.get("push_consumer_group", "iac-code-push") + push_consumer_name = config.get("push_consumer_name", "") + push_lease_timeout_ms = config.get("push_lease_timeout_ms", 300000) + auto_approve_permissions = config.get("auto_approve_permissions", False) + model = load_saved_model() or DEFAULT_MODEL + setup_logging(session_id="a2a-server", debug=debug) + try: + from iac_code.a2a.app import ( + resolve_api_key, + resolve_api_key_header, + resolve_basic_credentials, + resolve_token, + run_server, + ) + except ImportError as exc: + typer.echo( + _("A2A server dependencies are missing. Install with: pip install 'iac-code[a2a]'"), + err=True, + ) + raise typer.Exit(1) from exc + try: + if transport == "unix" and not socket_path: + raise RuntimeError("socket-path is required in --config for --transport unix.") + if transport == "redis-streams" and not redis_url: + raise RuntimeError("redis-url is required in --config for --transport redis-streams.") + if push_queue == "redis-streams" and not push_redis_url: + raise RuntimeError("push-redis-url is required in --config for push-queue: redis-streams.") + resolved_basic = resolve_basic_credentials(basic_username or None, basic_password or None) + run_server( + host=host, + port=port, + token=resolve_token(token or None), + model=model, + basic_username=resolved_basic[0] if resolved_basic else None, + basic_password=resolved_basic[1] if resolved_basic else None, + api_key=resolve_api_key(api_key or None), + api_key_header=resolve_api_key_header(api_key_header or None), + persistence_dir=persistence_dir or None, + artifact_dir=artifact_dir or None, + signing_secret=signing_secret or None, + push_notifications=push_notifications, + push_queue=push_queue, + push_redis_url=push_redis_url or None, + push_stream=push_stream, + push_retry_key=push_retry_key, + push_dead_stream=push_dead_stream, + push_consumer_group=push_consumer_group, + push_consumer_name=push_consumer_name or None, + push_lease_timeout_ms=push_lease_timeout_ms, + transport=transport, + socket_path=socket_path or None, + ws_path=ws_path, + grpc_host=grpc_host or None, + grpc_port=grpc_port, + redis_url=redis_url or None, + request_stream=request_stream, + response_stream=response_stream, + consumer_group=consumer_group, + auto_approve_permissions=auto_approve_permissions, + ) + except RuntimeError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + + +@a2a_client_app.command(name="call", help=_("Send a prompt to an A2A JSON-RPC endpoint.")) +def a2a_call( + ctx: typer.Context, + url: str = typer.Option("", "--url", help=_("A2A JSON-RPC endpoint URL")), + route: list[str] = typer.Option([], "--route", help=_("Route spec: name=url;skills=skill1,skill2;tags=tag1,tag2")), + route_name: str = typer.Option("", "--route-name", help=_("Named A2A route to call")), + prompt: str = typer.Option(..., "--prompt", "-p", help=_("Prompt to send")), + cwd: str = typer.Option(".", "--cwd", help=_("Working directory metadata to send with the request")), + context_id: str = typer.Option("", "--context-id", help=_("A2A context ID to continue")), + token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), + basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), + basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), + api_key: str = typer.Option("", "--api-key", help=_("API key for A2A HTTP requests")), + api_key_header: str = typer.Option("X-API-Key", "--api-key-header", help=_("HTTP header name for A2A API key")), + verify_card_secret: str = typer.Option( + "", + "--verify-card-secret", + "--signing-secret", + help=_("Secret used to verify the A2A Agent Card"), + ), + verify_card_jwks_url: str = typer.Option( + "", + "--verify-card-jwks-url", + help=_("Remote JWKS URL used to verify the A2A Agent Card"), + ), + require_card_signature: bool = typer.Option( + False, + "--require-card-signature", + "--require-signature", + help=_("Require a valid A2A Agent Card signature"), + ), + timeout: float = typer.Option(30.0, "--timeout", help=_("A2A call timeout in seconds")), + stream: bool = typer.Option(False, "--stream", help=_("Use A2A streaming message delivery")), +) -> None: + """Send a prompt to an A2A JSON-RPC endpoint.""" + try: + config = _a2a_client_config(ctx) + url = _a2a_config_value(ctx, config, "url", url) + route = _a2a_client_route_specs(ctx, config, route) + route_name = _a2a_config_value(ctx, config, "route_name", route_name) + cwd = _a2a_config_value(ctx, config, "cwd", cwd) + context_id = _a2a_config_value(ctx, config, "context_id", context_id) + timeout = _a2a_config_value(ctx, config, "timeout", timeout) + stream = _a2a_config_value(ctx, config, "stream", stream) + auth_options = _a2a_client_auth_options( + ctx, + config, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ) + card_options = _a2a_client_card_verification_options( + ctx, + config, + verify_card_secret=verify_card_secret, + verify_card_jwks_url=verify_card_jwks_url, + require_card_signature=require_card_signature, + ) + if not url: + from iac_code.a2a.router import A2ARouter + + selected = A2ARouter([_parse_a2a_route_spec(value) for value in route]).resolve( + name=route_name or None, + prompt=prompt, + ) + url = selected.url + output = asyncio.run( + _run_a2a_call( + url=url, + prompt=prompt, + cwd=cwd, + context_id=context_id or None, + token=auth_options["token"] or None, + basic_username=auth_options["basic_username"] or None, + basic_password=auth_options["basic_password"] or None, + api_key=auth_options["api_key"] or None, + api_key_header=auth_options["api_key_header"], + verify_card_secret=card_options["verify_card_secret"] or None, + verify_card_jwks_url=card_options["verify_card_jwks_url"] or None, + require_card_signature=card_options["require_card_signature"], + timeout_seconds=timeout, + stream=stream, + stream_callback=typer.echo if stream else None, + ) + ) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + if output: + typer.echo(output) + + +@a2a_client_app.command(name="discover", help=_("Discover an A2A Agent Card.")) +def a2a_discover( + ctx: typer.Context, + url: str = typer.Option("", "--url", help=_("A2A agent base URL")), + token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), + basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), + basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), + api_key: str = typer.Option("", "--api-key", help=_("API key for A2A HTTP requests")), + api_key_header: str = typer.Option("X-API-Key", "--api-key-header", help=_("HTTP header name for A2A API key")), + verify_card_secret: str = typer.Option( + "", + "--verify-card-secret", + "--signing-secret", + help=_("Secret used to verify the A2A Agent Card"), + ), + verify_card_jwks_url: str = typer.Option( + "", + "--verify-card-jwks-url", + help=_("Remote JWKS URL used to verify the A2A Agent Card"), + ), + require_card_signature: bool = typer.Option( + False, + "--require-card-signature", + "--require-signature", + help=_("Require a valid A2A Agent Card signature"), + ), +) -> None: + """Discover an A2A Agent Card.""" + try: + config = _a2a_client_config(ctx) + url = _require_a2a_client_value( + _a2a_config_value(ctx, config, "url", url), + option_name="--url", + config_name="url", + ) + auth_options = _a2a_client_auth_options( + ctx, + config, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ) + card_options = _a2a_client_card_verification_options( + ctx, + config, + verify_card_secret=verify_card_secret, + verify_card_jwks_url=verify_card_jwks_url, + require_card_signature=require_card_signature, + ) + output = asyncio.run( + _run_a2a_discover( + url=url, + token=auth_options["token"] or None, + basic_username=auth_options["basic_username"] or None, + basic_password=auth_options["basic_password"] or None, + api_key=auth_options["api_key"] or None, + api_key_header=auth_options["api_key_header"], + verify_card_secret=card_options["verify_card_secret"] or None, + verify_card_jwks_url=card_options["verify_card_jwks_url"] or None, + require_card_signature=card_options["require_card_signature"], + ) + ) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + typer.echo(output) + + +@a2a_client_app.command(name="task-get", help=_("Get an A2A task.")) +def a2a_task_get( + ctx: typer.Context, + url: str = typer.Option("", "--url", help=_("A2A JSON-RPC endpoint URL")), + task_id: str = typer.Option("", "--task-id", help=_("A2A task ID")), + history_length: int | None = typer.Option(None, "--history-length", help=_("Maximum task history items to return")), + token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), + basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), + basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), + api_key: str = typer.Option("", "--api-key", help=_("API key for A2A HTTP requests")), + api_key_header: str = typer.Option("X-API-Key", "--api-key-header", help=_("HTTP header name for A2A API key")), +) -> None: + try: + config = _a2a_client_config(ctx) + url = _require_a2a_client_value( + _a2a_config_value(ctx, config, "url", url), + option_name="--url", + config_name="url", + ) + task_id = _require_a2a_client_value( + _a2a_config_value(ctx, config, "task_id", task_id), + option_name="--task-id", + config_name="task-id", + ) + history_length = _a2a_config_value(ctx, config, "history_length", history_length) + auth_options = _a2a_client_auth_options( + ctx, + config, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ) + output = asyncio.run( + _run_a2a_client_json( + "get_task", + url=url, + token=auth_options["token"] or None, + basic_username=auth_options["basic_username"] or None, + basic_password=auth_options["basic_password"] or None, + api_key=auth_options["api_key"] or None, + api_key_header=auth_options["api_key_header"], + task_id=task_id, + history_length=history_length, + ) + ) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + typer.echo(output) + + +@a2a_client_app.command(name="task-list", help=_("List A2A tasks.")) +def a2a_task_list( + ctx: typer.Context, + url: str = typer.Option("", "--url", help=_("A2A JSON-RPC endpoint URL")), + context_id: str = typer.Option("", "--context-id", help=_("Filter by A2A context ID")), + status: str = typer.Option("", "--status", help=_("Filter by A2A task state")), + page_size: int | None = typer.Option(None, "--page-size", help=_("Maximum tasks to return")), + page_token: str = typer.Option("", "--page-token", help=_("Pagination token")), + include_artifacts: bool = typer.Option(False, "--include-artifacts", help=_("Include task artifacts")), + output: str = typer.Option("table", "--output", help=_("Output format: table or json")), + token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), + basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), + basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), + api_key: str = typer.Option("", "--api-key", help=_("API key for A2A HTTP requests")), + api_key_header: str = typer.Option("X-API-Key", "--api-key-header", help=_("HTTP header name for A2A API key")), +) -> None: + try: + config = _a2a_client_config(ctx) + url = _require_a2a_client_value( + _a2a_config_value(ctx, config, "url", url), + option_name="--url", + config_name="url", + ) + context_id = _a2a_config_value(ctx, config, "context_id", context_id) + status = _a2a_config_value(ctx, config, "status", status) + page_size = _a2a_config_value(ctx, config, "page_size", page_size) + page_token = _a2a_config_value(ctx, config, "page_token", page_token) + include_artifacts = _a2a_config_value(ctx, config, "include_artifacts", include_artifacts) + output = _a2a_config_value(ctx, config, "output", output) + auth_options = _a2a_client_auth_options( + ctx, + config, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ) + rendered = asyncio.run( + _run_a2a_task_list( + url=url, + token=auth_options["token"] or None, + basic_username=auth_options["basic_username"] or None, + basic_password=auth_options["basic_password"] or None, + api_key=auth_options["api_key"] or None, + api_key_header=auth_options["api_key_header"], + context_id=context_id or None, + status=status or None, + page_size=page_size, + page_token=page_token or None, + include_artifacts=include_artifacts or None, + output=output, + ) + ) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + typer.echo(rendered) + + +@a2a_client_app.command(name="task-cancel", help=_("Cancel an A2A task.")) +def a2a_task_cancel( + ctx: typer.Context, + url: str = typer.Option("", "--url", help=_("A2A JSON-RPC endpoint URL")), + task_id: str = typer.Option("", "--task-id", help=_("A2A task ID")), + token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), + basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), + basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), + api_key: str = typer.Option("", "--api-key", help=_("API key for A2A HTTP requests")), + api_key_header: str = typer.Option("X-API-Key", "--api-key-header", help=_("HTTP header name for A2A API key")), +) -> None: + try: + config = _a2a_client_config(ctx) + url = _require_a2a_client_value( + _a2a_config_value(ctx, config, "url", url), + option_name="--url", + config_name="url", + ) + task_id = _require_a2a_client_value( + _a2a_config_value(ctx, config, "task_id", task_id), + option_name="--task-id", + config_name="task-id", + ) + auth_options = _a2a_client_auth_options( + ctx, + config, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ) + output = asyncio.run( + _run_a2a_client_json( + "cancel_task", + url=url, + token=auth_options["token"] or None, + basic_username=auth_options["basic_username"] or None, + basic_password=auth_options["basic_password"] or None, + api_key=auth_options["api_key"] or None, + api_key_header=auth_options["api_key_header"], + task_id=task_id, + ) + ) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + typer.echo(output) + + +@a2a_client_app.command(name="task-subscribe", help=_("Subscribe to an A2A task event stream.")) +def a2a_task_subscribe( + ctx: typer.Context, + url: str = typer.Option("", "--url", help=_("A2A JSON-RPC endpoint URL")), + task_id: str = typer.Option("", "--task-id", help=_("A2A task ID")), + token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), + basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), + basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), + api_key: str = typer.Option("", "--api-key", help=_("API key for A2A HTTP requests")), + api_key_header: str = typer.Option("X-API-Key", "--api-key-header", help=_("HTTP header name for A2A API key")), +) -> None: + try: + config = _a2a_client_config(ctx) + url = _require_a2a_client_value( + _a2a_config_value(ctx, config, "url", url), + option_name="--url", + config_name="url", + ) + task_id = _require_a2a_client_value( + _a2a_config_value(ctx, config, "task_id", task_id), + option_name="--task-id", + config_name="task-id", + ) + auth_options = _a2a_client_auth_options( + ctx, + config, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ) + output = asyncio.run( + _run_a2a_task_subscribe( + url=url, + task_id=task_id, + token=auth_options["token"] or None, + basic_username=auth_options["basic_username"] or None, + basic_password=auth_options["basic_password"] or None, + api_key=auth_options["api_key"] or None, + api_key_header=auth_options["api_key_header"], + ) + ) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + typer.echo(output) + + +@a2a_client_app.command(name="push-config-create", help=_("Create an A2A task push notification config.")) +def a2a_push_config_create( + ctx: typer.Context, + url: str = typer.Option("", "--url", help=_("A2A JSON-RPC endpoint URL")), + task_id: str = typer.Option("", "--task-id", help=_("A2A task ID")), + config_id: str = typer.Option("", "--config-id", help=_("Push config ID")), + callback_url: str = typer.Option("", "--callback-url", help=_("Push callback URL")), + notification_token: str = typer.Option("", "--notification-token", help=_("Notification verification token")), + auth_scheme: str = typer.Option("", "--auth-scheme", help=_("Callback authentication scheme")), + auth_credentials: str = typer.Option("", "--auth-credentials", help=_("Callback authentication credentials")), + token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), + basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), + basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), + api_key: str = typer.Option("", "--api-key", help=_("API key for A2A HTTP requests")), + api_key_header: str = typer.Option("X-API-Key", "--api-key-header", help=_("HTTP header name for A2A API key")), +) -> None: + try: + config = _a2a_client_config(ctx) + url = _require_a2a_client_value( + _a2a_config_value(ctx, config, "url", url), + option_name="--url", + config_name="url", + ) + task_id = _require_a2a_client_value( + _a2a_config_value(ctx, config, "task_id", task_id), + option_name="--task-id", + config_name="task-id", + ) + config_id = _require_a2a_client_value( + _a2a_config_value(ctx, config, "config_id", config_id), + option_name="--config-id", + config_name="config-id", + ) + callback_url = _require_a2a_client_value( + _a2a_config_value(ctx, config, "callback_url", callback_url), + option_name="--callback-url", + config_name="callback-url", + ) + notification_token = _a2a_config_value(ctx, config, "notification_token", notification_token) + auth_scheme = _a2a_config_value(ctx, config, "auth_scheme", auth_scheme) + auth_credentials = _a2a_config_value(ctx, config, "auth_credentials", auth_credentials) + auth_options = _a2a_client_auth_options( + ctx, + config, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + authentication = None + if auth_scheme or auth_credentials: + authentication = {"scheme": auth_scheme, "credentials": auth_credentials} + try: + output = asyncio.run( + _run_a2a_client_json( + "create_push_notification_config", + url=url, + token=auth_options["token"] or None, + basic_username=auth_options["basic_username"] or None, + basic_password=auth_options["basic_password"] or None, + api_key=auth_options["api_key"] or None, + api_key_header=auth_options["api_key_header"], + task_id=task_id, + config_id=config_id, + callback_url_=callback_url, + token_=notification_token or None, + authentication=authentication, + ) + ) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + typer.echo(output) + + +@a2a_client_app.command(name="push-config-get", help=_("Get an A2A task push notification config.")) +def a2a_push_config_get( + ctx: typer.Context, + url: str = typer.Option("", "--url", help=_("A2A JSON-RPC endpoint URL")), + task_id: str = typer.Option("", "--task-id", help=_("A2A task ID")), + config_id: str = typer.Option("", "--config-id", help=_("Push config ID")), + token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), + basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), + basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), + api_key: str = typer.Option("", "--api-key", help=_("API key for A2A HTTP requests")), + api_key_header: str = typer.Option("X-API-Key", "--api-key-header", help=_("HTTP header name for A2A API key")), +) -> None: + try: + config = _a2a_client_config(ctx) + url = _require_a2a_client_value( + _a2a_config_value(ctx, config, "url", url), + option_name="--url", + config_name="url", + ) + task_id = _require_a2a_client_value( + _a2a_config_value(ctx, config, "task_id", task_id), + option_name="--task-id", + config_name="task-id", + ) + config_id = _require_a2a_client_value( + _a2a_config_value(ctx, config, "config_id", config_id), + option_name="--config-id", + config_name="config-id", + ) + auth_options = _a2a_client_auth_options( + ctx, + config, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ) + output = asyncio.run( + _run_a2a_client_json( + "get_push_notification_config", + url=url, + token=auth_options["token"] or None, + basic_username=auth_options["basic_username"] or None, + basic_password=auth_options["basic_password"] or None, + api_key=auth_options["api_key"] or None, + api_key_header=auth_options["api_key_header"], + task_id=task_id, + config_id=config_id, + ) + ) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + typer.echo(output) + + +@a2a_client_app.command(name="push-config-list", help=_("List A2A task push notification configs.")) +def a2a_push_config_list( + ctx: typer.Context, + url: str = typer.Option("", "--url", help=_("A2A JSON-RPC endpoint URL")), + task_id: str = typer.Option("", "--task-id", help=_("A2A task ID")), + page_size: int | None = typer.Option(None, "--page-size", help=_("Maximum configs to return")), + page_token: str = typer.Option("", "--page-token", help=_("Pagination token")), + token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), + basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), + basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), + api_key: str = typer.Option("", "--api-key", help=_("API key for A2A HTTP requests")), + api_key_header: str = typer.Option("X-API-Key", "--api-key-header", help=_("HTTP header name for A2A API key")), +) -> None: + try: + config = _a2a_client_config(ctx) + url = _require_a2a_client_value( + _a2a_config_value(ctx, config, "url", url), + option_name="--url", + config_name="url", + ) + task_id = _require_a2a_client_value( + _a2a_config_value(ctx, config, "task_id", task_id), + option_name="--task-id", + config_name="task-id", + ) + page_size = _a2a_config_value(ctx, config, "page_size", page_size) + page_token = _a2a_config_value(ctx, config, "page_token", page_token) + auth_options = _a2a_client_auth_options( + ctx, + config, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ) + output = asyncio.run( + _run_a2a_client_json( + "list_push_notification_configs", + url=url, + token=auth_options["token"] or None, + basic_username=auth_options["basic_username"] or None, + basic_password=auth_options["basic_password"] or None, + api_key=auth_options["api_key"] or None, + api_key_header=auth_options["api_key_header"], + task_id=task_id, + page_size=page_size, + page_token=page_token or None, + ) + ) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + typer.echo(output) + + +@a2a_client_app.command(name="push-config-delete", help=_("Delete an A2A task push notification config.")) +def a2a_push_config_delete( + ctx: typer.Context, + url: str = typer.Option("", "--url", help=_("A2A JSON-RPC endpoint URL")), + task_id: str = typer.Option("", "--task-id", help=_("A2A task ID")), + config_id: str = typer.Option("", "--config-id", help=_("Push config ID")), + token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), + basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), + basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), + api_key: str = typer.Option("", "--api-key", help=_("API key for A2A HTTP requests")), + api_key_header: str = typer.Option("X-API-Key", "--api-key-header", help=_("HTTP header name for A2A API key")), +) -> None: + try: + config = _a2a_client_config(ctx) + url = _require_a2a_client_value( + _a2a_config_value(ctx, config, "url", url), + option_name="--url", + config_name="url", + ) + task_id = _require_a2a_client_value( + _a2a_config_value(ctx, config, "task_id", task_id), + option_name="--task-id", + config_name="task-id", + ) + config_id = _require_a2a_client_value( + _a2a_config_value(ctx, config, "config_id", config_id), + option_name="--config-id", + config_name="config-id", + ) + auth_options = _a2a_client_auth_options( + ctx, + config, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ) + output = asyncio.run( + _run_a2a_client_json( + "delete_push_notification_config", + url=url, + token=auth_options["token"] or None, + basic_username=auth_options["basic_username"] or None, + basic_password=auth_options["basic_password"] or None, + api_key=auth_options["api_key"] or None, + api_key_header=auth_options["api_key_header"], + task_id=task_id, + config_id=config_id, + ) + ) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + typer.echo(output) + + +@a2a_client_app.command(name="extended-card", help=_("Get an authenticated extended A2A Agent Card.")) +def a2a_extended_card( + ctx: typer.Context, + url: str = typer.Option("", "--url", help=_("A2A JSON-RPC endpoint URL")), + token: str = typer.Option("", "--token", help=_("Bearer token for A2A HTTP requests")), + basic_username: str = typer.Option("", "--basic-username", help=_("Basic auth username for A2A HTTP requests")), + basic_password: str = typer.Option("", "--basic-password", help=_("Basic auth password for A2A HTTP requests")), + api_key: str = typer.Option("", "--api-key", help=_("API key for A2A HTTP requests")), + api_key_header: str = typer.Option("X-API-Key", "--api-key-header", help=_("HTTP header name for A2A API key")), +) -> None: + try: + config = _a2a_client_config(ctx) + url = _require_a2a_client_value( + _a2a_config_value(ctx, config, "url", url), + option_name="--url", + config_name="url", + ) + auth_options = _a2a_client_auth_options( + ctx, + config, + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ) + output = asyncio.run( + _run_a2a_client_json( + "get_extended_agent_card", + url=url, + token=auth_options["token"] or None, + basic_username=auth_options["basic_username"] or None, + basic_password=auth_options["basic_password"] or None, + api_key=auth_options["api_key"] or None, + api_key_header=auth_options["api_key_header"], + ) + ) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + typer.echo(output) + + +@a2a_client_app.command(name="route-preview", help=_("Preview A2A route resolution.")) +def a2a_route_preview( + route: list[str] = typer.Option( + [], + "--route", + help=_("Route spec: name=url;skills=skill1,skill2;tags=tag1,tag2"), + ), + name: str = typer.Option("", "--name", help=_("Route name to resolve")), + skill: str = typer.Option("", "--skill", help=_("Skill ID to resolve")), + prompt: str = typer.Option("", "--prompt", help=_("Prompt text used for tag/name route matching")), + route_state_dir: str = typer.Option( + "", + "--route-state-dir", + "--persistence-dir", + help=_("Directory for persisted A2A routes"), + ), + save_routes: bool = typer.Option(False, "--save-routes", help=_("Save the provided routes as a route snapshot")), +) -> None: + """Preview A2A route resolution.""" + try: + routes = [_parse_a2a_route_spec(value) for value in route] + if not routes: + raise ValueError("At least one --route is required.") + if save_routes or route_state_dir: + if not route_state_dir: + raise ValueError("--route-state-dir is required with --save-routes.") + _save_a2a_route_snapshots(route_state_dir, routes) + + from iac_code.a2a.router import A2ARouter + + resolved = A2ARouter(routes).resolve(name=name or None, skill=skill or None, prompt=prompt or None) + except Exception as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + typer.echo(json.dumps(asdict(resolved), ensure_ascii=False, indent=2, sort_keys=True)) + + +def _build_a2a_auth_config( + *, + token: str | None, + basic_username: str | None, + basic_password: str | None, + api_key: str | None, + api_key_header: str, +): + try: + from iac_code.a2a.transport import A2AAuthConfig + except ImportError as exc: + typer.echo( + _("A2A client dependencies are missing. Install with: pip install 'iac-code[a2a]'"), + err=True, + ) + raise typer.Exit(1) from exc + + return A2AAuthConfig( + bearer_token=token, + api_key=api_key, + api_key_header=api_key_header or "X-API-Key", + basic_username=basic_username, + basic_password=basic_password, + ) + + +async def _run_a2a_call( + *, + url: str, + prompt: str, + cwd: str, + context_id: str | None, + token: str | None, + basic_username: str | None, + basic_password: str | None, + api_key: str | None, + api_key_header: str, + verify_card_secret: str | None, + verify_card_jwks_url: str | None, + require_card_signature: bool, + timeout_seconds: float = 30.0, + stream: bool = False, + stream_callback: Callable[[str], None] | None = None, +) -> str: + from iac_code.a2a.client import A2AClient + + client = A2AClient( + auth=_build_a2a_auth_config( + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ), + verification_secret=verify_card_secret, + verification_jwks_url=verify_card_jwks_url, + require_card_signature=require_card_signature, + timeout_seconds=timeout_seconds, + ) + try: + card = await client.discover(url) + endpoint_url = client.select_endpoint_url(card, fallback_url=url) + if stream: + lines = [] + async for event in client.stream_message(endpoint_url, prompt, cwd=str(Path(cwd)), context_id=context_id): + line = _format_a2a_stream_event(event) + if stream_callback is not None: + stream_callback(line) + else: + lines.append(line) + return "\n".join(lines) + response = await client.send_message(endpoint_url, prompt, cwd=str(Path(cwd)), context_id=context_id) + return response.text or json.dumps(response.payload, ensure_ascii=False, indent=2, sort_keys=True) + finally: + await client.aclose() + + +async def _run_a2a_discover( + *, + url: str, + token: str | None, + basic_username: str | None, + basic_password: str | None, + api_key: str | None, + api_key_header: str, + verify_card_secret: str | None, + verify_card_jwks_url: str | None, + require_card_signature: bool, +) -> str: + from iac_code.a2a.client import A2AClient + + client = A2AClient( + auth=_build_a2a_auth_config( + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ), + verification_secret=verify_card_secret, + verification_jwks_url=verify_card_jwks_url, + require_card_signature=require_card_signature, + ) + try: + card = await client.discover(url) + return json.dumps(card, ensure_ascii=False, indent=2, sort_keys=True) + finally: + await client.aclose() + + +async def _run_a2a_client_json( + method_name: str, + *, + url: str, + token: str | None, + basic_username: str | None, + basic_password: str | None, + api_key: str | None, + api_key_header: str, + **kwargs: Any, +) -> str: + from iac_code.a2a.client import A2AClient + + client = A2AClient( + auth=_build_a2a_auth_config( + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ), + ) + try: + method_kwargs = dict(kwargs) + notification_token = method_kwargs.pop("token_", None) + if notification_token is not None: + method_kwargs["token"] = notification_token + callback_url = method_kwargs.pop("callback_url_", None) + if callback_url is not None: + method_kwargs["url"] = callback_url + method = getattr(client, method_name) + if method_name == "create_push_notification_config": + response = await method(endpoint_url=url, **method_kwargs) + else: + response = await method(url, **method_kwargs) + return json.dumps(response, ensure_ascii=False, indent=2, sort_keys=True) + finally: + await client.aclose() + + +async def _run_a2a_task_list( + *, + url: str, + token: str | None, + basic_username: str | None, + basic_password: str | None, + api_key: str | None, + api_key_header: str, + context_id: str | None, + status: str | None, + page_size: int | None, + page_token: str | None, + include_artifacts: bool | None, + output: str, +) -> str: + if output not in {"table", "json"}: + raise ValueError("--output must be table or json.") + + from iac_code.a2a.client import A2AClient + + client = A2AClient( + auth=_build_a2a_auth_config( + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ), + ) + try: + response = await client.list_tasks( + url, + context_id=context_id, + status=status, + page_size=page_size, + page_token=page_token, + include_artifacts=include_artifacts, + ) + if output == "json": + return json.dumps(response, ensure_ascii=False, indent=2, sort_keys=True) + return _format_a2a_task_list( + response, + url=url, + context_id=context_id, + status=status, + page_size=page_size, + include_artifacts=bool(include_artifacts), + ) + finally: + await client.aclose() + + +async def _run_a2a_task_subscribe( + *, + url: str, + task_id: str, + token: str | None, + basic_username: str | None, + basic_password: str | None, + api_key: str | None, + api_key_header: str, +) -> str: + from iac_code.a2a.client import A2AClient + + client = A2AClient( + auth=_build_a2a_auth_config( + token=token, + basic_username=basic_username, + basic_password=basic_password, + api_key=api_key, + api_key_header=api_key_header, + ), + ) + try: + events = [event async for event in client.subscribe_task(url, task_id)] + return "\n".join(json.dumps(event, ensure_ascii=False, sort_keys=True) for event in events) + finally: + await client.aclose() + + +def _format_a2a_stream_event(event: dict[str, Any]) -> str: + text = _extract_a2a_text(event) + if text: + return text + return json.dumps(event, ensure_ascii=False, sort_keys=True) + + +def _extract_a2a_text(payload: dict[str, Any]) -> str: + result = payload.get("result") + if not isinstance(result, dict): + return "" + text = result.get("text") + if isinstance(text, str) and text: + return text + status = result.get("status") + if isinstance(status, dict): + message_text = _extract_a2a_message_text(status.get("message")) + if message_text: + return message_text + message_text = _extract_a2a_message_text(result.get("message")) + if message_text: + return message_text + return "" + + +def _extract_a2a_message_text(message: Any) -> str: + if not isinstance(message, dict): + return "" + parts = message.get("parts") + if not isinstance(parts, list): + return "" + texts = [str(part["text"]) for part in parts if isinstance(part, dict) and isinstance(part.get("text"), str)] + return " ".join(texts) + + +def _format_a2a_task_list( + response: dict[str, Any], + *, + url: str, + context_id: str | None, + status: str | None, + page_size: int | None, + include_artifacts: bool, +) -> str: + result = response.get("result") + if not isinstance(result, dict): + return json.dumps(response, ensure_ascii=False, indent=2, sort_keys=True) + + raw_tasks = result.get("tasks") + tasks = raw_tasks if isinstance(raw_tasks, list) else [] + if not tasks: + return "No A2A tasks found." + + rows = [["ID", "Status", "Context", "Updated", "Message"]] + for item in tasks: + task = item if isinstance(item, dict) else {} + raw_status = task.get("status") + status_obj = raw_status if isinstance(raw_status, dict) else {} + rows.append( + [ + _clip(str(task.get("id") or ""), 28), + _friendly_task_state(str(status_obj.get("state") or "")), + _clip(str(task.get("contextId") or task.get("context_id") or ""), 22), + _clip(str(status_obj.get("timestamp") or ""), 20), + _clip(_extract_a2a_message_text(status_obj.get("message")), 56), + ] + ) + + table = _render_table(rows) + summary = _format_a2a_task_list_summary(result, len(tasks)) + next_token = result.get("nextPageToken") or result.get("next_page_token") + lines = [table, summary] + if isinstance(next_token, str) and next_token: + lines.append( + "Next page: " + + _format_a2a_task_list_next_command( + url=url, + context_id=context_id, + status=status, + page_size=page_size, + page_token=next_token, + include_artifacts=include_artifacts, + ) + ) + return "\n".join(lines) + + +def _format_a2a_task_list_summary(result: dict[str, Any], shown: int) -> str: + total = result.get("totalSize") or result.get("total_size") + if isinstance(total, int): + return f"Showing {shown} of {total} tasks." + return f"Showing {shown} tasks." + + +def _format_a2a_task_list_next_command( + *, + url: str, + context_id: str | None, + status: str | None, + page_size: int | None, + page_token: str, + include_artifacts: bool, +) -> str: + parts = ["iac-code", "a2a-client", "task-list", "--url", url] + if context_id: + parts.extend(["--context-id", context_id]) + if status: + parts.extend(["--status", status]) + if page_size is not None: + parts.extend(["--page-size", str(page_size)]) + if include_artifacts: + parts.append("--include-artifacts") + parts.extend(["--page-token", page_token]) + return " ".join(shlex.quote(part) for part in parts) + + +def _render_table(rows: list[list[str]]) -> str: + widths = [max(len(row[index]) for row in rows) for index in range(len(rows[0]))] + rendered = [] + for row_index, row in enumerate(rows): + rendered.append(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)).rstrip()) + if row_index == 0: + rendered.append(" ".join("-" * width for width in widths).rstrip()) + return "\n".join(rendered) + + +def _friendly_task_state(value: str) -> str: + if value.startswith("TASK_STATE_"): + return value.removeprefix("TASK_STATE_").lower().replace("_", "-") + return value.lower().replace("_", "-") + + +def _clip(value: str, limit: int) -> str: + if len(value) <= limit: + return value + return value[: limit - 3] + "..." + + +def _parse_a2a_route_spec(value: str): + try: + from iac_code.a2a.router import A2ARoute + except ImportError as exc: + typer.echo( + _("A2A client dependencies are missing. Install with: pip install 'iac-code[a2a]'"), + err=True, + ) + raise typer.Exit(1) from exc + + parts = [part.strip() for part in value.split(";") if part.strip()] + if not parts or "=" not in parts[0]: + raise ValueError("A2A route must start with name=url.") + name, url = (part.strip() for part in parts[0].split("=", 1)) + if not name or not url: + raise ValueError("A2A route name and URL are required.") + + skills: list[str] = [] + tags: list[str] = [] + legacy_parts: list[str] = [] + for part in parts[1:]: + key, separator, raw = part.partition("=") + if not separator: + legacy_parts.append(part) + continue + values = [item.strip() for item in raw.split(",") if item.strip()] + if key == "skills": + skills = values + elif key == "tags": + tags = values + else: + raise ValueError(f"Unknown A2A route segment {key!r}. Expected skills or tags.") + if legacy_parts and not skills: + skills = [legacy_parts[0]] + if len(legacy_parts) > 1 and not tags: + tags = [item.strip() for item in legacy_parts[1].split(",") if item.strip()] + return A2ARoute(name=name, url=url, skills=skills, tags=tags) + + +def _save_a2a_route_snapshots(persistence_dir: str, routes: list[Any]) -> None: + try: + from iac_code.a2a.persistence import A2APersistenceStore, A2ARouteSnapshot + except ImportError as exc: + typer.echo( + _("A2A client dependencies are missing. Install with: pip install 'iac-code[a2a]'"), + err=True, + ) + raise typer.Exit(1) from exc + + A2APersistenceStore(persistence_dir).save_routes( + [ + A2ARouteSnapshot(name=route.name, url=route.url, skills=list(route.skills), tags=list(route.tags)) + for route in routes + ] + ) + + if __name__ == "__main__": app() diff --git a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po index ce9844f6..ae93d53b 100644 --- a/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/de/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: iac-code 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-18 10:11+0800\n" +"POT-Creation-Date: 2026-05-18 12:25+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: de\n" @@ -135,47 +135,65 @@ msgstr "" " Dokumentation: https://aliyun.github.io/iac-" "code/de/docs/configuration/authentication\n" -#: src/iac_code/cli/main.py:23 src/iac_code/commands/help.py:26 +#: src/iac_code/cli/main.py:32 src/iac_code/commands/help.py:26 msgid "AI-powered infrastructure orchestration tool" msgstr "KI-gestütztes Tool zur Orchestrierung von Infrastruktur" -#: src/iac_code/cli/main.py:34 +#: src/iac_code/cli/main.py:40 +msgid "Use iac-code as an A2A client." +msgstr "iac-code als A2A-Client verwenden." + +#: src/iac_code/cli/main.py:49 +msgid "YAML config file containing A2A client options" +msgstr "YAML-Konfigurationsdatei mit A2A-Client-Optionen" + +#: src/iac_code/cli/main.py:56 src/iac_code/cli/main.py:1271 +#: src/iac_code/cli/main.py:1632 src/iac_code/cli/main.py:1671 +msgid "" +"A2A client dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"A2A-Client-Abhängigkeiten fehlen. Installieren mit: pip install 'iac-" +"code[a2a]'" + +#: src/iac_code/cli/main.py:70 msgid "LLM model to use" msgstr "Zu verwendendes LLM-Modell" -#: src/iac_code/cli/main.py:35 +#: src/iac_code/cli/main.py:71 msgid "Non-interactive mode: run a single prompt and exit" msgstr "Nicht-interaktiver Modus: Einen Prompt ausführen und beenden" -#: src/iac_code/cli/main.py:36 +#: src/iac_code/cli/main.py:72 msgid "Output format: text, json, stream-json" msgstr "Ausgabeformat: text, json, stream-json" -#: src/iac_code/cli/main.py:37 +#: src/iac_code/cli/main.py:73 msgid "Maximum agent turns in headless mode" msgstr "Maximale Agent-Runden im Headless-Modus" -#: src/iac_code/cli/main.py:38 src/iac_code/cli/main.py:275 +#: src/iac_code/cli/main.py:74 src/iac_code/cli/main.py:311 +#: src/iac_code/cli/main.py:460 msgid "Enable debug logging" msgstr "Debug-Protokollierung aktivieren" -#: src/iac_code/cli/main.py:39 +#: src/iac_code/cli/main.py:75 msgid "Show version and exit" msgstr "Version anzeigen und beenden" -#: src/iac_code/cli/main.py:40 +#: src/iac_code/cli/main.py:76 msgid "Resume a session by ID" msgstr "Eine Sitzung anhand der ID fortsetzen" -#: src/iac_code/cli/main.py:41 +#: src/iac_code/cli/main.py:77 msgid "Resume the most recent session" msgstr "Die zuletzt genutzte Sitzung fortsetzen" -#: src/iac_code/cli/main.py:48 src/iac_code/i18n/__init__.py:54 +#: src/iac_code/cli/main.py:84 src/iac_code/i18n/__init__.py:54 msgid "Install completion for the current shell." msgstr "Vervollständigung für die aktuelle Shell installieren." -#: src/iac_code/cli/main.py:56 src/iac_code/i18n/__init__.py:55 +#: src/iac_code/cli/main.py:92 src/iac_code/i18n/__init__.py:55 msgid "" "Show completion for the current shell, to copy it or customize the " "installation." @@ -183,7 +201,7 @@ msgstr "" "Vervollständigung für die aktuelle Shell anzeigen, zum Kopieren oder " "Anpassen der Installation." -#: src/iac_code/cli/main.py:61 +#: src/iac_code/cli/main.py:97 msgid "" "Comma-separated tool permission patterns to allow, e.g. 'bash(git " "*),write_file'" @@ -191,39 +209,296 @@ msgstr "" "Durch Komma getrennte Tool-Berechtigungsmuster zum Erlauben, z.B. " "'bash(git *),write_file'*),write_file'" -#: src/iac_code/cli/main.py:66 +#: src/iac_code/cli/main.py:102 msgid "Comma-separated tool permission patterns to deny" msgstr "Durch Komma getrennte Tool-Berechtigungsmuster zum Verweigern" -#: src/iac_code/cli/main.py:71 +#: src/iac_code/cli/main.py:107 msgid "Permission mode: default, accept_edits, bypass_permissions, dont_ask" msgstr "" "Permission mode: default, accept_edits, bypass_permissions, " "dont_askBerechtigungsmodus: default, accept_edits, bypass_permissions, " "dont_ask" -#: src/iac_code/cli/main.py:85 +#: src/iac_code/cli/main.py:121 msgid "Error: --resume and --continue cannot be used together." msgstr "" "Error: --resume and --continue cannot be used together.Fehler: --resume " "und --continue können nicht gemeinsam verwendet werden." -#: src/iac_code/cli/main.py:270 +#: src/iac_code/cli/main.py:306 msgid "Run iac-code as an ACP server." msgstr "iac-code als ACP-Server ausführen." -#: src/iac_code/cli/main.py:272 +#: src/iac_code/cli/main.py:308 msgid "Transport type: stdio or http" msgstr "Transporttyp: stdio oder http" -#: src/iac_code/cli/main.py:273 +#: src/iac_code/cli/main.py:309 msgid "HTTP server port" msgstr "HTTP-Server-Port" -#: src/iac_code/cli/main.py:274 +#: src/iac_code/cli/main.py:310 src/iac_code/cli/main.py:450 msgid "HTTP server host" msgstr "HTTP-Server-Host" +#: src/iac_code/cli/main.py:446 +msgid "Run iac-code as an A2A 1.0 server." +msgstr "iac-code als A2A 1.0-Server ausführen." + +#: src/iac_code/cli/main.py:449 +msgid "YAML config file for A2A server options" +msgstr "YAML-Konfigurationsdatei für A2A-Server-Optionen" + +#: src/iac_code/cli/main.py:453 +msgid "" +"HTTP server port. 41242 is the iac-code default inspired by Gemini CLI, " +"not a registered A2A port." +msgstr "" +"HTTP-Serverport. 41242 ist der von Gemini CLI inspirierte iac-code-" +"Standard, kein registrierter A2A-Port." + +#: src/iac_code/cli/main.py:458 +msgid "" +"A2A transport: http, stdio, unix, websocket, grpc, grpc-jsonrpc, or " +"redis-streams" +msgstr "" +"A2A-Transport: http, stdio, unix, websocket, grpc, grpc-jsonrpc oder " +"redis-streams" + +#: src/iac_code/cli/main.py:505 +msgid "" +"A2A server dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"A2A-Server-Abhängigkeiten fehlen. Installieren mit: pip install 'iac-" +"code[a2a]'" + +#: src/iac_code/cli/main.py:554 +msgid "Send a prompt to an A2A JSON-RPC endpoint." +msgstr "Sendet einen Prompt an einen A2A-JSON-RPC-Endpunkt." + +#: src/iac_code/cli/main.py:557 src/iac_code/cli/main.py:721 +#: src/iac_code/cli/main.py:774 src/iac_code/cli/main.py:834 +#: src/iac_code/cli/main.py:884 src/iac_code/cli/main.py:933 +#: src/iac_code/cli/main.py:1012 src/iac_code/cli/main.py:1069 +#: src/iac_code/cli/main.py:1125 src/iac_code/cli/main.py:1182 +msgid "A2A JSON-RPC endpoint URL" +msgstr "URL des A2A-JSON-RPC-Endpunkts" + +#: src/iac_code/cli/main.py:558 src/iac_code/cli/main.py:1227 +msgid "Route spec: name=url;skills=skill1,skill2;tags=tag1,tag2" +msgstr "Routen-Spezifikation: name=url;skills=skill1,skill2;tags=tag1,tag2" + +#: src/iac_code/cli/main.py:559 +msgid "Named A2A route to call" +msgstr "Aufzurufende benannte A2A-Route" + +#: src/iac_code/cli/main.py:560 +msgid "Prompt to send" +msgstr "Zu sendender Prompt" + +#: src/iac_code/cli/main.py:561 +msgid "Working directory metadata to send with the request" +msgstr "Arbeitsverzeichnis-Metadaten, die mit der Anfrage gesendet werden" + +#: src/iac_code/cli/main.py:562 +msgid "A2A context ID to continue" +msgstr "Fortzusetzende A2A-Kontext-ID" + +#: src/iac_code/cli/main.py:563 src/iac_code/cli/main.py:652 +#: src/iac_code/cli/main.py:724 src/iac_code/cli/main.py:781 +#: src/iac_code/cli/main.py:836 src/iac_code/cli/main.py:886 +#: src/iac_code/cli/main.py:940 src/iac_code/cli/main.py:1015 +#: src/iac_code/cli/main.py:1073 src/iac_code/cli/main.py:1128 +#: src/iac_code/cli/main.py:1183 +msgid "Bearer token for A2A HTTP requests" +msgstr "Bearer-Token für A2A-HTTP-Anfragen" + +#: src/iac_code/cli/main.py:564 src/iac_code/cli/main.py:653 +#: src/iac_code/cli/main.py:725 src/iac_code/cli/main.py:782 +#: src/iac_code/cli/main.py:837 src/iac_code/cli/main.py:887 +#: src/iac_code/cli/main.py:941 src/iac_code/cli/main.py:1016 +#: src/iac_code/cli/main.py:1074 src/iac_code/cli/main.py:1129 +#: src/iac_code/cli/main.py:1184 +msgid "Basic auth username for A2A HTTP requests" +msgstr "Basic-Auth-Benutzername für A2A-HTTP-Anfragen" + +#: src/iac_code/cli/main.py:565 src/iac_code/cli/main.py:654 +#: src/iac_code/cli/main.py:726 src/iac_code/cli/main.py:783 +#: src/iac_code/cli/main.py:838 src/iac_code/cli/main.py:888 +#: src/iac_code/cli/main.py:942 src/iac_code/cli/main.py:1017 +#: src/iac_code/cli/main.py:1075 src/iac_code/cli/main.py:1130 +#: src/iac_code/cli/main.py:1185 +msgid "Basic auth password for A2A HTTP requests" +msgstr "Basic-Auth-Passwort für A2A-HTTP-Anfragen" + +#: src/iac_code/cli/main.py:566 src/iac_code/cli/main.py:655 +#: src/iac_code/cli/main.py:727 src/iac_code/cli/main.py:784 +#: src/iac_code/cli/main.py:839 src/iac_code/cli/main.py:889 +#: src/iac_code/cli/main.py:943 src/iac_code/cli/main.py:1018 +#: src/iac_code/cli/main.py:1076 src/iac_code/cli/main.py:1131 +#: src/iac_code/cli/main.py:1186 +msgid "API key for A2A HTTP requests" +msgstr "API-Schlüssel für A2A-HTTP-Anfragen" + +#: src/iac_code/cli/main.py:567 src/iac_code/cli/main.py:656 +#: src/iac_code/cli/main.py:728 src/iac_code/cli/main.py:785 +#: src/iac_code/cli/main.py:840 src/iac_code/cli/main.py:890 +#: src/iac_code/cli/main.py:944 src/iac_code/cli/main.py:1019 +#: src/iac_code/cli/main.py:1077 src/iac_code/cli/main.py:1132 +#: src/iac_code/cli/main.py:1187 +msgid "HTTP header name for A2A API key" +msgstr "HTTP-Header-Name für den A2A-API-Schlüssel" + +#: src/iac_code/cli/main.py:572 src/iac_code/cli/main.py:661 +msgid "Secret used to verify the A2A Agent Card" +msgstr "Geheimnis zur Verifizierung der A2A Agent Card" + +#: src/iac_code/cli/main.py:577 src/iac_code/cli/main.py:666 +msgid "Remote JWKS URL used to verify the A2A Agent Card" +msgstr "Remote-JWKS-URL zur Verifizierung der A2A Agent Card" + +#: src/iac_code/cli/main.py:583 src/iac_code/cli/main.py:672 +msgid "Require a valid A2A Agent Card signature" +msgstr "Eine gültige A2A-Agent-Card-Signatur erfordern" + +#: src/iac_code/cli/main.py:585 +msgid "A2A call timeout in seconds" +msgstr "A2A-Aufruf-Timeout in Sekunden" + +#: src/iac_code/cli/main.py:586 +msgid "Use A2A streaming message delivery" +msgstr "A2A-Streaming-Nachrichtenübertragung verwenden" + +#: src/iac_code/cli/main.py:648 +msgid "Discover an A2A Agent Card." +msgstr "Eine A2A Agent Card entdecken." + +#: src/iac_code/cli/main.py:651 +msgid "A2A agent base URL" +msgstr "Basis-URL des A2A-Agenten" + +#: src/iac_code/cli/main.py:718 +msgid "Get an A2A task." +msgstr "Eine A2A-Aufgabe abrufen." + +#: src/iac_code/cli/main.py:722 src/iac_code/cli/main.py:835 +#: src/iac_code/cli/main.py:885 src/iac_code/cli/main.py:934 +#: src/iac_code/cli/main.py:1013 src/iac_code/cli/main.py:1070 +#: src/iac_code/cli/main.py:1126 +msgid "A2A task ID" +msgstr "A2A-Aufgaben-ID" + +#: src/iac_code/cli/main.py:723 +msgid "Maximum task history items to return" +msgstr "Maximale Anzahl zurückzugebender Aufgabenverlaufselemente" + +#: src/iac_code/cli/main.py:771 +msgid "List A2A tasks." +msgstr "A2A-Aufgaben auflisten." + +#: src/iac_code/cli/main.py:775 +msgid "Filter by A2A context ID" +msgstr "Nach A2A-Kontext-ID filtern" + +#: src/iac_code/cli/main.py:776 +msgid "Filter by A2A task state" +msgstr "Nach A2A-Aufgabenzustand filtern" + +#: src/iac_code/cli/main.py:777 +msgid "Maximum tasks to return" +msgstr "Maximale Anzahl zurückzugebender Aufgaben" + +#: src/iac_code/cli/main.py:778 src/iac_code/cli/main.py:1072 +msgid "Pagination token" +msgstr "Paginierungs-Token" + +#: src/iac_code/cli/main.py:779 +msgid "Include task artifacts" +msgstr "Aufgaben-Artefakte einschließen" + +#: src/iac_code/cli/main.py:780 +msgid "Output format: table or json" +msgstr "Ausgabeformat: table oder json" + +#: src/iac_code/cli/main.py:831 +msgid "Cancel an A2A task." +msgstr "Eine A2A-Aufgabe abbrechen." + +#: src/iac_code/cli/main.py:881 +msgid "Subscribe to an A2A task event stream." +msgstr "A2A-Aufgaben-Ereignisstrom abonnieren." + +#: src/iac_code/cli/main.py:930 +msgid "Create an A2A task push notification config." +msgstr "Eine Push-Benachrichtigungskonfiguration für eine A2A-Aufgabe erstellen." + +#: src/iac_code/cli/main.py:935 src/iac_code/cli/main.py:1014 +#: src/iac_code/cli/main.py:1127 +msgid "Push config ID" +msgstr "Push-Konfigurations-ID" + +#: src/iac_code/cli/main.py:936 +msgid "Push callback URL" +msgstr "Push-Callback-URL" + +#: src/iac_code/cli/main.py:937 +msgid "Notification verification token" +msgstr "Token zur Benachrichtigungsverifizierung" + +#: src/iac_code/cli/main.py:938 +msgid "Callback authentication scheme" +msgstr "Callback-Authentifizierungsschema" + +#: src/iac_code/cli/main.py:939 +msgid "Callback authentication credentials" +msgstr "Callback-Authentifizierungsanmeldeinformationen" + +#: src/iac_code/cli/main.py:1009 +msgid "Get an A2A task push notification config." +msgstr "Eine Push-Benachrichtigungskonfiguration für eine A2A-Aufgabe abrufen." + +#: src/iac_code/cli/main.py:1066 +msgid "List A2A task push notification configs." +msgstr "Push-Benachrichtigungskonfigurationen für A2A-Aufgaben auflisten." + +#: src/iac_code/cli/main.py:1071 +msgid "Maximum configs to return" +msgstr "Maximale Anzahl zurückzugebender Konfigurationen" + +#: src/iac_code/cli/main.py:1122 +msgid "Delete an A2A task push notification config." +msgstr "Eine Push-Benachrichtigungskonfiguration für eine A2A-Aufgabe löschen." + +#: src/iac_code/cli/main.py:1179 +msgid "Get an authenticated extended A2A Agent Card." +msgstr "Eine authentifizierte erweiterte A2A Agent Card abrufen." + +#: src/iac_code/cli/main.py:1222 +msgid "Preview A2A route resolution." +msgstr "Vorschau der A2A-Routenauflösung." + +#: src/iac_code/cli/main.py:1229 +msgid "Route name to resolve" +msgstr "Aufzulösender Routenname" + +#: src/iac_code/cli/main.py:1230 +msgid "Skill ID to resolve" +msgstr "Aufzulösende Skill-ID" + +#: src/iac_code/cli/main.py:1231 +msgid "Prompt text used for tag/name route matching" +msgstr "Prompt-Text für die Tag-/Namens-Routenübereinstimmung" + +#: src/iac_code/cli/main.py:1236 +msgid "Directory for persisted A2A routes" +msgstr "Verzeichnis für persistierte A2A-Routen" + +#: src/iac_code/cli/main.py:1238 +msgid "Save the provided routes as a route snapshot" +msgstr "Speichert die angegebenen Routen als Routen-Snapshot" + #: src/iac_code/commands/__init__.py:22 msgid "Show available commands" msgstr "Verfügbare Befehle anzeigen" diff --git a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po index 831b0813..4b7c6b37 100644 --- a/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/es/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: iac-code 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-18 10:11+0800\n" +"POT-Creation-Date: 2026-05-18 12:25+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: es\n" @@ -139,47 +139,65 @@ msgstr "" " Documentación: https://aliyun.github.io/iac-" "code/es/docs/configuration/authentication\n" -#: src/iac_code/cli/main.py:23 src/iac_code/commands/help.py:26 +#: src/iac_code/cli/main.py:32 src/iac_code/commands/help.py:26 msgid "AI-powered infrastructure orchestration tool" msgstr "Herramienta de orquestación de infraestructura asistida por IA" -#: src/iac_code/cli/main.py:34 +#: src/iac_code/cli/main.py:40 +msgid "Use iac-code as an A2A client." +msgstr "Usa iac-code como cliente A2A." + +#: src/iac_code/cli/main.py:49 +msgid "YAML config file containing A2A client options" +msgstr "Archivo de configuración YAML con opciones del cliente A2A" + +#: src/iac_code/cli/main.py:56 src/iac_code/cli/main.py:1271 +#: src/iac_code/cli/main.py:1632 src/iac_code/cli/main.py:1671 +msgid "" +"A2A client dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"Faltan las dependencias del cliente A2A. Instálalas con: pip install " +"'iac-code[a2a]'" + +#: src/iac_code/cli/main.py:70 msgid "LLM model to use" msgstr "Modelo LLM a utilizar" -#: src/iac_code/cli/main.py:35 +#: src/iac_code/cli/main.py:71 msgid "Non-interactive mode: run a single prompt and exit" msgstr "Modo no interactivo: ejecuta un único prompt y termina" -#: src/iac_code/cli/main.py:36 +#: src/iac_code/cli/main.py:72 msgid "Output format: text, json, stream-json" msgstr "Formato de salida: text, json, stream-json" -#: src/iac_code/cli/main.py:37 +#: src/iac_code/cli/main.py:73 msgid "Maximum agent turns in headless mode" msgstr "Turnos máximos del agente en modo sin interfaz" -#: src/iac_code/cli/main.py:38 src/iac_code/cli/main.py:275 +#: src/iac_code/cli/main.py:74 src/iac_code/cli/main.py:311 +#: src/iac_code/cli/main.py:460 msgid "Enable debug logging" msgstr "Habilitar el registro de depuración" -#: src/iac_code/cli/main.py:39 +#: src/iac_code/cli/main.py:75 msgid "Show version and exit" msgstr "Mostrar la versión y salir" -#: src/iac_code/cli/main.py:40 +#: src/iac_code/cli/main.py:76 msgid "Resume a session by ID" msgstr "Reanudar una sesión por ID" -#: src/iac_code/cli/main.py:41 +#: src/iac_code/cli/main.py:77 msgid "Resume the most recent session" msgstr "Reanudar la sesión más reciente" -#: src/iac_code/cli/main.py:48 src/iac_code/i18n/__init__.py:54 +#: src/iac_code/cli/main.py:84 src/iac_code/i18n/__init__.py:54 msgid "Install completion for the current shell." msgstr "Instalar la finalización automática para el shell actual." -#: src/iac_code/cli/main.py:56 src/iac_code/i18n/__init__.py:55 +#: src/iac_code/cli/main.py:92 src/iac_code/i18n/__init__.py:55 msgid "" "Show completion for the current shell, to copy it or customize the " "installation." @@ -187,7 +205,7 @@ msgstr "" "Mostrar el script de finalización del shell actual para copiarlo o " "personalizar la instalación." -#: src/iac_code/cli/main.py:61 +#: src/iac_code/cli/main.py:97 msgid "" "Comma-separated tool permission patterns to allow, e.g. 'bash(git " "*),write_file'" @@ -195,34 +213,293 @@ msgstr "" "Patrones de permisos de herramientas a permitir (separados por comas), " "p.ej. 'bash(git *),write_file'*),write_file'" -#: src/iac_code/cli/main.py:66 +#: src/iac_code/cli/main.py:102 msgid "Comma-separated tool permission patterns to deny" msgstr "Patrones de permisos de herramientas a denegar (separados por comas)" -#: src/iac_code/cli/main.py:71 +#: src/iac_code/cli/main.py:107 msgid "Permission mode: default, accept_edits, bypass_permissions, dont_ask" msgstr "Modo de permisos: default, accept_edits, bypass_permissions, dont_ask" -#: src/iac_code/cli/main.py:85 +#: src/iac_code/cli/main.py:121 msgid "Error: --resume and --continue cannot be used together." msgstr "Error: --resume y --continue no pueden usarse a la vez." -#: src/iac_code/cli/main.py:270 +#: src/iac_code/cli/main.py:306 msgid "Run iac-code as an ACP server." msgstr "Ejecutar iac-code como servidor ACP." -#: src/iac_code/cli/main.py:272 +#: src/iac_code/cli/main.py:308 msgid "Transport type: stdio or http" msgstr "Tipo de transporte: stdio o http" -#: src/iac_code/cli/main.py:273 +#: src/iac_code/cli/main.py:309 msgid "HTTP server port" msgstr "Puerto del servidor HTTP" -#: src/iac_code/cli/main.py:274 +#: src/iac_code/cli/main.py:310 src/iac_code/cli/main.py:450 msgid "HTTP server host" msgstr "Host del servidor HTTP" +#: src/iac_code/cli/main.py:446 +msgid "Run iac-code as an A2A 1.0 server." +msgstr "Ejecuta iac-code como un servidor A2A 1.0." + +#: src/iac_code/cli/main.py:449 +msgid "YAML config file for A2A server options" +msgstr "Archivo de configuración YAML para opciones del servidor A2A" + +#: src/iac_code/cli/main.py:453 +msgid "" +"HTTP server port. 41242 is the iac-code default inspired by Gemini CLI, " +"not a registered A2A port." +msgstr "" +"Puerto del servidor HTTP. 41242 es el valor predeterminado de iac-code " +"inspirado en Gemini CLI, no un puerto A2A registrado." + +#: src/iac_code/cli/main.py:458 +msgid "" +"A2A transport: http, stdio, unix, websocket, grpc, grpc-jsonrpc, or " +"redis-streams" +msgstr "" +"Transporte A2A: http, stdio, unix, websocket, grpc, grpc-jsonrpc o redis-" +"streams" + +#: src/iac_code/cli/main.py:505 +msgid "" +"A2A server dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"Faltan las dependencias del servidor A2A. Instálalas con: pip install " +"'iac-code[a2a]'" + +#: src/iac_code/cli/main.py:554 +msgid "Send a prompt to an A2A JSON-RPC endpoint." +msgstr "Envía un prompt a un endpoint JSON-RPC A2A." + +#: src/iac_code/cli/main.py:557 src/iac_code/cli/main.py:721 +#: src/iac_code/cli/main.py:774 src/iac_code/cli/main.py:834 +#: src/iac_code/cli/main.py:884 src/iac_code/cli/main.py:933 +#: src/iac_code/cli/main.py:1012 src/iac_code/cli/main.py:1069 +#: src/iac_code/cli/main.py:1125 src/iac_code/cli/main.py:1182 +msgid "A2A JSON-RPC endpoint URL" +msgstr "URL del endpoint JSON-RPC A2A" + +#: src/iac_code/cli/main.py:558 src/iac_code/cli/main.py:1227 +msgid "Route spec: name=url;skills=skill1,skill2;tags=tag1,tag2" +msgstr "Especificación de ruta: name=url;skills=skill1,skill2;tags=tag1,tag2" + +#: src/iac_code/cli/main.py:559 +msgid "Named A2A route to call" +msgstr "Ruta A2A con nombre a llamar" + +#: src/iac_code/cli/main.py:560 +msgid "Prompt to send" +msgstr "Prompt a enviar" + +#: src/iac_code/cli/main.py:561 +msgid "Working directory metadata to send with the request" +msgstr "Metadatos del directorio de trabajo a enviar con la solicitud" + +#: src/iac_code/cli/main.py:562 +msgid "A2A context ID to continue" +msgstr "ID de contexto A2A a continuar" + +#: src/iac_code/cli/main.py:563 src/iac_code/cli/main.py:652 +#: src/iac_code/cli/main.py:724 src/iac_code/cli/main.py:781 +#: src/iac_code/cli/main.py:836 src/iac_code/cli/main.py:886 +#: src/iac_code/cli/main.py:940 src/iac_code/cli/main.py:1015 +#: src/iac_code/cli/main.py:1073 src/iac_code/cli/main.py:1128 +#: src/iac_code/cli/main.py:1183 +msgid "Bearer token for A2A HTTP requests" +msgstr "Token Bearer para solicitudes HTTP A2A" + +#: src/iac_code/cli/main.py:564 src/iac_code/cli/main.py:653 +#: src/iac_code/cli/main.py:725 src/iac_code/cli/main.py:782 +#: src/iac_code/cli/main.py:837 src/iac_code/cli/main.py:887 +#: src/iac_code/cli/main.py:941 src/iac_code/cli/main.py:1016 +#: src/iac_code/cli/main.py:1074 src/iac_code/cli/main.py:1129 +#: src/iac_code/cli/main.py:1184 +msgid "Basic auth username for A2A HTTP requests" +msgstr "Nombre de usuario de autenticación básica para solicitudes HTTP A2A" + +#: src/iac_code/cli/main.py:565 src/iac_code/cli/main.py:654 +#: src/iac_code/cli/main.py:726 src/iac_code/cli/main.py:783 +#: src/iac_code/cli/main.py:838 src/iac_code/cli/main.py:888 +#: src/iac_code/cli/main.py:942 src/iac_code/cli/main.py:1017 +#: src/iac_code/cli/main.py:1075 src/iac_code/cli/main.py:1130 +#: src/iac_code/cli/main.py:1185 +msgid "Basic auth password for A2A HTTP requests" +msgstr "Contraseña de autenticación básica para solicitudes HTTP A2A" + +#: src/iac_code/cli/main.py:566 src/iac_code/cli/main.py:655 +#: src/iac_code/cli/main.py:727 src/iac_code/cli/main.py:784 +#: src/iac_code/cli/main.py:839 src/iac_code/cli/main.py:889 +#: src/iac_code/cli/main.py:943 src/iac_code/cli/main.py:1018 +#: src/iac_code/cli/main.py:1076 src/iac_code/cli/main.py:1131 +#: src/iac_code/cli/main.py:1186 +msgid "API key for A2A HTTP requests" +msgstr "Clave de API para solicitudes HTTP A2A" + +#: src/iac_code/cli/main.py:567 src/iac_code/cli/main.py:656 +#: src/iac_code/cli/main.py:728 src/iac_code/cli/main.py:785 +#: src/iac_code/cli/main.py:840 src/iac_code/cli/main.py:890 +#: src/iac_code/cli/main.py:944 src/iac_code/cli/main.py:1019 +#: src/iac_code/cli/main.py:1077 src/iac_code/cli/main.py:1132 +#: src/iac_code/cli/main.py:1187 +msgid "HTTP header name for A2A API key" +msgstr "Nombre del encabezado HTTP para la clave de API A2A" + +#: src/iac_code/cli/main.py:572 src/iac_code/cli/main.py:661 +msgid "Secret used to verify the A2A Agent Card" +msgstr "Secreto utilizado para verificar la A2A Agent Card" + +#: src/iac_code/cli/main.py:577 src/iac_code/cli/main.py:666 +msgid "Remote JWKS URL used to verify the A2A Agent Card" +msgstr "URL JWKS remota utilizada para verificar la A2A Agent Card" + +#: src/iac_code/cli/main.py:583 src/iac_code/cli/main.py:672 +msgid "Require a valid A2A Agent Card signature" +msgstr "Requerir una firma válida de A2A Agent Card" + +#: src/iac_code/cli/main.py:585 +msgid "A2A call timeout in seconds" +msgstr "Tiempo de espera de llamada A2A en segundos" + +#: src/iac_code/cli/main.py:586 +msgid "Use A2A streaming message delivery" +msgstr "Usar entrega de mensajes en streaming A2A" + +#: src/iac_code/cli/main.py:648 +msgid "Discover an A2A Agent Card." +msgstr "Descubre una A2A Agent Card." + +#: src/iac_code/cli/main.py:651 +msgid "A2A agent base URL" +msgstr "URL base del agente A2A" + +#: src/iac_code/cli/main.py:718 +msgid "Get an A2A task." +msgstr "Obtén una tarea A2A." + +#: src/iac_code/cli/main.py:722 src/iac_code/cli/main.py:835 +#: src/iac_code/cli/main.py:885 src/iac_code/cli/main.py:934 +#: src/iac_code/cli/main.py:1013 src/iac_code/cli/main.py:1070 +#: src/iac_code/cli/main.py:1126 +msgid "A2A task ID" +msgstr "ID de tarea A2A" + +#: src/iac_code/cli/main.py:723 +msgid "Maximum task history items to return" +msgstr "Número máximo de elementos del historial de tareas a devolver" + +#: src/iac_code/cli/main.py:771 +msgid "List A2A tasks." +msgstr "Lista las tareas A2A." + +#: src/iac_code/cli/main.py:775 +msgid "Filter by A2A context ID" +msgstr "Filtrar por ID de contexto A2A" + +#: src/iac_code/cli/main.py:776 +msgid "Filter by A2A task state" +msgstr "Filtrar por estado de tarea A2A" + +#: src/iac_code/cli/main.py:777 +msgid "Maximum tasks to return" +msgstr "Número máximo de tareas a devolver" + +#: src/iac_code/cli/main.py:778 src/iac_code/cli/main.py:1072 +msgid "Pagination token" +msgstr "Token de paginación" + +#: src/iac_code/cli/main.py:779 +msgid "Include task artifacts" +msgstr "Incluir artefactos de tarea" + +#: src/iac_code/cli/main.py:780 +msgid "Output format: table or json" +msgstr "Formato de salida: table o json" + +#: src/iac_code/cli/main.py:831 +msgid "Cancel an A2A task." +msgstr "Cancela una tarea A2A." + +#: src/iac_code/cli/main.py:881 +msgid "Subscribe to an A2A task event stream." +msgstr "Suscríbete a un flujo de eventos de tarea A2A." + +#: src/iac_code/cli/main.py:930 +msgid "Create an A2A task push notification config." +msgstr "Crea una configuración de notificación push de tarea A2A." + +#: src/iac_code/cli/main.py:935 src/iac_code/cli/main.py:1014 +#: src/iac_code/cli/main.py:1127 +msgid "Push config ID" +msgstr "ID de configuración push" + +#: src/iac_code/cli/main.py:936 +msgid "Push callback URL" +msgstr "URL de callback push" + +#: src/iac_code/cli/main.py:937 +msgid "Notification verification token" +msgstr "Token de verificación de notificación" + +#: src/iac_code/cli/main.py:938 +msgid "Callback authentication scheme" +msgstr "Esquema de autenticación de callback" + +#: src/iac_code/cli/main.py:939 +msgid "Callback authentication credentials" +msgstr "Credenciales de autenticación de callback" + +#: src/iac_code/cli/main.py:1009 +msgid "Get an A2A task push notification config." +msgstr "Obtén una configuración de notificación push de tarea A2A." + +#: src/iac_code/cli/main.py:1066 +msgid "List A2A task push notification configs." +msgstr "Lista las configuraciones de notificación push de tareas A2A." + +#: src/iac_code/cli/main.py:1071 +msgid "Maximum configs to return" +msgstr "Número máximo de configuraciones a devolver" + +#: src/iac_code/cli/main.py:1122 +msgid "Delete an A2A task push notification config." +msgstr "Elimina una configuración de notificación push de tarea A2A." + +#: src/iac_code/cli/main.py:1179 +msgid "Get an authenticated extended A2A Agent Card." +msgstr "Obtén una A2A Agent Card extendida autenticada." + +#: src/iac_code/cli/main.py:1222 +msgid "Preview A2A route resolution." +msgstr "Vista previa de la resolución de rutas A2A." + +#: src/iac_code/cli/main.py:1229 +msgid "Route name to resolve" +msgstr "Nombre de ruta a resolver" + +#: src/iac_code/cli/main.py:1230 +msgid "Skill ID to resolve" +msgstr "ID de habilidad a resolver" + +#: src/iac_code/cli/main.py:1231 +msgid "Prompt text used for tag/name route matching" +msgstr "" +"Texto del prompt utilizado para la coincidencia de rutas por " +"etiqueta/nombre" + +#: src/iac_code/cli/main.py:1236 +msgid "Directory for persisted A2A routes" +msgstr "Directorio para rutas A2A persistentes" + +#: src/iac_code/cli/main.py:1238 +msgid "Save the provided routes as a route snapshot" +msgstr "Guarda las rutas proporcionadas como una instantánea de rutas" + #: src/iac_code/commands/__init__.py:22 msgid "Show available commands" msgstr "Mostrar los comandos disponibles" diff --git a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po index 0f4ac575..8da9c6eb 100644 --- a/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/fr/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: iac-code 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-18 10:11+0800\n" +"POT-Creation-Date: 2026-05-18 12:25+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: fr\n" @@ -135,47 +135,65 @@ msgstr "" " Documentation : https://aliyun.github.io/iac-" "code/fr/docs/configuration/authentication\n" -#: src/iac_code/cli/main.py:23 src/iac_code/commands/help.py:26 +#: src/iac_code/cli/main.py:32 src/iac_code/commands/help.py:26 msgid "AI-powered infrastructure orchestration tool" msgstr "Outil d’orchestration d’infrastructure assisté par IA" -#: src/iac_code/cli/main.py:34 +#: src/iac_code/cli/main.py:40 +msgid "Use iac-code as an A2A client." +msgstr "Utilise iac-code comme client A2A." + +#: src/iac_code/cli/main.py:49 +msgid "YAML config file containing A2A client options" +msgstr "Fichier de configuration YAML contenant les options client A2A" + +#: src/iac_code/cli/main.py:56 src/iac_code/cli/main.py:1271 +#: src/iac_code/cli/main.py:1632 src/iac_code/cli/main.py:1671 +msgid "" +"A2A client dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"Les dépendances du client A2A sont manquantes. Installez-les avec : pip " +"install 'iac-code[a2a]'" + +#: src/iac_code/cli/main.py:70 msgid "LLM model to use" msgstr "Modèle LLM à utiliser" -#: src/iac_code/cli/main.py:35 +#: src/iac_code/cli/main.py:71 msgid "Non-interactive mode: run a single prompt and exit" msgstr "Mode non interactif : exécuter un seul prompt puis quitter" -#: src/iac_code/cli/main.py:36 +#: src/iac_code/cli/main.py:72 msgid "Output format: text, json, stream-json" msgstr "Format de sortie : text, json, stream-json" -#: src/iac_code/cli/main.py:37 +#: src/iac_code/cli/main.py:73 msgid "Maximum agent turns in headless mode" msgstr "Nombre maximal de tours d’agent en mode headless" -#: src/iac_code/cli/main.py:38 src/iac_code/cli/main.py:275 +#: src/iac_code/cli/main.py:74 src/iac_code/cli/main.py:311 +#: src/iac_code/cli/main.py:460 msgid "Enable debug logging" msgstr "Activer la journalisation debug" -#: src/iac_code/cli/main.py:39 +#: src/iac_code/cli/main.py:75 msgid "Show version and exit" msgstr "Afficher la version et quitter" -#: src/iac_code/cli/main.py:40 +#: src/iac_code/cli/main.py:76 msgid "Resume a session by ID" msgstr "Reprendre une session par identifiant" -#: src/iac_code/cli/main.py:41 +#: src/iac_code/cli/main.py:77 msgid "Resume the most recent session" msgstr "Reprendre la session la plus récente" -#: src/iac_code/cli/main.py:48 src/iac_code/i18n/__init__.py:54 +#: src/iac_code/cli/main.py:84 src/iac_code/i18n/__init__.py:54 msgid "Install completion for the current shell." msgstr "Installer la complétion pour le shell actuel." -#: src/iac_code/cli/main.py:56 src/iac_code/i18n/__init__.py:55 +#: src/iac_code/cli/main.py:92 src/iac_code/i18n/__init__.py:55 msgid "" "Show completion for the current shell, to copy it or customize the " "installation." @@ -183,7 +201,7 @@ msgstr "" "Afficher la complétion pour le shell actuel afin de la copier ou de " "personnaliser l’installation." -#: src/iac_code/cli/main.py:61 +#: src/iac_code/cli/main.py:97 msgid "" "Comma-separated tool permission patterns to allow, e.g. 'bash(git " "*),write_file'" @@ -191,36 +209,293 @@ msgstr "" "Modèles de permissions d'outils à autoriser (séparés par des virgules), " "ex. 'bash(git *),write_file'*),write_file'" -#: src/iac_code/cli/main.py:66 +#: src/iac_code/cli/main.py:102 msgid "Comma-separated tool permission patterns to deny" msgstr "Modèles de permissions d'outils à refuser (séparés par des virgules)" -#: src/iac_code/cli/main.py:71 +#: src/iac_code/cli/main.py:107 msgid "Permission mode: default, accept_edits, bypass_permissions, dont_ask" msgstr "" "Permission mode: default, accept_edits, bypass_permissions, dont_askMode " "de permissions : default, accept_edits, bypass_permissions, dont_ask" -#: src/iac_code/cli/main.py:85 +#: src/iac_code/cli/main.py:121 msgid "Error: --resume and --continue cannot be used together." msgstr "Erreur : --resume et --continue ne peuvent pas être utilisés ensemble." -#: src/iac_code/cli/main.py:270 +#: src/iac_code/cli/main.py:306 msgid "Run iac-code as an ACP server." msgstr "Exécuter iac-code comme serveur ACP." -#: src/iac_code/cli/main.py:272 +#: src/iac_code/cli/main.py:308 msgid "Transport type: stdio or http" msgstr "Type de transport : stdio ou http" -#: src/iac_code/cli/main.py:273 +#: src/iac_code/cli/main.py:309 msgid "HTTP server port" msgstr "Port du serveur HTTP" -#: src/iac_code/cli/main.py:274 +#: src/iac_code/cli/main.py:310 src/iac_code/cli/main.py:450 msgid "HTTP server host" msgstr "Hôte du serveur HTTP" +#: src/iac_code/cli/main.py:446 +msgid "Run iac-code as an A2A 1.0 server." +msgstr "Exécute iac-code en tant que serveur A2A 1.0." + +#: src/iac_code/cli/main.py:449 +msgid "YAML config file for A2A server options" +msgstr "Fichier de configuration YAML pour les options du serveur A2A" + +#: src/iac_code/cli/main.py:453 +msgid "" +"HTTP server port. 41242 is the iac-code default inspired by Gemini CLI, " +"not a registered A2A port." +msgstr "" +"Port du serveur HTTP. 41242 est la valeur par défaut d'iac-code inspirée " +"de Gemini CLI, pas un port A2A enregistré." + +#: src/iac_code/cli/main.py:458 +msgid "" +"A2A transport: http, stdio, unix, websocket, grpc, grpc-jsonrpc, or " +"redis-streams" +msgstr "" +"Transport A2A : http, stdio, unix, websocket, grpc, grpc-jsonrpc ou " +"redis-streams" + +#: src/iac_code/cli/main.py:505 +msgid "" +"A2A server dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"Les dépendances du serveur A2A sont manquantes. Installez-les avec : pip " +"install 'iac-code[a2a]'" + +#: src/iac_code/cli/main.py:554 +msgid "Send a prompt to an A2A JSON-RPC endpoint." +msgstr "Envoie un prompt à un point de terminaison JSON-RPC A2A." + +#: src/iac_code/cli/main.py:557 src/iac_code/cli/main.py:721 +#: src/iac_code/cli/main.py:774 src/iac_code/cli/main.py:834 +#: src/iac_code/cli/main.py:884 src/iac_code/cli/main.py:933 +#: src/iac_code/cli/main.py:1012 src/iac_code/cli/main.py:1069 +#: src/iac_code/cli/main.py:1125 src/iac_code/cli/main.py:1182 +msgid "A2A JSON-RPC endpoint URL" +msgstr "URL du point de terminaison JSON-RPC A2A" + +#: src/iac_code/cli/main.py:558 src/iac_code/cli/main.py:1227 +msgid "Route spec: name=url;skills=skill1,skill2;tags=tag1,tag2" +msgstr "Spécification de route : name=url;skills=skill1,skill2;tags=tag1,tag2" + +#: src/iac_code/cli/main.py:559 +msgid "Named A2A route to call" +msgstr "Route A2A nommée à appeler" + +#: src/iac_code/cli/main.py:560 +msgid "Prompt to send" +msgstr "Prompt à envoyer" + +#: src/iac_code/cli/main.py:561 +msgid "Working directory metadata to send with the request" +msgstr "Métadonnées du répertoire de travail à envoyer avec la requête" + +#: src/iac_code/cli/main.py:562 +msgid "A2A context ID to continue" +msgstr "ID de contexte A2A à poursuivre" + +#: src/iac_code/cli/main.py:563 src/iac_code/cli/main.py:652 +#: src/iac_code/cli/main.py:724 src/iac_code/cli/main.py:781 +#: src/iac_code/cli/main.py:836 src/iac_code/cli/main.py:886 +#: src/iac_code/cli/main.py:940 src/iac_code/cli/main.py:1015 +#: src/iac_code/cli/main.py:1073 src/iac_code/cli/main.py:1128 +#: src/iac_code/cli/main.py:1183 +msgid "Bearer token for A2A HTTP requests" +msgstr "Jeton Bearer pour les requêtes HTTP A2A" + +#: src/iac_code/cli/main.py:564 src/iac_code/cli/main.py:653 +#: src/iac_code/cli/main.py:725 src/iac_code/cli/main.py:782 +#: src/iac_code/cli/main.py:837 src/iac_code/cli/main.py:887 +#: src/iac_code/cli/main.py:941 src/iac_code/cli/main.py:1016 +#: src/iac_code/cli/main.py:1074 src/iac_code/cli/main.py:1129 +#: src/iac_code/cli/main.py:1184 +msgid "Basic auth username for A2A HTTP requests" +msgstr "Nom d'utilisateur d'authentification basique pour les requêtes HTTP A2A" + +#: src/iac_code/cli/main.py:565 src/iac_code/cli/main.py:654 +#: src/iac_code/cli/main.py:726 src/iac_code/cli/main.py:783 +#: src/iac_code/cli/main.py:838 src/iac_code/cli/main.py:888 +#: src/iac_code/cli/main.py:942 src/iac_code/cli/main.py:1017 +#: src/iac_code/cli/main.py:1075 src/iac_code/cli/main.py:1130 +#: src/iac_code/cli/main.py:1185 +msgid "Basic auth password for A2A HTTP requests" +msgstr "Mot de passe d'authentification basique pour les requêtes HTTP A2A" + +#: src/iac_code/cli/main.py:566 src/iac_code/cli/main.py:655 +#: src/iac_code/cli/main.py:727 src/iac_code/cli/main.py:784 +#: src/iac_code/cli/main.py:839 src/iac_code/cli/main.py:889 +#: src/iac_code/cli/main.py:943 src/iac_code/cli/main.py:1018 +#: src/iac_code/cli/main.py:1076 src/iac_code/cli/main.py:1131 +#: src/iac_code/cli/main.py:1186 +msgid "API key for A2A HTTP requests" +msgstr "Clé d'API pour les requêtes HTTP A2A" + +#: src/iac_code/cli/main.py:567 src/iac_code/cli/main.py:656 +#: src/iac_code/cli/main.py:728 src/iac_code/cli/main.py:785 +#: src/iac_code/cli/main.py:840 src/iac_code/cli/main.py:890 +#: src/iac_code/cli/main.py:944 src/iac_code/cli/main.py:1019 +#: src/iac_code/cli/main.py:1077 src/iac_code/cli/main.py:1132 +#: src/iac_code/cli/main.py:1187 +msgid "HTTP header name for A2A API key" +msgstr "Nom de l'en-tête HTTP pour la clé d'API A2A" + +#: src/iac_code/cli/main.py:572 src/iac_code/cli/main.py:661 +msgid "Secret used to verify the A2A Agent Card" +msgstr "Secret utilisé pour vérifier l'A2A Agent Card" + +#: src/iac_code/cli/main.py:577 src/iac_code/cli/main.py:666 +msgid "Remote JWKS URL used to verify the A2A Agent Card" +msgstr "URL JWKS distante utilisée pour vérifier l'A2A Agent Card" + +#: src/iac_code/cli/main.py:583 src/iac_code/cli/main.py:672 +msgid "Require a valid A2A Agent Card signature" +msgstr "Exiger une signature valide de l'A2A Agent Card" + +#: src/iac_code/cli/main.py:585 +msgid "A2A call timeout in seconds" +msgstr "Délai d'expiration de l'appel A2A en secondes" + +#: src/iac_code/cli/main.py:586 +msgid "Use A2A streaming message delivery" +msgstr "Utiliser la diffusion en continu de messages A2A" + +#: src/iac_code/cli/main.py:648 +msgid "Discover an A2A Agent Card." +msgstr "Découvre une A2A Agent Card." + +#: src/iac_code/cli/main.py:651 +msgid "A2A agent base URL" +msgstr "URL de base de l'agent A2A" + +#: src/iac_code/cli/main.py:718 +msgid "Get an A2A task." +msgstr "Récupère une tâche A2A." + +#: src/iac_code/cli/main.py:722 src/iac_code/cli/main.py:835 +#: src/iac_code/cli/main.py:885 src/iac_code/cli/main.py:934 +#: src/iac_code/cli/main.py:1013 src/iac_code/cli/main.py:1070 +#: src/iac_code/cli/main.py:1126 +msgid "A2A task ID" +msgstr "ID de tâche A2A" + +#: src/iac_code/cli/main.py:723 +msgid "Maximum task history items to return" +msgstr "Nombre maximal d'éléments d'historique de tâche à retourner" + +#: src/iac_code/cli/main.py:771 +msgid "List A2A tasks." +msgstr "Liste les tâches A2A." + +#: src/iac_code/cli/main.py:775 +msgid "Filter by A2A context ID" +msgstr "Filtrer par ID de contexte A2A" + +#: src/iac_code/cli/main.py:776 +msgid "Filter by A2A task state" +msgstr "Filtrer par état de tâche A2A" + +#: src/iac_code/cli/main.py:777 +msgid "Maximum tasks to return" +msgstr "Nombre maximal de tâches à retourner" + +#: src/iac_code/cli/main.py:778 src/iac_code/cli/main.py:1072 +msgid "Pagination token" +msgstr "Jeton de pagination" + +#: src/iac_code/cli/main.py:779 +msgid "Include task artifacts" +msgstr "Inclure les artefacts de tâche" + +#: src/iac_code/cli/main.py:780 +msgid "Output format: table or json" +msgstr "Format de sortie : table ou json" + +#: src/iac_code/cli/main.py:831 +msgid "Cancel an A2A task." +msgstr "Annule une tâche A2A." + +#: src/iac_code/cli/main.py:881 +msgid "Subscribe to an A2A task event stream." +msgstr "S'abonne à un flux d'événements de tâche A2A." + +#: src/iac_code/cli/main.py:930 +msgid "Create an A2A task push notification config." +msgstr "Crée une configuration de notifications push de tâche A2A." + +#: src/iac_code/cli/main.py:935 src/iac_code/cli/main.py:1014 +#: src/iac_code/cli/main.py:1127 +msgid "Push config ID" +msgstr "ID de configuration push" + +#: src/iac_code/cli/main.py:936 +msgid "Push callback URL" +msgstr "URL de rappel push" + +#: src/iac_code/cli/main.py:937 +msgid "Notification verification token" +msgstr "Jeton de vérification de notification" + +#: src/iac_code/cli/main.py:938 +msgid "Callback authentication scheme" +msgstr "Schéma d'authentification du rappel" + +#: src/iac_code/cli/main.py:939 +msgid "Callback authentication credentials" +msgstr "Identifiants d'authentification du rappel" + +#: src/iac_code/cli/main.py:1009 +msgid "Get an A2A task push notification config." +msgstr "Récupère une configuration de notifications push de tâche A2A." + +#: src/iac_code/cli/main.py:1066 +msgid "List A2A task push notification configs." +msgstr "Liste les configurations de notifications push de tâches A2A." + +#: src/iac_code/cli/main.py:1071 +msgid "Maximum configs to return" +msgstr "Nombre maximal de configurations à retourner" + +#: src/iac_code/cli/main.py:1122 +msgid "Delete an A2A task push notification config." +msgstr "Supprime une configuration de notifications push de tâche A2A." + +#: src/iac_code/cli/main.py:1179 +msgid "Get an authenticated extended A2A Agent Card." +msgstr "Récupère une A2A Agent Card étendue authentifiée." + +#: src/iac_code/cli/main.py:1222 +msgid "Preview A2A route resolution." +msgstr "Aperçu de la résolution des routes A2A." + +#: src/iac_code/cli/main.py:1229 +msgid "Route name to resolve" +msgstr "Nom de route à résoudre" + +#: src/iac_code/cli/main.py:1230 +msgid "Skill ID to resolve" +msgstr "ID de compétence à résoudre" + +#: src/iac_code/cli/main.py:1231 +msgid "Prompt text used for tag/name route matching" +msgstr "Texte du prompt utilisé pour la correspondance des routes par tag/nom" + +#: src/iac_code/cli/main.py:1236 +msgid "Directory for persisted A2A routes" +msgstr "Répertoire pour les routes A2A persistées" + +#: src/iac_code/cli/main.py:1238 +msgid "Save the provided routes as a route snapshot" +msgstr "Enregistre les routes fournies sous forme d'instantané de routes" + #: src/iac_code/commands/__init__.py:22 msgid "Show available commands" msgstr "Afficher les commandes disponibles" diff --git a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po index 5bd3cd06..e4908291 100644 --- a/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/ja/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: iac-code 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-18 10:11+0800\n" +"POT-Creation-Date: 2026-05-18 12:25+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: ja\n" @@ -130,86 +130,355 @@ msgstr "" " ドキュメント:https://aliyun.github.io/iac-" "code/ja/docs/configuration/authentication\n" -#: src/iac_code/cli/main.py:23 src/iac_code/commands/help.py:26 +#: src/iac_code/cli/main.py:32 src/iac_code/commands/help.py:26 msgid "AI-powered infrastructure orchestration tool" msgstr "AI 駆動のインフラストラクチャ・オーケストレーションツール" -#: src/iac_code/cli/main.py:34 +#: src/iac_code/cli/main.py:40 +msgid "Use iac-code as an A2A client." +msgstr "iac-code を A2A クライアントとして使用します。" + +#: src/iac_code/cli/main.py:49 +msgid "YAML config file containing A2A client options" +msgstr "A2A クライアントオプションを含む YAML 設定ファイル" + +#: src/iac_code/cli/main.py:56 src/iac_code/cli/main.py:1271 +#: src/iac_code/cli/main.py:1632 src/iac_code/cli/main.py:1671 +msgid "" +"A2A client dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "A2A クライアントの依存関係が不足しています。次のコマンドでインストールしてください: pip install 'iac-code[a2a]'" + +#: src/iac_code/cli/main.py:70 msgid "LLM model to use" msgstr "使用する LLM モデル" -#: src/iac_code/cli/main.py:35 +#: src/iac_code/cli/main.py:71 msgid "Non-interactive mode: run a single prompt and exit" msgstr "非対話モード:単一のプロンプトを実行して終了します" -#: src/iac_code/cli/main.py:36 +#: src/iac_code/cli/main.py:72 msgid "Output format: text, json, stream-json" msgstr "出力形式:text、json、stream-json" -#: src/iac_code/cli/main.py:37 +#: src/iac_code/cli/main.py:73 msgid "Maximum agent turns in headless mode" msgstr "ヘッドレスモードでの最大エージェンターン数" -#: src/iac_code/cli/main.py:38 src/iac_code/cli/main.py:275 +#: src/iac_code/cli/main.py:74 src/iac_code/cli/main.py:311 +#: src/iac_code/cli/main.py:460 msgid "Enable debug logging" msgstr "デバッグログを有効にする" -#: src/iac_code/cli/main.py:39 +#: src/iac_code/cli/main.py:75 msgid "Show version and exit" msgstr "バージョンを表示して終了する" -#: src/iac_code/cli/main.py:40 +#: src/iac_code/cli/main.py:76 msgid "Resume a session by ID" msgstr "ID でセッションを再開する" -#: src/iac_code/cli/main.py:41 +#: src/iac_code/cli/main.py:77 msgid "Resume the most recent session" msgstr "直近のセッションを再開する" -#: src/iac_code/cli/main.py:48 src/iac_code/i18n/__init__.py:54 +#: src/iac_code/cli/main.py:84 src/iac_code/i18n/__init__.py:54 msgid "Install completion for the current shell." msgstr "現在の shell に補完をインストールします。" -#: src/iac_code/cli/main.py:56 src/iac_code/i18n/__init__.py:55 +#: src/iac_code/cli/main.py:92 src/iac_code/i18n/__init__.py:55 msgid "" "Show completion for the current shell, to copy it or customize the " "installation." msgstr "現在の shell 向けの補完スクリプトを表示します。コピーしたり、インストールをカスタマイズしたりできます。" -#: src/iac_code/cli/main.py:61 +#: src/iac_code/cli/main.py:97 msgid "" "Comma-separated tool permission patterns to allow, e.g. 'bash(git " "*),write_file'" msgstr "許可するツール権限パターン(カンマ区切り)、例: 'bash(git *),write_file'*),write_file'" -#: src/iac_code/cli/main.py:66 +#: src/iac_code/cli/main.py:102 msgid "Comma-separated tool permission patterns to deny" msgstr "拒否するツール権限パターン(カンマ区切り)" -#: src/iac_code/cli/main.py:71 +#: src/iac_code/cli/main.py:107 msgid "Permission mode: default, accept_edits, bypass_permissions, dont_ask" msgstr "権限モード: default, accept_edits, bypass_permissions, dont_ask" -#: src/iac_code/cli/main.py:85 +#: src/iac_code/cli/main.py:121 msgid "Error: --resume and --continue cannot be used together." msgstr "エラー:--resume と --continue は同時に使用できません。" -#: src/iac_code/cli/main.py:270 +#: src/iac_code/cli/main.py:306 msgid "Run iac-code as an ACP server." msgstr "iac-code を ACP サーバーとして実行します。" -#: src/iac_code/cli/main.py:272 +#: src/iac_code/cli/main.py:308 msgid "Transport type: stdio or http" msgstr "トランスポートの種類:stdio または http" -#: src/iac_code/cli/main.py:273 +#: src/iac_code/cli/main.py:309 msgid "HTTP server port" msgstr "HTTP サーバーのポート" -#: src/iac_code/cli/main.py:274 +#: src/iac_code/cli/main.py:310 src/iac_code/cli/main.py:450 msgid "HTTP server host" msgstr "HTTP サーバーのホスト" +#: src/iac_code/cli/main.py:446 +msgid "Run iac-code as an A2A 1.0 server." +msgstr "iac-code を A2A 1.0 サーバーとして実行します。" + +#: src/iac_code/cli/main.py:449 +msgid "YAML config file for A2A server options" +msgstr "A2A サーバーオプション用の YAML 設定ファイル" + +#: src/iac_code/cli/main.py:453 +msgid "" +"HTTP server port. 41242 is the iac-code default inspired by Gemini CLI, " +"not a registered A2A port." +msgstr "" +"HTTP サーバーポート。41242 は Gemini CLI に触発された iac-code のデフォルトで、登録済みの A2A " +"ポートではありません。" + +#: src/iac_code/cli/main.py:458 +msgid "" +"A2A transport: http, stdio, unix, websocket, grpc, grpc-jsonrpc, or " +"redis-streams" +msgstr "A2A トランスポート: http、stdio、unix、websocket、grpc、grpc-jsonrpc、または redis-streams" + +#: src/iac_code/cli/main.py:505 +msgid "" +"A2A server dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "A2A サーバーの依存関係が不足しています。次のコマンドでインストールしてください: pip install 'iac-code[a2a]'" + +#: src/iac_code/cli/main.py:554 +msgid "Send a prompt to an A2A JSON-RPC endpoint." +msgstr "A2A JSON-RPC エンドポイントにプロンプトを送信します。" + +#: src/iac_code/cli/main.py:557 src/iac_code/cli/main.py:721 +#: src/iac_code/cli/main.py:774 src/iac_code/cli/main.py:834 +#: src/iac_code/cli/main.py:884 src/iac_code/cli/main.py:933 +#: src/iac_code/cli/main.py:1012 src/iac_code/cli/main.py:1069 +#: src/iac_code/cli/main.py:1125 src/iac_code/cli/main.py:1182 +msgid "A2A JSON-RPC endpoint URL" +msgstr "A2A JSON-RPC エンドポイント URL" + +#: src/iac_code/cli/main.py:558 src/iac_code/cli/main.py:1227 +msgid "Route spec: name=url;skills=skill1,skill2;tags=tag1,tag2" +msgstr "ルート仕様: name=url;skills=skill1,skill2;tags=tag1,tag2" + +#: src/iac_code/cli/main.py:559 +msgid "Named A2A route to call" +msgstr "呼び出す名前付き A2A ルート" + +#: src/iac_code/cli/main.py:560 +msgid "Prompt to send" +msgstr "送信するプロンプト" + +#: src/iac_code/cli/main.py:561 +msgid "Working directory metadata to send with the request" +msgstr "リクエストと共に送信する作業ディレクトリのメタデータ" + +#: src/iac_code/cli/main.py:562 +msgid "A2A context ID to continue" +msgstr "継続する A2A コンテキスト ID" + +#: src/iac_code/cli/main.py:563 src/iac_code/cli/main.py:652 +#: src/iac_code/cli/main.py:724 src/iac_code/cli/main.py:781 +#: src/iac_code/cli/main.py:836 src/iac_code/cli/main.py:886 +#: src/iac_code/cli/main.py:940 src/iac_code/cli/main.py:1015 +#: src/iac_code/cli/main.py:1073 src/iac_code/cli/main.py:1128 +#: src/iac_code/cli/main.py:1183 +msgid "Bearer token for A2A HTTP requests" +msgstr "A2A HTTP リクエスト用の Bearer トークン" + +#: src/iac_code/cli/main.py:564 src/iac_code/cli/main.py:653 +#: src/iac_code/cli/main.py:725 src/iac_code/cli/main.py:782 +#: src/iac_code/cli/main.py:837 src/iac_code/cli/main.py:887 +#: src/iac_code/cli/main.py:941 src/iac_code/cli/main.py:1016 +#: src/iac_code/cli/main.py:1074 src/iac_code/cli/main.py:1129 +#: src/iac_code/cli/main.py:1184 +msgid "Basic auth username for A2A HTTP requests" +msgstr "A2A HTTP リクエスト用の Basic 認証ユーザー名" + +#: src/iac_code/cli/main.py:565 src/iac_code/cli/main.py:654 +#: src/iac_code/cli/main.py:726 src/iac_code/cli/main.py:783 +#: src/iac_code/cli/main.py:838 src/iac_code/cli/main.py:888 +#: src/iac_code/cli/main.py:942 src/iac_code/cli/main.py:1017 +#: src/iac_code/cli/main.py:1075 src/iac_code/cli/main.py:1130 +#: src/iac_code/cli/main.py:1185 +msgid "Basic auth password for A2A HTTP requests" +msgstr "A2A HTTP リクエスト用の Basic 認証パスワード" + +#: src/iac_code/cli/main.py:566 src/iac_code/cli/main.py:655 +#: src/iac_code/cli/main.py:727 src/iac_code/cli/main.py:784 +#: src/iac_code/cli/main.py:839 src/iac_code/cli/main.py:889 +#: src/iac_code/cli/main.py:943 src/iac_code/cli/main.py:1018 +#: src/iac_code/cli/main.py:1076 src/iac_code/cli/main.py:1131 +#: src/iac_code/cli/main.py:1186 +msgid "API key for A2A HTTP requests" +msgstr "A2A HTTP リクエスト用の API キー" + +#: src/iac_code/cli/main.py:567 src/iac_code/cli/main.py:656 +#: src/iac_code/cli/main.py:728 src/iac_code/cli/main.py:785 +#: src/iac_code/cli/main.py:840 src/iac_code/cli/main.py:890 +#: src/iac_code/cli/main.py:944 src/iac_code/cli/main.py:1019 +#: src/iac_code/cli/main.py:1077 src/iac_code/cli/main.py:1132 +#: src/iac_code/cli/main.py:1187 +msgid "HTTP header name for A2A API key" +msgstr "A2A API キー用の HTTP ヘッダー名" + +#: src/iac_code/cli/main.py:572 src/iac_code/cli/main.py:661 +msgid "Secret used to verify the A2A Agent Card" +msgstr "A2A Agent Card の検証に使用するシークレット" + +#: src/iac_code/cli/main.py:577 src/iac_code/cli/main.py:666 +msgid "Remote JWKS URL used to verify the A2A Agent Card" +msgstr "A2A Agent Card の検証に使用するリモート JWKS URL" + +#: src/iac_code/cli/main.py:583 src/iac_code/cli/main.py:672 +msgid "Require a valid A2A Agent Card signature" +msgstr "有効な A2A Agent Card 署名を要求します" + +#: src/iac_code/cli/main.py:585 +msgid "A2A call timeout in seconds" +msgstr "A2A 呼び出しのタイムアウト(秒)" + +#: src/iac_code/cli/main.py:586 +msgid "Use A2A streaming message delivery" +msgstr "A2A ストリーミングメッセージ配信を使用します" + +#: src/iac_code/cli/main.py:648 +msgid "Discover an A2A Agent Card." +msgstr "A2A Agent Card を検出します。" + +#: src/iac_code/cli/main.py:651 +msgid "A2A agent base URL" +msgstr "A2A エージェントのベース URL" + +#: src/iac_code/cli/main.py:718 +msgid "Get an A2A task." +msgstr "A2A タスクを取得します。" + +#: src/iac_code/cli/main.py:722 src/iac_code/cli/main.py:835 +#: src/iac_code/cli/main.py:885 src/iac_code/cli/main.py:934 +#: src/iac_code/cli/main.py:1013 src/iac_code/cli/main.py:1070 +#: src/iac_code/cli/main.py:1126 +msgid "A2A task ID" +msgstr "A2A タスク ID" + +#: src/iac_code/cli/main.py:723 +msgid "Maximum task history items to return" +msgstr "返却するタスク履歴項目の最大数" + +#: src/iac_code/cli/main.py:771 +msgid "List A2A tasks." +msgstr "A2A タスクを一覧表示します。" + +#: src/iac_code/cli/main.py:775 +msgid "Filter by A2A context ID" +msgstr "A2A コンテキスト ID でフィルタリングします" + +#: src/iac_code/cli/main.py:776 +msgid "Filter by A2A task state" +msgstr "A2A タスク状態でフィルタリングします" + +#: src/iac_code/cli/main.py:777 +msgid "Maximum tasks to return" +msgstr "返却するタスクの最大数" + +#: src/iac_code/cli/main.py:778 src/iac_code/cli/main.py:1072 +msgid "Pagination token" +msgstr "ページネーショントークン" + +#: src/iac_code/cli/main.py:779 +msgid "Include task artifacts" +msgstr "タスクアーティファクトを含めます" + +#: src/iac_code/cli/main.py:780 +msgid "Output format: table or json" +msgstr "出力形式: table または json" + +#: src/iac_code/cli/main.py:831 +msgid "Cancel an A2A task." +msgstr "A2A タスクをキャンセルします。" + +#: src/iac_code/cli/main.py:881 +msgid "Subscribe to an A2A task event stream." +msgstr "A2A タスクのイベントストリームを購読します。" + +#: src/iac_code/cli/main.py:930 +msgid "Create an A2A task push notification config." +msgstr "A2A タスクプッシュ通知設定を作成します。" + +#: src/iac_code/cli/main.py:935 src/iac_code/cli/main.py:1014 +#: src/iac_code/cli/main.py:1127 +msgid "Push config ID" +msgstr "プッシュ設定 ID" + +#: src/iac_code/cli/main.py:936 +msgid "Push callback URL" +msgstr "プッシュコールバック URL" + +#: src/iac_code/cli/main.py:937 +msgid "Notification verification token" +msgstr "通知検証トークン" + +#: src/iac_code/cli/main.py:938 +msgid "Callback authentication scheme" +msgstr "コールバック認証方式" + +#: src/iac_code/cli/main.py:939 +msgid "Callback authentication credentials" +msgstr "コールバック認証資格情報" + +#: src/iac_code/cli/main.py:1009 +msgid "Get an A2A task push notification config." +msgstr "A2A タスクプッシュ通知設定を取得します。" + +#: src/iac_code/cli/main.py:1066 +msgid "List A2A task push notification configs." +msgstr "A2A タスクプッシュ通知設定を一覧表示します。" + +#: src/iac_code/cli/main.py:1071 +msgid "Maximum configs to return" +msgstr "返却する設定の最大数" + +#: src/iac_code/cli/main.py:1122 +msgid "Delete an A2A task push notification config." +msgstr "A2A タスクプッシュ通知設定を削除します。" + +#: src/iac_code/cli/main.py:1179 +msgid "Get an authenticated extended A2A Agent Card." +msgstr "認証済みの拡張 A2A Agent Card を取得します。" + +#: src/iac_code/cli/main.py:1222 +msgid "Preview A2A route resolution." +msgstr "A2A ルート解決をプレビューします。" + +#: src/iac_code/cli/main.py:1229 +msgid "Route name to resolve" +msgstr "解決するルート名" + +#: src/iac_code/cli/main.py:1230 +msgid "Skill ID to resolve" +msgstr "解決するスキル ID" + +#: src/iac_code/cli/main.py:1231 +msgid "Prompt text used for tag/name route matching" +msgstr "タグ/名前ルートのマッチングに使用するプロンプトテキスト" + +#: src/iac_code/cli/main.py:1236 +msgid "Directory for persisted A2A routes" +msgstr "永続化された A2A ルート用のディレクトリ" + +#: src/iac_code/cli/main.py:1238 +msgid "Save the provided routes as a route snapshot" +msgstr "指定されたルートをルートスナップショットとして保存します" + #: src/iac_code/commands/__init__.py:22 msgid "Show available commands" msgstr "利用可能なコマンドを表示します" diff --git a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po index f5d16582..15a269f6 100644 --- a/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/pt/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: iac-code 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-18 10:11+0800\n" +"POT-Creation-Date: 2026-05-18 12:25+0800\n" "PO-Revision-Date: 2026-05-13 00:00+0000\n" "Last-Translator: \n" "Language: pt\n" @@ -134,47 +134,65 @@ msgstr "" " Documentação: https://aliyun.github.io/iac-" "code/pt/docs/configuration/authentication\n" -#: src/iac_code/cli/main.py:23 src/iac_code/commands/help.py:26 +#: src/iac_code/cli/main.py:32 src/iac_code/commands/help.py:26 msgid "AI-powered infrastructure orchestration tool" msgstr "Ferramenta de orquestração de infraestrutura com IA" -#: src/iac_code/cli/main.py:34 +#: src/iac_code/cli/main.py:40 +msgid "Use iac-code as an A2A client." +msgstr "Usa o iac-code como cliente A2A." + +#: src/iac_code/cli/main.py:49 +msgid "YAML config file containing A2A client options" +msgstr "Arquivo de configuração YAML com opções do cliente A2A" + +#: src/iac_code/cli/main.py:56 src/iac_code/cli/main.py:1271 +#: src/iac_code/cli/main.py:1632 src/iac_code/cli/main.py:1671 +msgid "" +"A2A client dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"As dependências do cliente A2A estão ausentes. Instale com: pip install " +"'iac-code[a2a]'" + +#: src/iac_code/cli/main.py:70 msgid "LLM model to use" msgstr "Modelo LLM a utilizar" -#: src/iac_code/cli/main.py:35 +#: src/iac_code/cli/main.py:71 msgid "Non-interactive mode: run a single prompt and exit" msgstr "Modo não interativo: executa um único prompt e encerra" -#: src/iac_code/cli/main.py:36 +#: src/iac_code/cli/main.py:72 msgid "Output format: text, json, stream-json" msgstr "Formato de saída: text, json, stream-json" -#: src/iac_code/cli/main.py:37 +#: src/iac_code/cli/main.py:73 msgid "Maximum agent turns in headless mode" msgstr "Número máximo de passos do agent em modo headless" -#: src/iac_code/cli/main.py:38 src/iac_code/cli/main.py:275 +#: src/iac_code/cli/main.py:74 src/iac_code/cli/main.py:311 +#: src/iac_code/cli/main.py:460 msgid "Enable debug logging" msgstr "Ativar debug" -#: src/iac_code/cli/main.py:39 +#: src/iac_code/cli/main.py:75 msgid "Show version and exit" msgstr "Mostrar versão e sair" -#: src/iac_code/cli/main.py:40 +#: src/iac_code/cli/main.py:76 msgid "Resume a session by ID" msgstr "Retomar uma sessão pelo ID" -#: src/iac_code/cli/main.py:41 +#: src/iac_code/cli/main.py:77 msgid "Resume the most recent session" msgstr "Retomar a sessão mais recente" -#: src/iac_code/cli/main.py:48 src/iac_code/i18n/__init__.py:54 +#: src/iac_code/cli/main.py:84 src/iac_code/i18n/__init__.py:54 msgid "Install completion for the current shell." msgstr "Instalar completion para o shell atual." -#: src/iac_code/cli/main.py:56 src/iac_code/i18n/__init__.py:55 +#: src/iac_code/cli/main.py:92 src/iac_code/i18n/__init__.py:55 msgid "" "Show completion for the current shell, to copy it or customize the " "installation." @@ -182,7 +200,7 @@ msgstr "" "Exibir o completion do shell atual, para copiar ou personalizar a " "instalação." -#: src/iac_code/cli/main.py:61 +#: src/iac_code/cli/main.py:97 msgid "" "Comma-separated tool permission patterns to allow, e.g. 'bash(git " "*),write_file'" @@ -190,34 +208,291 @@ msgstr "" "Padrões de permissão de ferramentas a permitir (separados por vírgula), " "ex. 'bash(git *),write_file'*),write_file'" -#: src/iac_code/cli/main.py:66 +#: src/iac_code/cli/main.py:102 msgid "Comma-separated tool permission patterns to deny" msgstr "Padrões de permissão de ferramentas a negar (separados por vírgula)" -#: src/iac_code/cli/main.py:71 +#: src/iac_code/cli/main.py:107 msgid "Permission mode: default, accept_edits, bypass_permissions, dont_ask" msgstr "Modo de permissão: default, accept_edits, bypass_permissions, dont_ask" -#: src/iac_code/cli/main.py:85 +#: src/iac_code/cli/main.py:121 msgid "Error: --resume and --continue cannot be used together." msgstr "Erro: --resume e --continue não podem ser usados juntos." -#: src/iac_code/cli/main.py:270 +#: src/iac_code/cli/main.py:306 msgid "Run iac-code as an ACP server." msgstr "Executar o iac-code como servidor ACP." -#: src/iac_code/cli/main.py:272 +#: src/iac_code/cli/main.py:308 msgid "Transport type: stdio or http" msgstr "Tipo de transporte: stdio ou http" -#: src/iac_code/cli/main.py:273 +#: src/iac_code/cli/main.py:309 msgid "HTTP server port" msgstr "Porta do servidor HTTP" -#: src/iac_code/cli/main.py:274 +#: src/iac_code/cli/main.py:310 src/iac_code/cli/main.py:450 msgid "HTTP server host" msgstr "Host do servidor HTTP" +#: src/iac_code/cli/main.py:446 +msgid "Run iac-code as an A2A 1.0 server." +msgstr "Executa o iac-code como servidor A2A 1.0." + +#: src/iac_code/cli/main.py:449 +msgid "YAML config file for A2A server options" +msgstr "Arquivo de configuração YAML para opções do servidor A2A" + +#: src/iac_code/cli/main.py:453 +msgid "" +"HTTP server port. 41242 is the iac-code default inspired by Gemini CLI, " +"not a registered A2A port." +msgstr "" +"Porta do servidor HTTP. 41242 é o padrão do iac-code inspirado no Gemini " +"CLI, não uma porta A2A registrada." + +#: src/iac_code/cli/main.py:458 +msgid "" +"A2A transport: http, stdio, unix, websocket, grpc, grpc-jsonrpc, or " +"redis-streams" +msgstr "" +"Transporte A2A: http, stdio, unix, websocket, grpc, grpc-jsonrpc ou " +"redis-streams" + +#: src/iac_code/cli/main.py:505 +msgid "" +"A2A server dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "" +"As dependências do servidor A2A estão ausentes. Instale com: pip install " +"'iac-code[a2a]'" + +#: src/iac_code/cli/main.py:554 +msgid "Send a prompt to an A2A JSON-RPC endpoint." +msgstr "Envia um prompt para um endpoint JSON-RPC A2A." + +#: src/iac_code/cli/main.py:557 src/iac_code/cli/main.py:721 +#: src/iac_code/cli/main.py:774 src/iac_code/cli/main.py:834 +#: src/iac_code/cli/main.py:884 src/iac_code/cli/main.py:933 +#: src/iac_code/cli/main.py:1012 src/iac_code/cli/main.py:1069 +#: src/iac_code/cli/main.py:1125 src/iac_code/cli/main.py:1182 +msgid "A2A JSON-RPC endpoint URL" +msgstr "URL do endpoint JSON-RPC A2A" + +#: src/iac_code/cli/main.py:558 src/iac_code/cli/main.py:1227 +msgid "Route spec: name=url;skills=skill1,skill2;tags=tag1,tag2" +msgstr "Especificação de rota: name=url;skills=skill1,skill2;tags=tag1,tag2" + +#: src/iac_code/cli/main.py:559 +msgid "Named A2A route to call" +msgstr "Rota A2A nomeada a chamar" + +#: src/iac_code/cli/main.py:560 +msgid "Prompt to send" +msgstr "Prompt para enviar" + +#: src/iac_code/cli/main.py:561 +msgid "Working directory metadata to send with the request" +msgstr "Metadados do diretório de trabalho a enviar com a requisição" + +#: src/iac_code/cli/main.py:562 +msgid "A2A context ID to continue" +msgstr "ID de contexto A2A para continuar" + +#: src/iac_code/cli/main.py:563 src/iac_code/cli/main.py:652 +#: src/iac_code/cli/main.py:724 src/iac_code/cli/main.py:781 +#: src/iac_code/cli/main.py:836 src/iac_code/cli/main.py:886 +#: src/iac_code/cli/main.py:940 src/iac_code/cli/main.py:1015 +#: src/iac_code/cli/main.py:1073 src/iac_code/cli/main.py:1128 +#: src/iac_code/cli/main.py:1183 +msgid "Bearer token for A2A HTTP requests" +msgstr "Token Bearer para requisições HTTP A2A" + +#: src/iac_code/cli/main.py:564 src/iac_code/cli/main.py:653 +#: src/iac_code/cli/main.py:725 src/iac_code/cli/main.py:782 +#: src/iac_code/cli/main.py:837 src/iac_code/cli/main.py:887 +#: src/iac_code/cli/main.py:941 src/iac_code/cli/main.py:1016 +#: src/iac_code/cli/main.py:1074 src/iac_code/cli/main.py:1129 +#: src/iac_code/cli/main.py:1184 +msgid "Basic auth username for A2A HTTP requests" +msgstr "Nome de usuário de autenticação básica para requisições HTTP A2A" + +#: src/iac_code/cli/main.py:565 src/iac_code/cli/main.py:654 +#: src/iac_code/cli/main.py:726 src/iac_code/cli/main.py:783 +#: src/iac_code/cli/main.py:838 src/iac_code/cli/main.py:888 +#: src/iac_code/cli/main.py:942 src/iac_code/cli/main.py:1017 +#: src/iac_code/cli/main.py:1075 src/iac_code/cli/main.py:1130 +#: src/iac_code/cli/main.py:1185 +msgid "Basic auth password for A2A HTTP requests" +msgstr "Senha de autenticação básica para requisições HTTP A2A" + +#: src/iac_code/cli/main.py:566 src/iac_code/cli/main.py:655 +#: src/iac_code/cli/main.py:727 src/iac_code/cli/main.py:784 +#: src/iac_code/cli/main.py:839 src/iac_code/cli/main.py:889 +#: src/iac_code/cli/main.py:943 src/iac_code/cli/main.py:1018 +#: src/iac_code/cli/main.py:1076 src/iac_code/cli/main.py:1131 +#: src/iac_code/cli/main.py:1186 +msgid "API key for A2A HTTP requests" +msgstr "Chave de API para requisições HTTP A2A" + +#: src/iac_code/cli/main.py:567 src/iac_code/cli/main.py:656 +#: src/iac_code/cli/main.py:728 src/iac_code/cli/main.py:785 +#: src/iac_code/cli/main.py:840 src/iac_code/cli/main.py:890 +#: src/iac_code/cli/main.py:944 src/iac_code/cli/main.py:1019 +#: src/iac_code/cli/main.py:1077 src/iac_code/cli/main.py:1132 +#: src/iac_code/cli/main.py:1187 +msgid "HTTP header name for A2A API key" +msgstr "Nome do cabeçalho HTTP para a chave de API A2A" + +#: src/iac_code/cli/main.py:572 src/iac_code/cli/main.py:661 +msgid "Secret used to verify the A2A Agent Card" +msgstr "Segredo usado para verificar o A2A Agent Card" + +#: src/iac_code/cli/main.py:577 src/iac_code/cli/main.py:666 +msgid "Remote JWKS URL used to verify the A2A Agent Card" +msgstr "URL JWKS remota usada para verificar o A2A Agent Card" + +#: src/iac_code/cli/main.py:583 src/iac_code/cli/main.py:672 +msgid "Require a valid A2A Agent Card signature" +msgstr "Exigir uma assinatura válida do A2A Agent Card" + +#: src/iac_code/cli/main.py:585 +msgid "A2A call timeout in seconds" +msgstr "Tempo limite da chamada A2A em segundos" + +#: src/iac_code/cli/main.py:586 +msgid "Use A2A streaming message delivery" +msgstr "Usar entrega de mensagens em streaming A2A" + +#: src/iac_code/cli/main.py:648 +msgid "Discover an A2A Agent Card." +msgstr "Descobre um A2A Agent Card." + +#: src/iac_code/cli/main.py:651 +msgid "A2A agent base URL" +msgstr "URL base do agente A2A" + +#: src/iac_code/cli/main.py:718 +msgid "Get an A2A task." +msgstr "Obtém uma tarefa A2A." + +#: src/iac_code/cli/main.py:722 src/iac_code/cli/main.py:835 +#: src/iac_code/cli/main.py:885 src/iac_code/cli/main.py:934 +#: src/iac_code/cli/main.py:1013 src/iac_code/cli/main.py:1070 +#: src/iac_code/cli/main.py:1126 +msgid "A2A task ID" +msgstr "ID da tarefa A2A" + +#: src/iac_code/cli/main.py:723 +msgid "Maximum task history items to return" +msgstr "Número máximo de itens do histórico de tarefas a retornar" + +#: src/iac_code/cli/main.py:771 +msgid "List A2A tasks." +msgstr "Lista as tarefas A2A." + +#: src/iac_code/cli/main.py:775 +msgid "Filter by A2A context ID" +msgstr "Filtrar por ID de contexto A2A" + +#: src/iac_code/cli/main.py:776 +msgid "Filter by A2A task state" +msgstr "Filtrar por estado de tarefa A2A" + +#: src/iac_code/cli/main.py:777 +msgid "Maximum tasks to return" +msgstr "Número máximo de tarefas a retornar" + +#: src/iac_code/cli/main.py:778 src/iac_code/cli/main.py:1072 +msgid "Pagination token" +msgstr "Token de paginação" + +#: src/iac_code/cli/main.py:779 +msgid "Include task artifacts" +msgstr "Incluir artefatos de tarefa" + +#: src/iac_code/cli/main.py:780 +msgid "Output format: table or json" +msgstr "Formato de saída: table ou json" + +#: src/iac_code/cli/main.py:831 +msgid "Cancel an A2A task." +msgstr "Cancela uma tarefa A2A." + +#: src/iac_code/cli/main.py:881 +msgid "Subscribe to an A2A task event stream." +msgstr "Assina um fluxo de eventos de tarefa A2A." + +#: src/iac_code/cli/main.py:930 +msgid "Create an A2A task push notification config." +msgstr "Cria uma configuração de notificação push de tarefa A2A." + +#: src/iac_code/cli/main.py:935 src/iac_code/cli/main.py:1014 +#: src/iac_code/cli/main.py:1127 +msgid "Push config ID" +msgstr "ID da configuração push" + +#: src/iac_code/cli/main.py:936 +msgid "Push callback URL" +msgstr "URL de callback push" + +#: src/iac_code/cli/main.py:937 +msgid "Notification verification token" +msgstr "Token de verificação de notificação" + +#: src/iac_code/cli/main.py:938 +msgid "Callback authentication scheme" +msgstr "Esquema de autenticação de callback" + +#: src/iac_code/cli/main.py:939 +msgid "Callback authentication credentials" +msgstr "Credenciais de autenticação de callback" + +#: src/iac_code/cli/main.py:1009 +msgid "Get an A2A task push notification config." +msgstr "Obtém uma configuração de notificação push de tarefa A2A." + +#: src/iac_code/cli/main.py:1066 +msgid "List A2A task push notification configs." +msgstr "Lista as configurações de notificação push de tarefas A2A." + +#: src/iac_code/cli/main.py:1071 +msgid "Maximum configs to return" +msgstr "Número máximo de configurações a retornar" + +#: src/iac_code/cli/main.py:1122 +msgid "Delete an A2A task push notification config." +msgstr "Exclui uma configuração de notificação push de tarefa A2A." + +#: src/iac_code/cli/main.py:1179 +msgid "Get an authenticated extended A2A Agent Card." +msgstr "Obtém um A2A Agent Card estendido autenticado." + +#: src/iac_code/cli/main.py:1222 +msgid "Preview A2A route resolution." +msgstr "Pré-visualização da resolução de rotas A2A." + +#: src/iac_code/cli/main.py:1229 +msgid "Route name to resolve" +msgstr "Nome da rota a resolver" + +#: src/iac_code/cli/main.py:1230 +msgid "Skill ID to resolve" +msgstr "ID da habilidade a resolver" + +#: src/iac_code/cli/main.py:1231 +msgid "Prompt text used for tag/name route matching" +msgstr "Texto do prompt usado para correspondência de rotas por tag/nome" + +#: src/iac_code/cli/main.py:1236 +msgid "Directory for persisted A2A routes" +msgstr "Diretório para rotas A2A persistidas" + +#: src/iac_code/cli/main.py:1238 +msgid "Save the provided routes as a route snapshot" +msgstr "Salva as rotas fornecidas como um snapshot de rotas" + #: src/iac_code/commands/__init__.py:22 msgid "Show available commands" msgstr "Mostrar comandos disponíveis" diff --git a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po index 4caf0c2c..36797ebd 100644 --- a/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +++ b/src/iac_code/i18n/locales/zh/LC_MESSAGES/messages.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: iac-code 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-18 10:11+0800\n" +"POT-Creation-Date: 2026-05-18 12:25+0800\n" "PO-Revision-Date: 2026-04-02 00:00+0000\n" "Last-Translator: \n" "Language: zh\n" @@ -128,86 +128,353 @@ msgstr "" " 文档:https://aliyun.github.io/iac-code/zh-" "Hans/docs/configuration/authentication\n" -#: src/iac_code/cli/main.py:23 src/iac_code/commands/help.py:26 +#: src/iac_code/cli/main.py:32 src/iac_code/commands/help.py:26 msgid "AI-powered infrastructure orchestration tool" msgstr "AI 驱动的基础设施编排工具" -#: src/iac_code/cli/main.py:34 +#: src/iac_code/cli/main.py:40 +msgid "Use iac-code as an A2A client." +msgstr "将 iac-code 作为 A2A 客户端使用。" + +#: src/iac_code/cli/main.py:49 +msgid "YAML config file containing A2A client options" +msgstr "包含 A2A 客户端选项的 YAML 配置文件" + +#: src/iac_code/cli/main.py:56 src/iac_code/cli/main.py:1271 +#: src/iac_code/cli/main.py:1632 src/iac_code/cli/main.py:1671 +msgid "" +"A2A client dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "缺少 A2A 客户端依赖。请使用以下命令安装:pip install 'iac-code[a2a]'" + +#: src/iac_code/cli/main.py:70 msgid "LLM model to use" msgstr "使用的 LLM 模型" -#: src/iac_code/cli/main.py:35 +#: src/iac_code/cli/main.py:71 msgid "Non-interactive mode: run a single prompt and exit" msgstr "非交互模式:运行单个提示并退出" -#: src/iac_code/cli/main.py:36 +#: src/iac_code/cli/main.py:72 msgid "Output format: text, json, stream-json" msgstr "输出格式:text、json、stream-json" -#: src/iac_code/cli/main.py:37 +#: src/iac_code/cli/main.py:73 msgid "Maximum agent turns in headless mode" msgstr "无头模式下最大智能体轮次" -#: src/iac_code/cli/main.py:38 src/iac_code/cli/main.py:275 +#: src/iac_code/cli/main.py:74 src/iac_code/cli/main.py:311 +#: src/iac_code/cli/main.py:460 msgid "Enable debug logging" msgstr "启用调试日志" -#: src/iac_code/cli/main.py:39 +#: src/iac_code/cli/main.py:75 msgid "Show version and exit" msgstr "显示版本号并退出" -#: src/iac_code/cli/main.py:40 +#: src/iac_code/cli/main.py:76 msgid "Resume a session by ID" msgstr "通过 ID 恢复会话" -#: src/iac_code/cli/main.py:41 +#: src/iac_code/cli/main.py:77 msgid "Resume the most recent session" msgstr "恢复最近一次会话" -#: src/iac_code/cli/main.py:48 src/iac_code/i18n/__init__.py:54 +#: src/iac_code/cli/main.py:84 src/iac_code/i18n/__init__.py:54 msgid "Install completion for the current shell." msgstr "为当前 shell 安装自动补全。" -#: src/iac_code/cli/main.py:56 src/iac_code/i18n/__init__.py:55 +#: src/iac_code/cli/main.py:92 src/iac_code/i18n/__init__.py:55 msgid "" "Show completion for the current shell, to copy it or customize the " "installation." msgstr "显示当前 shell 的自动补全脚本,可复制或自定义安装。" -#: src/iac_code/cli/main.py:61 +#: src/iac_code/cli/main.py:97 msgid "" "Comma-separated tool permission patterns to allow, e.g. 'bash(git " "*),write_file'" msgstr "允许的工具权限模式(逗号分隔),例如 'bash(git *),write_file'" -#: src/iac_code/cli/main.py:66 +#: src/iac_code/cli/main.py:102 msgid "Comma-separated tool permission patterns to deny" msgstr "拒绝的工具权限模式(逗号分隔)" -#: src/iac_code/cli/main.py:71 +#: src/iac_code/cli/main.py:107 msgid "Permission mode: default, accept_edits, bypass_permissions, dont_ask" msgstr "权限模式:default, accept_edits, bypass_permissions, dont_ask" -#: src/iac_code/cli/main.py:85 +#: src/iac_code/cli/main.py:121 msgid "Error: --resume and --continue cannot be used together." msgstr "错误:--resume 和 --continue 不能同时使用。" -#: src/iac_code/cli/main.py:270 +#: src/iac_code/cli/main.py:306 msgid "Run iac-code as an ACP server." msgstr "将 iac-code 作为 ACP 服务器运行。" -#: src/iac_code/cli/main.py:272 +#: src/iac_code/cli/main.py:308 msgid "Transport type: stdio or http" msgstr "传输类型:stdio 或 http" -#: src/iac_code/cli/main.py:273 +#: src/iac_code/cli/main.py:309 msgid "HTTP server port" msgstr "HTTP 服务器端口" -#: src/iac_code/cli/main.py:274 +#: src/iac_code/cli/main.py:310 src/iac_code/cli/main.py:450 msgid "HTTP server host" msgstr "HTTP 服务器主机" +#: src/iac_code/cli/main.py:446 +msgid "Run iac-code as an A2A 1.0 server." +msgstr "将 iac-code 作为 A2A 1.0 服务器运行。" + +#: src/iac_code/cli/main.py:449 +msgid "YAML config file for A2A server options" +msgstr "用于 A2A 服务器选项的 YAML 配置文件" + +#: src/iac_code/cli/main.py:453 +msgid "" +"HTTP server port. 41242 is the iac-code default inspired by Gemini CLI, " +"not a registered A2A port." +msgstr "HTTP 服务器端口。41242 是受 Gemini CLI 启发的 iac-code 默认值,并非已注册的 A2A 端口。" + +#: src/iac_code/cli/main.py:458 +msgid "" +"A2A transport: http, stdio, unix, websocket, grpc, grpc-jsonrpc, or " +"redis-streams" +msgstr "A2A 传输方式:http、stdio、unix、websocket、grpc、grpc-jsonrpc 或 redis-streams" + +#: src/iac_code/cli/main.py:505 +msgid "" +"A2A server dependencies are missing. Install with: pip install 'iac-" +"code[a2a]'" +msgstr "缺少 A2A 服务器依赖。请使用以下命令安装:pip install 'iac-code[a2a]'" + +#: src/iac_code/cli/main.py:554 +msgid "Send a prompt to an A2A JSON-RPC endpoint." +msgstr "向 A2A JSON-RPC 端点发送提示。" + +#: src/iac_code/cli/main.py:557 src/iac_code/cli/main.py:721 +#: src/iac_code/cli/main.py:774 src/iac_code/cli/main.py:834 +#: src/iac_code/cli/main.py:884 src/iac_code/cli/main.py:933 +#: src/iac_code/cli/main.py:1012 src/iac_code/cli/main.py:1069 +#: src/iac_code/cli/main.py:1125 src/iac_code/cli/main.py:1182 +msgid "A2A JSON-RPC endpoint URL" +msgstr "A2A JSON-RPC 端点 URL" + +#: src/iac_code/cli/main.py:558 src/iac_code/cli/main.py:1227 +msgid "Route spec: name=url;skills=skill1,skill2;tags=tag1,tag2" +msgstr "路由规范:name=url;skills=skill1,skill2;tags=tag1,tag2" + +#: src/iac_code/cli/main.py:559 +msgid "Named A2A route to call" +msgstr "要调用的命名 A2A 路由" + +#: src/iac_code/cli/main.py:560 +msgid "Prompt to send" +msgstr "要发送的提示" + +#: src/iac_code/cli/main.py:561 +msgid "Working directory metadata to send with the request" +msgstr "随请求一起发送的工作目录元数据" + +#: src/iac_code/cli/main.py:562 +msgid "A2A context ID to continue" +msgstr "要继续的 A2A 上下文 ID" + +#: src/iac_code/cli/main.py:563 src/iac_code/cli/main.py:652 +#: src/iac_code/cli/main.py:724 src/iac_code/cli/main.py:781 +#: src/iac_code/cli/main.py:836 src/iac_code/cli/main.py:886 +#: src/iac_code/cli/main.py:940 src/iac_code/cli/main.py:1015 +#: src/iac_code/cli/main.py:1073 src/iac_code/cli/main.py:1128 +#: src/iac_code/cli/main.py:1183 +msgid "Bearer token for A2A HTTP requests" +msgstr "用于 A2A HTTP 请求的 Bearer 令牌" + +#: src/iac_code/cli/main.py:564 src/iac_code/cli/main.py:653 +#: src/iac_code/cli/main.py:725 src/iac_code/cli/main.py:782 +#: src/iac_code/cli/main.py:837 src/iac_code/cli/main.py:887 +#: src/iac_code/cli/main.py:941 src/iac_code/cli/main.py:1016 +#: src/iac_code/cli/main.py:1074 src/iac_code/cli/main.py:1129 +#: src/iac_code/cli/main.py:1184 +msgid "Basic auth username for A2A HTTP requests" +msgstr "用于 A2A HTTP 请求的基本认证用户名" + +#: src/iac_code/cli/main.py:565 src/iac_code/cli/main.py:654 +#: src/iac_code/cli/main.py:726 src/iac_code/cli/main.py:783 +#: src/iac_code/cli/main.py:838 src/iac_code/cli/main.py:888 +#: src/iac_code/cli/main.py:942 src/iac_code/cli/main.py:1017 +#: src/iac_code/cli/main.py:1075 src/iac_code/cli/main.py:1130 +#: src/iac_code/cli/main.py:1185 +msgid "Basic auth password for A2A HTTP requests" +msgstr "用于 A2A HTTP 请求的基本认证密码" + +#: src/iac_code/cli/main.py:566 src/iac_code/cli/main.py:655 +#: src/iac_code/cli/main.py:727 src/iac_code/cli/main.py:784 +#: src/iac_code/cli/main.py:839 src/iac_code/cli/main.py:889 +#: src/iac_code/cli/main.py:943 src/iac_code/cli/main.py:1018 +#: src/iac_code/cli/main.py:1076 src/iac_code/cli/main.py:1131 +#: src/iac_code/cli/main.py:1186 +msgid "API key for A2A HTTP requests" +msgstr "用于 A2A HTTP 请求的 API 密钥" + +#: src/iac_code/cli/main.py:567 src/iac_code/cli/main.py:656 +#: src/iac_code/cli/main.py:728 src/iac_code/cli/main.py:785 +#: src/iac_code/cli/main.py:840 src/iac_code/cli/main.py:890 +#: src/iac_code/cli/main.py:944 src/iac_code/cli/main.py:1019 +#: src/iac_code/cli/main.py:1077 src/iac_code/cli/main.py:1132 +#: src/iac_code/cli/main.py:1187 +msgid "HTTP header name for A2A API key" +msgstr "A2A API 密钥使用的 HTTP 请求头名称" + +#: src/iac_code/cli/main.py:572 src/iac_code/cli/main.py:661 +msgid "Secret used to verify the A2A Agent Card" +msgstr "用于验证 A2A Agent Card 的密钥" + +#: src/iac_code/cli/main.py:577 src/iac_code/cli/main.py:666 +msgid "Remote JWKS URL used to verify the A2A Agent Card" +msgstr "用于验证 A2A Agent Card 的远程 JWKS URL" + +#: src/iac_code/cli/main.py:583 src/iac_code/cli/main.py:672 +msgid "Require a valid A2A Agent Card signature" +msgstr "要求 A2A Agent Card 提供有效签名" + +#: src/iac_code/cli/main.py:585 +msgid "A2A call timeout in seconds" +msgstr "A2A 调用超时时间(秒)" + +#: src/iac_code/cli/main.py:586 +msgid "Use A2A streaming message delivery" +msgstr "使用 A2A 流式消息传递" + +#: src/iac_code/cli/main.py:648 +msgid "Discover an A2A Agent Card." +msgstr "发现 A2A Agent Card。" + +#: src/iac_code/cli/main.py:651 +msgid "A2A agent base URL" +msgstr "A2A 代理基础 URL" + +#: src/iac_code/cli/main.py:718 +msgid "Get an A2A task." +msgstr "获取 A2A 任务。" + +#: src/iac_code/cli/main.py:722 src/iac_code/cli/main.py:835 +#: src/iac_code/cli/main.py:885 src/iac_code/cli/main.py:934 +#: src/iac_code/cli/main.py:1013 src/iac_code/cli/main.py:1070 +#: src/iac_code/cli/main.py:1126 +msgid "A2A task ID" +msgstr "A2A 任务 ID" + +#: src/iac_code/cli/main.py:723 +msgid "Maximum task history items to return" +msgstr "最多返回的任务历史条目数" + +#: src/iac_code/cli/main.py:771 +msgid "List A2A tasks." +msgstr "列出 A2A 任务。" + +#: src/iac_code/cli/main.py:775 +msgid "Filter by A2A context ID" +msgstr "按 A2A 上下文 ID 过滤" + +#: src/iac_code/cli/main.py:776 +msgid "Filter by A2A task state" +msgstr "按 A2A 任务状态过滤" + +#: src/iac_code/cli/main.py:777 +msgid "Maximum tasks to return" +msgstr "最多返回的任务数" + +#: src/iac_code/cli/main.py:778 src/iac_code/cli/main.py:1072 +msgid "Pagination token" +msgstr "分页令牌" + +#: src/iac_code/cli/main.py:779 +msgid "Include task artifacts" +msgstr "包含任务工件" + +#: src/iac_code/cli/main.py:780 +msgid "Output format: table or json" +msgstr "输出格式:table 或 json" + +#: src/iac_code/cli/main.py:831 +msgid "Cancel an A2A task." +msgstr "取消 A2A 任务。" + +#: src/iac_code/cli/main.py:881 +msgid "Subscribe to an A2A task event stream." +msgstr "订阅 A2A 任务事件流。" + +#: src/iac_code/cli/main.py:930 +msgid "Create an A2A task push notification config." +msgstr "创建 A2A 任务推送通知配置。" + +#: src/iac_code/cli/main.py:935 src/iac_code/cli/main.py:1014 +#: src/iac_code/cli/main.py:1127 +msgid "Push config ID" +msgstr "推送配置 ID" + +#: src/iac_code/cli/main.py:936 +msgid "Push callback URL" +msgstr "推送回调 URL" + +#: src/iac_code/cli/main.py:937 +msgid "Notification verification token" +msgstr "通知验证令牌" + +#: src/iac_code/cli/main.py:938 +msgid "Callback authentication scheme" +msgstr "回调认证方案" + +#: src/iac_code/cli/main.py:939 +msgid "Callback authentication credentials" +msgstr "回调认证凭据" + +#: src/iac_code/cli/main.py:1009 +msgid "Get an A2A task push notification config." +msgstr "获取 A2A 任务推送通知配置。" + +#: src/iac_code/cli/main.py:1066 +msgid "List A2A task push notification configs." +msgstr "列出 A2A 任务推送通知配置。" + +#: src/iac_code/cli/main.py:1071 +msgid "Maximum configs to return" +msgstr "最多返回的配置数" + +#: src/iac_code/cli/main.py:1122 +msgid "Delete an A2A task push notification config." +msgstr "删除 A2A 任务推送通知配置。" + +#: src/iac_code/cli/main.py:1179 +msgid "Get an authenticated extended A2A Agent Card." +msgstr "获取经过身份验证的扩展 A2A Agent Card。" + +#: src/iac_code/cli/main.py:1222 +msgid "Preview A2A route resolution." +msgstr "预览 A2A 路由解析。" + +#: src/iac_code/cli/main.py:1229 +msgid "Route name to resolve" +msgstr "要解析的路由名称" + +#: src/iac_code/cli/main.py:1230 +msgid "Skill ID to resolve" +msgstr "要解析的技能 ID" + +#: src/iac_code/cli/main.py:1231 +msgid "Prompt text used for tag/name route matching" +msgstr "用于标签/名称路由匹配的提示文本" + +#: src/iac_code/cli/main.py:1236 +msgid "Directory for persisted A2A routes" +msgstr "持久化 A2A 路由的目录" + +#: src/iac_code/cli/main.py:1238 +msgid "Save the provided routes as a route snapshot" +msgstr "将提供的路由保存为路由快照" + #: src/iac_code/commands/__init__.py:22 msgid "Show available commands" msgstr "显示可用命令" diff --git a/tests/a2a/__init__.py b/tests/a2a/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/a2a/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/a2a/fakes.py b/tests/a2a/fakes.py new file mode 100644 index 00000000..f111beaf --- /dev/null +++ b/tests/a2a/fakes.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any + +from iac_code.types.stream_events import TextDeltaEvent + + +class FakeEventQueue: + def __init__(self) -> None: + self.events: list[Any] = [] + + async def enqueue_event(self, event: Any) -> None: + self.events.append(event) + + +@dataclass +class UnknownEvent: + value: str = "unknown" + + +@dataclass +class FakeRedisPendingEntry: + entry_id: str + fields: dict[str, str] + consumer: str + last_delivered_ms: int + + +def text_delta(text: str) -> TextDeltaEvent: + return TextDeltaEvent(text=text) + + +def pending_future() -> asyncio.Future[bool]: + return asyncio.get_running_loop().create_future() + + +class FakeAgentLoop: + def __init__(self, events: list[Any]) -> None: + self.events = events + self.prompts: list[str] = [] + + async def run_streaming(self, prompt: str): + self.prompts.append(prompt) + for event in self.events: + await asyncio.sleep(0) + yield event + + +class FakeRuntime(SimpleNamespace): + pass + + +class FakeRedisPushStore: + def __init__(self, *, xautoclaim_response_shape: str = "tuple") -> None: + self.streams: dict[str, list[tuple[str, dict[str, str]]]] = {} + self.groups: set[tuple[str, str]] = set() + self.group_positions: dict[tuple[str, str], int] = {} + self.pending: dict[tuple[str, str], dict[str, FakeRedisPendingEntry]] = {} + self.zsets: dict[str, dict[str, float]] = {} + self.acked: list[tuple[str, str, str]] = [] + self.closed = False + self.now_ms = 0 + self.xautoclaim_response_shape = xautoclaim_response_shape + self._next_id = 1 + + async def xgroup_create(self, name, groupname, id="$", mkstream=False): + if (name, groupname) in self.groups: + raise RuntimeError("BUSYGROUP Consumer Group name already exists") + self.groups.add((name, groupname)) + stream = self.streams.setdefault(name, []) if mkstream else self.streams.get(name, []) + self.group_positions[(name, groupname)] = len(stream) if id == "$" else 0 + + async def xadd(self, name, fields): + entry_id = f"{self._next_id}-0" + self._next_id += 1 + self.streams.setdefault(name, []).append((entry_id, dict(fields))) + return entry_id + + async def xreadgroup(self, groupname, consumername, streams, count=1, block=0): + stream = next(iter(streams)) + available = self.streams.setdefault(stream, []) + group_key = (stream, groupname) + position = self.group_positions.setdefault(group_key, 0) + if position >= len(available): + return [] + entries = available[position : position + count] + self.group_positions[group_key] = position + len(entries) + pending = self.pending.setdefault(group_key, {}) + for entry_id, fields in entries: + pending[entry_id] = FakeRedisPendingEntry( + entry_id=entry_id, + fields=dict(fields), + consumer=consumername, + last_delivered_ms=self.now_ms, + ) + return [(stream, entries)] + + async def xautoclaim(self, name, groupname, consumername, min_idle_time, start_id="0-0", count=1): + pending = self.pending.get((name, groupname), {}) + entries = [] + for entry_id in sorted(pending): + entry = pending[entry_id] + if self.now_ms - entry.last_delivered_ms < min_idle_time: + continue + entry.consumer = consumername + entry.last_delivered_ms = self.now_ms + entries.append((entry.entry_id, entry.fields)) + if len(entries) >= count: + break + result = ("0-0", entries, []) + return list(result) if self.xautoclaim_response_shape == "list" else result + + async def xack(self, name, groupname, *ids): + self.acked.extend((name, groupname, entry_id) for entry_id in ids) + pending = self.pending.setdefault((name, groupname), {}) + for entry_id in ids: + pending.pop(entry_id, None) + return len(ids) + + async def zadd(self, name, mapping): + self.zsets.setdefault(name, {}).update(mapping) + return len(mapping) + + async def zrangebyscore(self, name, min, max, start=None, num=None): + upper = float(max) + members = [member for member, score in self.zsets.get(name, {}).items() if score <= upper] + members.sort(key=lambda member: self.zsets[name][member]) + if start is not None and num is not None: + return members[start : start + num] + return members + + async def zrem(self, name, *members): + values = self.zsets.setdefault(name, {}) + removed = 0 + for member in members: + if member in values: + removed += 1 + del values[member] + return removed + + async def aclose(self): + self.closed = True + + +class FakeRequestContext: + def __init__( + self, + *, + task_id: str = "task-1", + context_id: str = "ctx-1", + text: str = "hello", + metadata: dict[str, Any] | None = None, + ) -> None: + self.task_id = task_id + self.context_id = context_id + self.metadata = metadata or {} + self._text = text + self.message = SimpleNamespace(metadata=self.metadata) + + def get_user_input(self) -> str: + return self._text diff --git a/tests/a2a/test_agent_card.py b/tests/a2a/test_agent_card.py new file mode 100644 index 00000000..339f6ce4 --- /dev/null +++ b/tests/a2a/test_agent_card.py @@ -0,0 +1,148 @@ +from a2a.server.routes.agent_card_routes import agent_card_to_dict + +from iac_code.a2a.agent_card import build_agent_card + + +def test_agent_card_declares_a2a_1_jsonrpc_interface() -> None: + card = build_agent_card(host="127.0.0.1", port=41242, token_enabled=False) + data = agent_card_to_dict(card) + + assert data["name"] == "iac-code" + assert data["supportedInterfaces"][0]["protocolVersion"] == "1.0" + assert data["supportedInterfaces"][0]["protocolBinding"] == "JSONRPC" + assert data["supportedInterfaces"][0]["url"] == "http://127.0.0.1:41242/" + assert data["capabilities"]["streaming"] is True + assert data["capabilities"]["pushNotifications"] is False + assert any(skill["id"] == "iac_generation" for skill in data["skills"]) + + +def test_agent_card_advertises_supported_input_mime_modes() -> None: + card = build_agent_card(host="127.0.0.1", port=41242, token_enabled=False) + data = agent_card_to_dict(card) + + assert data["defaultInputModes"] == [ + "text/plain", + "application/json", + "text/markdown", + "text/yaml", + "application/yaml", + "application/x-yaml", + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "audio/mpeg", + "audio/wav", + "audio/ogg", + "application/octet-stream", + ] + assert data["defaultOutputModes"] == ["text/plain"] + assert all(skill["inputModes"] == data["defaultInputModes"] for skill in data["skills"]) + assert all(skill["outputModes"] == ["text/plain"] for skill in data["skills"]) + + +def test_agent_card_advertises_optional_iac_code_extension() -> None: + card = build_agent_card(host="127.0.0.1", port=41242, token_enabled=False) + data = agent_card_to_dict(card) + + extension = data["capabilities"]["extensions"][0] + assert extension["uri"] == "urn:iac-code:a2a:artifact-metadata:v1" + assert extension.get("required", False) is False + + +def test_agent_card_accepts_required_extensions() -> None: + card = build_agent_card( + host="127.0.0.1", + port=41242, + token_enabled=False, + agent_extensions=[ + {"uri": "urn:iac-code:test-required", "description": "test required extension", "required": True} + ], + ) + data = agent_card_to_dict(card) + + assert data["capabilities"]["extensions"][1]["uri"] == "urn:iac-code:test-required" + assert data["capabilities"]["extensions"][1]["required"] is True + + +def test_agent_card_lists_enabled_runtime_interfaces() -> None: + card = build_agent_card( + host="127.0.0.1", + port=41242, + token_enabled=False, + supported_interfaces=[ + {"url": "unix:///tmp/iac-code.sock", "protocolBinding": "unix", "protocolVersion": "1.0"}, + {"url": "ws://127.0.0.1:41243/a2a", "protocolBinding": "websocket", "protocolVersion": "1.0"}, + ], + ) + data = agent_card_to_dict(card) + + assert data["supportedInterfaces"][0]["protocolBinding"] == "unix" + assert data["supportedInterfaces"][1]["protocolBinding"] == "websocket" + + +def test_agent_card_can_advertise_jsonrpc_rest_and_grpc_interfaces() -> None: + card = build_agent_card( + host="127.0.0.1", + port=41242, + token_enabled=False, + supported_interfaces=[ + {"url": "http://127.0.0.1:41242/", "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}, + {"url": "http://127.0.0.1:41242", "protocolBinding": "HTTP+JSON", "protocolVersion": "1.0"}, + {"url": "grpc://127.0.0.1:41243", "protocolBinding": "grpc", "protocolVersion": "1.0"}, + ], + ) + data = agent_card_to_dict(card) + + assert [item["protocolBinding"] for item in data["supportedInterfaces"]] == ["JSONRPC", "HTTP+JSON", "grpc"] + + +def test_agent_card_advertises_bearer_auth_only_when_enabled() -> None: + unauthenticated = build_agent_card(host="127.0.0.1", port=41242, token_enabled=False) + unauth_data = agent_card_to_dict(unauthenticated) + assert "securityRequirements" not in unauth_data + assert "securitySchemes" not in unauth_data + assert "trusted local environments" in unauth_data["description"] + + authenticated = build_agent_card(host="127.0.0.1", port=41242, token_enabled=True) + auth_data = agent_card_to_dict(authenticated) + assert auth_data["securityRequirements"][0]["schemes"]["bearerAuth"]["list"] == [""] + assert auth_data["securitySchemes"]["bearerAuth"]["httpAuthSecurityScheme"]["scheme"] == "bearer" + + +def test_agent_card_advertises_basic_auth_when_enabled() -> None: + card = build_agent_card(host="127.0.0.1", port=41242, token_enabled=False, basic_enabled=True) + data = agent_card_to_dict(card) + + assert data["securityRequirements"][0]["schemes"]["basicAuth"]["list"] == [""] + assert data["securitySchemes"]["basicAuth"]["httpAuthSecurityScheme"]["scheme"] == "basic" + + +def test_agent_card_advertises_api_key_auth_when_enabled() -> None: + card = build_agent_card( + host="127.0.0.1", + port=41242, + token_enabled=False, + api_key_enabled=True, + api_key_header="X-IAC-Code-Key", + ) + data = agent_card_to_dict(card) + + assert data["securityRequirements"][0]["schemes"]["apiKeyAuth"]["list"] == [""] + scheme = data["securitySchemes"]["apiKeyAuth"]["apiKeySecurityScheme"] + assert scheme["location"] == "header" + assert scheme["name"] == "X-IAC-Code-Key" + + +def test_agent_card_can_include_signature() -> None: + card = build_agent_card(host="127.0.0.1", port=41242, token_enabled=False, signing_secret="s" * 32) + data = agent_card_to_dict(card) + + assert data["signatures"][0]["protected"] + + +def test_agent_card_advertises_standard_push_config_when_enabled() -> None: + card = build_agent_card(host="127.0.0.1", port=41242, token_enabled=False, push_notifications=True) + data = agent_card_to_dict(card) + + assert data["capabilities"]["pushNotifications"] is True diff --git a/tests/a2a/test_app.py b/tests/a2a/test_app.py new file mode 100644 index 00000000..74ed5ea8 --- /dev/null +++ b/tests/a2a/test_app.py @@ -0,0 +1,1196 @@ +import asyncio +import json +from base64 import b64encode +from pathlib import Path + +import pytest +from a2a.server.context import ServerCallContext +from a2a.types import ( + Message, + Part, + Role, + SendMessageConfiguration, + SendMessageRequest, + SubscribeToTaskRequest, + Task, +) +from a2a.utils.errors import TaskNotFoundError +from starlette.testclient import TestClient + +from iac_code.a2a.app import ( + A2AAuthMiddleware, + _serve_async_transport, + _supported_interfaces, + create_app, + resolve_api_key, + resolve_basic_credentials, + resolve_token, +) +from iac_code.a2a.persistence import A2APersistenceStore +from iac_code.a2a.transports.dispatcher import create_runtime_components +from iac_code.types.stream_events import TextDeltaEvent, ToolResultEvent + +from .fakes import FakeAgentLoop, FakeRuntime + + +def test_resolve_token_prefers_cli_value(monkeypatch) -> None: + monkeypatch.setenv("IACCODE_A2A_HTTP_TOKEN", "env-token") + assert resolve_token("cli-token") == "cli-token" + + +def test_resolve_token_uses_environment(monkeypatch) -> None: + monkeypatch.setenv("IACCODE_A2A_HTTP_TOKEN", "env-token") + assert resolve_token(None) == "env-token" + + +def test_resolve_basic_credentials_uses_cli_values(monkeypatch) -> None: + monkeypatch.setenv("IACCODE_A2A_BASIC_USERNAME", "env-user") + monkeypatch.setenv("IACCODE_A2A_BASIC_PASSWORD", "env-pass") + + assert resolve_basic_credentials("cli-user", "cli-pass") == ("cli-user", "cli-pass") + + +def test_resolve_basic_credentials_uses_environment(monkeypatch) -> None: + monkeypatch.setenv("IACCODE_A2A_BASIC_USERNAME", "env-user") + monkeypatch.setenv("IACCODE_A2A_BASIC_PASSWORD", "env-pass") + + assert resolve_basic_credentials(None, None) == ("env-user", "env-pass") + + +def test_resolve_basic_credentials_requires_pair(monkeypatch) -> None: + monkeypatch.setenv("IACCODE_A2A_BASIC_USERNAME", "env-user") + monkeypatch.delenv("IACCODE_A2A_BASIC_PASSWORD", raising=False) + + assert resolve_basic_credentials(None, None) is None + + +def test_resolve_api_key_prefers_cli_value(monkeypatch) -> None: + monkeypatch.setenv("IACCODE_A2A_API_KEY", "env-key") + + assert resolve_api_key("cli-key") == "cli-key" + + +def test_resolve_api_key_uses_environment(monkeypatch) -> None: + monkeypatch.setenv("IACCODE_A2A_API_KEY", "env-key") + + assert resolve_api_key(None) == "env-key" + + +def test_health_route() -> None: + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + client = TestClient(app) + + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + + +def test_agent_card_route() -> None: + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + client = TestClient(app) + + response = client.get("/.well-known/agent-card.json") + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "iac-code" + assert data["url"] == "http://127.0.0.1:41242/" + assert data["preferredTransport"] == "JSONRPC" + assert data["protocolVersion"] == "1.0" + assert data["supportedInterfaces"][0]["protocolVersion"] == "1.0" + + +def test_agent_card_route_sets_cache_headers_and_supports_revalidation() -> None: + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + client = TestClient(app) + + response = client.get("/.well-known/agent-card.json") + etag = response.headers["etag"] + + assert response.headers["cache-control"] == "public, max-age=60" + assert etag.startswith('"sha256-') + assert response.headers["last-modified"] + + revalidated = client.get("/.well-known/agent-card.json", headers={"If-None-Match": etag}) + + assert revalidated.status_code == 304 + assert revalidated.content == b"" + assert revalidated.headers["etag"] == etag + + +@pytest.mark.parametrize( + ("app_kwargs", "headers", "expected_status"), + [ + ({"token": "secret"}, {"Authorization": "Bearer wrong"}, 401), + ( + {"token": None, "basic_username": "iac", "basic_password": "secret"}, + {"Authorization": f"Basic {b64encode(b'iac:secret').decode()}"}, + 200, + ), + ( + {"token": None, "basic_username": "iac", "basic_password": "secret"}, + {"Authorization": f"Basic {b64encode(b'iac:wrong').decode()}"}, + 401, + ), + ({"token": None, "api_key": "secret-key"}, {"X-API-Key": "secret-key"}, 200), + ({"token": None, "api_key": "secret-key"}, {"X-API-Key": "wrong"}, 401), + ], +) +def test_agent_card_auth_schemes(app_kwargs, headers, expected_status) -> None: + app = create_app(host="127.0.0.1", port=41242, model="qwen3.6-plus", **app_kwargs) + client = TestClient(app) + + response = client.get("/.well-known/agent-card.json", headers=headers) + + assert response.status_code == expected_status + + +def test_basic_auth_rejects_empty_decoded_username_or_password() -> None: + middleware = A2AAuthMiddleware( + app=None, + token=None, + basic_username="", + basic_password="secret", + api_key=None, + api_key_header="X-API-Key", + ) + empty_username = b64encode(b":secret").decode() + + assert middleware._valid_basic_auth(f"Basic {empty_username}") is False + + +def test_api_key_auth_with_custom_header() -> None: + app = create_app( + host="127.0.0.1", + port=41242, + token=None, + model="qwen3.6-plus", + api_key="secret-key", + api_key_header="X-Custom-Key", + ) + client = TestClient(app) + + accepted = client.get("/.well-known/agent-card.json", headers={"X-Custom-Key": "secret-key"}) + assert accepted.status_code == 200 + + rejected_default = client.get("/.well-known/agent-card.json", headers={"X-API-Key": "secret-key"}) + assert rejected_default.status_code == 401 + + rejected_wrong = client.get("/.well-known/agent-card.json", headers={"X-Custom-Key": "wrong"}) + assert rejected_wrong.status_code == 401 + + +def test_supported_interfaces_preserves_explicit_zero_grpc_port() -> None: + interfaces = _supported_interfaces( + transport="grpc", + host="127.0.0.1", + port=41242, + socket_path=None, + ws_path="/a2a", + grpc_host=None, + grpc_port=0, + redis_url=None, + request_stream="requests", + response_stream="responses", + consumer_group="iac-code", + ) + + assert interfaces == [{"url": "grpc://127.0.0.1:0", "protocolBinding": "grpc", "protocolVersion": "1.0"}] + + +def test_supported_interfaces_advertises_grpc_jsonrpc_compatibility_binding() -> None: + interfaces = _supported_interfaces( + transport="grpc-jsonrpc", + host="127.0.0.1", + port=41242, + socket_path=None, + ws_path="/a2a", + grpc_host=None, + grpc_port=0, + redis_url=None, + request_stream="requests", + response_stream="responses", + consumer_group="iac-code", + ) + + assert interfaces == [ + {"url": "grpc-jsonrpc://127.0.0.1:0", "protocolBinding": "grpc-jsonrpc", "protocolVersion": "1.0"} + ] + + +def test_supported_interfaces_advertises_jsonrpc_and_rest_for_http_transport() -> None: + interfaces = _supported_interfaces( + transport="http", + host="127.0.0.1", + port=41242, + socket_path=None, + ws_path="/a2a", + grpc_host=None, + grpc_port=None, + redis_url=None, + request_stream="requests", + response_stream="responses", + consumer_group="iac-code", + ) + + assert interfaces == [ + {"url": "http://127.0.0.1:41242/", "protocolBinding": "JSONRPC", "protocolVersion": "1.0"}, + {"url": "http://127.0.0.1:41242", "protocolBinding": "HTTP+JSON", "protocolVersion": "1.0"}, + ] + + +def test_auth_allows_any_configured_scheme() -> None: + app = create_app( + host="127.0.0.1", + port=41242, + token="bearer-secret", + model="qwen3.6-plus", + api_key="api-secret", + ) + client = TestClient(app) + + response = client.get("/.well-known/agent-card.json", headers={"X-API-Key": "api-secret"}) + + assert response.status_code == 200 + + +def test_send_message_through_sdk_route(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="hello from route")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + client = TestClient(app) + + response = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + + data = response.json() + assert "error" not in data + assert data["result"]["task"]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + assert loop.prompts == ["hello"] + + +@pytest.mark.parametrize("version_header", ["0.3", "0.3.0", "1.0", None]) +def test_send_message_through_v03_route(monkeypatch, tmp_path, version_header: str | None) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="hello from v03 route")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + client = TestClient(app) + + headers = {"A2A-Version": version_header} if version_header else {} + response = client.post( + "/", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "message/send", + "params": { + "message": { + "messageId": "msg-1", + "role": "user", + "parts": [{"kind": "text", "text": "hello v03"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + + data = response.json() + assert "error" not in data + assert data["result"]["status"]["state"] == "input-required" + assert loop.prompts == ["hello v03"] + + +def test_streaming_v03_method_with_v10_header_returns_sse(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="hello from mixed streaming route")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + client = TestClient(app) + + with client.stream( + "POST", + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "message/stream", + "params": { + "message": { + "messageId": "msg-1", + "role": "user", + "parts": [{"kind": "text", "text": "hello mixed"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text"]}, + }, + }, + ) as response: + body = response.read().decode() + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + assert "hello from mixed streaming route" in body + assert loop.prompts == ["hello mixed"] + + +def test_follow_up_message_through_sdk_route_updates_existing_task(monkeypatch, tmp_path) -> None: + class EchoAgentLoop: + def __init__(self) -> None: + self.prompts: list[str] = [] + + async def run_streaming(self, prompt: str): + self.prompts.append(prompt) + yield TextDeltaEvent(text=f"turn-{len(self.prompts)}:{prompt}") + + loop = EchoAgentLoop() + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + with TestClient(app) as client: + first = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + first_data = first.json() + task = first_data["result"]["task"] + + second = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "2", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-2", + "taskId": task["id"], + "contextId": task["contextId"], + "role": "ROLE_USER", + "parts": [{"text": "follow up"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + + second_data = second.json() + assert "error" not in second_data + assert loop.prompts == ["hello", "follow up"] + assert "turn-2:follow up" in json.dumps(second_data) + + +def test_get_task_applies_history_length_without_mutating_stored_history(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="history chunk")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + with TestClient(app) as client: + sent = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ).json() + task_id = sent["result"]["task"]["id"] + + trimmed = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "2", + "method": "GetTask", + "params": {"id": task_id, "historyLength": 0}, + }, + ).json() + full = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "3", + "method": "GetTask", + "params": {"id": task_id}, + }, + ).json() + + assert "history" not in trimmed["result"] + assert full["result"]["history"] + + +def test_send_message_applies_history_length_to_returned_task(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="history chunk")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + client = TestClient(app) + + response = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"], "historyLength": 0}, + }, + }, + ) + + assert "history" not in response.json()["result"]["task"] + + +def test_send_message_accepts_data_part_as_json_prompt(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="ok")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + client = TestClient(app) + + response = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"data": {"template": "value"}, "mediaType": "application/json"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + + data = response.json() + assert "error" not in data + assert loop.prompts == ['{"template":"value"}'] + + +def test_send_message_accepts_file_url_part_from_workspace(monkeypatch, tmp_path) -> None: + source = tmp_path / "template.yaml" + source.write_text("ROSTemplateFormatVersion: '2015-09-01'\n", encoding="utf-8") + loop = FakeAgentLoop([TextDeltaEvent(text="ok")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + client = TestClient(app) + + response = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"url": source.as_uri(), "mediaType": "text/plain"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + + assert "error" not in response.json() + assert loop.prompts == ["ROSTemplateFormatVersion: '2015-09-01'\n"] + + +def test_send_message_stores_standard_artifact_update_in_task(monkeypatch, tmp_path) -> None: + result = {"artifact": {"filename": "result.txt", "mediaType": "text/plain", "content": "hello artifact"}} + loop = FakeAgentLoop( + [ + TextDeltaEvent(text="done"), + ToolResultEvent(tool_use_id="tool-1", tool_name="write_file", result=result, is_error=False), + ] + ) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app( + host="127.0.0.1", + port=41242, + token=None, + model="qwen3.6-plus", + artifact_dir=tmp_path / "artifacts", + ) + client = TestClient(app) + + response = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + + task = response.json()["result"]["task"] + assert task["artifacts"][0]["name"] == "result.txt" + assert task["artifacts"][0]["parts"][0]["url"].startswith("file://") + assert task["artifacts"][0]["parts"][0]["mediaType"] == "text/plain" + + +def test_send_message_stores_binary_artifact_update_in_task(monkeypatch, tmp_path) -> None: + result = { + "artifact": { + "filename": "diagram.png", + "mediaType": "image/png", + "bytes": "iVBORw0KGgppbWFnZQ==", + } + } + loop = FakeAgentLoop( + [ + TextDeltaEvent(text="done"), + ToolResultEvent(tool_use_id="tool-1", tool_name="draw", result=result, is_error=False), + ] + ) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app( + host="127.0.0.1", + port=41242, + token=None, + model="qwen3.6-plus", + artifact_dir=tmp_path / "artifacts", + ) + client = TestClient(app) + + response = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain", "image/png"]}, + }, + }, + ) + + task = response.json()["result"]["task"] + assert task["artifacts"][0]["name"] == "diagram.png" + assert task["artifacts"][0]["parts"][0]["mediaType"] == "image/png" + artifact_path = Path(task["artifacts"][0]["parts"][0]["url"].removeprefix("file://")) + assert artifact_path.read_bytes() == b"\x89PNG\r\n\x1a\nimage" + + +def test_required_a2a_extension_must_be_requested(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="unused")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app( + host="127.0.0.1", + port=41242, + token=None, + model="qwen3.6-plus", + agent_extensions=[ + {"uri": "urn:iac-code:test-required", "description": "test required extension", "required": True} + ], + ) + client = TestClient(app) + + response = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + + data = response.json() + assert "result" not in data + assert data["error"]["message"] == "Required A2A extensions were not requested: urn:iac-code:test-required" + assert loop.prompts == [] + + +def test_requested_required_a2a_extension_allows_message(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="ok")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app( + host="127.0.0.1", + port=41242, + token=None, + model="qwen3.6-plus", + agent_extensions=[ + {"uri": "urn:iac-code:test-required", "description": "test required extension", "required": True} + ], + ) + client = TestClient(app) + + response = client.post( + "/", + headers={"A2A-Version": "1.0", "A2A-Extensions": "urn:iac-code:test-required"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + + data = response.json() + assert "error" not in data + assert loop.prompts == ["hello"] + + +def test_push_notification_config_methods_round_trip(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="done")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app( + host="127.0.0.1", + port=41242, + token=None, + model="qwen3.6-plus", + persistence_dir=tmp_path / "state", + push_notifications=True, + ) + with TestClient(app) as client: + card = client.get("/.well-known/agent-card.json").json() + sent = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ).json() + task_id = sent["result"]["task"]["id"] + created = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "2", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": task_id, + "id": "cfg-1", + "url": "https://callback.example/a2a", + "token": "token-1", + "authentication": {"scheme": "bearer", "credentials": "secret"}, + }, + }, + ).json() + listed = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "3", + "method": "ListTaskPushNotificationConfigs", + "params": {"taskId": task_id, "pageSize": 1}, + }, + ).json() + fetched = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "4", + "method": "GetTaskPushNotificationConfig", + "params": {"taskId": task_id, "id": "cfg-1"}, + }, + ).json() + deleted = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "5", + "method": "DeleteTaskPushNotificationConfig", + "params": {"taskId": task_id, "id": "cfg-1"}, + }, + ).json() + + assert card["capabilities"]["pushNotifications"] is True + assert created["result"]["id"] == "cfg-1" + assert created["result"]["authentication"]["scheme"] == "bearer" + assert listed["result"]["configs"][0]["id"] == "cfg-1" + assert fetched["result"]["url"] == "https://callback.example/a2a" + assert deleted["result"] is None + + +def test_push_notification_config_rejects_private_callback_url(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="done")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app( + host="127.0.0.1", + port=41242, + token=None, + model="qwen3.6-plus", + persistence_dir=tmp_path / "state", + push_notifications=True, + ) + with TestClient(app) as client: + sent = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ).json() + task_id = sent["result"]["task"]["id"] + rejected = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "2", + "method": "CreateTaskPushNotificationConfig", + "params": {"taskId": task_id, "id": "cfg-1", "url": "http://127.0.0.1:9999/a2a"}, + }, + ).json() + + assert "result" not in rejected + assert "private" in rejected["error"]["message"] + + +def test_get_extended_agent_card_returns_private_card() -> None: + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + client = TestClient(app) + + public_card = client.get("/.well-known/agent-card.json").json() + extended = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={"jsonrpc": "2.0", "id": "1", "method": "GetExtendedAgentCard", "params": {}}, + ).json() + + assert public_card["capabilities"]["extendedAgentCard"] is True + assert extended["result"]["skills"][-1]["id"] == "iac_code_runtime_details" + + +def test_cancel_non_running_task_returns_standard_jsonrpc_error(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="done")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + with TestClient(app) as client: + sent = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ).json() + task_id = sent["result"]["task"]["id"] + + canceled = client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={"jsonrpc": "2.0", "id": "2", "method": "CancelTask", "params": {"id": task_id}}, + ).json() + + assert "result" not in canceled + assert canceled["error"]["message"] == "Task cannot be canceled" + + +@pytest.mark.asyncio +async def test_subscribe_to_inactive_task_returns_error_without_hanging(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="done")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + components = create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242) + call_context = ServerCallContext() + + result = await components.handler.on_message_send( + SendMessageRequest( + message=Message( + message_id="msg-1", + role=Role.ROLE_USER, + parts=[Part(text="hello")], + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + configuration=SendMessageConfiguration(accepted_output_modes=["text/plain"]), + ), + call_context, + ) + assert isinstance(result, Task) + + stream = components.handler.on_subscribe_to_task(SubscribeToTaskRequest(id=result.id), call_context) + with pytest.raises(TaskNotFoundError, match="not active"): + await asyncio.wait_for(anext(stream), timeout=0.1) + await components.aclose() + + +@pytest.mark.asyncio +async def test_subscribe_to_active_task_yields_initial_task_then_updates(monkeypatch, tmp_path) -> None: + release = asyncio.Event() + prompts: list[str] = [] + + class ControlledLoop: + async def run_streaming(self, prompt: str): + prompts.append(prompt) + yield TextDeltaEvent(text="first") + await release.wait() + yield TextDeltaEvent(text="second") + + runtime = FakeRuntime(agent_loop=ControlledLoop(), session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + components = create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242) + call_context = ServerCallContext() + + result = await components.handler.on_message_send( + SendMessageRequest( + message=Message( + message_id="msg-1", + role=Role.ROLE_USER, + parts=[Part(text="hello")], + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + configuration=SendMessageConfiguration(accepted_output_modes=["text/plain"], return_immediately=True), + ), + call_context, + ) + assert isinstance(result, Task) + + stream = components.handler.on_subscribe_to_task(SubscribeToTaskRequest(id=result.id), call_context) + first_event = await asyncio.wait_for(anext(stream), timeout=1) + release.set() + remaining_events = [] + + async def collect_remaining_events() -> None: + async for event in stream: + remaining_events.append(event) + + await asyncio.wait_for(collect_remaining_events(), timeout=1) + + assert isinstance(first_event, Task) + assert first_event.id == result.id + assert "second" in json.dumps([event.__class__.__name__ + str(event) for event in remaining_events]) + assert prompts == ["hello"] + await components.aclose() + + +def test_create_app_wires_stateful_server_primitives(monkeypatch, tmp_path) -> None: + calls: dict[str, object] = {} + + class SpyTaskStore: + def __init__(self, **kwargs) -> None: + calls["task_store_kwargs"] = kwargs + + async def start_cleanup_loop(self) -> None: + calls["cleanup_started"] = True + + async def stop_cleanup_loop(self) -> None: + calls["cleanup_stopped"] = True + + class SpyExecutor: + def __init__(self, **kwargs) -> None: + calls["executor_kwargs"] = kwargs + + class SpyPushConfigStore: + def __init__(self, **kwargs) -> None: + calls["push_store_kwargs"] = kwargs + + async def resolve_headers_for_dispatch(self, task_id: str, config_id: str) -> dict[str, str]: + return {} + + class SpyPushSender: + def __init__(self, **kwargs) -> None: + calls["push_sender_kwargs"] = kwargs + + class SpyPushQueue: + def __init__(self, root, **kwargs) -> None: + calls["push_queue_root"] = root + calls["push_queue_kwargs"] = kwargs + + class SpyPushWorker: + def __init__(self, **kwargs) -> None: + calls["push_worker_kwargs"] = kwargs + self.started = asyncio.Event() + + async def serve_forever(self) -> None: + calls["push_worker_started"] = True + self.started.set() + await asyncio.Event().wait() + + async def aclose(self) -> None: + calls["push_worker_closed"] = True + + monkeypatch.setattr("iac_code.a2a.transports.dispatcher.A2ATaskStore", SpyTaskStore) + monkeypatch.setattr("iac_code.a2a.transports.dispatcher.IacCodeA2AExecutor", SpyExecutor) + monkeypatch.setattr("iac_code.a2a.transports.dispatcher.A2APushConfigStore", SpyPushConfigStore) + monkeypatch.setattr("iac_code.a2a.transports.dispatcher.A2APushSender", SpyPushSender) + monkeypatch.setattr("iac_code.a2a.transports.dispatcher.LocalFileA2APushQueue", SpyPushQueue) + monkeypatch.setattr("iac_code.a2a.transports.dispatcher.A2APushDeliveryWorker", SpyPushWorker) + + app = create_app( + host="127.0.0.1", + port=41242, + token=None, + model="qwen3.6-plus", + persistence_dir=tmp_path / "state", + artifact_dir=tmp_path / "artifacts", + signing_secret="s" * 32, + signing_key_id="local-key", + push_notifications=True, + ) + with TestClient(app) as client: + response = client.get("/.well-known/agent-card.json") + + assert response.status_code == 200 + card = response.json() + assert card["capabilities"]["pushNotifications"] is True + assert card["signatures"][0]["protected"] + persistence = calls["task_store_kwargs"]["persistence"] + assert isinstance(persistence, A2APersistenceStore) + assert persistence.root == tmp_path / "state" + assert calls["push_store_kwargs"]["persistence"] is persistence + assert calls["push_store_kwargs"]["secret_keyring"] is calls["push_queue_kwargs"]["secret_keyring"] + assert calls["push_queue_root"] == persistence.root / "push_queue" + assert calls["push_sender_kwargs"]["config_store"] is not None + assert calls["push_sender_kwargs"]["queue"] is not None + assert calls["push_worker_kwargs"]["queue"] is not None + assert calls["push_worker_started"] is True + assert calls["push_worker_closed"] is True + executor_kwargs = calls["executor_kwargs"] + assert executor_kwargs["task_store"] is not None + assert executor_kwargs["artifact_store"].root == tmp_path / "artifacts" + + +@pytest.mark.asyncio +async def test_runtime_components_close_owned_redis_push_queue(monkeypatch, tmp_path) -> None: + captured = {} + + class FakeRedisQueue: + def __init__(self, **kwargs): + captured.update(kwargs) + self.closed = False + + async def aclose(self) -> None: + self.closed = True + captured["queue_closed"] = True + + class FakeRedisModule: + @staticmethod + def from_url(url): + captured["redis_url"] = url + return object() + + monkeypatch.setattr("iac_code.a2a.transports.dispatcher.RedisStreamsA2APushQueue", FakeRedisQueue) + monkeypatch.setattr("iac_code.a2a.transports.dispatcher.require_redis_asyncio", lambda: FakeRedisModule) + + components = create_runtime_components( + model="qwen3.6-plus", + host="127.0.0.1", + port=41242, + persistence_dir=tmp_path, + push_notifications=True, + push_queue="redis-streams", + push_redis_url="redis://localhost:6379/0", + push_stream="custom:push", + push_retry_key="custom:push:retry", + push_dead_stream="custom:push:dead", + push_consumer_group="custom-workers", + push_consumer_name="worker-a", + push_lease_timeout_ms=120000, + ) + + assert captured["redis_url"] == "redis://localhost:6379/0" + assert captured["stream"] == "custom:push" + assert captured["retry_key"] == "custom:push:retry" + assert captured["dead_stream"] == "custom:push:dead" + assert captured["consumer_group"] == "custom-workers" + assert captured["consumer_name"] == "worker-a" + assert captured["lease_timeout_ms"] == 120000 + assert captured["secret_keyring"] is not None + assert components.push_worker is not None + assert components.push_queue is not None + + await components.aclose() + + assert captured["queue_closed"] is True + + +@pytest.mark.asyncio +async def test_async_transport_runner_starts_push_worker() -> None: + calls: dict[str, bool] = {} + + class SpyTaskStore: + async def start_cleanup_loop(self) -> None: + calls["cleanup_started"] = True + + class SpyPushWorker: + async def serve_forever(self) -> None: + calls["push_started"] = True + await asyncio.Event().wait() + + async def aclose(self) -> None: + calls["push_closed"] = True + + class SpyComponents: + task_store = SpyTaskStore() + push_worker = SpyPushWorker() + + async def aclose(self) -> None: + await self.push_worker.aclose() + calls["components_closed"] = True + + class SpyServer: + async def serve(self) -> None: + calls["server_served"] = True + + async def aclose(self) -> None: + calls["server_closed"] = True + + await _serve_async_transport(SpyServer(), components=SpyComponents()) + + assert calls == { + "cleanup_started": True, + "push_started": True, + "server_served": True, + "server_closed": True, + "push_closed": True, + "components_closed": True, + } diff --git a/tests/a2a/test_artifacts.py b/tests/a2a/test_artifacts.py new file mode 100644 index 00000000..1744c5c6 --- /dev/null +++ b/tests/a2a/test_artifacts.py @@ -0,0 +1,48 @@ +import pytest + +from iac_code.a2a.artifacts import A2AArtifactStore, UnsafeArtifactNameError + + +def test_artifact_store_writes_text_and_metadata(tmp_path) -> None: + store = A2AArtifactStore(tmp_path) + + metadata = store.save_text( + filename="template.yaml", + content="ROSTemplateFormatVersion: '2015-09-01'", + media_type="text/yaml", + ) + + assert metadata.filename == "template.yaml" + assert metadata.byte_size > 0 + assert metadata.sha256 + assert metadata.uri.startswith("file://") + assert store.path_for(metadata.artifact_id).read_text(encoding="utf-8").startswith("ROSTemplate") + + +def test_artifact_store_writes_binary_and_metadata(tmp_path) -> None: + store = A2AArtifactStore(tmp_path) + + metadata = store.save_bytes(filename="diagram.png", content=b"\x89PNG\r\n\x1a\nimage", media_type="image/png") + + assert metadata.filename == "diagram.png" + assert metadata.media_type == "image/png" + assert metadata.byte_size == 13 + assert metadata.sha256 + assert metadata.uri.startswith("file://") + assert store.path_for(metadata.artifact_id).read_bytes() == b"\x89PNG\r\n\x1a\nimage" + + +def test_artifact_store_decodes_base64_content(tmp_path) -> None: + store = A2AArtifactStore(tmp_path) + + metadata = store.save_base64(filename="sample.bin", content="AAFiYXNlNjQ=", media_type="application/octet-stream") + + assert metadata.byte_size == 8 + assert store.path_for(metadata.artifact_id).read_bytes() == b"\x00\x01base64" + + +def test_artifact_store_rejects_path_traversal(tmp_path) -> None: + store = A2AArtifactStore(tmp_path) + + with pytest.raises(UnsafeArtifactNameError): + store.save_text(filename="../secret.txt", content="bad", media_type="text/plain") diff --git a/tests/a2a/test_client.py b/tests/a2a/test_client.py new file mode 100644 index 00000000..9afd109d --- /dev/null +++ b/tests/a2a/test_client.py @@ -0,0 +1,396 @@ +import base64 + +import pytest +from a2a.utils.signing import ProtectedHeader, create_agent_card_signer +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from iac_code.a2a.client import A2ACardVerificationError, A2AClient, A2AClientResponse, _BoundHttpA2AClient +from iac_code.a2a.signing import ( + ASYMMETRIC_SIGNATURE_ALGORITHM, + _agent_card_from_dict, + _agent_card_to_dict, + sign_agent_card_dict, +) +from iac_code.a2a.transport import A2AAuthConfig + + +class FakeHTTPResponse: + def __init__(self, payload: dict[str, object], status_code: int = 200) -> None: + self._payload = payload + self.status_code = status_code + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + def json(self) -> dict[str, object]: + return self._payload + + +class FakeHTTPClient: + def __init__(self) -> None: + self.requests: list[tuple[str, str, dict[str, object] | None, dict[str, str] | None]] = [] + self.closed = False + + async def get(self, url: str, headers: dict[str, str] | None = None) -> FakeHTTPResponse: + self.requests.append(("GET", url, None, headers)) + return FakeHTTPResponse({"name": "remote", "url": "http://remote/", "preferredTransport": "JSONRPC"}) + + async def post(self, url: str, json: dict[str, object], headers: dict[str, str] | None = None) -> FakeHTTPResponse: + self.requests.append(("POST", url, json, headers)) + return FakeHTTPResponse({"result": {"status": {"state": "input-required"}, "text": "done"}}) + + def stream(self, method: str, url: str, json: dict[str, object], headers: dict[str, str] | None = None): + self.requests.append((method, url, json, headers)) + + class StreamResponse: + def raise_for_status(self) -> None: + return None + + async def iter_lines(self): + yield "" + yield 'data: {"result": {"status": {"state": "working"}}}' + yield "event: ignored" + yield 'data: {"result": {"status": {"state": "input-required"}}}' + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + return StreamResponse() + + async def aclose(self) -> None: + self.closed = True + + +def _base64url_uint(value: int) -> str: + raw = value.to_bytes((value.bit_length() + 7) // 8, "big") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _rsa_private_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def _rsa_public_jwk(private_key, kid: str) -> dict[str, str]: + numbers = private_key.public_key().public_numbers() + return { + "kty": "RSA", + "kid": kid, + "alg": ASYMMETRIC_SIGNATURE_ALGORITHM, + "use": "sig", + "n": _base64url_uint(numbers.n), + "e": _base64url_uint(numbers.e), + } + + +def _sign_with_rsa(card: dict[str, object], private_key, *, kid: str, jku: str) -> dict[str, object]: + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + protected_header: ProtectedHeader = { + "alg": ASYMMETRIC_SIGNATURE_ALGORITHM, + "typ": "JOSE", + "kid": kid, + "jku": jku, + } + signer = create_agent_card_signer(signing_key=private_pem, protected_header=protected_header) + return _agent_card_to_dict(signer(_agent_card_from_dict(card))) + + +@pytest.mark.asyncio +async def test_discover_fetches_agent_card_with_auth_headers() -> None: + http = FakeHTTPClient() + client = A2AClient(http_client=http, auth=A2AAuthConfig(bearer_token="secret")) + + card = await client.discover("http://remote") + + assert card["name"] == "remote" + assert http.requests[0] == ( + "GET", + "http://remote/.well-known/agent-card.json", + None, + {"Authorization": "Bearer secret"}, + ) + + +@pytest.mark.asyncio +async def test_send_message_posts_a2a_1_jsonrpc_request() -> None: + http = FakeHTTPClient() + client = A2AClient(http_client=http) + + response = await client.send_message("http://remote/", "hello", cwd="/tmp/work") + + assert isinstance(response, A2AClientResponse) + assert response.text == "done" + method, url, payload, headers = http.requests[-1] + assert method == "POST" + assert url == "http://remote/" + assert payload is not None + assert payload["method"] == "SendMessage" + assert payload["params"]["message"]["parts"][0]["text"] == "hello" + assert payload["params"]["message"]["metadata"]["iac_code"]["cwd"] == "/tmp/work" + assert headers == {"A2A-Version": "1.0"} + + +@pytest.mark.asyncio +async def test_stream_message_posts_stream_request_and_yields_events() -> None: + http = FakeHTTPClient() + client = A2AClient(http_client=http) + + events = [event async for event in client.stream_message("http://remote/", "hello", cwd="/tmp/work")] + + assert events[0]["result"]["status"]["state"] == "working" + assert events[1]["result"]["status"]["state"] == "input-required" + assert http.requests[-1][2]["method"] == "SendStreamingMessage" + assert http.requests[-1][3] == {"A2A-Version": "1.0"} + + +@pytest.mark.asyncio +async def test_task_management_methods_post_a2a_requests() -> None: + http = FakeHTTPClient() + client = A2AClient(http_client=http) + + await client.get_task("http://remote/", "task-1", history_length=2) + await client.list_tasks("http://remote/", context_id="ctx-1", status="TASK_STATE_WORKING", page_size=10) + await client.cancel_task("http://remote/", "task-1") + + assert http.requests[-3][2]["method"] == "GetTask" + assert http.requests[-3][2]["params"] == {"id": "task-1", "historyLength": 2} + assert http.requests[-2][2]["method"] == "ListTasks" + assert http.requests[-2][2]["params"]["contextId"] == "ctx-1" + assert http.requests[-2][2]["params"]["status"] == "TASK_STATE_WORKING" + assert http.requests[-1][2]["method"] == "CancelTask" + assert http.requests[-1][2]["params"] == {"id": "task-1"} + + +@pytest.mark.asyncio +async def test_push_config_and_extended_card_methods_post_a2a_requests() -> None: + http = FakeHTTPClient() + client = A2AClient(http_client=http) + + await client.create_push_notification_config( + "http://remote/", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + token="token-1", + authentication={"scheme": "bearer", "credentials": "secret"}, + ) + await client.get_push_notification_config("http://remote/", task_id="task-1", config_id="cfg-1") + await client.list_push_notification_configs("http://remote/", task_id="task-1", page_size=1) + await client.delete_push_notification_config("http://remote/", task_id="task-1", config_id="cfg-1") + await client.get_extended_agent_card("http://remote/") + + methods = [request[2]["method"] for request in http.requests[-5:]] + assert methods == [ + "CreateTaskPushNotificationConfig", + "GetTaskPushNotificationConfig", + "ListTaskPushNotificationConfigs", + "DeleteTaskPushNotificationConfig", + "GetExtendedAgentCard", + ] + assert http.requests[-5][2]["params"]["authentication"]["scheme"] == "bearer" + + +@pytest.mark.asyncio +async def test_subscribe_task_posts_stream_request() -> None: + http = FakeHTTPClient() + client = A2AClient(http_client=http) + + events = [event async for event in client.subscribe_task("http://remote/", "task-1")] + + assert events[0]["result"]["status"]["state"] == "working" + assert http.requests[-1][2]["method"] == "SubscribeToTask" + assert http.requests[-1][2]["params"] == {"id": "task-1"} + + +@pytest.mark.asyncio +async def test_send_message_includes_context_id_and_auth_headers() -> None: + http = FakeHTTPClient() + client = A2AClient(http_client=http, auth=A2AAuthConfig(api_key="key-1", api_key_header="X-IAC-Code-Key")) + + await client.send_message("http://remote/", "hello", cwd="/tmp/work", context_id="ctx-1") + + payload = http.requests[-1][2] + headers = http.requests[-1][3] + assert payload is not None + assert payload["params"]["message"]["contextId"] == "ctx-1" + assert headers == {"A2A-Version": "1.0", "X-IAC-Code-Key": "key-1"} + + +def test_select_endpoint_url_prefers_first_supported_interface() -> None: + card = { + "url": "http://fallback.example/rpc", + "supportedInterfaces": [ + { + "url": "http://card.example/a2a", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + } + ], + } + + assert A2AClient.select_endpoint_url(card, fallback_url="http://input.example/") == "http://card.example/a2a" + + +def test_select_endpoint_url_falls_back_when_card_has_no_interface_url() -> None: + assert A2AClient.select_endpoint_url({"name": "remote"}, fallback_url="http://input.example/") == ( + "http://input.example/" + ) + + +@pytest.mark.asyncio +async def test_discover_verifies_signed_card_when_configured() -> None: + http = FakeHTTPClient() + signed = sign_agent_card_dict({"name": "remote", "url": "http://remote/"}, secret="s" * 32, key_id="local") + + async def fake_get(url: str, headers: dict[str, str] | None = None) -> FakeHTTPResponse: + return FakeHTTPResponse(signed) + + http.get = fake_get + client = A2AClient(http_client=http, verification_secret="s" * 32, require_card_signature=True) + + assert (await client.discover("http://remote"))["name"] == "remote" + + +@pytest.mark.asyncio +async def test_discover_rejects_bad_signature_when_strict() -> None: + http = FakeHTTPClient() + signed = sign_agent_card_dict({"name": "remote", "url": "http://remote/"}, secret="s" * 32, key_id="local") + + async def fake_get(url: str, headers: dict[str, str] | None = None) -> FakeHTTPResponse: + return FakeHTTPResponse(signed) + + http.get = fake_get + client = A2AClient(http_client=http, verification_secret="w" * 32, require_card_signature=True) + + with pytest.raises(A2ACardVerificationError, match="signature-mismatch"): + await client.discover("http://remote") + + +@pytest.mark.asyncio +async def test_discover_fetches_remote_jwks_from_protected_header_jku() -> None: + private_key = _rsa_private_key() + signed = _sign_with_rsa( + {"name": "remote", "url": "http://remote/"}, + private_key, + kid="rsa-current", + jku="http://remote/.well-known/jwks.json", + ) + jwks = {"keys": [_rsa_public_jwk(private_key, "rsa-current")]} + + class RemoteJwksHTTPClient(FakeHTTPClient): + async def get(self, url: str, headers: dict[str, str] | None = None) -> FakeHTTPResponse: + self.requests.append(("GET", url, None, headers)) + if url == "http://remote/.well-known/agent-card.json": + return FakeHTTPResponse(signed) + if url == "http://remote/.well-known/jwks.json": + return FakeHTTPResponse(jwks) + raise AssertionError(f"unexpected URL {url}") + + http = RemoteJwksHTTPClient() + client = A2AClient(http_client=http, require_card_signature=True) + + assert (await client.discover("http://remote"))["name"] == "remote" + assert http.requests[1] == ("GET", "http://remote/.well-known/jwks.json", None, None) + + +@pytest.mark.asyncio +async def test_discover_refreshes_remote_jwks_when_key_rotates() -> None: + old_private_key = _rsa_private_key() + new_private_key = _rsa_private_key() + jku = "http://remote/.well-known/jwks.json" + old_signed = _sign_with_rsa({"name": "remote", "url": "http://remote/"}, old_private_key, kid="rsa-old", jku=jku) + new_signed = _sign_with_rsa({"name": "remote", "url": "http://remote/"}, new_private_key, kid="rsa-new", jku=jku) + old_jwks = {"keys": [_rsa_public_jwk(old_private_key, "rsa-old")]} + new_jwks = {"keys": [_rsa_public_jwk(new_private_key, "rsa-new")]} + + class RotatingJwksHTTPClient(FakeHTTPClient): + def __init__(self) -> None: + super().__init__() + self.card_responses = [old_signed, new_signed] + self.jwks_responses = [old_jwks, new_jwks] + + async def get(self, url: str, headers: dict[str, str] | None = None) -> FakeHTTPResponse: + self.requests.append(("GET", url, None, headers)) + if url == "http://remote/.well-known/agent-card.json": + return FakeHTTPResponse(self.card_responses.pop(0)) + if url == jku: + return FakeHTTPResponse(self.jwks_responses.pop(0)) + raise AssertionError(f"unexpected URL {url}") + + http = RotatingJwksHTTPClient() + client = A2AClient(http_client=http, require_card_signature=True) + + assert (await client.discover("http://remote"))["name"] == "remote" + assert (await client.discover("http://remote"))["name"] == "remote" + assert [request[1] for request in http.requests].count(jku) == 2 + + +@pytest.mark.asyncio +async def test_remote_jwks_cache_expires_after_ttl() -> None: + jku = "http://remote/.well-known/jwks.json" + + class CountingJwksHTTPClient(FakeHTTPClient): + async def get(self, url: str, headers: dict[str, str] | None = None) -> FakeHTTPResponse: + self.requests.append(("GET", url, None, headers)) + if url == jku: + return FakeHTTPResponse({"keys": [{"kid": f"key-{len(self.requests)}"}]}) + return await super().get(url, headers=headers) + + now = 100.0 + http = CountingJwksHTTPClient() + client = A2AClient(http_client=http, jwks_cache_ttl_seconds=10.0, clock=lambda: now) + + first = await client._remote_jwks(jku, force_refresh=False) + second = await client._remote_jwks(jku, force_refresh=False) + now = 111.0 + third = await client._remote_jwks(jku, force_refresh=False) + + assert first is second + assert third is not first + assert [request[1] for request in http.requests].count(jku) == 2 + + +@pytest.mark.asyncio +async def test_aclose_does_not_close_injected_http_client() -> None: + http = FakeHTTPClient() + client = A2AClient(http_client=http) + + await client.aclose() + + assert http.closed is False + + +@pytest.mark.asyncio +async def test_aclose_closes_owned_http_client(monkeypatch: pytest.MonkeyPatch) -> None: + http = FakeHTTPClient() + monkeypatch.setattr("iac_code.a2a.client.httpx.AsyncClient", lambda: http) + client = A2AClient() + + await client.aclose() + + assert http.closed is True + + +@pytest.mark.asyncio +async def test_bound_http_client_aclose_does_not_close_shared_transport() -> None: + class SharedTransport: + def __init__(self) -> None: + self.closed = False + + async def aclose(self) -> None: + self.closed = True + + transport = SharedTransport() + bound = _BoundHttpA2AClient(transport, "http://remote/") + + await bound.aclose() + + assert transport.closed is False diff --git a/tests/a2a/test_events.py b/tests/a2a/test_events.py new file mode 100644 index 00000000..7ce37de5 --- /dev/null +++ b/tests/a2a/test_events.py @@ -0,0 +1,349 @@ +import pytest +from a2a.types import TaskArtifactUpdateEvent +from google.protobuf.json_format import MessageToDict + +from iac_code.a2a.events import _METADATA_MAX_CHARS, _truncate, publish_stream_event +from iac_code.types.stream_events import ( + ErrorEvent, + MessageEndEvent, + PermissionRequestEvent, + TextDeltaEvent, + ThinkingDeltaEvent, + ToolInputDeltaEvent, + ToolResultEvent, + ToolUseEndEvent, + ToolUseStartEvent, + Usage, +) + +from .fakes import FakeEventQueue, UnknownEvent, pending_future + + +def dump(event): + return MessageToDict(event, preserving_proto_field_name=False) + + +@pytest.mark.asyncio +async def test_text_delta_publishes_agent_message() -> None: + queue = FakeEventQueue() + + await publish_stream_event(queue, task_id="task-1", context_id="ctx-1", event=TextDeltaEvent(text="hello")) + + assert len(queue.events) == 1 + dumped = dump(queue.events[0]) + assert dumped["status"]["message"]["parts"][0]["text"] == "hello" + assert dumped["status"]["message"]["role"] == "ROLE_AGENT" + + +@pytest.mark.asyncio +async def test_empty_text_delta_is_ignored() -> None: + queue = FakeEventQueue() + + await publish_stream_event(queue, task_id="task-1", context_id="ctx-1", event=TextDeltaEvent(text="")) + + assert queue.events == [] + + +@pytest.mark.asyncio +async def test_permission_request_is_denied_by_default_and_truncated() -> None: + queue = FakeEventQueue() + future = pending_future() + long_value = "x" * (_METADATA_MAX_CHARS + 100) + event = PermissionRequestEvent( + tool_name="bash", tool_input={"cmd": long_value}, tool_use_id="tool-1", response_future=future + ) + + await publish_stream_event(queue, task_id="task-1", context_id="ctx-1", event=event) + + assert future.result() is False + dumped = dump(queue.events[0]) + assert dumped["metadata"]["iac_code"]["permission"]["autoApproved"] is False + assert len(dumped["metadata"]["iac_code"]["permission"]["toolInput"]["cmd"]) == _METADATA_MAX_CHARS + + +@pytest.mark.asyncio +async def test_permission_request_uses_configured_default_decision() -> None: + queue = FakeEventQueue() + future = pending_future() + event = PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="tool-1", + response_future=future, + ) + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=event, + auto_approve_permissions=True, + ) + + assert future.result() is True + dumped = dump(queue.events[0]) + assert dumped["metadata"]["iac_code"]["permission"]["autoApproved"] is True + + +@pytest.mark.asyncio +async def test_permission_request_uses_async_resolver() -> None: + queue = FakeEventQueue() + future = pending_future() + event = PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="tool-1", + response_future=future, + ) + seen: list[str] = [] + + async def approve(request: PermissionRequestEvent) -> bool: + seen.append(request.tool_use_id) + return True + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=event, + permission_resolver=approve, + ) + + assert seen == ["tool-1"] + assert future.result() is True + dumped = dump(queue.events[0]) + assert dumped["metadata"]["iac_code"]["permission"]["autoApproved"] is True + + +@pytest.mark.asyncio +async def test_unknown_event_is_skipped() -> None: + queue = FakeEventQueue() + + await publish_stream_event(queue, task_id="task-1", context_id="ctx-1", event=UnknownEvent()) + + assert queue.events == [] + + +@pytest.mark.asyncio +async def test_unknown_event_logs_debug(caplog: pytest.LogCaptureFixture) -> None: + queue = FakeEventQueue() + caplog.set_level("DEBUG") + + await publish_stream_event(queue, task_id="task-1", context_id="ctx-1", event=UnknownEvent()) + + assert "Skipping unmapped A2A stream event: UnknownEvent" in caplog.text + + +def test_truncate_limits_nested_depth() -> None: + value = "leaf" + for _ in range(80): + value = {"next": value} + + truncated = _truncate(value) + + current = truncated + for _ in range(32): + current = current["next"] + assert current == "[truncated-depth]" + + +@pytest.mark.asyncio +async def test_error_event_uses_error_field() -> None: + queue = FakeEventQueue() + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ErrorEvent(error="boom with /secret/path", is_retryable=False), + ) + + dumped = dump(queue.events[0]) + assert dumped["status"]["state"] == "TASK_STATE_FAILED" + assert dumped["status"]["message"]["parts"][0]["text"] == "An internal error occurred." + + +@pytest.mark.asyncio +async def test_thinking_delta_is_explicitly_ignored() -> None: + queue = FakeEventQueue() + + await publish_stream_event(queue, task_id="task-1", context_id="ctx-1", event=ThinkingDeltaEvent(text="hidden")) + + assert queue.events == [] + + +@pytest.mark.asyncio +async def test_tool_events_publish_metadata_updates() -> None: + queue = FakeEventQueue() + + await publish_stream_event( + queue, task_id="task-1", context_id="ctx-1", event=ToolUseStartEvent(tool_use_id="tool-1", name="bash") + ) + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolInputDeltaEvent(tool_use_id="tool-1", partial_json='{"cmd"'), + ) + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolUseEndEvent(tool_use_id="tool-1", name="bash", input={"cmd": "pwd"}), + ) + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolResultEvent(tool_use_id="tool-1", tool_name="bash", result="ok", is_error=False), + ) + + dumped = [dump(event) for event in queue.events] + assert dumped[0]["metadata"]["iac_code"]["tool"]["status"] == "started" + assert dumped[1]["metadata"]["iac_code"]["tool"]["status"] == "input_delta" + assert dumped[2]["metadata"]["iac_code"]["tool"]["status"] == "input_complete" + assert dumped[2]["metadata"]["iac_code"]["tool"]["name"] == "bash" + assert dumped[3]["metadata"]["iac_code"]["tool"]["status"] == "completed" + + +@pytest.mark.asyncio +async def test_tool_result_externalizes_large_file_metadata(tmp_path) -> None: + from iac_code.a2a.artifacts import A2AArtifactStore + + queue = FakeEventQueue() + store = A2AArtifactStore(tmp_path) + result = {"artifact": {"filename": "result.txt", "mediaType": "text/plain", "content": "hello artifact"}} + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolResultEvent(tool_use_id="tool-1", tool_name="write_file", result=result, is_error=False), + artifact_store=store, + ) + + dumped = dump(queue.events[1]) + artifact = dumped["metadata"]["iac_code"]["tool"]["artifact"] + assert artifact["filename"] == "result.txt" + assert artifact["byteSize"] == 14 + + +@pytest.mark.asyncio +async def test_tool_result_publishes_standard_artifact_update_event(tmp_path) -> None: + from iac_code.a2a.artifacts import A2AArtifactStore + + queue = FakeEventQueue() + store = A2AArtifactStore(tmp_path) + result = {"artifact": {"filename": "result.txt", "mediaType": "text/plain", "content": "hello artifact"}} + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolResultEvent(tool_use_id="tool-1", tool_name="write_file", result=result, is_error=False), + artifact_store=store, + ) + + artifact_event = queue.events[0] + assert isinstance(artifact_event, TaskArtifactUpdateEvent) + dumped = dump(artifact_event) + assert dumped["artifact"]["name"] == "result.txt" + assert dumped["artifact"]["parts"][0]["url"].startswith("file://") + assert dumped["artifact"]["parts"][0]["mediaType"] == "text/plain" + assert dumped["artifact"]["metadata"]["byteSize"] == 14 + assert dumped["lastChunk"] is True + assert dumped.get("append", False) is False + assert ( + dumped["artifact"]["artifactId"] + == dump(queue.events[1])["metadata"]["iac_code"]["tool"]["artifact"]["artifactId"] + ) + + +@pytest.mark.asyncio +async def test_tool_result_skips_non_text_artifact_content(tmp_path) -> None: + from iac_code.a2a.artifacts import A2AArtifactStore + + queue = FakeEventQueue() + store = A2AArtifactStore(tmp_path) + result = {"artifact": {"filename": "result.bin", "mediaType": "application/octet-stream", "content": b"binary"}} + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolResultEvent(tool_use_id="tool-1", tool_name="write_file", result=result, is_error=False), + artifact_store=store, + ) + + dumped = dump(queue.events[0]) + assert "artifact" not in dumped["metadata"]["iac_code"]["tool"] + + +@pytest.mark.asyncio +async def test_tool_result_externalizes_base64_binary_artifact(tmp_path) -> None: + from iac_code.a2a.artifacts import A2AArtifactStore + + queue = FakeEventQueue() + store = A2AArtifactStore(tmp_path) + result = { + "artifact": { + "filename": "diagram.png", + "mediaType": "image/png", + "bytes": "iVBORw0KGgppbWFnZQ==", + } + } + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolResultEvent(tool_use_id="tool-1", tool_name="draw", result=result, is_error=False), + artifact_store=store, + ) + + artifact_event = queue.events[0] + assert isinstance(artifact_event, TaskArtifactUpdateEvent) + dumped = dump(artifact_event) + assert dumped["artifact"]["parts"][0]["mediaType"] == "image/png" + assert dumped["artifact"]["metadata"]["byteSize"] == 13 + artifact_metadata = dump(queue.events[1])["metadata"]["iac_code"]["tool"]["artifact"] + assert artifact_metadata["mediaType"] == "image/png" + assert store.path_for(artifact_metadata["artifactId"]).read_bytes() == b"\x89PNG\r\n\x1a\nimage" + + +@pytest.mark.asyncio +async def test_tool_result_externalizes_workspace_path_binary_artifact(tmp_path) -> None: + from iac_code.a2a.artifacts import A2AArtifactStore + + source = tmp_path / "voice.wav" + source.write_bytes(b"RIFFaudio") + queue = FakeEventQueue() + store = A2AArtifactStore(tmp_path / "artifacts") + result = {"artifact": {"filename": "voice.wav", "mediaType": "audio/wav", "path": str(source)}} + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=ToolResultEvent(tool_use_id="tool-1", tool_name="record", result=result, is_error=False), + artifact_store=store, + ) + + artifact_metadata = dump(queue.events[1])["metadata"]["iac_code"]["tool"]["artifact"] + assert artifact_metadata["byteSize"] == 9 + assert store.path_for(artifact_metadata["artifactId"]).read_bytes() == b"RIFFaudio" + + +@pytest.mark.asyncio +async def test_message_end_publishes_usage_metadata() -> None: + queue = FakeEventQueue() + + await publish_stream_event( + queue, + task_id="task-1", + context_id="ctx-1", + event=MessageEndEvent(stop_reason="end_turn", usage=Usage(input_tokens=2, output_tokens=3)), + ) + + dumped = dump(queue.events[0]) + assert dumped["metadata"]["iac_code"]["usage"]["totalTokens"] == 5 diff --git a/tests/a2a/test_executor.py b/tests/a2a/test_executor.py new file mode 100644 index 00000000..87d01e1d --- /dev/null +++ b/tests/a2a/test_executor.py @@ -0,0 +1,520 @@ +import asyncio +from pathlib import Path + +import pytest +from a2a.types import TaskStatusUpdateEvent +from google.protobuf.json_format import MessageToDict + +from iac_code.a2a.executor import IacCodeA2AExecutor +from iac_code.a2a.metrics import NoOpA2AMetrics +from iac_code.a2a.persistence import A2APersistenceStore +from iac_code.a2a.task_store import A2ATaskStore +from iac_code.types.stream_events import PermissionRequestEvent, TextDeltaEvent, ToolResultEvent + +from .fakes import FakeAgentLoop, FakeEventQueue, FakeRequestContext, FakeRuntime, pending_future + + +def dump(event): + return MessageToDict(event, preserving_proto_field_name=False) + + +@pytest.mark.asyncio +async def test_executor_runs_prompt_and_finishes_input_required( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="hi")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}) + + await executor.execute(context, queue) + + assert loop.prompts == ["hello"] + states = [dump(event)["status"]["state"] for event in queue.events if isinstance(event, TaskStatusUpdateEvent)] + assert states[0] == "TASK_STATE_SUBMITTED" + assert "TASK_STATE_WORKING" in states + assert states[-1] == "TASK_STATE_INPUT_REQUIRED" + record = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + assert "".join(record.output_text) == "hi" + + +@pytest.mark.asyncio +async def test_executor_passes_artifact_store_to_stream_event_publisher( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + artifact_store = object() + seen_artifact_stores: list[object | None] = [] + seen_auto_approve_permissions: list[bool] = [] + + async def spy_publish_stream_event( + event_queue, + *, + task_id, + context_id, + event, + artifact_store=None, + permission_resolver=None, + auto_approve_permissions=False, + ): + seen_artifact_stores.append(artifact_store) + seen_auto_approve_permissions.append(auto_approve_permissions) + return None + + loop = FakeAgentLoop( + [ + ToolResultEvent( + tool_use_id="tool-1", + tool_name="write_file", + result={"artifact": {"filename": "out.txt", "content": "hello", "mediaType": "text/plain"}}, + is_error=False, + ) + ] + ) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + monkeypatch.setattr("iac_code.a2a.executor.publish_stream_event", spy_publish_stream_event) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus", artifact_store=artifact_store) + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), FakeEventQueue()) + + assert seen_artifact_stores == [artifact_store] + assert seen_auto_approve_permissions == [False] + + +@pytest.mark.asyncio +async def test_executor_auto_approves_permissions_when_configured( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + future = pending_future() + loop = FakeAgentLoop( + [ + PermissionRequestEvent( + tool_name="bash", + tool_input={"cmd": "pwd"}, + tool_use_id="tool-1", + response_future=future, + ) + ] + ) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor( + task_store=store, + model="qwen3.6-plus", + auto_approve_permissions=True, + ) + queue = FakeEventQueue() + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), queue) + + assert future.result() is True + permission_events = [ + dump(event)["metadata"]["iac_code"]["permission"] + for event in queue.events + if "permission" in dump(event).get("metadata", {}).get("iac_code", {}) + ] + assert permission_events[0]["autoApproved"] is True + + +@pytest.mark.asyncio +async def test_executor_persists_terminal_task_state_and_output( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="persisted output")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + persistence = A2APersistenceStore(tmp_path / "state") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), FakeEventQueue()) + + snapshot = persistence.load_task("task-1") + assert snapshot is not None + assert snapshot.state == "input-required" + assert snapshot.output_text == ["persisted output"] + + +@pytest.mark.asyncio +async def test_executor_persists_working_state_for_interrupted_restoration( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + started = asyncio.Event() + + class SlowLoop: + async def run_streaming(self, prompt: str): + started.set() + await asyncio.sleep(60) + yield TextDeltaEvent(text="never") + + runtime = FakeRuntime(agent_loop=SlowLoop(), session_id="session-1") + persistence = A2APersistenceStore(tmp_path / "state") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}) + queue = FakeEventQueue() + running = asyncio.create_task(executor.execute(context, queue)) + await started.wait() + + task_snapshot = persistence.load_task("task-1") + context_snapshot = persistence.load_context("ctx-1") + assert task_snapshot is not None + assert task_snapshot.state == "working" + assert context_snapshot is not None + assert context_snapshot.active_task_id == "task-1" + + await executor.cancel(context, queue) + await running + + +@pytest.mark.asyncio +async def test_executor_notifies_push_for_terminal_success(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + class SpyPushNotifier: + def __init__(self) -> None: + self.calls: list[dict[str, str]] = [] + + async def notify_task_state(self, **kwargs) -> bool: + self.calls.append(kwargs) + return True + + loop = FakeAgentLoop([TextDeltaEvent(text="hi")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + notifier = SpyPushNotifier() + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus", push_notifier=notifier) + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), FakeEventQueue()) + + assert notifier.calls == [{"task_id": "task-1", "context_id": "ctx-1", "state": "input-required"}] + + +@pytest.mark.asyncio +async def test_executor_logs_and_swallows_push_failures( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + class FailingPushNotifier: + async def notify_task_state(self, **kwargs) -> bool: + raise RuntimeError("push endpoint down") + + class ExplodingLoop: + async def run_streaming(self, prompt: str): + raise RuntimeError("internal failure") + yield TextDeltaEvent(text="never") + + runtime = FakeRuntime(agent_loop=ExplodingLoop(), session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus", push_notifier=FailingPushNotifier()) + + await executor.execute(FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}), FakeEventQueue()) + + assert "A2A push notification failed" in caplog.text + + +@pytest.mark.asyncio +async def test_executor_rejects_invalid_workspace(tmp_path: Path) -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path / "missing")}}) + + await executor.execute(context, queue) + + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_FAILED" + assert "workspace" in dumped["status"]["message"]["parts"][0]["text"].lower() + + +@pytest.mark.asyncio +async def test_executor_rejects_workspace_outside_allowed_roots( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + allowed = tmp_path / "allowed" + outside = tmp_path / "outside" + allowed.mkdir() + outside.mkdir() + monkeypatch.setenv("IACCODE_A2A_ALLOWED_CWDS", str(allowed)) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(outside)}}) + + await executor.execute(context, queue) + + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_FAILED" + assert "workspace" in dumped["status"]["message"]["parts"][0]["text"].lower() + + +@pytest.mark.asyncio +async def test_executor_reports_invalid_task_id() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + context = FakeRequestContext(task_id="../bad") + + await executor.execute(context, queue) + + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_FAILED" + assert dumped["status"]["message"]["parts"][0]["text"] == "Invalid A2A id" + + +@pytest.mark.asyncio +async def test_executor_rejects_empty_prompt_before_creating_runtime( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + def fail_if_called(options): + raise AssertionError("runtime should not be created for empty prompt") + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", fail_if_called) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + context = FakeRequestContext(text=" ", metadata={"iac_code": {"cwd": str(tmp_path)}}) + + await executor.execute(context, queue) + + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_FAILED" + assert dumped["status"]["message"]["parts"][0]["text"] == "A2A server currently accepts text input only." + + +@pytest.mark.asyncio +async def test_cancel_bypasses_context_lock(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + started = asyncio.Event() + + class SlowLoop: + async def run_streaming(self, prompt: str): + started.set() + await asyncio.sleep(60) + yield TextDeltaEvent(text="never") + + runtime = FakeRuntime(agent_loop=SlowLoop(), session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}) + queue = FakeEventQueue() + running = asyncio.create_task(executor.execute(context, queue)) + await started.wait() + + await executor.cancel(context, queue) + await running + + assert dump(queue.events[-1])["status"]["state"] == "TASK_STATE_CANCELED" + + +@pytest.mark.asyncio +async def test_same_context_concurrent_message_is_rejected(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + started = asyncio.Event() + + class SlowLoop: + async def run_streaming(self, prompt: str): + started.set() + await asyncio.sleep(60) + yield TextDeltaEvent(text="never") + + runtime = FakeRuntime(agent_loop=SlowLoop(), session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + first = FakeRequestContext(task_id="task-1", context_id="ctx-1", metadata={"iac_code": {"cwd": str(tmp_path)}}) + second = FakeRequestContext(task_id="task-2", context_id="ctx-1", metadata={"iac_code": {"cwd": str(tmp_path)}}) + first_queue = FakeEventQueue() + second_queue = FakeEventQueue() + running = asyncio.create_task(executor.execute(first, first_queue)) + await started.wait() + + await executor.execute(second, second_queue) + await executor.cancel(first, first_queue) + await running + + dumped = dump(second_queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_FAILED" + assert "already working" in dumped["status"]["message"]["parts"][0]["text"] + + +@pytest.mark.asyncio +async def test_same_context_lock_race_fails_fast(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + class ContendedLock: + def __init__(self) -> None: + self.acquire_requested = asyncio.Event() + self.acquire_waiter = asyncio.get_running_loop().create_future() + + def acquire(self) -> asyncio.Future[bool]: + self.acquire_requested.set() + return self.acquire_waiter + + def release(self) -> None: + raise AssertionError("release should not be called when acquire times out") + + runtime = FakeRuntime(agent_loop=FakeAgentLoop([TextDeltaEvent(text="never")]), session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + ctx = await store.get_or_create_context( + context_id="ctx-1", + cwd=str(tmp_path), + runtime_factory=lambda sid: runtime, + ) + lock = ContendedLock() + ctx.lock = lock + + async def deterministic_timeout(awaitable, timeout): + assert awaitable is lock.acquire_waiter + assert timeout == 1 + raise TimeoutError + + monkeypatch.setattr("iac_code.a2a.executor.asyncio.wait_for", deterministic_timeout) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + + await executor.execute( + FakeRequestContext( + task_id="task-2", + context_id="ctx-1", + metadata={"iac_code": {"cwd": str(tmp_path)}}, + ), + queue, + ) + + assert lock.acquire_requested.is_set() + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_FAILED" + assert "already working" in dumped["status"]["message"]["parts"][0]["text"] + + +@pytest.mark.asyncio +async def test_independent_contexts_execute_concurrently(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + prompts: list[str] = [] + + class FastLoop: + async def run_streaming(self, prompt: str): + prompts.append(prompt) + await asyncio.sleep(0) + yield TextDeltaEvent(text=prompt) + + monkeypatch.setattr( + "iac_code.a2a.executor.create_agent_runtime", + lambda options: FakeRuntime(agent_loop=FastLoop(), session_id=options.session_id), + ) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + await asyncio.gather( + executor.execute( + FakeRequestContext( + task_id="task-1", context_id="ctx-1", text="one", metadata={"iac_code": {"cwd": str(tmp_path)}} + ), + FakeEventQueue(), + ), + executor.execute( + FakeRequestContext( + task_id="task-2", context_id="ctx-2", text="two", metadata={"iac_code": {"cwd": str(tmp_path)}} + ), + FakeEventQueue(), + ), + ) + + assert sorted(prompts) == ["one", "two"] + + +@pytest.mark.asyncio +async def test_auth_error_is_sanitized(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + def raise_auth_error(options): + raise ValueError("provider not configured: secret internal detail") + + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", raise_auth_error) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}) + + await executor.execute(context, queue) + + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_FAILED" + assert ( + dumped["status"]["message"]["parts"][0]["text"] + == "Authentication required. Please configure your API credentials." + ) + + +@pytest.mark.asyncio +async def test_retryable_executor_error_returns_input_required(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + class TimeoutLoop: + async def run_streaming(self, prompt: str): + raise TimeoutError("upstream timed out") + yield TextDeltaEvent(text="never") + + runtime = FakeRuntime(agent_loop=TimeoutLoop(), session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}) + + await executor.execute(context, queue) + + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + assert dumped["status"]["message"]["parts"][0]["text"] == "A temporary error occurred. Please retry." + + +@pytest.mark.asyncio +async def test_retryable_setup_error_returns_input_required(tmp_path: Path) -> None: + class TimeoutTaskStore(A2ATaskStore): + async def ensure_task_not_expired(self, task_id: str) -> None: + raise TimeoutError("task store timed out") + + store = TimeoutTaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}) + + await executor.execute(context, queue) + + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + assert dumped["status"]["message"]["parts"][0]["text"] == "A temporary error occurred. Please retry." + + +@pytest.mark.asyncio +async def test_unexpected_error_is_sanitized(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + class ExplodingLoop: + async def run_streaming(self, prompt: str): + raise RuntimeError("internal path /secret/config.yml leaked") + yield TextDeltaEvent(text="never") + + runtime = FakeRuntime(agent_loop=ExplodingLoop(), session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + executor = IacCodeA2AExecutor(task_store=store, model="qwen3.6-plus") + queue = FakeEventQueue() + context = FakeRequestContext(metadata={"iac_code": {"cwd": str(tmp_path)}}) + + await executor.execute(context, queue) + + dumped = dump(queue.events[-1]) + assert dumped["status"]["state"] == "TASK_STATE_FAILED" + assert dumped["status"]["message"]["parts"][0]["text"] == "An internal error occurred." diff --git a/tests/a2a/test_grpc_transport.py b/tests/a2a/test_grpc_transport.py new file mode 100644 index 00000000..e8a29237 --- /dev/null +++ b/tests/a2a/test_grpc_transport.py @@ -0,0 +1,135 @@ +import asyncio + +import pytest + +from iac_code.a2a.transports.base import A2ATransportDependencyError +from iac_code.a2a.transports.dispatcher import create_runtime_components +from iac_code.a2a.transports.grpc import GrpcA2AServer, require_grpc +from iac_code.a2a.transports.grpc_jsonrpc import GrpcA2AClient, JsonRpcEnvelope, _JsonRpcServicer + + +class FakeGrpcStub: + def __init__(self) -> None: + self.requests = [] + + async def Send(self, envelope: JsonRpcEnvelope) -> JsonRpcEnvelope: # noqa: N802 + self.requests.append(envelope) + return JsonRpcEnvelope(payload=b'{"jsonrpc":"2.0","id":"1","result":{"ok":true}}') + + async def Stream(self, envelope: JsonRpcEnvelope): # noqa: N802 + self.requests.append(envelope) + yield JsonRpcEnvelope(payload=b'{"jsonrpc":"2.0","id":"1","result":{"state":"working"}}') + yield JsonRpcEnvelope(payload=b'{"jsonrpc":"2.0","id":"1","result":{"state":"done"},"final":true}') + + +def test_require_grpc_reports_missing_dependency(monkeypatch) -> None: + real_import = __import__ + + def fail_grpc_import(name, *args, **kwargs): + if name == "grpc": + raise ImportError(name) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("builtins.__import__", fail_grpc_import) + + with pytest.raises(A2ATransportDependencyError, match="iac-code\\[a2a-grpc\\]"): + require_grpc() + + +@pytest.mark.asyncio +async def test_grpc_client_sends_json_payload() -> None: + client = GrpcA2AClient(stub=FakeGrpcStub()) + + response = await client.send({"jsonrpc": "2.0", "id": "1", "method": "message/send"}) + + assert response["result"]["ok"] is True + + +@pytest.mark.asyncio +async def test_grpc_client_streams_json_payloads() -> None: + client = GrpcA2AClient(stub=FakeGrpcStub()) + + events = [event async for event in client.stream({"jsonrpc": "2.0", "id": "1", "method": "message/stream"})] + + assert events[0]["result"]["state"] == "working" + assert events[-1]["final"] is True + + +def test_grpc_server_requires_host_and_port() -> None: + with pytest.raises(ValueError, match="gRPC host and port"): + GrpcA2AServer(components=None, host="", port=0) + + +def test_grpc_server_allows_ephemeral_zero_port() -> None: + GrpcA2AServer(components=None, host="127.0.0.1", port=0) + + +@pytest.mark.asyncio +async def test_grpc_stream_swallows_client_disconnect() -> None: + class DisconnectingDispatcher: + async def dispatch_stream(self, payload): + raise asyncio.CancelledError() + yield payload + + class CancelledContext: + def cancelled(self) -> bool: + return True + + servicer = _JsonRpcServicer.__new__(_JsonRpcServicer) + servicer._dispatcher = DisconnectingDispatcher() + + events = [ + event async for event in servicer.Stream(JsonRpcEnvelope(payload=b'{"jsonrpc":"2.0"}'), CancelledContext()) + ] + + assert events == [] + + +@pytest.mark.asyncio +async def test_official_grpc_server_registers_a2a_service(monkeypatch, tmp_path) -> None: + registered: dict[str, object] = {} + + class FakeServer: + def add_insecure_port(self, address: str) -> None: + registered["address"] = address + + async def start(self) -> None: + registered["started"] = True + + async def wait_for_termination(self) -> None: + registered["waited"] = True + + async def stop(self, grace: int) -> None: + registered["stopped"] = grace + + class FakeAio: + @staticmethod + def server() -> FakeServer: + return FakeServer() + + class FakeGrpcModule: + aio = FakeAio + + def fake_register(servicer, server) -> None: + registered["servicer_type"] = type(servicer).__name__ + registered["server"] = server + + monkeypatch.setattr("iac_code.a2a.transports.grpc.require_grpc", lambda: FakeGrpcModule) + monkeypatch.setattr("a2a.types.a2a_pb2_grpc.add_A2AServiceServicer_to_server", fake_register) + + components = create_runtime_components( + model="qwen3.6-plus", + host="127.0.0.1", + port=41242, + persistence_dir=tmp_path / "state", + artifact_dir=tmp_path / "artifacts", + ) + server = GrpcA2AServer(components=components, host="127.0.0.1", port=41243) + + await server.serve() + await server.aclose() + + assert registered["address"] == "127.0.0.1:41243" + assert registered["servicer_type"] == "GrpcHandler" + assert registered["started"] is True + assert registered["waited"] is True diff --git a/tests/a2a/test_http_transport.py b/tests/a2a/test_http_transport.py new file mode 100644 index 00000000..90cf28b9 --- /dev/null +++ b/tests/a2a/test_http_transport.py @@ -0,0 +1,26 @@ +import pytest + +from iac_code.a2a.transports.http import HttpA2AClient + +from .test_client import FakeHTTPClient + + +@pytest.mark.asyncio +async def test_http_transport_does_not_close_injected_http_client() -> None: + http = FakeHTTPClient() + client = HttpA2AClient(http_client=http) + + await client.aclose() + + assert http.closed is False + + +@pytest.mark.asyncio +async def test_http_transport_closes_owned_http_client(monkeypatch: pytest.MonkeyPatch) -> None: + http = FakeHTTPClient() + monkeypatch.setattr("iac_code.a2a.transports.http.httpx.AsyncClient", lambda: http) + client = HttpA2AClient() + + await client.aclose() + + assert http.closed is True diff --git a/tests/a2a/test_metrics.py b/tests/a2a/test_metrics.py new file mode 100644 index 00000000..8142b049 --- /dev/null +++ b/tests/a2a/test_metrics.py @@ -0,0 +1,24 @@ +from iac_code.a2a.metrics import A2AMetrics, NoOpA2AMetrics + + +def test_noop_metrics_records_events() -> None: + metrics: A2AMetrics = NoOpA2AMetrics() + + metrics.record_task_created() + metrics.record_turn_completed() + metrics.record_task_canceled() + metrics.record_task_failed() + metrics.record_context_evicted() + metrics.record_executor_error() + + +def test_noop_metrics_support_push_delivery_hooks() -> None: + metrics: A2AMetrics = NoOpA2AMetrics() + + metrics.record_push_enqueued() + metrics.record_push_delivered(duration_ms=12.5) + metrics.record_push_retry_scheduled() + metrics.record_push_dead_lettered() + metrics.record_push_permanent_failure() + metrics.record_push_transient_failure() + metrics.record_push_queue_depth(3) diff --git a/tests/a2a/test_parts.py b/tests/a2a/test_parts.py new file mode 100644 index 00000000..d431276e --- /dev/null +++ b/tests/a2a/test_parts.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import base64 + +import pytest +from a2a.types import Part +from google.protobuf.struct_pb2 import Value + +from iac_code.a2a import parts + + +def _data_part(value: dict[str, object]) -> Part: + data = Value() + data.struct_value.update(value) + return Part(data=data, media_type="application/json") + + +def _binary_data_part(value: dict[str, object], *, media_type: str) -> Part: + data = Value() + data.struct_value.update(value) + return Part(data=data, media_type=media_type) + + +def test_text_part_defaults_to_plain_text(tmp_path) -> None: + assert parts.part_to_prompt(Part(text="create a vpc"), cwd=tmp_path) == "create a vpc" + + +def test_text_part_accepts_advertised_text_like_media_type(tmp_path) -> None: + part = Part(text="# Review this template", media_type="text/markdown") + + assert parts.part_to_prompt(part, cwd=tmp_path) == "# Review this template" + + +def test_text_part_accepts_extra_text_mime_type_from_env(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("IACCODE_A2A_TEXT_MIME_TYPES", "application/vnd.iac+yaml") + part = Part(text="Resources: {}", media_type="application/vnd.iac+yaml") + + assert "application/vnd.iac+yaml" in parts.supported_input_mime_types() + assert parts.part_to_prompt(part, cwd=tmp_path) == "Resources: {}" + + +def test_data_part_serializes_compact_json(tmp_path) -> None: + assert parts.part_to_prompt(_data_part({"template": "value", "count": 2}), cwd=tmp_path) == ( + '{"count":2.0,"template":"value"}' + ) + + +def test_raw_part_accepts_utf8_text_like_media_type(tmp_path) -> None: + part = Part(raw="name: vpc\n".encode(), media_type="text/yaml") + + assert parts.part_to_prompt(part, cwd=tmp_path) == "name: vpc\n" + + +def test_file_url_part_reads_text_file_inside_workspace(tmp_path) -> None: + source = tmp_path / "template.yaml" + source.write_text("ROSTemplateFormatVersion: '2015-09-01'\n", encoding="utf-8") + + assert parts.part_to_prompt(Part(url=source.as_uri(), media_type="text/plain"), cwd=tmp_path) == ( + "ROSTemplateFormatVersion: '2015-09-01'\n" + ) + + +def test_raw_image_part_adds_multimodal_manifest(tmp_path) -> None: + raw_png = b"\x89PNG\r\n\x1a\nimage-bytes" + + prompt = parts.part_to_prompt(Part(raw=raw_png, media_type="image/png", filename="diagram.png"), cwd=tmp_path) + + assert "A2A multimodal attachment:" in prompt + assert "filename=diagram.png" in prompt + assert "mediaType=image/png" in prompt + assert "byteSize=19" in prompt + assert "sha256=" in prompt + assert "image-bytes" not in prompt + + +def test_file_url_audio_part_adds_multimodal_manifest(tmp_path) -> None: + source = tmp_path / "voice.wav" + source.write_bytes(b"RIFFaudio") + + prompt = parts.part_to_prompt(Part(url=source.as_uri(), media_type="audio/wav"), cwd=tmp_path) + + assert "A2A multimodal attachment:" in prompt + assert "filename=voice.wav" in prompt + assert "mediaType=audio/wav" in prompt + assert "byteSize=9" in prompt + assert f"source={source.as_uri()}" in prompt + + +def test_binary_data_part_decodes_base64_manifest(tmp_path) -> None: + encoded = base64.b64encode(b"\x00\x01binary").decode("ascii") + + prompt = parts.part_to_prompt( + _binary_data_part({"filename": "sample.bin", "bytes": encoded}, media_type="application/octet-stream"), + cwd=tmp_path, + ) + + assert "filename=sample.bin" in prompt + assert "mediaType=application/octet-stream" in prompt + assert "byteSize=8" in prompt + + +@pytest.mark.parametrize( + ("part", "message"), + [ + (Part(text="bad", media_type="application/octet-stream"), "unsupported media type"), + (Part(raw=b"\xff", media_type="text/plain"), "UTF-8"), + (Part(url="https://example.com/template.yaml", media_type="text/plain"), "local file://"), + (Part(url="http://127.0.0.1/template.yaml", media_type="text/plain"), "local file://"), + ], +) +def test_part_rejects_unsupported_or_unsafe_inputs(part: Part, message: str, tmp_path) -> None: + with pytest.raises(ValueError, match=message): + parts.part_to_prompt(part, cwd=tmp_path) + + +def test_file_url_rejects_path_traversal_outside_workspace(tmp_path) -> None: + outside = tmp_path.parent / "outside.txt" + outside.write_text("secret", encoding="utf-8") + + with pytest.raises(ValueError, match="outside the allowed workspace") as exc_info: + parts.part_to_prompt(Part(url=outside.as_uri(), media_type="text/plain"), cwd=tmp_path) + + assert str(outside) not in str(exc_info.value) + + +def test_file_url_rejects_symlink_escape_without_leaking_path(tmp_path) -> None: + outside = tmp_path.parent / "outside.txt" + outside.write_text("secret", encoding="utf-8") + link = tmp_path / "link.txt" + link.symlink_to(outside) + + with pytest.raises(ValueError, match="outside the allowed workspace") as exc_info: + parts.part_to_prompt(Part(url=link.as_uri(), media_type="text/plain"), cwd=tmp_path) + + assert str(outside) not in str(exc_info.value) + assert str(link) not in str(exc_info.value) + + +@pytest.mark.parametrize("name", ["missing.txt", "directory"]) +def test_file_url_rejects_missing_files_and_directories(name: str, tmp_path) -> None: + path = tmp_path / name + if name == "directory": + path.mkdir() + + with pytest.raises(ValueError, match="existing file"): + parts.part_to_prompt(Part(url=path.as_uri(), media_type="text/plain"), cwd=tmp_path) + + +def test_inline_raw_data_and_file_content_size_limits(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(parts, "MAX_INLINE_BYTES", 3) + monkeypatch.setattr(parts, "MAX_FILE_BYTES", 3) + source = tmp_path / "large.txt" + source.write_text("abcd", encoding="utf-8") + + with pytest.raises(ValueError, match="too large"): + parts.part_to_prompt(Part(raw=b"abcd", media_type="text/plain"), cwd=tmp_path) + + with pytest.raises(ValueError, match="too large"): + parts.part_to_prompt(_data_part({"abcd": "efgh"}), cwd=tmp_path) + + with pytest.raises(ValueError, match="too large"): + parts.part_to_prompt(Part(url=source.as_uri(), media_type="text/plain"), cwd=tmp_path) + + +def test_message_parts_join_non_empty_values(tmp_path) -> None: + assert parts.parts_to_prompt([Part(text="first"), Part(text=""), Part(text="second")], cwd=tmp_path) == ( + "first\nsecond" + ) diff --git a/tests/a2a/test_persistence.py b/tests/a2a/test_persistence.py new file mode 100644 index 00000000..bef09eaf --- /dev/null +++ b/tests/a2a/test_persistence.py @@ -0,0 +1,75 @@ +from iac_code.a2a.persistence import A2AContextSnapshot, A2APersistenceStore, A2ARouteSnapshot, A2ATaskSnapshot + + +def test_persistence_round_trips_task_and_context(tmp_path) -> None: + store = A2APersistenceStore(tmp_path) + + store.save_task(A2ATaskSnapshot(task_id="task-1", context_id="ctx-1", state="working", output_text=["hi"])) + store.save_context(A2AContextSnapshot(context_id="ctx-1", session_id="session-1", cwd=str(tmp_path))) + + assert store.load_task("task-1").state == "working" + assert store.load_context("ctx-1").session_id == "session-1" + + +def test_persistence_rejects_path_traversal_ids(tmp_path) -> None: + store = A2APersistenceStore(tmp_path) + + try: + store.save_task(A2ATaskSnapshot(task_id="../escape", context_id="ctx-1", state="working")) + except ValueError as exc: + assert "Invalid A2A id" in str(exc) + else: + raise AssertionError("path traversal task id should be rejected") + + assert not (tmp_path / "escape.json").exists() + + +def test_working_tasks_restore_as_interrupted(tmp_path) -> None: + store = A2APersistenceStore(tmp_path) + store.save_task(A2ATaskSnapshot(task_id="task-1", context_id="ctx-1", state="working")) + + restored = store.restore_task("task-1") + + assert restored.state == "interrupted" + assert "cannot be revived" in restored.status_message + assert store.load_task("task-1").state == "interrupted" + + +def test_submitted_tasks_restore_as_interrupted(tmp_path) -> None: + store = A2APersistenceStore(tmp_path) + store.save_task(A2ATaskSnapshot(task_id="task-1", context_id="ctx-1", state="submitted")) + + restored = store.restore_task("task-1") + + assert restored.state == "interrupted" + assert store.load_task("task-1").state == "interrupted" + + +def test_input_required_tasks_restore_without_interruption(tmp_path) -> None: + store = A2APersistenceStore(tmp_path) + store.save_task(A2ATaskSnapshot(task_id="task-1", context_id="ctx-1", state="input-required")) + + restored = store.restore_task("task-1") + + assert restored.state == "input-required" + assert store.load_task("task-1").state == "input-required" + + +def test_corrupt_task_file_is_skipped(tmp_path) -> None: + store = A2APersistenceStore(tmp_path) + (tmp_path / "tasks").mkdir() + (tmp_path / "tasks" / "bad.json").write_text("{broken", encoding="utf-8") + + assert store.list_tasks() == [] + + +def test_persistence_round_trips_route_snapshots(tmp_path) -> None: + store = A2APersistenceStore(tmp_path) + + store.save_routes( + [A2ARouteSnapshot(name="template", url="http://template", skills=["iac_generation"], tags=["ros"])] + ) + + assert store.load_routes() == [ + A2ARouteSnapshot(name="template", url="http://template", skills=["iac_generation"], tags=["ros"]) + ] diff --git a/tests/a2a/test_push.py b/tests/a2a/test_push.py new file mode 100644 index 00000000..730d4bbd --- /dev/null +++ b/tests/a2a/test_push.py @@ -0,0 +1,275 @@ +import json + +import pytest +from a2a.server.context import ServerCallContext +from a2a.types import TaskPushNotificationConfig, TaskState, TaskStatus, TaskStatusUpdateEvent +from cryptography.fernet import Fernet + +from iac_code.a2a.persistence import A2APersistenceStore +from iac_code.a2a.push import ( + A2APushConfig, + A2APushConfigStore, + A2APushNotifier, + A2APushSecretKeyring, + A2APushSender, + InvalidPushNotificationConfigError, +) +from iac_code.a2a.push_queue import LocalFileA2APushQueue +from iac_code.a2a.push_secrets import A2APushSecretError + + +class FakeHTTPClient: + def __init__(self, *, failures: int = 0) -> None: + self.posts: list[tuple[str, dict[str, object]]] = [] + self.closed = False + self.failures = failures + + async def post( + self, + url: str, + json: dict[str, object], + timeout: float | None = None, + headers: dict[str, str] | None = None, + ) -> object: + self.posts.append((url, {"json": json, "headers": headers or {}})) + if self.failures > 0: + self.failures -= 1 + raise RuntimeError("temporary push failure") + + class Response: + def raise_for_status(self) -> None: + return None + + return Response() + + async def aclose(self) -> None: + self.closed = True + + +def test_push_config_rejects_non_http_url() -> None: + with pytest.raises(InvalidPushNotificationConfigError): + A2APushConfig(task_id="task-1", callback_url="file:///tmp/callback") + + +@pytest.mark.asyncio +async def test_notifier_posts_terminal_task_payload(tmp_path) -> None: + http = FakeHTTPClient() + persistence = A2APersistenceStore(tmp_path) + notifier = A2APushNotifier(persistence=persistence, http_client=http) + config = A2APushConfig(task_id="task-1", callback_url="https://example.test/a2a") + + notifier.save_config(config) + delivered = await notifier.notify_task_state(task_id="task-1", context_id="ctx-1", state="completed") + + assert delivered is True + assert http.posts[0][0] == "https://example.test/a2a" + assert http.posts[0][1]["json"]["taskId"] == "task-1" + + +@pytest.mark.asyncio +async def test_notifier_retries_temporary_push_failures(tmp_path) -> None: + http = FakeHTTPClient(failures=2) + persistence = A2APersistenceStore(tmp_path) + notifier = A2APushNotifier(persistence=persistence, http_client=http, retry_delay_seconds=0) + notifier.save_config(A2APushConfig(task_id="task-1", callback_url="https://example.test/a2a")) + + delivered = await notifier.notify_task_state(task_id="task-1", context_id="ctx-1", state="completed") + + assert delivered is True + assert len(http.posts) == 3 + + +@pytest.mark.asyncio +async def test_notifier_does_not_close_injected_http_client(tmp_path) -> None: + http = FakeHTTPClient() + notifier = A2APushNotifier(persistence=A2APersistenceStore(tmp_path), http_client=http) + + await notifier.aclose() + + assert http.closed is False + + +@pytest.mark.asyncio +async def test_push_config_store_persists_configs_by_owner(tmp_path) -> None: + store = A2APushConfigStore(persistence=A2APersistenceStore(tmp_path)) + alice = ServerCallContext() + alice.user = type("User", (), {"user_name": "alice", "is_authenticated": True})() + bob = ServerCallContext() + bob.user = type("User", (), {"user_name": "bob", "is_authenticated": True})() + + await store.set_info( + "task-1", + TaskPushNotificationConfig(task_id="task-1", id="cfg-1", url="https://callback.example/a2a"), + alice, + ) + + assert [config.id for config in await store.get_info("task-1", alice)] == ["cfg-1"] + assert await store.get_info("task-1", bob) == [] + assert [config.id for config in await store.get_info_for_dispatch("task-1")] == ["cfg-1"] + + +@pytest.mark.asyncio +async def test_push_config_store_preserves_existing_config_when_atomic_replace_fails(monkeypatch, tmp_path) -> None: + store = A2APushConfigStore(persistence=A2APersistenceStore(tmp_path)) + context = ServerCallContext() + await store.set_info( + "task-1", + TaskPushNotificationConfig(task_id="task-1", id="cfg-1", url="https://old.example/a2a"), + context, + ) + + def fail_replace(src, dst): + raise OSError("replace failed") + + monkeypatch.setattr("iac_code.a2a.push.os.replace", fail_replace) + + with pytest.raises(OSError, match="replace failed"): + await store.set_info( + "task-1", + TaskPushNotificationConfig(task_id="task-1", id="cfg-1", url="https://new.example/a2a"), + context, + ) + + configs = await store.get_info("task-1", context) + assert configs[0].url == "https://old.example/a2a" + assert list((tmp_path / "push_configs").glob("**/*.tmp")) == [] + + +def test_push_notifier_preserves_existing_config_when_atomic_replace_fails(monkeypatch, tmp_path) -> None: + notifier = A2APushNotifier(persistence=A2APersistenceStore(tmp_path), http_client=FakeHTTPClient()) + notifier.save_config(A2APushConfig(task_id="task-1", callback_url="https://old.example/a2a")) + + def fail_replace(src, dst): + raise OSError("replace failed") + + monkeypatch.setattr("iac_code.a2a.push.os.replace", fail_replace) + + with pytest.raises(OSError, match="replace failed"): + notifier.save_config(A2APushConfig(task_id="task-1", callback_url="https://new.example/a2a")) + + assert notifier.load_config("task-1").callback_url == "https://old.example/a2a" + assert list((tmp_path / "push").glob("*.tmp")) == [] + + +@pytest.mark.asyncio +async def test_push_config_store_encrypts_token_and_auth_credentials_at_rest(tmp_path) -> None: + store = A2APushConfigStore( + persistence=A2APersistenceStore(tmp_path), + secret_keyring=A2APushSecretKeyring(tmp_path / "push_keys.json"), + ) + context = ServerCallContext() + + await store.set_info( + "task-1", + TaskPushNotificationConfig( + task_id="task-1", + id="cfg-1", + url="https://callback.example/a2a", + token="token-1", + authentication={"scheme": "bearer", "credentials": "secret-1"}, + ), + context, + ) + + raw = next((tmp_path / "push_configs").glob("*/*/cfg-1.json")).read_text(encoding="utf-8") + assert "token-1" not in raw + assert "secret-1" not in raw + assert "iacCodeEncryptedFields" in raw + + loaded = await store.get_info("task-1", context) + assert loaded[0].token == "token-1" + assert loaded[0].authentication.credentials == "secret-1" + assert await store.resolve_headers_for_dispatch("task-1", "cfg-1") == { + "X-A2A-Notification-Token": "token-1", + "Authorization": "Bearer secret-1", + } + + +@pytest.mark.asyncio +async def test_push_config_store_key_rotation_keeps_old_configs_readable(tmp_path) -> None: + keyring = A2APushSecretKeyring(tmp_path / "push_keys.json") + store = A2APushConfigStore(persistence=A2APersistenceStore(tmp_path), secret_keyring=keyring) + context = ServerCallContext() + + await store.set_info( + "task-1", + TaskPushNotificationConfig(task_id="task-1", id="cfg-old", url="https://callback.example/a2a", token="old"), + context, + ) + old_key_id = keyring.active_key_id + + new_key_id = keyring.rotate() + await store.set_info( + "task-1", + TaskPushNotificationConfig(task_id="task-1", id="cfg-new", url="https://callback.example/a2a", token="new"), + context, + ) + + assert new_key_id != old_key_id + configs = {config.id: config.token for config in await store.get_info("task-1", context)} + assert configs == {"cfg-old": "old", "cfg-new": "new"} + raw_old = next((tmp_path / "push_configs").glob("*/*/cfg-old.json")).read_text(encoding="utf-8") + raw_new = next((tmp_path / "push_configs").glob("*/*/cfg-new.json")).read_text(encoding="utf-8") + assert old_key_id in raw_old + assert new_key_id in raw_new + + +def test_push_secret_keyring_can_use_env_managed_keys(monkeypatch, tmp_path) -> None: + key = Fernet.generate_key().decode("ascii") + monkeypatch.setenv( + "IAC_CODE_A2A_PUSH_KEYRING", + json.dumps({"activeKeyId": "shared", "keys": [{"id": "shared", "fernetKey": key}]}), + ) + producer = A2APushSecretKeyring(tmp_path / "producer.json") + consumer = A2APushSecretKeyring(tmp_path / "consumer.json") + + envelope = producer.encrypt("shared secret") + + assert consumer.decrypt(envelope) == "shared secret" + assert producer.active_key_id == "shared" + assert not (tmp_path / "producer.json").exists() + with pytest.raises(A2APushSecretError, match="environment-managed"): + producer.rotate() + + +@pytest.mark.asyncio +async def test_push_sender_enqueues_standard_stream_response_without_persisting_auth_headers(tmp_path) -> None: + persistence = A2APersistenceStore(tmp_path) + store = A2APushConfigStore(persistence=persistence) + queue = LocalFileA2APushQueue(tmp_path / "push_queue") + sender = A2APushSender(config_store=store, queue=queue) + context = ServerCallContext() + await store.set_info( + "task-1", + TaskPushNotificationConfig( + task_id="task-1", + id="cfg-1", + url="https://callback.example/a2a", + token="token-1", + authentication={"scheme": "bearer", "credentials": "secret"}, + ), + context, + ) + + await sender.send_notification( + "task-1", + TaskStatusUpdateEvent( + task_id="task-1", + context_id="ctx-1", + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED), + ), + ) + + jobs = list((tmp_path / "push_queue" / "pending").glob("*.json")) + assert jobs + claimed = await queue.claim() + assert claimed is not None + assert claimed.task_id == "task-1" + assert claimed.config_id == "cfg-1" + assert claimed.url == "https://callback.example/a2a" + assert claimed.payload["statusUpdate"]["taskId"] == "task-1" + assert claimed.headers == {} + assert await store.resolve_headers_for_dispatch("task-1", "cfg-1") == { + "X-A2A-Notification-Token": "token-1", + "Authorization": "Bearer secret", + } diff --git a/tests/a2a/test_push_queue.py b/tests/a2a/test_push_queue.py new file mode 100644 index 00000000..90eb5e72 --- /dev/null +++ b/tests/a2a/test_push_queue.py @@ -0,0 +1,438 @@ +import json + +import pytest + +from iac_code.a2a.push import A2APushSecretKeyring +from iac_code.a2a.push_queue import ( + A2APushJob, + A2APushRetryPolicy, + LocalFileA2APushQueue, + redact_push_headers, +) + +from .fakes import FakeRedisPushStore + + +@pytest.mark.asyncio +async def test_local_file_push_queue_enqueues_claims_acks_and_persists(tmp_path) -> None: + queue = LocalFileA2APushQueue(tmp_path) + job = A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"statusUpdate": {"taskId": "task-1"}}, + headers={"Authorization": "Bearer secret"}, + ) + + await queue.enqueue(job) + claimed = await queue.claim() + + assert claimed is not None + assert claimed.job_id == "job-1" + assert (tmp_path / "inflight" / "job-1.json").exists() + + await queue.ack("job-1") + + assert not (tmp_path / "inflight" / "job-1.json").exists() + + +@pytest.mark.asyncio +async def test_local_file_push_queue_retries_and_dead_letters(tmp_path) -> None: + queue = LocalFileA2APushQueue(tmp_path) + job = A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + headers={}, + ) + + await queue.enqueue(job) + claimed = await queue.claim() + assert claimed is not None + + await queue.retry(claimed.with_attempt(attempt=1, next_attempt_at=123.0, last_error="timeout")) + retried = json.loads((tmp_path / "pending" / "job-1.json").read_text(encoding="utf-8")) + + assert retried["attempt"] == 1 + assert retried["nextAttemptAt"] == 123.0 + + claimed_again = await queue.claim(now=122.0) + assert claimed_again is None + + claimed_again = await queue.claim(now=124.0) + assert claimed_again is not None + await queue.dead_letter(claimed_again.with_attempt(attempt=3, last_error="HTTP 400")) + + assert (tmp_path / "dead" / "job-1.json").exists() + + +@pytest.mark.asyncio +async def test_local_file_push_queue_recovers_expired_inflight_jobs(tmp_path) -> None: + queue = LocalFileA2APushQueue(tmp_path, inflight_timeout_seconds=10.0) + job = A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + headers={}, + ) + + await queue.enqueue(job) + claimed = await queue.claim(now=100.0) + assert claimed is not None + assert claimed.next_attempt_at == 110.0 + + restarted = LocalFileA2APushQueue(tmp_path, inflight_timeout_seconds=10.0) + assert await restarted.claim(now=109.0) is None + + recovered = await restarted.claim(now=111.0) + + assert recovered is not None + assert recovered.job_id == "job-1" + assert (tmp_path / "inflight" / "job-1.json").exists() + + +@pytest.mark.asyncio +async def test_local_file_push_queue_does_not_persist_sensitive_headers(tmp_path) -> None: + queue = LocalFileA2APushQueue(tmp_path) + job = A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + headers={"Authorization": "Bearer secret", "X-A2A-Notification-Token": "token"}, + ) + + await queue.enqueue(job) + + raw = (tmp_path / "pending" / "job-1.json").read_text(encoding="utf-8") + assert "secret" not in raw + assert "token" not in raw + + +@pytest.mark.asyncio +async def test_local_file_push_queue_encrypts_jobs_when_keyring_is_configured(tmp_path) -> None: + queue = LocalFileA2APushQueue(tmp_path / "queue", secret_keyring=A2APushSecretKeyring(tmp_path / "keys.json")) + job = A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"message": "private task payload"}, + ) + + await queue.enqueue(job) + + raw = (tmp_path / "queue" / "pending" / "job-1.json").read_text(encoding="utf-8") + assert "private task payload" not in raw + assert "callback.example" not in raw + assert "iacCodeEncryptedPushJob" in raw + claimed = await queue.claim() + assert claimed is not None + assert claimed.payload == {"message": "private task payload"} + assert claimed.url == "https://callback.example/a2a" + + +def test_retry_policy_uses_exponential_backoff_with_cap() -> None: + policy = A2APushRetryPolicy(initial_delay_seconds=1.0, max_delay_seconds=10.0, jitter_ratio=0.0) + + assert policy.delay_for_attempt(1) == 1.0 + assert policy.delay_for_attempt(2) == 2.0 + assert policy.delay_for_attempt(5) == 10.0 + + +def test_redact_push_headers_removes_credentials() -> None: + assert redact_push_headers( + { + "Authorization": "Bearer secret", + "X-A2A-Notification-Token": "token", + "X-Trace": "trace-1", + } + ) == { + "Authorization": "[redacted]", + "X-A2A-Notification-Token": "[redacted]", + "X-Trace": "trace-1", + } + + +@pytest.mark.asyncio +async def test_redis_push_queue_enqueues_claims_and_acks() -> None: + from iac_code.a2a.push_queue import RedisStreamsA2APushQueue + + redis = FakeRedisPushStore() + queue = RedisStreamsA2APushQueue( + redis=redis, + stream="push", + retry_key="push:retry", + dead_stream="push:dead", + consumer_group="workers", + consumer_name="worker-1", + ) + await queue.enqueue( + A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + headers={"Authorization": "Bearer secret"}, + ) + ) + + claimed = await queue.claim(now=100.0) + + assert claimed is not None + assert claimed.job_id == "job-1" + assert "secret" not in str(redis.streams) + await queue.ack("job-1") + assert redis.acked == [("push", "workers", "1-0")] + + +@pytest.mark.asyncio +async def test_redis_push_queue_encrypts_jobs_when_keyring_is_configured(tmp_path) -> None: + from iac_code.a2a.push_queue import RedisStreamsA2APushQueue + + redis = FakeRedisPushStore() + queue = RedisStreamsA2APushQueue( + redis=redis, + stream="push", + retry_key="push:retry", + dead_stream="push:dead", + consumer_group="workers", + consumer_name="worker-1", + secret_keyring=A2APushSecretKeyring(tmp_path / "keys.json"), + ) + await queue.enqueue( + A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"message": "private task payload"}, + ) + ) + + assert "private task payload" not in str(redis.streams) + assert "callback.example" not in str(redis.streams) + claimed = await queue.claim(now=100.0) + assert claimed is not None + assert claimed.payload == {"message": "private task payload"} + assert claimed.url == "https://callback.example/a2a" + + +@pytest.mark.asyncio +async def test_redis_push_queue_claims_new_jobs_only_once_per_group() -> None: + from iac_code.a2a.push_queue import RedisStreamsA2APushQueue + + redis = FakeRedisPushStore() + worker_1 = RedisStreamsA2APushQueue( + redis=redis, + stream="push", + retry_key="push:retry", + dead_stream="push:dead", + consumer_group="workers", + consumer_name="worker-1", + lease_timeout_ms=1000, + ) + worker_2 = RedisStreamsA2APushQueue( + redis=redis, + stream="push", + retry_key="push:retry", + dead_stream="push:dead", + consumer_group="workers", + consumer_name="worker-2", + lease_timeout_ms=1000, + ) + await worker_1.enqueue( + A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + ) + ) + + claimed = await worker_1.claim(now=100.0) + duplicate = await worker_2.claim(now=100.0) + + assert claimed is not None + assert claimed.job_id == "job-1" + assert duplicate is None + + +@pytest.mark.asyncio +async def test_redis_push_queue_reclaims_pending_jobs_only_after_idle_timeout() -> None: + from iac_code.a2a.push_queue import RedisStreamsA2APushQueue + + redis = FakeRedisPushStore() + worker_1 = RedisStreamsA2APushQueue( + redis=redis, + stream="push", + retry_key="push:retry", + dead_stream="push:dead", + consumer_group="workers", + consumer_name="worker-1", + lease_timeout_ms=1000, + ) + worker_2 = RedisStreamsA2APushQueue( + redis=redis, + stream="push", + retry_key="push:retry", + dead_stream="push:dead", + consumer_group="workers", + consumer_name="worker-2", + lease_timeout_ms=1000, + ) + await worker_1.enqueue( + A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + ) + ) + + redis.now_ms = 0 + claimed = await worker_1.claim(now=100.0) + redis.now_ms = 999 + before_timeout = await worker_2.claim(now=101.0) + redis.now_ms = 1000 + after_timeout = await worker_2.claim(now=102.0) + + assert claimed is not None + assert before_timeout is None + assert after_timeout is not None + assert after_timeout.job_id == "job-1" + + +@pytest.mark.asyncio +async def test_redis_push_queue_accepts_list_shaped_xautoclaim_response() -> None: + from iac_code.a2a.push_queue import RedisStreamsA2APushQueue + + redis = FakeRedisPushStore(xautoclaim_response_shape="list") + worker_1 = RedisStreamsA2APushQueue( + redis=redis, + stream="push", + retry_key="push:retry", + dead_stream="push:dead", + consumer_group="workers", + consumer_name="worker-1", + lease_timeout_ms=1000, + ) + worker_2 = RedisStreamsA2APushQueue( + redis=redis, + stream="push", + retry_key="push:retry", + dead_stream="push:dead", + consumer_group="workers", + consumer_name="worker-2", + lease_timeout_ms=1000, + ) + await worker_1.enqueue( + A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + ) + ) + assert await worker_1.claim(now=100.0) is not None + + redis.now_ms = 1000 + reclaimed = await worker_2.claim(now=101.0) + + assert reclaimed is not None + assert reclaimed.job_id == "job-1" + + +@pytest.mark.asyncio +async def test_redis_push_queue_retries_via_sorted_set_and_promotes_due_jobs() -> None: + from iac_code.a2a.push_queue import RedisStreamsA2APushQueue + + redis = FakeRedisPushStore() + queue = RedisStreamsA2APushQueue( + redis=redis, + stream="push", + retry_key="push:retry", + dead_stream="push:dead", + consumer_group="workers", + consumer_name="worker-1", + ) + job = A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + ) + await queue.enqueue(job) + claimed = await queue.claim(now=100.0) + assert claimed is not None + + await queue.retry(claimed.with_attempt(attempt=1, next_attempt_at=125.0, last_error="timeout")) + assert redis.zsets["push:retry"] + assert await queue.claim(now=124.0) is None + + promoted = await queue.claim(now=125.0) + + assert promoted is not None + assert promoted.attempt == 1 + assert promoted.last_error == "timeout" + + +@pytest.mark.asyncio +async def test_redis_push_queue_dead_letters_claimed_job() -> None: + from iac_code.a2a.push_queue import RedisStreamsA2APushQueue + + redis = FakeRedisPushStore() + queue = RedisStreamsA2APushQueue( + redis=redis, + stream="push", + retry_key="push:retry", + dead_stream="push:dead", + consumer_group="workers", + consumer_name="worker-1", + ) + job = A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + ) + await queue.enqueue(job) + claimed = await queue.claim(now=100.0) + assert claimed is not None + + await queue.dead_letter(claimed.with_attempt(attempt=3, last_error="HTTP 400")) + + assert redis.streams["push:dead"] + assert redis.acked == [("push", "workers", "1-0")] + + +@pytest.mark.asyncio +async def test_redis_push_queue_closes_owned_redis_client() -> None: + from iac_code.a2a.push_queue import RedisStreamsA2APushQueue + + redis = FakeRedisPushStore() + queue = RedisStreamsA2APushQueue( + redis=redis, + stream="push", + retry_key="push:retry", + dead_stream="push:dead", + consumer_group="workers", + consumer_name="worker-1", + owns_redis=True, + ) + + await queue.aclose() + + assert redis.closed is True diff --git a/tests/a2a/test_push_worker.py b/tests/a2a/test_push_worker.py new file mode 100644 index 00000000..91f44749 --- /dev/null +++ b/tests/a2a/test_push_worker.py @@ -0,0 +1,365 @@ +import pytest + +from iac_code.a2a.metrics import NoOpA2AMetrics +from iac_code.a2a.push_queue import A2APushJob, A2APushRetryPolicy, LocalFileA2APushQueue +from iac_code.a2a.push_worker import A2APushDeliveryWorker, LoggingA2APushAlertSink + +from .fakes import FakeRedisPushStore + + +class FakeResponse: + def __init__(self, status_code: int) -> None: + self.status_code = status_code + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +class FakeHTTPClient: + def __init__(self, statuses: list[int]) -> None: + self.statuses = statuses + self.posts: list[dict[str, object]] = [] + + async def post(self, url: str, *, json, headers, timeout, **kwargs): + self.posts.append({"url": url, "json": json, "headers": headers, "timeout": timeout, **kwargs}) + return FakeResponse(self.statuses.pop(0)) + + +class FakeOneShotHTTPClient(FakeHTTPClient): + instances: list["FakeOneShotHTTPClient"] = [] + + def __init__(self, *, limits=None) -> None: + super().__init__([204]) + self.limits = limits + self.closed = False + self.instances.append(self) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + self.closed = True + + +class RecordingConnector: + def __init__(self, status_code: int = 204) -> None: + self.status_code = status_code + self.posts: list[dict[str, object]] = [] + + async def post(self, url: str, *, json, headers, timeout): + self.posts.append({"url": url, "json": json, "headers": headers, "timeout": timeout}) + return FakeResponse(self.status_code) + + +@pytest.mark.asyncio +async def test_push_worker_uses_injected_connector_for_delivery(tmp_path) -> None: + queue = LocalFileA2APushQueue(tmp_path) + connector = RecordingConnector() + worker = A2APushDeliveryWorker(queue=queue, connector=connector, metrics=NoOpA2AMetrics()) + await queue.enqueue( + A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + headers={}, + ) + ) + + delivered = await worker.run_once() + + assert delivered is True + assert connector.posts[0]["url"] == "https://callback.example/a2a" + + +@pytest.mark.asyncio +async def test_push_worker_delivers_redis_claimed_job() -> None: + from iac_code.a2a.push_queue import RedisStreamsA2APushQueue + + redis = FakeRedisPushStore() + queue = RedisStreamsA2APushQueue( + redis=redis, + stream="push", + retry_key="push:retry", + dead_stream="push:dead", + consumer_group="workers", + consumer_name="worker-1", + ) + connector = RecordingConnector() + worker = A2APushDeliveryWorker(queue=queue, connector=connector, metrics=NoOpA2AMetrics()) + await queue.enqueue( + A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + ) + ) + + delivered = await worker.run_once() + + assert delivered is True + assert connector.posts + assert redis.acked == [("push", "workers", "1-0")] + + +@pytest.mark.asyncio +async def test_default_callback_connector_rejects_private_dns(monkeypatch) -> None: + from iac_code.a2a.push import InvalidPushNotificationConfigError + from iac_code.a2a.push_worker import DefaultA2APushCallbackConnector + + monkeypatch.setattr( + "socket.getaddrinfo", + lambda host, port: [(2, 1, 6, "", ("10.0.0.1", port))], + ) + connector = DefaultA2APushCallbackConnector(http_client=FakeHTTPClient([204])) + + with pytest.raises(InvalidPushNotificationConfigError): + await connector.post("https://callback.example/a2a", json={"ok": True}, headers={}, timeout=5.0) + + +@pytest.mark.asyncio +async def test_default_callback_connector_pins_validated_ip_and_preserves_host(monkeypatch) -> None: + from iac_code.a2a.push_worker import DefaultA2APushCallbackConnector + + FakeOneShotHTTPClient.instances = [] + monkeypatch.setattr( + "socket.getaddrinfo", + lambda host, port: [(2, 1, 6, "", ("93.184.216.34", port))], + ) + monkeypatch.setattr("iac_code.a2a.push_worker.httpx.AsyncClient", FakeOneShotHTTPClient) + http = FakeHTTPClient([204]) + connector = DefaultA2APushCallbackConnector(http_client=http) + + await connector.post( + "https://callback.example:8443/a2a", + json={"ok": True}, + headers={"X-Trace": "trace-1"}, + timeout=5.0, + ) + + assert http.posts == [] + assert FakeOneShotHTTPClient.instances[0].posts[0]["url"] == "https://93.184.216.34:8443/a2a" + assert FakeOneShotHTTPClient.instances[0].posts[0]["headers"] == { + "X-Trace": "trace-1", + "Host": "callback.example:8443", + } + assert FakeOneShotHTTPClient.instances[0].posts[0]["extensions"] == {"sni_hostname": "callback.example"} + + +@pytest.mark.asyncio +async def test_default_callback_connector_uses_isolated_clients_for_pinned_hosts_on_same_ip(monkeypatch) -> None: + from iac_code.a2a.push_worker import DefaultA2APushCallbackConnector + + FakeOneShotHTTPClient.instances = [] + monkeypatch.setattr( + "socket.getaddrinfo", + lambda host, port: [(2, 1, 6, "", ("93.184.216.34", port))], + ) + monkeypatch.setattr("iac_code.a2a.push_worker.httpx.AsyncClient", FakeOneShotHTTPClient) + pooled_http = FakeHTTPClient([204, 204]) + connector = DefaultA2APushCallbackConnector(http_client=pooled_http) + + await connector.post("https://first.example/a2a", json={"ok": True}, headers={}, timeout=5.0) + await connector.post("https://second.example/a2a", json={"ok": True}, headers={}, timeout=5.0) + + assert pooled_http.posts == [] + assert len(FakeOneShotHTTPClient.instances) == 2 + assert [client.posts[0]["url"] for client in FakeOneShotHTTPClient.instances] == [ + "https://93.184.216.34/a2a", + "https://93.184.216.34/a2a", + ] + assert [client.posts[0]["extensions"] for client in FakeOneShotHTTPClient.instances] == [ + {"sni_hostname": "first.example"}, + {"sni_hostname": "second.example"}, + ] + assert all(client.closed for client in FakeOneShotHTTPClient.instances) + + +@pytest.mark.asyncio +async def test_default_callback_connector_rejects_empty_dns_result(monkeypatch) -> None: + from iac_code.a2a.push import InvalidPushNotificationConfigError + from iac_code.a2a.push_worker import DefaultA2APushCallbackConnector + + monkeypatch.setattr("socket.getaddrinfo", lambda host, port: []) + connector = DefaultA2APushCallbackConnector(http_client=FakeHTTPClient([204])) + + with pytest.raises(InvalidPushNotificationConfigError): + await connector.post("https://callback.example/a2a", json={"ok": True}, headers={}, timeout=5.0) + + +@pytest.mark.asyncio +async def test_default_callback_connector_rejects_validator_without_pinned_addresses(monkeypatch) -> None: + from iac_code.a2a.push import InvalidPushNotificationConfigError + from iac_code.a2a.push_worker import DefaultA2APushCallbackConnector + + monkeypatch.setattr("iac_code.a2a.push_worker._validate_resolved_callback_host", lambda url: None) + connector = DefaultA2APushCallbackConnector(http_client=FakeHTTPClient([204])) + + with pytest.raises(InvalidPushNotificationConfigError, match="verified callback addresses"): + await connector.post("https://callback.example/a2a", json={"ok": True}, headers={}, timeout=5.0) + + +@pytest.mark.asyncio +async def test_default_callback_connector_brackets_ipv6_literal_host_header(monkeypatch) -> None: + from iac_code.a2a.push_worker import DefaultA2APushCallbackConnector + + FakeOneShotHTTPClient.instances = [] + monkeypatch.setattr( + "socket.getaddrinfo", + lambda host, port: [(10, 1, 6, "", ("2001:4860:4860::8888", port, 0, 0))], + ) + monkeypatch.setattr("iac_code.a2a.push_worker.httpx.AsyncClient", FakeOneShotHTTPClient) + http = FakeHTTPClient([204]) + connector = DefaultA2APushCallbackConnector(http_client=http) + + await connector.post( + "https://[2001:4860:4860::8888]:8443/a2a", + json={"ok": True}, + headers={}, + timeout=5.0, + ) + + assert http.posts == [] + assert FakeOneShotHTTPClient.instances[0].posts[0]["headers"]["Host"] == "[2001:4860:4860::8888]:8443" + + +@pytest.mark.asyncio +async def test_push_worker_delivers_and_acks_success(tmp_path) -> None: + queue = LocalFileA2APushQueue(tmp_path) + connector = RecordingConnector() + worker = A2APushDeliveryWorker(queue=queue, connector=connector, metrics=NoOpA2AMetrics()) + await queue.enqueue( + A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + headers={"Authorization": "Bearer secret"}, + ) + ) + + delivered = await worker.run_once() + + assert delivered is True + assert connector.posts[0]["url"] == "https://callback.example/a2a" + assert not (tmp_path / "inflight" / "job-1.json").exists() + + +@pytest.mark.asyncio +async def test_push_worker_does_not_retry_or_dead_letter_when_ack_fails_after_callback_success() -> None: + class AckFailingQueue: + def __init__(self) -> None: + self.job = A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + headers={}, + ) + self.acked: list[str] = [] + self.retried: list[A2APushJob] = [] + self.dead: list[A2APushJob] = [] + + async def claim(self, *, now=None): + return self.job + + async def ack(self, job_id: str) -> None: + self.acked.append(job_id) + raise RuntimeError("ack failed") + + async def retry(self, job: A2APushJob) -> None: + self.retried.append(job) + + async def dead_letter(self, job: A2APushJob) -> None: + self.dead.append(job) + + queue = AckFailingQueue() + connector = RecordingConnector() + worker = A2APushDeliveryWorker(queue=queue, connector=connector, metrics=NoOpA2AMetrics()) + + delivered = await worker.run_once() + + assert delivered is False + assert connector.posts + assert queue.acked == ["job-1"] + assert queue.retried == [] + assert queue.dead == [] + + +@pytest.mark.asyncio +async def test_push_worker_retries_transient_failure_with_backoff(tmp_path) -> None: + queue = LocalFileA2APushQueue(tmp_path) + connector = RecordingConnector(status_code=503) + worker = A2APushDeliveryWorker( + queue=queue, + connector=connector, + metrics=NoOpA2AMetrics(), + retry_policy=A2APushRetryPolicy(initial_delay_seconds=2.0, max_delay_seconds=10.0, jitter_ratio=0.0), + clock=lambda: 100.0, + ) + await queue.enqueue( + A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + headers={}, + ) + ) + + delivered = await worker.run_once() + + assert delivered is False + retried = await queue.claim(now=101.0) + assert retried is None + retried = await queue.claim(now=102.0) + assert retried is not None + assert retried.attempt == 1 + + +@pytest.mark.asyncio +async def test_push_worker_dead_letters_permanent_failure(tmp_path) -> None: + queue = LocalFileA2APushQueue(tmp_path) + connector = RecordingConnector(status_code=400) + worker = A2APushDeliveryWorker( + queue=queue, + connector=connector, + metrics=NoOpA2AMetrics(), + alert_sink=LoggingA2APushAlertSink(), + ) + await queue.enqueue( + A2APushJob( + job_id="job-1", + task_id="task-1", + config_id="cfg-1", + url="https://callback.example/a2a", + payload={"ok": True}, + headers={}, + ) + ) + + delivered = await worker.run_once() + + assert delivered is False + assert (tmp_path / "dead" / "job-1.json").exists() + + +@pytest.mark.asyncio +async def test_resolved_callback_host_rejects_private_dns(monkeypatch) -> None: + from iac_code.a2a.push import InvalidPushNotificationConfigError + from iac_code.a2a.push_worker import _validate_resolved_callback_host + + monkeypatch.setattr( + "socket.getaddrinfo", + lambda host, port: [(2, 1, 6, "", ("10.0.0.1", port))], + ) + + with pytest.raises(InvalidPushNotificationConfigError): + await _validate_resolved_callback_host("https://callback.example/a2a") diff --git a/tests/a2a/test_redis_streams_transport.py b/tests/a2a/test_redis_streams_transport.py new file mode 100644 index 00000000..f18064e8 --- /dev/null +++ b/tests/a2a/test_redis_streams_transport.py @@ -0,0 +1,295 @@ +import json + +import pytest + +from iac_code.a2a.transports.base import A2ATransportDependencyError +from iac_code.a2a.transports.dispatcher import create_runtime_components +from iac_code.a2a.transports.redis_streams import ( + RedisStreamsA2AClient, + RedisStreamsA2AServer, + RedisStreamsMessage, + parse_redis_entry, + require_redis, +) +from iac_code.types.stream_events import TextDeltaEvent + +from .fakes import FakeAgentLoop, FakeRuntime + + +class FakeRedis: + def __init__(self) -> None: + self.streams = {} + self.closed = False + self.acked: list[tuple[str, str, str]] = [] + self.created_groups: list[tuple[str, str, str, bool]] = [] + self.read_groups: list[tuple[str, str, dict[str, str], int, int]] = [] + + async def xadd(self, stream, fields): + self.streams.setdefault(stream, []).append(fields) + return "1-0" + + async def xread(self, streams, count=1, block=0): + stream = next(iter(streams)) + items = self.streams.get(stream, []) + if not items: + return [] + return [(stream, [("1-0", items.pop(0))])] + + async def aclose(self): + self.closed = True + + async def xgroup_create(self, name, groupname, id="0-0", mkstream=False): + self.created_groups.append((name, groupname, id, mkstream)) + + async def xreadgroup(self, groupname, consumername, streams, count=1, block=0): + self.read_groups.append((groupname, consumername, streams, count, block)) + stream = next(iter(streams)) + items = self.streams.get(stream, []) + if not items: + return [] + return [(stream, [("1-0", items.pop(0))])] + + async def xack(self, stream, group, entry_id): + self.acked.append((stream, group, entry_id)) + return 1 + + +def test_parse_redis_entry_decodes_payload() -> None: + message = parse_redis_entry( + "1-0", + {"correlation_id": "corr-1", "payload": json.dumps({"jsonrpc": "2.0", "id": "1"}), "final": "true"}, + ) + + assert message == RedisStreamsMessage( + entry_id="1-0", + correlation_id="corr-1", + payload={"jsonrpc": "2.0", "id": "1"}, + final=True, + ) + + +def test_require_redis_reports_missing_dependency(monkeypatch) -> None: + def fail_redis_import(name): + if name == "redis.asyncio": + raise ModuleNotFoundError(name) + raise AssertionError(name) + + monkeypatch.setattr("iac_code.a2a.transports.redis_streams.import_module", fail_redis_import) + + with pytest.raises(A2ATransportDependencyError, match="iac-code\\[a2a-redis\\]"): + require_redis() + + +@pytest.mark.asyncio +async def test_redis_client_sends_and_reads_response() -> None: + redis = FakeRedis() + client = RedisStreamsA2AClient( + redis=redis, + request_stream="requests", + response_stream="responses", + timeout_seconds=1, + ) + await redis.xadd( + "responses", + { + "correlation_id": "corr-fixed", + "payload": json.dumps({"jsonrpc": "2.0", "id": "1", "result": {"ok": True}}), + "final": "true", + }, + ) + + response = await client.send({"jsonrpc": "2.0", "id": "1", "method": "message/send"}, correlation_id="corr-fixed") + + assert response["result"]["ok"] is True + assert redis.streams["requests"][0]["correlation_id"] == "corr-fixed" + + +@pytest.mark.asyncio +async def test_redis_server_processes_one_unary_request(monkeypatch, tmp_path) -> None: + redis = FakeRedis() + loop = FakeAgentLoop([TextDeltaEvent(text="redis ok")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + components = create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242) + server = RedisStreamsA2AServer( + redis=redis, + components=components, + request_stream="requests", + response_stream="responses", + consumer_group="iac-code", + ) + await redis.xadd( + "requests", + { + "correlation_id": "corr-server", + "reply_stream": "responses", + "payload": json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "message/send", + "params": { + "message": { + "messageId": "msg-1", + "role": "user", + "parts": [{"kind": "text", "text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + } + ), + }, + ) + + assert await server.serve_once() is True + + response = parse_redis_entry("1-0", redis.streams["responses"][0]) + assert response.correlation_id == "corr-server" + assert response.final is True + assert response.payload["result"]["status"]["state"] == "input-required" + assert redis.acked == [("requests", "iac-code", "1-0")] + await server.aclose() + + +@pytest.mark.asyncio +async def test_redis_server_creates_group_and_reads_with_consumer_group(monkeypatch, tmp_path) -> None: + redis = FakeRedis() + loop = FakeAgentLoop([TextDeltaEvent(text="redis ok")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + components = create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242) + server = RedisStreamsA2AServer( + redis=redis, + components=components, + request_stream="requests", + response_stream="responses", + consumer_group="iac-code", + consumer_name="worker-1", + ) + await redis.xadd( + "requests", + { + "correlation_id": "corr-server", + "reply_stream": "responses", + "payload": json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "message/send", + "params": { + "message": { + "messageId": "msg-1", + "role": "user", + "parts": [{"kind": "text", "text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + } + ), + }, + ) + + assert await server.serve_once() is True + + assert redis.created_groups == [("requests", "iac-code", "0-0", True)] + assert redis.read_groups == [("iac-code", "worker-1", {"requests": ">"}, 1, 100)] + assert redis.acked == [("requests", "iac-code", "1-0")] + await server.aclose() + + +@pytest.mark.asyncio +async def test_redis_server_acks_request_when_dispatch_fails() -> None: + class FailingDispatcher: + async def dispatch(self, payload): + raise RuntimeError("dispatch failed") + + redis = FakeRedis() + components = create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242) + server = RedisStreamsA2AServer( + redis=redis, + components=components, + request_stream="requests", + response_stream="responses", + consumer_group="iac-code", + ) + server._dispatcher = FailingDispatcher() + fields = { + "correlation_id": "corr-server", + "reply_stream": "responses", + "payload": json.dumps({"jsonrpc": "2.0", "id": "1", "method": "message/send"}), + } + + with pytest.raises(RuntimeError, match="dispatch failed"): + await server._process_entry("9-0", fields) + + assert redis.acked == [("requests", "iac-code", "9-0")] + await server.aclose() + + +@pytest.mark.asyncio +async def test_redis_client_reconnects_after_read_error() -> None: + class FailingRedis(FakeRedis): + async def xread(self, streams, count=1, block=0): + raise RuntimeError("connection lost") + + replacement = FakeRedis() + await replacement.xadd( + "responses", + { + "correlation_id": "corr-fixed", + "payload": json.dumps({"jsonrpc": "2.0", "id": "1", "result": {"ok": True}}), + "final": "true", + }, + ) + created = [] + + async def redis_factory(): + created.append(replacement) + return replacement + + client = RedisStreamsA2AClient( + redis=FailingRedis(), + request_stream="requests", + response_stream="responses", + timeout_seconds=1, + redis_factory=redis_factory, + ) + + response = await client.send({"jsonrpc": "2.0", "id": "1", "method": "message/send"}, correlation_id="corr-fixed") + + assert response["result"]["ok"] is True + assert created == [replacement] + + +@pytest.mark.asyncio +async def test_redis_client_bounds_xread_with_wait_for(monkeypatch: pytest.MonkeyPatch) -> None: + redis = FakeRedis() + await redis.xadd( + "responses", + { + "correlation_id": "corr-fixed", + "payload": json.dumps({"jsonrpc": "2.0", "id": "1", "result": {"ok": True}}), + "final": "true", + }, + ) + wait_for_timeouts = [] + + async def fake_wait_for(awaitable, timeout): + wait_for_timeouts.append(timeout) + return await awaitable + + monkeypatch.setattr("iac_code.a2a.transports.redis_streams.asyncio.wait_for", fake_wait_for) + client = RedisStreamsA2AClient( + redis=redis, + request_stream="requests", + response_stream="responses", + timeout_seconds=1, + ) + + response = await client.send({"jsonrpc": "2.0", "id": "1", "method": "message/send"}, correlation_id="corr-fixed") + + assert response["result"]["ok"] is True + assert wait_for_timeouts + assert all(0 < timeout <= 1 for timeout in wait_for_timeouts) diff --git a/tests/a2a/test_rest_binding.py b/tests/a2a/test_rest_binding.py new file mode 100644 index 00000000..a69766a8 --- /dev/null +++ b/tests/a2a/test_rest_binding.py @@ -0,0 +1,43 @@ +from starlette.testclient import TestClient + +from iac_code.a2a.app import create_app +from iac_code.types.stream_events import TextDeltaEvent + +from .fakes import FakeAgentLoop, FakeRuntime + + +def test_rest_message_send_uses_official_route(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="ok")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + client = TestClient(app) + + response = client.post( + "/message:send", + headers={"A2A-Version": "1.0"}, + json={ + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + ) + + assert response.status_code == 200 + assert response.json()["task"]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + assert loop.prompts == ["hello"] + + +def test_rest_extended_agent_card_route_returns_card() -> None: + app = create_app(host="127.0.0.1", port=41242, token=None, model="qwen3.6-plus") + client = TestClient(app) + + response = client.get("/extendedAgentCard", headers={"A2A-Version": "1.0"}) + + assert response.status_code == 200 + assert response.json()["name"] == "iac-code" diff --git a/tests/a2a/test_router.py b/tests/a2a/test_router.py new file mode 100644 index 00000000..ce9cd264 --- /dev/null +++ b/tests/a2a/test_router.py @@ -0,0 +1,61 @@ +import pytest + +from iac_code.a2a.router import A2ARoute, A2ARouter, AmbiguousA2ARouteError, MissingA2ARouteError + + +def test_router_selects_explicit_route_name() -> None: + router = A2ARouter([A2ARoute(name="template", url="http://template", skills=["iac_generation"], tags=["ros"])]) + + assert router.resolve(name="template").url == "http://template" + + +def test_router_selects_by_skill() -> None: + router = A2ARouter( + [ + A2ARoute(name="template", url="http://template", skills=["iac_generation"], tags=["ros"]), + A2ARoute(name="review", url="http://review", skills=["iac_review"], tags=["review"]), + ] + ) + + assert router.resolve(skill="iac_review").name == "review" + + +def test_router_selects_by_tag_from_prompt() -> None: + router = A2ARouter([A2ARoute(name="terraform", url="http://tf", skills=[], tags=["terraform"])]) + + assert router.resolve(prompt="convert this terraform module").name == "terraform" + + +def test_router_caches_default_prompt_terms() -> None: + class CountingTag(str): + calls = 0 + + def lower(self): + CountingTag.calls += 1 + return super().lower() + + router = A2ARouter([A2ARoute(name="terraform", url="http://tf", skills=[], tags=[CountingTag("terraform")])]) + assert CountingTag.calls == 1 + + assert router.resolve(prompt="terraform please").name == "terraform" + assert router.resolve(prompt="another terraform request").name == "terraform" + assert CountingTag.calls == 1 + + +def test_router_reports_ambiguous_matches() -> None: + router = A2ARouter( + [ + A2ARoute(name="one", url="http://one", skills=[], tags=["ros"]), + A2ARoute(name="two", url="http://two", skills=[], tags=["ros"]), + ] + ) + + with pytest.raises(AmbiguousA2ARouteError, match="one, two"): + router.resolve(prompt="build ros template") + + +def test_router_reports_missing_route_with_known_names() -> None: + router = A2ARouter([A2ARoute(name="known", url="http://known", skills=[], tags=[])]) + + with pytest.raises(MissingA2ARouteError, match="known"): + router.resolve(name="missing") diff --git a/tests/a2a/test_sdk_contract.py b/tests/a2a/test_sdk_contract.py new file mode 100644 index 00000000..2327a89c --- /dev/null +++ b/tests/a2a/test_sdk_contract.py @@ -0,0 +1,81 @@ +import sys +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + +def test_a2a_sdk_server_contract_imports() -> None: + import inspect + + from a2a.server.agent_execution import AgentExecutor, RequestContext + from a2a.server.context import ServerCallContext + from a2a.server.events import EventQueue + from a2a.server.request_handlers import DefaultRequestHandler + from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes + from a2a.server.tasks import TaskStore + from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentInterface, + AgentProvider, + AgentSkill, + HTTPAuthSecurityScheme, + Message, + Part, + Task, + TaskState, + TaskStatus, + TaskStatusUpdateEvent, + ) + + assert AgentExecutor is not None + assert RequestContext is not None + assert ServerCallContext is not None + assert EventQueue is not None + assert TaskStore is not None + assert DefaultRequestHandler is not None + assert create_agent_card_routes is not None + assert create_jsonrpc_routes is not None + assert AgentCard is not None + assert AgentCapabilities is not None + assert AgentInterface is not None + assert AgentProvider is not None + assert AgentSkill is not None + assert HTTPAuthSecurityScheme is not None + assert Message is not None + assert Part is not None + assert Task is not None + assert TaskState is not None + assert TaskStatus is not None + assert TaskStatusUpdateEvent is not None + + request_handler_sig = inspect.signature(DefaultRequestHandler) + assert "agent_executor" in request_handler_sig.parameters + assert "task_store" in request_handler_sig.parameters + assert "agent_card" in request_handler_sig.parameters + assert hasattr(RequestContext, "get_user_input") + + assert "text" in Part.DESCRIPTOR.fields_by_name + assert "data" in Part.DESCRIPTOR.fields_by_name + + +def test_a2a_extra_includes_server_runner_dependency() -> None: + pyproject = tomllib.loads(Path("pyproject.toml").read_text()) + + a2a_extra = pyproject["project"]["optional-dependencies"]["a2a"] + + assert any(dependency.startswith("uvicorn") for dependency in a2a_extra) + assert any("signing" in dependency for dependency in a2a_extra) + + +def test_runtime_transport_extras_match_dependency_errors() -> None: + pyproject = tomllib.loads(Path("pyproject.toml").read_text()) + + optional_dependencies = pyproject["project"]["optional-dependencies"] + + assert any("signing" in dependency for dependency in optional_dependencies["a2a-signing"]) + assert any(dependency.startswith("grpcio") for dependency in optional_dependencies["a2a-grpc"]) + assert any(dependency.startswith("redis") for dependency in optional_dependencies["a2a-redis"]) diff --git a/tests/a2a/test_signing.py b/tests/a2a/test_signing.py new file mode 100644 index 00000000..aae15071 --- /dev/null +++ b/tests/a2a/test_signing.py @@ -0,0 +1,239 @@ +import base64 +import json +from copy import deepcopy + +import pytest +from a2a.utils.signing import ProtectedHeader, create_agent_card_signer +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from iac_code.a2a.signing import ( + ASYMMETRIC_SIGNATURE_ALGORITHM, + SIGNATURE_ALGORITHM, + _agent_card_from_dict, + _agent_card_to_dict, + canonicalize_agent_card, + sign_agent_card_dict, + verify_agent_card_dict, +) + +SECRET = "s" * 32 +OTHER_SECRET = "d" * 32 + + +def _base64url_decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode((value + padding).encode("ascii")) + + +def _base64url_uint(value: int) -> str: + raw = value.to_bytes((value.bit_length() + 7) // 8, "big") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _rsa_private_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def _rsa_public_jwk(private_key, kid: str) -> dict[str, str]: + numbers = private_key.public_key().public_numbers() + return { + "kty": "RSA", + "kid": kid, + "alg": ASYMMETRIC_SIGNATURE_ALGORITHM, + "use": "sig", + "n": _base64url_uint(numbers.n), + "e": _base64url_uint(numbers.e), + } + + +def _sign_with_rsa(card: dict[str, object], private_key, *, kid: str, jku: str | None = None) -> dict[str, object]: + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + protected_header: ProtectedHeader = { + "alg": ASYMMETRIC_SIGNATURE_ALGORITHM, + "typ": "JOSE", + "kid": kid, + "jku": jku, + } + signer = create_agent_card_signer(signing_key=private_pem, protected_header=protected_header) + return _agent_card_to_dict(signer(_agent_card_from_dict(card))) + + +def test_canonicalize_agent_card_is_key_order_stable() -> None: + left = {"name": "iac-code", "skills": [{"id": "iac"}], "version": "1"} + right = {"version": "1", "skills": [{"id": "iac"}], "name": "iac-code"} + + assert canonicalize_agent_card(left) == canonicalize_agent_card(right) + + +def test_sign_and_verify_agent_card_dict() -> None: + card = {"name": "iac-code", "version": "1"} + + signed = sign_agent_card_dict(card, secret=SECRET, key_id="local") + result = verify_agent_card_dict(signed, secret=SECRET) + + assert result.valid is True + assert result.reason == "valid" + assert signed["signatures"][0]["protected"] + assert "header" not in signed["signatures"][0] + + +def test_signature_uses_jws_protected_header() -> None: + signed = sign_agent_card_dict({"name": "iac-code", "version": "1"}, secret=SECRET, key_id="local") + + signature = signed["signatures"][0] + protected = signature["protected"] + protected_header = json.loads(_base64url_decode(protected).decode("utf-8")) + + assert protected + assert protected_header["alg"] == SIGNATURE_ALGORITHM + assert protected_header["typ"] == "JOSE" + assert protected_header["kid"] == "local" + assert "alg" not in signature.get("header", {}) + + +def test_verify_rejects_tampered_protected_header() -> None: + signed = sign_agent_card_dict({"name": "iac-code", "version": "1"}, secret=SECRET, key_id="local") + signed["signatures"][0]["protected"] = "" + + result = verify_agent_card_dict(signed, secret=SECRET) + + assert result.valid is False + assert result.reason == "malformed-signature" + + +def test_verify_unsigned_card_is_allowed_by_default() -> None: + result = verify_agent_card_dict({"name": "unsigned"}, secret=SECRET) + + assert result.valid is True + assert result.reason == "unsigned" + + +def test_verify_unsigned_card_can_be_strict() -> None: + result = verify_agent_card_dict({"name": "unsigned"}, secret=SECRET, require_signature=True) + + assert result.valid is False + assert result.reason == "missing-signature" + + +def test_verify_rejects_mismatched_signature() -> None: + signed = sign_agent_card_dict({"name": "iac-code"}, secret=SECRET, key_id="local") + + result = verify_agent_card_dict(signed, secret=OTHER_SECRET) + + assert result.valid is False + assert result.reason == "signature-mismatch" + + +def test_verify_selects_secret_by_key_id() -> None: + signed = sign_agent_card_dict({"name": "iac-code"}, secret=SECRET, key_id="local") + + result = verify_agent_card_dict( + signed, + secrets={"old": OTHER_SECRET, "local": SECRET}, + require_signature=True, + ) + + assert result.valid is True + assert result.key_id == "local" + + +def test_verify_rejects_unknown_key_id() -> None: + signed = sign_agent_card_dict({"name": "iac-code"}, secret=SECRET, key_id="local") + + result = verify_agent_card_dict(signed, secrets={"other": OTHER_SECRET}, require_signature=True) + + assert result.valid is False + assert result.reason == "unknown-key" + assert result.key_id == "local" + assert result.message == "unknown-key: kid=local" + + +def test_verify_reports_unsupported_algorithm_detail() -> None: + signed = sign_agent_card_dict({"name": "iac-code"}, secret=SECRET, key_id="local") + protected = json.loads(_base64url_decode(signed["signatures"][0]["protected"]).decode("utf-8")) + protected["alg"] = "ES256" + signed["signatures"][0]["protected"] = base64.urlsafe_b64encode(json.dumps(protected).encode()).decode().rstrip("=") + + result = verify_agent_card_dict(signed, secret=SECRET, require_signature=True) + + assert result.valid is False + assert result.reason == "unsupported-algorithm" + assert result.message == "unsupported-algorithm: alg=ES256" + + +def test_verify_uses_oct_jwks_key() -> None: + signed = sign_agent_card_dict({"name": "iac-code"}, secret=SECRET, key_id="local") + jwks = { + "keys": [ + { + "kty": "oct", + "kid": "local", + "k": base64.urlsafe_b64encode(SECRET.encode()).decode().rstrip("="), + } + ] + } + + result = verify_agent_card_dict(signed, jwks=jwks, require_signature=True) + + assert result.valid is True + assert result.key_id == "local" + + +def test_verify_uses_rsa_jwks_key() -> None: + private_key = _rsa_private_key() + signed = _sign_with_rsa({"name": "iac-code"}, private_key, kid="rsa-current") + jwks = {"keys": [_rsa_public_jwk(private_key, "rsa-current")]} + + result = verify_agent_card_dict(signed, jwks=jwks, require_signature=True) + + assert result.valid is True + assert result.key_id == "rsa-current" + + +def test_verify_selects_rotated_rsa_jwks_key_by_key_id() -> None: + old_private_key = _rsa_private_key() + current_private_key = _rsa_private_key() + signed = _sign_with_rsa({"name": "iac-code"}, current_private_key, kid="rsa-current") + jwks = { + "keys": [ + _rsa_public_jwk(old_private_key, "rsa-old"), + _rsa_public_jwk(current_private_key, "rsa-current"), + ] + } + + result = verify_agent_card_dict(signed, jwks=jwks, require_signature=True) + + assert result.valid is True + assert result.key_id == "rsa-current" + + +def test_verify_rejects_ambiguous_unsigned_key_id_with_multiple_keys() -> None: + signed = sign_agent_card_dict({"name": "iac-code"}, secret=SECRET, key_id="local") + unsigned_kid = deepcopy(signed) + protected = json.loads(_base64url_decode(unsigned_kid["signatures"][0]["protected"]).decode("utf-8")) + protected.pop("kid") + unsigned_kid["signatures"][0]["protected"] = ( + base64.urlsafe_b64encode(json.dumps(protected).encode()).decode().rstrip("=") + ) + + result = verify_agent_card_dict(unsigned_kid, secrets={"one": SECRET, "two": OTHER_SECRET}, require_signature=True) + + assert result.valid is False + assert result.reason == "ambiguous-key" + + +def test_verify_does_not_mask_unexpected_verifier_errors(monkeypatch: pytest.MonkeyPatch) -> None: + signed = sign_agent_card_dict({"name": "iac-code"}, secret=SECRET, key_id="local") + + def explode(card): + raise RuntimeError("unexpected") + + monkeypatch.setattr("iac_code.a2a.signing._agent_card_from_dict", explode) + + with pytest.raises(RuntimeError, match="unexpected"): + verify_agent_card_dict(signed, secret=SECRET) diff --git a/tests/a2a/test_stdio_transport.py b/tests/a2a/test_stdio_transport.py new file mode 100644 index 00000000..4b014bee --- /dev/null +++ b/tests/a2a/test_stdio_transport.py @@ -0,0 +1,90 @@ +import asyncio + +import pytest + +from iac_code.a2a.transports.dispatcher import create_runtime_components +from iac_code.a2a.transports.stdio import StdioA2AClient, StdioA2AServer, decode_frame, encode_frame +from iac_code.types.stream_events import TextDeltaEvent + +from .fakes import FakeAgentLoop, FakeRuntime + + +class MemoryWriter: + def __init__(self, reader: asyncio.StreamReader) -> None: + self.reader = reader + self.closed = False + + def write(self, data: bytes) -> None: + self.reader.feed_data(data) + + async def drain(self) -> None: + return None + + def close(self) -> None: + self.closed = True + self.reader.feed_eof() + + async def wait_closed(self) -> None: + return None + + +def make_stream_pair() -> tuple[asyncio.StreamReader, MemoryWriter]: + reader = asyncio.StreamReader() + return reader, MemoryWriter(reader) + + +def test_encode_decode_frame_round_trip() -> None: + payload = {"jsonrpc": "2.0", "id": "1", "result": {"ok": True}} + + assert decode_frame(encode_frame(payload)) == payload + + +@pytest.mark.asyncio +async def test_stdio_server_handles_unary_request(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="stdio ok")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + client_to_server, client_writer = make_stream_pair() + server_to_client, server_writer = make_stream_pair() + components = create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242) + server = StdioA2AServer(components=components, reader=client_to_server, writer=server_writer) + task = asyncio.create_task(server.serve()) + + client_writer.write( + encode_frame( + { + "jsonrpc": "2.0", + "id": "1", + "method": "message/send", + "params": { + "message": { + "messageId": "msg-1", + "role": "user", + "parts": [{"kind": "text", "text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + } + ) + ) + + response = decode_frame(await asyncio.wait_for(server_to_client.readline(), timeout=1)) + assert response["id"] == "1" + assert response["result"]["status"]["state"] == "input-required" + client_writer.close() + await task + await components.aclose() + + +@pytest.mark.asyncio +async def test_stdio_client_sends_request_and_reads_response() -> None: + request_reader, request_writer = make_stream_pair() + response_reader, response_writer = make_stream_pair() + client = StdioA2AClient(reader=response_reader, writer=request_writer) + + pending = asyncio.create_task(client.send({"jsonrpc": "2.0", "id": "1", "method": "ping"})) + request = decode_frame(await asyncio.wait_for(request_reader.readline(), timeout=1)) + response_writer.write(encode_frame({"jsonrpc": "2.0", "id": request["id"], "result": {"pong": True}})) + + assert await pending == {"jsonrpc": "2.0", "id": "1", "result": {"pong": True}} diff --git a/tests/a2a/test_task_store.py b/tests/a2a/test_task_store.py new file mode 100644 index 00000000..fe45df8f --- /dev/null +++ b/tests/a2a/test_task_store.py @@ -0,0 +1,297 @@ +import asyncio + +import pytest +from a2a.auth.user import User +from a2a.server.context import ServerCallContext +from a2a.types import Artifact, ListTasksRequest, Part, Task, TaskState, TaskStatus +from a2a.utils.errors import InvalidParamsError +from google.protobuf.timestamp_pb2 import Timestamp + +from iac_code.a2a.metrics import NoOpA2AMetrics +from iac_code.a2a.task_store import A2ATaskStore + + +class FailingPersistence: + def __init__(self) -> None: + self.fail = True + + def save_task(self, snapshot) -> None: + if self.fail: + raise OSError("disk full") + + def save_context(self, snapshot) -> None: + if self.fail: + raise OSError("disk full") + + +class NamedUser(User): + def __init__(self, user_name: str) -> None: + self._user_name = user_name + + @property + def is_authenticated(self) -> bool: + return True + + @property + def user_name(self) -> str: + return self._user_name + + +def call_context(user_name: str) -> ServerCallContext: + return ServerCallContext(user=NamedUser(user_name)) + + +def timestamp(seconds: int) -> Timestamp: + value = Timestamp() + value.FromSeconds(seconds) + return value + + +def sdk_task( + task_id: str, + *, + context_id: str = "ctx-1", + state: int = TaskState.TASK_STATE_SUBMITTED, + updated_at: int = 1, + with_artifact: bool = False, +) -> Task: + task = Task( + id=task_id, + context_id=context_id, + status=TaskStatus(state=TaskState.Name(state), timestamp=timestamp(updated_at)), + ) + if with_artifact: + task.artifacts.append(Artifact(artifact_id=f"artifact-{task_id}", parts=[Part(text="artifact")])) + return task + + +@pytest.mark.asyncio +async def test_context_reuses_runtime_until_evicted() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) + context = await store.get_or_create_context(context_id="ctx-1", cwd="/tmp", runtime_factory=lambda sid: f"rt-{sid}") + again = await store.get_or_create_context(context_id="ctx-1", cwd="/tmp", runtime_factory=lambda sid: f"new-{sid}") + + assert again is context + assert again.runtime == context.runtime + + +@pytest.mark.asyncio +async def test_context_rejects_workspace_change() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) + await store.get_or_create_context(context_id="ctx-1", cwd="/tmp/one", runtime_factory=lambda sid: object()) + + with pytest.raises(ValueError, match="different workspace"): + await store.get_or_create_context(context_id="ctx-1", cwd="/tmp/two", runtime_factory=lambda sid: object()) + + +@pytest.mark.asyncio +async def test_expired_task_rejects_follow_up() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=0, cleanup_interval_seconds=300) + await store.get_or_create_context(context_id="ctx-1", cwd="/tmp", runtime_factory=lambda sid: object()) + await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + await store.cleanup_once(now_offset_seconds=1) + + with pytest.raises(ValueError, match="expired"): + await store.ensure_task_not_expired("task-1") + + +@pytest.mark.asyncio +async def test_cleanup_removes_expired_sdk_tasks_after_tombstone_window() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=0, cleanup_interval_seconds=300) + await store.get_or_create_context(context_id="ctx-1", cwd="/tmp", runtime_factory=lambda sid: object()) + await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + await store.save(Task(id="task-1", context_id="ctx-1", status=TaskStatus(state="TASK_STATE_SUBMITTED"))) + + await store.cleanup_once(now_offset_seconds=1) + assert await store.get("task-1") is not None + + await store.cleanup_once(now_offset_seconds=302) + assert await store.get("task-1") is None + + +@pytest.mark.asyncio +async def test_cancel_active_task_does_not_need_context_lock() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) + context = await store.get_or_create_context(context_id="ctx-1", cwd="/tmp", runtime_factory=lambda sid: object()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + + async def sleeper() -> None: + await asyncio.sleep(60) + + active = asyncio.create_task(sleeper()) + task.active_task = active + async with context.lock: + assert await store.cancel_task("task-1") is True + + await asyncio.sleep(0) + assert active.cancelled() or active.done() + + +@pytest.mark.asyncio +async def test_task_status_access_waits_for_mutation_lock() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + + async def sleeper() -> None: + await asyncio.sleep(60) + + active = asyncio.create_task(sleeper()) + task.active_task = active + + async with store._mutation_lock: + active_check = asyncio.create_task(store.is_task_active("task-1")) + await asyncio.sleep(0) + assert active_check.done() is False + + assert await active_check is True + + async with store._mutation_lock: + cancel_attempt = asyncio.create_task(store.cancel_task("task-1")) + await asyncio.sleep(0) + assert cancel_attempt.done() is False + + assert await cancel_attempt is True + await asyncio.sleep(0) + assert active.cancelled() or active.done() + + +@pytest.mark.asyncio +async def test_task_id_cannot_move_between_contexts() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) + await store.get_or_create_task(task_id="task-1", context_id="ctx-a") + + with pytest.raises(ValueError, match="different context"): + await store.get_or_create_task(task_id="task-1", context_id="ctx-b") + + +@pytest.mark.asyncio +async def test_cleanup_does_not_evict_in_flight_context() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=0, cleanup_interval_seconds=300) + context = await store.get_or_create_context(context_id="ctx-1", cwd="/tmp", runtime_factory=lambda sid: object()) + context.active_task_id = "task-1" + + await store.cleanup_once(now_offset_seconds=1) + + same = await store.get_or_create_context(context_id="ctx-1", cwd="/tmp", runtime_factory=lambda sid: object()) + assert same is context + + +@pytest.mark.asyncio +async def test_list_filters_by_context_with_index() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) + await store.save(Task(id="task-1", context_id="ctx-a", status=TaskStatus(state="TASK_STATE_SUBMITTED"))) + await store.save(Task(id="task-2", context_id="ctx-b", status=TaskStatus(state="TASK_STATE_SUBMITTED"))) + + response = await store.list(ListTasksRequest(context_id="ctx-a")) + + assert [task.id for task in response.tasks] == ["task-1"] + + +@pytest.mark.asyncio +async def test_list_filters_status_sorts_desc_and_paginates_with_cursor() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), idle_timeout_seconds=60, cleanup_interval_seconds=300) + await store.save(sdk_task("task-old", state=TaskState.TASK_STATE_WORKING, updated_at=10)) + await store.save(sdk_task("task-new", state=TaskState.TASK_STATE_WORKING, updated_at=30)) + await store.save(sdk_task("task-failed", state=TaskState.TASK_STATE_FAILED, updated_at=40)) + await store.save(sdk_task("task-mid", state=TaskState.TASK_STATE_WORKING, updated_at=20)) + + first = await store.list(ListTasksRequest(status=TaskState.TASK_STATE_WORKING, page_size=2)) + + assert [task.id for task in first.tasks] == ["task-new", "task-mid"] + assert first.page_size == 2 + assert first.total_size == 3 + assert first.next_page_token + + second = await store.list( + ListTasksRequest(status=TaskState.TASK_STATE_WORKING, page_size=2, page_token=first.next_page_token) + ) + + assert [task.id for task in second.tasks] == ["task-old"] + assert second.next_page_token == "" + + +@pytest.mark.asyncio +async def test_list_rejects_invalid_page_token() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + await store.save(sdk_task("task-1")) + + with pytest.raises(InvalidParamsError, match="Invalid page token"): + await store.list(ListTasksRequest(page_token="bWlzc2luZw==")) + + +@pytest.mark.asyncio +async def test_list_omits_artifacts_by_default_and_keeps_internal_task_unchanged() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + await store.save(sdk_task("task-1", with_artifact=True)) + + response = await store.list(ListTasksRequest()) + + assert len(response.tasks[0].artifacts) == 0 + assert len((await store.get("task-1")).artifacts) == 1 + + +@pytest.mark.asyncio +async def test_list_includes_artifacts_when_requested() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + await store.save(sdk_task("task-1", with_artifact=True)) + + response = await store.list(ListTasksRequest(include_artifacts=True)) + + assert response.tasks[0].artifacts[0].artifact_id == "artifact-task-1" + + +@pytest.mark.asyncio +async def test_task_store_scopes_sdk_tasks_by_authenticated_user() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics()) + await store.save(sdk_task("alice-task"), context=call_context("alice")) + await store.save(sdk_task("bob-task"), context=call_context("bob")) + + alice = await store.list(ListTasksRequest(), context=call_context("alice")) + bob = await store.list(ListTasksRequest(), context=call_context("bob")) + + assert [task.id for task in alice.tasks] == ["alice-task"] + assert [task.id for task in bob.tasks] == ["bob-task"] + assert await store.get("bob-task", context=call_context("alice")) is None + + +@pytest.mark.asyncio +async def test_task_store_mirrors_task_and_context_to_persistence(tmp_path) -> None: + from iac_code.a2a.persistence import A2APersistenceStore + + persistence = A2APersistenceStore(tmp_path) + store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=persistence) + + context = await store.get_or_create_context(context_id="ctx-1", cwd="/tmp", runtime_factory=lambda sid: object()) + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + + assert persistence.load_context("ctx-1").session_id == context.session_id + assert persistence.load_task("task-1").context_id == task.context_id + + +@pytest.mark.asyncio +async def test_task_store_persistence_failure_does_not_abort_task_creation() -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), persistence=FailingPersistence()) + + task = await store.get_or_create_task(task_id="task-1", context_id="ctx-1") + + assert task.task_id == "task-1" + + +@pytest.mark.asyncio +async def test_cleanup_loop_survives_cleanup_errors(monkeypatch: pytest.MonkeyPatch) -> None: + store = A2ATaskStore(metrics=NoOpA2AMetrics(), cleanup_interval_seconds=0.01) + calls = 0 + + async def flaky_cleanup_once() -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("cleanup failed") + + monkeypatch.setattr(store, "cleanup_once", flaky_cleanup_once) + + await store.start_cleanup_loop() + await asyncio.sleep(0.04) + await store.stop_cleanup_loop() + + assert calls >= 2 diff --git a/tests/a2a/test_transport.py b/tests/a2a/test_transport.py new file mode 100644 index 00000000..a1db1fb8 --- /dev/null +++ b/tests/a2a/test_transport.py @@ -0,0 +1,50 @@ +from base64 import b64encode + +import pytest + +from iac_code.a2a.transport import ( + A2AAuthConfig, + A2ATransportBinding, + UnsupportedA2ATransportError, + ensure_supported_transport, + headers_for_auth, + normalize_protocol_binding, +) + + +def test_normalize_protocol_binding_accepts_jsonrpc_aliases() -> None: + assert normalize_protocol_binding("JSONRPC") == "jsonrpc" + assert normalize_protocol_binding("json-rpc") == "jsonrpc" + assert normalize_protocol_binding("HTTP+JSONRPC") == "jsonrpc" + + +def test_ensure_supported_transport_accepts_jsonrpc_http() -> None: + binding = A2ATransportBinding(url="http://127.0.0.1:41242/", protocol_binding="JSONRPC") + + assert ensure_supported_transport(binding) is binding + + +def test_ensure_supported_transport_rejects_unknown_runtime() -> None: + binding = A2ATransportBinding(url="nats://broker/iac-code", protocol_binding="nats") + + with pytest.raises(UnsupportedA2ATransportError, match="nats"): + ensure_supported_transport(binding) + + +def test_headers_for_auth_combines_supported_http_auth() -> None: + config = A2AAuthConfig( + bearer_token="token-1", + api_key="key-1", + api_key_header="X-IAC-Code-Key", + ) + + assert headers_for_auth(config) == { + "Authorization": "Bearer token-1", + "X-IAC-Code-Key": "key-1", + } + + +def test_headers_for_auth_supports_basic_auth_when_configured() -> None: + config = A2AAuthConfig(basic_username="iac", basic_password="secret") + + assert headers_for_auth(config) == {"Authorization": "Basic " + b64encode(b"iac:secret").decode("ascii")} diff --git a/tests/a2a/test_transport_base.py b/tests/a2a/test_transport_base.py new file mode 100644 index 00000000..7107f968 --- /dev/null +++ b/tests/a2a/test_transport_base.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from iac_code.a2a.transports.base import binding_from_url, normalize_transport_name + + +def test_grpc_binding_names_distinguish_official_and_jsonrpc_compatibility() -> None: + assert normalize_transport_name("grpc") == "grpc" + assert normalize_transport_name("grpcs") == "grpc" + assert normalize_transport_name("grpc-jsonrpc") == "grpc-jsonrpc" + assert normalize_transport_name("grpc+jsonrpc") == "grpc-jsonrpc" + + official = binding_from_url("grpc://127.0.0.1:41243") + custom = binding_from_url("grpc-jsonrpc://127.0.0.1:41244") + + assert official.protocol_binding == "grpc" + assert custom.protocol_binding == "grpc-jsonrpc" diff --git a/tests/a2a/test_transport_client_dispatch.py b/tests/a2a/test_transport_client_dispatch.py new file mode 100644 index 00000000..e57fc7ef --- /dev/null +++ b/tests/a2a/test_transport_client_dispatch.py @@ -0,0 +1,50 @@ +import pytest + +from iac_code.a2a.client import A2AClient + + +class FakeTransportClient: + def __init__(self) -> None: + self.sent = [] + + async def send(self, payload): + self.sent.append(payload) + return {"jsonrpc": "2.0", "id": payload["id"], "result": {"text": "ok"}} + + async def stream(self, payload): + self.sent.append(payload) + yield {"jsonrpc": "2.0", "id": payload["id"], "result": {"status": {"state": "working"}}} + yield {"jsonrpc": "2.0", "id": payload["id"], "result": {"status": {"state": "done"}}, "final": True} + + async def aclose(self): + return None + + +@pytest.mark.asyncio +async def test_a2a_client_uses_registered_non_http_transport() -> None: + fake = FakeTransportClient() + captured = {} + + def factory(options): + captured["binding"] = options.binding + return fake + + client = A2AClient(transport_client_factory=factory) + + response = await client.send_message("unix:///tmp/iac-code.sock", "hello", cwd="/tmp/work") + + assert response.text == "ok" + assert fake.sent[0]["method"] == "SendMessage" + assert captured["binding"].url == "unix:///tmp/iac-code.sock" + assert captured["binding"].transport == "unix" + + +@pytest.mark.asyncio +async def test_a2a_client_streams_registered_non_http_transport() -> None: + fake = FakeTransportClient() + client = A2AClient(transport_client_factory=lambda options: fake) + + events = [event async for event in client.stream_message("ws://127.0.0.1:41243/a2a", "hello", cwd="/tmp/work")] + + assert events[-1]["final"] is True + assert fake.sent[0]["method"] == "SendStreamingMessage" diff --git a/tests/a2a/test_transport_dispatcher.py b/tests/a2a/test_transport_dispatcher.py new file mode 100644 index 00000000..95c55b13 --- /dev/null +++ b/tests/a2a/test_transport_dispatcher.py @@ -0,0 +1,110 @@ +import pytest + +from iac_code.a2a.transports.dispatcher import A2AJsonRpcDispatcher, A2ARuntimeComponents, create_runtime_components +from iac_code.types.stream_events import TextDeltaEvent + +from .fakes import FakeAgentLoop, FakeRuntime + + +@pytest.mark.asyncio +async def test_dispatcher_handles_unary_v03_message(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="hello from dispatcher")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + components = create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242) + dispatcher = A2AJsonRpcDispatcher(components) + + response = await dispatcher.dispatch( + { + "jsonrpc": "2.0", + "id": "1", + "method": "message/send", + "params": { + "message": { + "messageId": "msg-1", + "role": "user", + "parts": [{"kind": "text", "text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + } + ) + + assert response["id"] == "1" + assert response["result"]["status"]["state"] == "input-required" + assert loop.prompts == ["hello"] + await components.aclose() + + +@pytest.mark.asyncio +async def test_dispatcher_stream_yields_events(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="streamed")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + components = create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242) + dispatcher = A2AJsonRpcDispatcher(components) + + events = [ + event + async for event in dispatcher.dispatch_stream( + { + "jsonrpc": "2.0", + "id": "2", + "method": "message/stream", + "params": { + "message": { + "messageId": "msg-2", + "role": "user", + "parts": [{"kind": "text", "text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + } + ) + ] + + assert any(event["result"]["status"]["state"] == "working" for event in events) + assert events[-1]["result"]["status"]["state"] == "input-required" + await components.aclose() + + +def test_create_runtime_components_returns_shared_objects() -> None: + components = create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242) + + assert isinstance(components, A2ARuntimeComponents) + assert components.handler is not None + assert components.task_store is not None + + +@pytest.mark.asyncio +async def test_dispatcher_reuses_http_client(monkeypatch) -> None: + created = 0 + + class FakeResponse: + def raise_for_status(self) -> None: + return None + + def json(self): + return {"jsonrpc": "2.0", "id": "1", "result": {"ok": True}} + + class FakeHTTPClient: + def __init__(self, **kwargs) -> None: + nonlocal created + created += 1 + + async def post(self, *args, **kwargs): + return FakeResponse() + + async def aclose(self) -> None: + return None + + monkeypatch.setattr("iac_code.a2a.transports.dispatcher.httpx.AsyncClient", FakeHTTPClient) + dispatcher = A2AJsonRpcDispatcher(create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242)) + + await dispatcher.dispatch({"jsonrpc": "2.0", "id": "1", "method": "message/send"}) + await dispatcher.dispatch({"jsonrpc": "2.0", "id": "2", "method": "message/send"}) + await dispatcher.aclose() + + assert created == 1 diff --git a/tests/a2a/test_transport_registry.py b/tests/a2a/test_transport_registry.py new file mode 100644 index 00000000..5154d824 --- /dev/null +++ b/tests/a2a/test_transport_registry.py @@ -0,0 +1,72 @@ +import pytest + +from iac_code.a2a.transport import A2ATransportBinding, UnsupportedA2ATransportError, ensure_supported_transport +from iac_code.a2a.transports.base import ( + A2ARuntimeTransport, + TransportClientOptions, + TransportServerOptions, + TransportStreamEvent, + binding_from_url, + normalize_transport_name, + select_binding, +) + + +def test_normalize_transport_name_accepts_all_runnable_bindings() -> None: + assert normalize_transport_name("HTTP+JSONRPC") == "http" + assert normalize_transport_name("JSONRPC") == "http" + assert normalize_transport_name("stdio") == "stdio" + assert normalize_transport_name("unix") == "unix" + assert normalize_transport_name("websocket") == "websocket" + assert normalize_transport_name("ws") == "websocket" + assert normalize_transport_name("grpc") == "grpc" + assert normalize_transport_name("grpc-jsonrpc") == "grpc-jsonrpc" + assert normalize_transport_name("redis-streams") == "redis-streams" + + +def test_binding_from_url_derives_transport_name() -> None: + assert binding_from_url("https://127.0.0.1:41242/").transport == "http" + assert binding_from_url("stdio://iac-code").transport == "stdio" + assert binding_from_url("unix:///tmp/iac-code.sock").transport == "unix" + assert binding_from_url("wss://agent.example/a2a").transport == "websocket" + assert binding_from_url("grpc://127.0.0.1:50051").transport == "grpc" + assert binding_from_url("grpc-jsonrpc://127.0.0.1:50052").transport == "grpc-jsonrpc" + assert binding_from_url("redis-streams://localhost/0/iac-code").transport == "redis-streams" + + +def test_select_binding_prefers_first_supported_binding() -> None: + bindings = [ + A2ATransportBinding(url="nats://broker/iac-code", protocol_binding="nats"), + A2ATransportBinding(url="unix:///tmp/iac-code.sock", protocol_binding="unix"), + ] + + selected = select_binding(bindings) + + assert selected.url == "unix:///tmp/iac-code.sock" + assert selected.transport == "unix" + + +def test_select_binding_fails_when_no_runnable_binding_exists() -> None: + with pytest.raises(UnsupportedA2ATransportError, match="No runnable A2A transport"): + select_binding([A2ATransportBinding(url="nats://broker/iac-code", protocol_binding="nats")]) + + +def test_ensure_supported_transport_accepts_non_http_runtimes() -> None: + binding = A2ATransportBinding(url="ws://127.0.0.1:41243/a2a", protocol_binding="websocket") + + assert ensure_supported_transport(binding).url == binding.url + + +def test_runtime_options_are_plain_data() -> None: + server = TransportServerOptions(transport="unix", model="qwen3.6-plus", socket_path="/tmp/iac-code.sock") + client = TransportClientOptions(binding=binding_from_url("unix:///tmp/iac-code.sock")) + event = TransportStreamEvent(request_id="1", payload={"result": {"ok": True}}, final=True) + + assert server.transport == "unix" + assert client.binding.transport == "unix" + assert event.payload["result"]["ok"] is True + + +def test_runtime_transport_protocol_shape() -> None: + assert hasattr(A2ARuntimeTransport, "create_server") + assert hasattr(A2ARuntimeTransport, "create_client") diff --git a/tests/a2a/test_types.py b/tests/a2a/test_types.py new file mode 100644 index 00000000..017d7c7e --- /dev/null +++ b/tests/a2a/test_types.py @@ -0,0 +1,14 @@ +import pytest + +from iac_code.a2a.types import A2A_ID_MAX_LENGTH, validate_protocol_id + + +@pytest.mark.parametrize("value", ["abc", "abc-123", "abc_123", "abc.123", "abc:123"]) +def test_validate_protocol_id_accepts_safe_values(value: str) -> None: + assert validate_protocol_id(value) == value + + +@pytest.mark.parametrize("value", ["", "space value", "../x", "x/y", "x" * (A2A_ID_MAX_LENGTH + 1)]) +def test_validate_protocol_id_rejects_unsafe_values(value: str) -> None: + with pytest.raises(ValueError, match="Invalid A2A id"): + validate_protocol_id(value) diff --git a/tests/a2a/test_unix_transport.py b/tests/a2a/test_unix_transport.py new file mode 100644 index 00000000..d5a17a50 --- /dev/null +++ b/tests/a2a/test_unix_transport.py @@ -0,0 +1,77 @@ +import asyncio +import contextlib + +import pytest + +from iac_code.a2a.transports.dispatcher import create_runtime_components +from iac_code.a2a.transports.unix import UnixA2AClient, UnixA2AServer, validate_socket_path +from iac_code.types.stream_events import TextDeltaEvent + +from .fakes import FakeAgentLoop, FakeRuntime + + +def test_validate_socket_path_requires_existing_parent(tmp_path) -> None: + socket_path = tmp_path / "iac-code.sock" + + assert validate_socket_path(str(socket_path)) == socket_path + + with pytest.raises(ValueError, match="Unix socket parent does not exist"): + validate_socket_path(str(tmp_path / "missing" / "iac-code.sock")) + + +async def wait_for_socket(socket_path, timeout: float = 1.0) -> None: + deadline = asyncio.get_running_loop().time() + timeout + while not socket_path.exists(): + if asyncio.get_running_loop().time() >= deadline: + raise TimeoutError(f"Timed out waiting for Unix socket: {socket_path}") + await asyncio.sleep(0.01) + + +@pytest.mark.asyncio +async def test_unix_server_and_client_handle_unary_request(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="unix ok")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + monkeypatch.chdir(tmp_path) + socket_path = tmp_path / "iac-code.sock" + socket_name = "iac-code.sock" + components = create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242) + server = UnixA2AServer(components=components, socket_path=socket_name) + serve_task = asyncio.create_task(server.serve()) + + try: + await wait_for_socket(socket_path) + client = UnixA2AClient(socket_path=socket_name) + try: + response = await asyncio.wait_for( + client.send( + { + "jsonrpc": "2.0", + "id": "1", + "method": "message/send", + "params": { + "message": { + "messageId": "msg-1", + "role": "user", + "parts": [{"kind": "text", "text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + } + ), + timeout=1, + ) + finally: + await client.aclose() + + assert response["id"] == "1" + assert response["result"]["status"]["state"] == "input-required" + assert loop.prompts == ["hello"] + finally: + await server.aclose() + serve_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await serve_task + + assert not socket_path.exists() diff --git a/tests/a2a/test_websocket_transport.py b/tests/a2a/test_websocket_transport.py new file mode 100644 index 00000000..f45cb8f4 --- /dev/null +++ b/tests/a2a/test_websocket_transport.py @@ -0,0 +1,69 @@ +import json + +import pytest + +from iac_code.a2a.transports.dispatcher import create_runtime_components +from iac_code.a2a.transports.websocket import WebSocketA2AServerApp, websocket_event_frame +from iac_code.types.stream_events import TextDeltaEvent + +from .fakes import FakeAgentLoop, FakeRuntime + + +def test_websocket_event_frame_marks_final() -> None: + frame = websocket_event_frame({"jsonrpc": "2.0", "id": "1", "result": {"ok": True}}, final=True) + + assert frame == {"id": "1", "payload": {"jsonrpc": "2.0", "id": "1", "result": {"ok": True}}, "final": True} + + +@pytest.mark.asyncio +async def test_websocket_app_handles_unary_frame(monkeypatch, tmp_path) -> None: + loop = FakeAgentLoop([TextDeltaEvent(text="ws ok")]) + runtime = FakeRuntime(agent_loop=loop, session_id="session-1") + monkeypatch.setattr("iac_code.a2a.executor.create_agent_runtime", lambda options: runtime) + components = create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242) + app = WebSocketA2AServerApp(components=components, path="/a2a").create_app() + + from starlette.testclient import TestClient + + with TestClient(app) as client: + with client.websocket_connect("/a2a") as websocket: + websocket.send_text( + json.dumps( + { + "jsonrpc": "2.0", + "id": "1", + "method": "message/send", + "params": { + "message": { + "messageId": "msg-1", + "role": "user", + "parts": [{"kind": "text", "text": "hello"}], + "metadata": {"iac_code": {"cwd": str(tmp_path)}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + } + ) + ) + response = websocket.receive_json() + + assert response["final"] is True + assert response["payload"]["result"]["status"]["state"] == "input-required" + await components.aclose() + + +@pytest.mark.asyncio +async def test_websocket_app_reports_invalid_json_frame() -> None: + components = create_runtime_components(model="qwen3.6-plus", host="127.0.0.1", port=41242) + app = WebSocketA2AServerApp(components=components, path="/a2a").create_app() + + from starlette.testclient import TestClient + + with TestClient(app) as client: + with client.websocket_connect("/a2a") as websocket: + websocket.send_text("{broken") + response = websocket.receive_json() + + assert response["final"] is True + assert response["payload"]["error"]["code"] == -32700 + await components.aclose() diff --git a/tests/cli/test_a2a_command.py b/tests/cli/test_a2a_command.py new file mode 100644 index 00000000..ddd9c56d --- /dev/null +++ b/tests/cli/test_a2a_command.py @@ -0,0 +1,963 @@ +import re +from types import SimpleNamespace + +from typer.testing import CliRunner + +from iac_code.a2a.persistence import A2APersistenceStore, A2ARouteSnapshot +from iac_code.a2a.transport import A2AAuthConfig +from iac_code.cli.main import app +from iac_code.config import DEFAULT_MODEL + +_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") + + +def _strip_ansi(text: str) -> str: + return _ANSI_ESCAPE_RE.sub("", text) + + +def test_a2a_help_shows_common_server_options_only() -> None: + result = CliRunner().invoke(app, ["a2a", "--help"]) + + assert result.exit_code == 0 + stdout = _strip_ansi(result.stdout) + assert "--config" in stdout + assert "--host" in stdout + assert "--port" in stdout + assert "--transport" in stdout + assert "--debug" in stdout + assert "--socket-path" not in stdout + assert "--token" not in stdout + assert "--persistence-dir" not in stdout + assert "--push-redis-url" not in stdout + assert "--auto-approve-permissions" not in stdout + + +def test_a2a_command_rejects_removed_advanced_flags() -> None: + result = CliRunner().invoke(app, ["a2a", "--token", "cli-token"]) + + assert result.exit_code == 2 + assert "No such option: --token" in result.stderr + + +def test_a2a_client_help_groups_client_commands() -> None: + result = CliRunner().invoke(app, ["a2a-client", "--help"]) + + assert result.exit_code == 0 + stdout = _strip_ansi(result.stdout) + assert "call" in stdout + assert "discover" in stdout + assert "task-get" in stdout + assert "push-config-create" in stdout + assert "extended-card" in stdout + assert "route-preview" in stdout + + +def test_removed_top_level_a2a_client_command_is_rejected() -> None: + result = CliRunner().invoke(app, ["a2a-call", "--help"]) + + assert result.exit_code == 2 + assert "No such command" in result.stderr + + +def test_a2a_command_passes_config_options_to_server(monkeypatch, tmp_path) -> None: + called = {} + + def fake_run_server( + *, + host: str, + port: int, + token: str | None, + model: str, + basic_username: str | None, + basic_password: str | None, + api_key: str | None, + api_key_header: str, + persistence_dir: str | None, + artifact_dir: str | None, + signing_secret: str | None, + push_notifications: bool, + transport: str, + socket_path: str | None, + ws_path: str, + grpc_host: str | None, + grpc_port: int | None, + redis_url: str | None, + request_stream: str, + response_stream: str, + consumer_group: str, + push_queue: str, + push_redis_url: str | None, + push_stream: str, + push_retry_key: str, + push_dead_stream: str, + push_consumer_group: str, + push_consumer_name: str | None, + push_lease_timeout_ms: int, + auto_approve_permissions: bool, + ) -> None: + called.update( + { + "host": host, + "port": port, + "token": token, + "model": model, + "basic_username": basic_username, + "basic_password": basic_password, + "api_key": api_key, + "api_key_header": api_key_header, + "persistence_dir": persistence_dir, + "artifact_dir": artifact_dir, + "signing_secret": signing_secret, + "push_notifications": push_notifications, + "transport": transport, + "socket_path": socket_path, + "ws_path": ws_path, + "grpc_host": grpc_host, + "grpc_port": grpc_port, + "redis_url": redis_url, + "request_stream": request_stream, + "response_stream": response_stream, + "consumer_group": consumer_group, + "push_queue": push_queue, + "push_redis_url": push_redis_url, + "push_stream": push_stream, + "push_retry_key": push_retry_key, + "push_dead_stream": push_dead_stream, + "push_consumer_group": push_consumer_group, + "push_consumer_name": push_consumer_name, + "push_lease_timeout_ms": push_lease_timeout_ms, + "auto_approve_permissions": auto_approve_permissions, + } + ) + + monkeypatch.setattr("iac_code.a2a.app.run_server", fake_run_server) + monkeypatch.setattr("iac_code.a2a.app.resolve_token", lambda token: token or "env-token") + monkeypatch.setattr("iac_code.a2a.app.resolve_basic_credentials", lambda username, password: (username, password)) + monkeypatch.setattr("iac_code.a2a.app.resolve_api_key", lambda api_key: api_key or "env-api-key") + + config = tmp_path / "a2a.yml" + config.write_text( + "\n".join( + [ + "host: 0.0.0.0", + "port: 9999", + "token: cli-token", + "basic-username: cli-user", + "basic-password: cli-pass", + "api-key: cli-api-key", + "api-key-header: X-IAC-Code-Key", + "persistence-dir: /tmp/a2a-persist", + "artifact-dir: /tmp/a2a-artifacts", + "signing-secret: sign-me", + "push-notifications: true", + "push-queue: redis-streams", + "push-redis-url: redis://localhost:6379/0", + "push-stream: custom:push", + "push-retry-key: custom:push:retry", + "push-dead-stream: custom:push:dead", + "push-consumer-group: custom-workers", + "push-consumer-name: worker-a", + "push-lease-timeout-ms: 120000", + "auto-approve-permissions: true", + ] + ), + encoding="utf-8", + ) + + result = CliRunner().invoke( + app, + [ + "a2a", + "--config", + str(config), + ], + ) + + assert result.exit_code == 0 + assert called == { + "host": "0.0.0.0", + "port": 9999, + "token": "cli-token", + "model": DEFAULT_MODEL, + "basic_username": "cli-user", + "basic_password": "cli-pass", + "api_key": "cli-api-key", + "api_key_header": "X-IAC-Code-Key", + "persistence_dir": "/tmp/a2a-persist", + "artifact_dir": "/tmp/a2a-artifacts", + "signing_secret": "sign-me", + "push_notifications": True, + "transport": "http", + "socket_path": None, + "ws_path": "/a2a", + "grpc_host": None, + "grpc_port": None, + "redis_url": None, + "request_stream": "iac-code:a2a:requests", + "response_stream": "iac-code:a2a:responses", + "consumer_group": "iac-code", + "push_queue": "redis-streams", + "push_redis_url": "redis://localhost:6379/0", + "push_stream": "custom:push", + "push_retry_key": "custom:push:retry", + "push_dead_stream": "custom:push:dead", + "push_consumer_group": "custom-workers", + "push_consumer_name": "worker-a", + "push_lease_timeout_ms": 120000, + "auto_approve_permissions": True, + } + + +def test_a2a_command_rejects_missing_push_redis_url(tmp_path) -> None: + config = tmp_path / "a2a.yml" + config.write_text("push-notifications: true\npush-queue: redis-streams\n", encoding="utf-8") + + result = CliRunner().invoke(app, ["a2a", "--config", str(config)]) + + assert result.exit_code == 1 + assert "push-redis-url is required in --config" in result.stderr + + +def test_a2a_command_loads_config_file_and_cli_overrides(monkeypatch, tmp_path) -> None: + captured = {} + + def fake_run_server(**kwargs): + captured.update(kwargs) + + config = tmp_path / "a2a.yml" + config.write_text( + "\n".join( + [ + "host: 0.0.0.0", + "port: 12345", + "transport: websocket", + "ws_path: /agent", + "token: config-token", + "persistence_dir: /tmp/from-config", + "push_notifications: true", + "auto_approve_permissions: true", + ] + ), + encoding="utf-8", + ) + monkeypatch.setattr("iac_code.cli.main.load_saved_model", lambda: "qwen3.6-plus") + monkeypatch.setattr("iac_code.a2a.app.run_server", fake_run_server) + + result = CliRunner().invoke(app, ["a2a", "--config", str(config), "--port", "54321"]) + + assert result.exit_code == 0 + assert captured["host"] == "0.0.0.0" + assert captured["port"] == 54321 + assert captured["transport"] == "websocket" + assert captured["ws_path"] == "/agent" + assert captured["token"] == "config-token" + assert captured["persistence_dir"] == "/tmp/from-config" + assert captured["push_notifications"] is True + assert captured["auto_approve_permissions"] is True + + +def test_a2a_command_passes_unix_transport_options(monkeypatch, tmp_path) -> None: + captured = {} + + def fake_run_server(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr("iac_code.cli.main.load_saved_model", lambda: "qwen3.6-plus") + monkeypatch.setattr("iac_code.a2a.app.run_server", fake_run_server) + config = tmp_path / "a2a.yml" + config.write_text("socket-path: /tmp/iac-code.sock\n", encoding="utf-8") + result = CliRunner().invoke(app, ["a2a", "--config", str(config), "--transport", "unix"]) + + assert result.exit_code == 0 + assert captured["transport"] == "unix" + assert captured["socket_path"] == "/tmp/iac-code.sock" + + +def test_a2a_command_preserves_explicit_zero_grpc_port(monkeypatch, tmp_path) -> None: + captured = {} + + def fake_run_server(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr("iac_code.cli.main.load_saved_model", lambda: "qwen3.6-plus") + monkeypatch.setattr("iac_code.a2a.app.run_server", fake_run_server) + config = tmp_path / "a2a.yml" + config.write_text("grpc-port: 0\n", encoding="utf-8") + result = CliRunner().invoke(app, ["a2a", "--config", str(config)]) + + assert result.exit_code == 0 + assert captured["grpc_port"] == 0 + + +def test_a2a_command_rejects_missing_socket_path() -> None: + result = CliRunner().invoke(app, ["a2a", "--transport", "unix"]) + + assert result.exit_code == 1 + assert "socket-path is required in --config" in result.stderr + + +def test_a2a_call_sends_prompt_with_auth(monkeypatch, tmp_path) -> None: + called = {} + + class FakeClient: + def __init__( + self, + *, + auth: A2AAuthConfig | None = None, + verification_secret: str | None = None, + verification_jwks_url: str | None = None, + require_card_signature: bool = False, + timeout_seconds: float | None = None, + ) -> None: + called["auth"] = auth + called["verification_secret"] = verification_secret + called["verification_jwks_url"] = verification_jwks_url + called["require_card_signature"] = require_card_signature + called["timeout_seconds"] = timeout_seconds + + async def send_message(self, url: str, prompt: str, *, cwd: str, context_id: str | None = None): + called["send"] = {"url": url, "prompt": prompt, "cwd": cwd, "context_id": context_id} + return SimpleNamespace(text="created stack", payload={"result": {"text": "created stack"}}) + + async def discover(self, url: str): + called["discover"] = url + return { + "name": "iac-agent", + "supportedInterfaces": [ + { + "url": "http://agent.example/discovered-rpc", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + } + ], + } + + @staticmethod + def select_endpoint_url(card, *, fallback_url: str) -> str: + called["selected_card"] = card + called["fallback_url"] = fallback_url + return card["supportedInterfaces"][0]["url"] + + async def aclose(self) -> None: + called["closed"] = True + + monkeypatch.setattr("iac_code.a2a.client.A2AClient", FakeClient) + + result = CliRunner().invoke( + app, + [ + "a2a-client", + "call", + "--url", + "http://agent.example/rpc", + "--prompt", + "create vpc", + "--cwd", + str(tmp_path), + "--context-id", + "ctx-1", + "--token", + "bearer", + "--api-key", + "api", + "--api-key-header", + "X-IAC-Code-Key", + "--basic-username", + "user", + "--basic-password", + "pass", + "--verify-card-secret", + "card-secret", + "--verify-card-jwks-url", + "https://agent.example/.well-known/jwks.json", + "--require-card-signature", + "--timeout", + "12.5", + ], + ) + + assert result.exit_code == 0 + assert "created stack" in result.output + assert called["send"] == { + "url": "http://agent.example/discovered-rpc", + "prompt": "create vpc", + "cwd": str(tmp_path), + "context_id": "ctx-1", + } + assert called["discover"] == "http://agent.example/rpc" + assert called["fallback_url"] == "http://agent.example/rpc" + assert called["auth"] == A2AAuthConfig( + bearer_token="bearer", + api_key="api", + api_key_header="X-IAC-Code-Key", + basic_username="user", + basic_password="pass", + ) + assert called["verification_secret"] == "card-secret" + assert called["verification_jwks_url"] == "https://agent.example/.well-known/jwks.json" + assert called["require_card_signature"] is True + assert called["timeout_seconds"] == 12.5 + assert called["closed"] is True + + +def test_a2a_call_stream_prints_stream_events(monkeypatch, tmp_path) -> None: + called = {} + + class FakeClient: + def __init__(self, *, auth: A2AAuthConfig | None = None, **_kwargs) -> None: + called["auth"] = auth + + async def discover(self, url: str): + called["discover"] = url + return {"url": "http://agent.example/rpc"} + + @staticmethod + def select_endpoint_url(card, *, fallback_url: str) -> str: + return card.get("url", fallback_url) + + async def stream_message(self, url: str, prompt: str, *, cwd: str, context_id: str | None = None): + called["stream"] = {"url": url, "prompt": prompt, "cwd": cwd, "context_id": context_id} + yield {"result": {"status": {"state": "working", "message": {"parts": [{"text": "planning"}]}}}} + yield {"result": {"text": "created stack"}} + + async def send_message(self, *_args, **_kwargs): + raise AssertionError("stream mode must not call send_message") + + async def aclose(self) -> None: + called["closed"] = True + + monkeypatch.setattr("iac_code.a2a.client.A2AClient", FakeClient) + + result = CliRunner().invoke( + app, + [ + "a2a-client", + "call", + "--url", + "http://agent.example", + "--prompt", + "create vpc", + "--cwd", + str(tmp_path), + "--context-id", + "ctx-1", + "--stream", + ], + ) + + assert result.exit_code == 0 + assert "planning" in result.output + assert "created stack" in result.output + assert called["stream"] == { + "url": "http://agent.example/rpc", + "prompt": "create vpc", + "cwd": str(tmp_path), + "context_id": "ctx-1", + } + assert called["closed"] is True + + +def test_a2a_call_can_resolve_named_route(monkeypatch, tmp_path) -> None: + called = {} + + async def fake_run_a2a_call(**kwargs) -> str: + called.update(kwargs) + return "ok" + + monkeypatch.setattr("iac_code.cli.main._run_a2a_call", fake_run_a2a_call) + + result = CliRunner().invoke( + app, + [ + "a2a-client", + "call", + "--route", + "template=http://template.example/rpc;iac_generation;ros", + "--route-name", + "template", + "--prompt", + "create vpc", + "--cwd", + str(tmp_path), + ], + ) + + assert result.exit_code == 0 + assert called["url"] == "http://template.example/rpc" + assert called["prompt"] == "create vpc" + + +def test_a2a_client_call_loads_config_and_allows_cli_overrides(monkeypatch, tmp_path) -> None: + called = {} + + async def fake_run_a2a_call(**kwargs) -> str: + called.update(kwargs) + return "ok" + + monkeypatch.setattr("iac_code.cli.main._run_a2a_call", fake_run_a2a_call) + + config = tmp_path / "a2a-client.yml" + config.write_text( + "\n".join( + [ + "url: http://agent.example/rpc", + "cwd: /workspace/from-config", + "context-id: ctx-from-config", + "token: config-token", + "basic-username: config-user", + "basic-password: config-pass", + "api-key: config-api", + "api-key-header: X-IAC-Code-Key", + "verify-card-secret: config-secret", + "verify-card-jwks-url: https://agent.example/.well-known/jwks.json", + "require-card-signature: true", + "timeout: 20", + "stream: true", + ] + ), + encoding="utf-8", + ) + + result = CliRunner().invoke( + app, + [ + "a2a-client", + "--config", + str(config), + "call", + "--prompt", + "create vpc", + "--timeout", + "12.5", + ], + ) + + assert result.exit_code == 0 + stream_callback = called.pop("stream_callback") + assert called == { + "url": "http://agent.example/rpc", + "prompt": "create vpc", + "cwd": "/workspace/from-config", + "context_id": "ctx-from-config", + "token": "config-token", + "basic_username": "config-user", + "basic_password": "config-pass", + "api_key": "config-api", + "api_key_header": "X-IAC-Code-Key", + "verify_card_secret": "config-secret", + "verify_card_jwks_url": "https://agent.example/.well-known/jwks.json", + "require_card_signature": True, + "timeout_seconds": 12.5, + "stream": True, + } + assert stream_callback is not None + + +def test_a2a_client_call_loads_routes_from_config(monkeypatch, tmp_path) -> None: + called = {} + + async def fake_run_a2a_call(**kwargs) -> str: + called.update(kwargs) + return "ok" + + monkeypatch.setattr("iac_code.cli.main._run_a2a_call", fake_run_a2a_call) + + config = tmp_path / "a2a-client.yml" + config.write_text( + "\n".join( + [ + "route-name: template", + "routes:", + " - name: template", + " url: http://template.example/rpc", + " skills:", + " - iac_generation", + " tags:", + " - ros", + " - template", + " - name: review", + " url: http://review.example/rpc", + " skills:", + " - iac_review", + ] + ), + encoding="utf-8", + ) + + result = CliRunner().invoke( + app, + [ + "a2a-client", + "--config", + str(config), + "call", + "--prompt", + "create vpc", + ], + ) + + assert result.exit_code == 0 + assert called["url"] == "http://template.example/rpc" + assert called["prompt"] == "create vpc" + + +def test_a2a_client_task_get_loads_url_and_task_id_from_config(monkeypatch, tmp_path) -> None: + called = {} + + class FakeClient: + def __init__(self, *, auth: A2AAuthConfig | None = None) -> None: + called["auth"] = auth + + async def get_task(self, url: str, task_id: str, *, history_length: int | None = None): + called["get_task"] = {"url": url, "task_id": task_id, "history_length": history_length} + return {"result": {"id": task_id}} + + async def aclose(self) -> None: + called["closed"] = True + + monkeypatch.setattr("iac_code.a2a.client.A2AClient", FakeClient) + + config = tmp_path / "a2a-client.yml" + config.write_text( + "\n".join( + [ + "url: http://agent.example/rpc", + "task-id: task-from-config", + "history-length: 5", + "token: config-token", + ] + ), + encoding="utf-8", + ) + + result = CliRunner().invoke(app, ["a2a-client", "--config", str(config), "task-get"]) + + assert result.exit_code == 0 + assert '"id": "task-from-config"' in result.output + assert called["get_task"] == { + "url": "http://agent.example/rpc", + "task_id": "task-from-config", + "history_length": 5, + } + assert called["auth"] == A2AAuthConfig(bearer_token="config-token") + assert called["closed"] is True + + +def test_a2a_client_task_list_reports_missing_url_without_config() -> None: + result = CliRunner().invoke(app, ["a2a-client", "task-list"]) + + assert result.exit_code == 1 + assert "url is required. Provide --url or url in --config." in result.stderr + + +def test_a2a_discover_prints_agent_card(monkeypatch) -> None: + called = {} + + class FakeClient: + def __init__( + self, + *, + auth: A2AAuthConfig | None = None, + verification_secret: str | None = None, + verification_jwks_url: str | None = None, + require_card_signature: bool = False, + ) -> None: + called["auth"] = auth + called["verification_secret"] = verification_secret + called["verification_jwks_url"] = verification_jwks_url + called["require_card_signature"] = require_card_signature + + async def discover(self, base_url: str): + called["base_url"] = base_url + return {"name": "iac-agent", "skills": [{"id": "iac_generation"}]} + + async def aclose(self) -> None: + called["closed"] = True + + monkeypatch.setattr("iac_code.a2a.client.A2AClient", FakeClient) + + result = CliRunner().invoke( + app, + [ + "a2a-client", + "discover", + "--url", + "http://agent.example", + "--token", + "bearer", + "--verify-card-secret", + "card-secret", + "--verify-card-jwks-url", + "https://agent.example/.well-known/jwks.json", + "--require-card-signature", + ], + ) + + assert result.exit_code == 0 + assert '"name": "iac-agent"' in result.output + assert called == { + "auth": A2AAuthConfig(bearer_token="bearer"), + "verification_secret": "card-secret", + "verification_jwks_url": "https://agent.example/.well-known/jwks.json", + "require_card_signature": True, + "base_url": "http://agent.example", + "closed": True, + } + + +def test_a2a_task_get_calls_client(monkeypatch) -> None: + called = {} + + class FakeClient: + def __init__(self, *, auth: A2AAuthConfig | None = None) -> None: + called["auth"] = auth + + async def get_task(self, url: str, task_id: str, *, history_length: int | None = None): + called["get_task"] = {"url": url, "task_id": task_id, "history_length": history_length} + return {"result": {"id": task_id}} + + async def aclose(self) -> None: + called["closed"] = True + + monkeypatch.setattr("iac_code.a2a.client.A2AClient", FakeClient) + + result = CliRunner().invoke( + app, + [ + "a2a-client", + "task-get", + "--url", + "http://agent.example/rpc", + "--task-id", + "task-1", + "--history-length", + "3", + "--token", + "bearer", + ], + ) + + assert result.exit_code == 0 + assert '"id": "task-1"' in result.output + assert called["get_task"] == {"url": "http://agent.example/rpc", "task_id": "task-1", "history_length": 3} + assert called["auth"] == A2AAuthConfig(bearer_token="bearer") + assert called["closed"] is True + + +def test_a2a_task_list_prints_table_with_pagination_hint(monkeypatch) -> None: + called = {} + + class FakeClient: + def __init__(self, *, auth: A2AAuthConfig | None = None) -> None: + called["auth"] = auth + + async def list_tasks(self, url: str, **kwargs): + called["list_tasks"] = {"url": url, **kwargs} + return { + "result": { + "tasks": [ + { + "id": "task-1", + "contextId": "ctx-1", + "status": { + "state": "TASK_STATE_WORKING", + "timestamp": "2026-05-15T10:00:00Z", + "message": {"parts": [{"text": "creating vpc"}]}, + }, + }, + { + "id": "task-2", + "contextId": "ctx-2", + "status": { + "state": "TASK_STATE_COMPLETED", + "timestamp": "2026-05-15T09:00:00Z", + }, + }, + ], + "nextPageToken": "cursor-2", + "pageSize": 2, + "totalSize": 3, + } + } + + async def aclose(self) -> None: + called["closed"] = True + + monkeypatch.setattr("iac_code.a2a.client.A2AClient", FakeClient) + + result = CliRunner().invoke( + app, + [ + "a2a-client", + "task-list", + "--url", + "http://agent.example/rpc", + "--context-id", + "ctx-1", + "--status", + "TASK_STATE_WORKING", + "--page-size", + "2", + ], + ) + + assert result.exit_code == 0 + assert "ID" in result.output + assert "Status" in result.output + assert "task-1" in result.output + assert "working" in result.output + assert "creating vpc" in result.output + assert "Showing 2 of 3 tasks" in result.output + assert "iac-code a2a-client task-list" in result.output + assert "--page-token cursor-2" in result.output + assert called["list_tasks"] == { + "url": "http://agent.example/rpc", + "context_id": "ctx-1", + "status": "TASK_STATE_WORKING", + "page_size": 2, + "page_token": None, + "include_artifacts": None, + } + assert called["closed"] is True + + +def test_a2a_task_list_can_print_json(monkeypatch) -> None: + class FakeClient: + def __init__(self, *, auth: A2AAuthConfig | None = None) -> None: + pass + + async def list_tasks(self, url: str, **_kwargs): + return {"result": {"tasks": [{"id": "task-1"}], "totalSize": 1}} + + async def aclose(self) -> None: + pass + + monkeypatch.setattr("iac_code.a2a.client.A2AClient", FakeClient) + + result = CliRunner().invoke( + app, + [ + "a2a-client", + "task-list", + "--url", + "http://agent.example/rpc", + "--output", + "json", + ], + ) + + assert result.exit_code == 0 + assert '"tasks"' in result.output + assert "Next page" not in result.output + + +def test_a2a_push_config_create_calls_client(monkeypatch) -> None: + called = {} + + class FakeClient: + def __init__(self, *, auth: A2AAuthConfig | None = None) -> None: + called["auth"] = auth + + async def create_push_notification_config(self, **kwargs): + called["create"] = kwargs + return {"result": {"id": kwargs["config_id"], "url": kwargs["url"]}} + + async def aclose(self) -> None: + called["closed"] = True + + monkeypatch.setattr("iac_code.a2a.client.A2AClient", FakeClient) + + result = CliRunner().invoke( + app, + [ + "a2a-client", + "push-config-create", + "--url", + "http://agent.example/rpc", + "--task-id", + "task-1", + "--config-id", + "cfg-1", + "--callback-url", + "https://callback.example/a2a", + "--notification-token", + "token-1", + "--auth-scheme", + "bearer", + "--auth-credentials", + "secret", + ], + ) + + assert result.exit_code == 0 + assert '"id": "cfg-1"' in result.output + assert called["create"]["config_id"] == "cfg-1" + assert called["create"]["authentication"] == {"scheme": "bearer", "credentials": "secret"} + + +def test_a2a_route_preview_resolves_and_saves_routes(tmp_path) -> None: + persistence_dir = tmp_path / "a2a" + + result = CliRunner().invoke( + app, + [ + "a2a-client", + "route-preview", + "--route", + "template=http://template.example/rpc;skills=iac_generation;tags=ros,template", + "--route", + "review=http://review.example/rpc;skills=iac_review;tags=review", + "--skill", + "iac_generation", + "--route-state-dir", + str(persistence_dir), + ], + ) + + assert result.exit_code == 0 + assert "template" in result.output + assert "http://template.example/rpc" in result.output + assert A2APersistenceStore(persistence_dir).load_routes() == [ + A2ARouteSnapshot( + name="template", + url="http://template.example/rpc", + skills=["iac_generation"], + tags=["ros", "template"], + ), + A2ARouteSnapshot(name="review", url="http://review.example/rpc", skills=["iac_review"], tags=["review"]), + ] + + +def test_a2a_command_reports_missing_extra(monkeypatch) -> None: + def fake_run_server(**kwargs) -> None: + raise RuntimeError("A2A server dependencies are missing. Install iac-code with the 'a2a' extra.") + + monkeypatch.setattr("iac_code.a2a.app.run_server", fake_run_server) + monkeypatch.setattr("iac_code.a2a.app.resolve_token", lambda token: None) + monkeypatch.setattr("iac_code.a2a.app.resolve_basic_credentials", lambda username, password: None) + monkeypatch.setattr("iac_code.a2a.app.resolve_api_key", lambda api_key: None) + + result = CliRunner().invoke(app, ["a2a"]) + + assert result.exit_code == 1 + combined_output = (result.stdout or "") + (result.stderr or "") + (result.output or "") + assert "a2a" in combined_output + + +def test_a2a_command_reports_import_error(monkeypatch) -> None: + import builtins + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "iac_code.a2a.app": + raise ImportError("missing optional a2a dependency") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + result = CliRunner().invoke(app, ["a2a"]) + + assert result.exit_code == 1 + combined_output = (result.stdout or "") + (result.stderr or "") + (result.output or "") + assert "a2a" in combined_output diff --git a/tests/conftest.py b/tests/conftest.py index 406c57b2..3f0ba2db 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,12 @@ os.environ["LC_ALL"] = "en_US.UTF-8" os.environ["LANG"] = "en_US.UTF-8" +# Disable Rich/Click ANSI color output so substring assertions on help text +# (e.g. "--config" in result.stdout) work in CI where a TTY-like environment +# may otherwise insert escape sequences mid-token. +os.environ["NO_COLOR"] = "1" +os.environ["TERM"] = "dumb" + # Re-initialize i18n with English locale from iac_code.i18n import setup_i18n # noqa: E402 diff --git a/uv.lock b/uv.lock index 651f9a75..a94e238c 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,35 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[[package]] +name = "a2a-sdk" +version = "1.0.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "culsans", marker = "python_full_version < '3.13'" }, + { name = "google-api-core" }, + { name = "googleapis-common-protos" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "json-rpc" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "pydantic" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/64/35/8b7ac94f405f57c591925fa0afc105a0f797151876fffa666b57722eefa9/a2a_sdk-1.0.3.tar.gz", hash = "sha256:c57ddd910aece4a426ae26b8f0d0e8e2f3271a6adde974078075e4f600aaf628" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/53/6f/ae79f8210f1ecd70e1c37c310a523b26f1d6da458d4c1365914bf1ea58e0/a2a_sdk-1.0.3-py3-none-any.whl", hash = "sha256:068e5b2ceb4e962ac61d9e1fd43ca0c1016b64f0c80d901f6e23420bc8a31a93" }, +] + +[package.optional-dependencies] +http-server = [ + { name = "sse-starlette" }, + { name = "starlette" }, +] +signing = [ + { name = "pyjwt" }, +] + [[package]] name = "agent-client-protocol" version = "0.9.0" @@ -157,6 +186,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9" }, ] +[[package]] +name = "aiologic" +version = "0.16.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f6/27/206615942005471499f6fbc36621582e24d0686f33c74b2d018fcfd4fe67/aiologic-0.16.0-py3-none-any.whl", hash = "sha256:e00ce5f68c5607c864d26aec99c0a33a83bdf8237aa7312ffbb96805af67d8b6" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -784,6 +827,19 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4" }, ] +[[package]] +name = "culsans" +version = "0.11.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e0/5d/9fb19fb38f6d6120422064279ea5532e22b84aa2be8831d49607194feda3/culsans-0.11.0-py3-none-any.whl", hash = "sha256:278d118f63fc75b9db11b664b436a1b83cc30d9577127848ba41420e66eb5a47" }, +] + [[package]] name = "darabonba-core" version = "1.0.5" @@ -975,6 +1031,35 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d" }, ] +[[package]] +name = "google-api-core" +version = "2.30.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8" }, +] + +[[package]] +name = "google-auth" +version = "2.52.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d4/f8/80d2493cbedece1c623dc3e3cb1883300871af0dcdae254409522985ac23/google_auth-2.52.0.tar.gz", hash = "sha256:01f30e1a9e3638698d89464f5e603ce29d18e1c0e63ec31ac570aba4e164aaf5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ee/fc/2cdc74252746f547f81ff3f02d4d4234a3f411b5de5b61af97e633a060b9/google_auth-2.52.0-py3-none-any.whl", hash = "sha256:aee92803ba0ff93a70a3b8a35c7b4797837751cd6380b63ff38372b98f3ed627" }, +] + [[package]] name = "googleapis-common-protos" version = "1.74.0" @@ -1048,6 +1133,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f" }, ] +[[package]] +name = "grpcio-status" +version = "1.80.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1128,6 +1227,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc" }, +] + [[package]] name = "iac-code" source = { editable = "." } @@ -1155,6 +1263,22 @@ dependencies = [ ] [package.optional-dependencies] +a2a = [ + { name = "a2a-sdk", extra = ["http-server", "signing"] }, + { name = "cryptography" }, + { name = "starlette" }, + { name = "uvicorn", extra = ["standard"] }, +] +a2a-grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] +a2a-redis = [ + { name = "redis" }, +] +a2a-signing = [ + { name = "a2a-sdk", extra = ["signing"] }, +] http = [ { name = "starlette" }, { name = "uvicorn", extra = ["standard"] }, @@ -1170,17 +1294,23 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "setuptools" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "ty" }, { name = "wheel" }, ] [package.metadata] requires-dist = [ + { name = "a2a-sdk", extras = ["http-server", "signing"], marker = "extra == 'a2a'", specifier = ">=1.0.2,<2" }, + { name = "a2a-sdk", extras = ["signing"], marker = "extra == 'a2a-signing'", specifier = ">=1.0.2,<2" }, { name = "agent-client-protocol", specifier = ">=0.9.0" }, { name = "alibabacloud-credentials", specifier = ">=0.3.0" }, { name = "alibabacloud-ros20190910", specifier = ">=3.0.0" }, { name = "anthropic", specifier = ">=0.40" }, { name = "cryptography", specifier = ">=42.0" }, + { name = "cryptography", marker = "extra == 'a2a'", specifier = ">=42.0" }, + { name = "grpcio", marker = "extra == 'a2a-grpc'", specifier = ">=1.60.0" }, + { name = "grpcio-status", marker = "extra == 'a2a-grpc'", specifier = ">=1.60.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "jsonschema", specifier = ">=4.20" }, { name = "keyring", specifier = ">=25.0" }, @@ -1191,15 +1321,18 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, { name = "pyperclip", specifier = ">=1.8.0" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "redis", marker = "extra == 'a2a-redis'", specifier = ">=5.0.0" }, { name = "rich", specifier = ">=13.0" }, + { name = "starlette", marker = "extra == 'a2a'", specifier = ">=0.39.0" }, { name = "starlette", marker = "extra == 'http'", specifier = ">=0.39.0" }, { name = "tiktoken", specifier = ">=0.7.0" }, { name = "tree-sitter", specifier = ">=0.23" }, { name = "tree-sitter-bash", specifier = ">=0.23" }, { name = "typer", specifier = ">=0.9.0" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'a2a'", specifier = ">=0.30.0" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'http'", specifier = ">=0.30.0" }, ] -provides-extras = ["http"] +provides-extras = ["http", "a2a", "a2a-signing", "a2a-grpc", "a2a-redis"] [package.metadata.requires-dev] dev = [ @@ -1211,6 +1344,7 @@ dev = [ { name = "pytest-xdist", specifier = ">=3.0" }, { name = "ruff", specifier = ">=0.4.0" }, { name = "setuptools", specifier = ">=68.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "ty", specifier = ">=0.0.34" }, { name = "wheel" }, ] @@ -1402,6 +1536,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a" }, ] +[[package]] +name = "json-rpc" +version = "1.15.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/6d/9e/59f4a5b7855ced7346ebf40a2e9a8942863f644378d956f68bcef2c88b90/json-rpc-1.15.0.tar.gz", hash = "sha256:e6441d56c1dcd54241c937d0a2dcd193bdf0bdc539b5316524713f554b7f85b9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/94/9e/820c4b086ad01ba7d77369fb8b11470a01fac9b4977f02e18659cf378b6b/json_rpc-1.15.0-py2.py3-none-any.whl", hash = "sha256:4a4668bbbe7116feb4abbd0f54e64a4adcf4b8f648f19ffa0848ad0f6606a9bf" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -1955,6 +2098,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237" }, ] +[[package]] +name = "proto-plus" +version = "1.28.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8" }, +] + [[package]] name = "protobuf" version = "6.33.6" @@ -1970,6 +2125,27 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901" }, ] +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2119,6 +2295,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" }, ] +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c" }, +] + [[package]] name = "pyperclip" version = "1.11.0" @@ -2282,6 +2470,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, ] +[[package]] +name = "redis" +version = "7.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec" }, +] + [[package]] name = "referencing" version = "0.37.0" @@ -2632,6 +2832,19 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973" }, +] + [[package]] name = "starlette" version = "1.0.0" diff --git a/website/docs/a2a/command-reference.md b/website/docs/a2a/command-reference.md new file mode 100644 index 00000000..f429e0f9 --- /dev/null +++ b/website/docs/a2a/command-reference.md @@ -0,0 +1,504 @@ +--- +title: Command Reference +description: Complete CLI command reference for running and calling iac-code over A2A. +sidebar_position: 3 +--- + +# A2A Command Reference + +This page documents every A2A-related `iac-code` command. Use it when you need exact option names, common command patterns, and the operational meaning of each flag. + +## Command Overview + +| Command | Purpose | +|---------|---------| +| `iac-code a2a` | Run iac-code as an A2A server | +| `iac-code a2a-client call` | Discover a remote Agent Card and send a prompt | +| `iac-code a2a-client discover` | Fetch and optionally verify an Agent Card | +| `iac-code a2a-client task-get` | Fetch one task by ID | +| `iac-code a2a-client task-list` | List tasks with filters and pagination | +| `iac-code a2a-client task-cancel` | Cancel an active task | +| `iac-code a2a-client task-subscribe` | Subscribe to an active task event stream | +| `iac-code a2a-client push-config-create` | Create a task push notification config | +| `iac-code a2a-client push-config-get` | Fetch one task push notification config | +| `iac-code a2a-client push-config-list` | List task push notification configs | +| `iac-code a2a-client push-config-delete` | Delete a task push notification config | +| `iac-code a2a-client extended-card` | Fetch the authenticated extended Agent Card | +| `iac-code a2a-route-preview` | Preview local route selection for `a2a-client call` | + +All HTTP client commands accept the same authentication options: + +| Option | Description | +|--------|-------------| +| `--token` | Bearer token sent as `Authorization: Bearer ` | +| `--basic-username` | Basic auth username | +| `--basic-password` | Basic auth password | +| `--api-key` | API key value | +| `--api-key-header` | API key header name; defaults to `X-API-Key` | + +## A2A Client Config + +All `a2a-client` subcommands accept a YAML config file at the group level: + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC" +``` + +CLI options override config values. Use config for stable connection, auth, verification, routing, and repeated task or push settings; keep one-off prompt text on the command line. + +```yaml +url: http://127.0.0.1:41242/ +token: your-bearer-token +basic-username: iac-code +basic-password: your-password +api-key: your-api-key +api-key-header: X-IAC-Code-Key +verify-card-secret: your-card-signing-secret +verify-card-jwks-url: https://a2a.example.com/.well-known/jwks.json +require-card-signature: true +timeout: 30 +cwd: /path/to/workspace +context-id: ctx-123 +task-id: task-123 +config-id: webhook-1 +callback-url: https://hooks.example.com/a2a +notification-token: notification-token +auth-scheme: bearer +auth-credentials: callback-token +routes: + - name: ros + url: http://127.0.0.1:41242/ + skills: + - iac_generation + tags: + - ros + - template +``` + +## `iac-code a2a` + +Run iac-code as an A2A server. + +```bash +iac-code a2a +``` + +By default, the server binds to `127.0.0.1:41242` and serves JSON-RPC over HTTP. Port `41242` is the iac-code default; it is not a registered A2A port. + +### Basic Server Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--config` | empty | YAML config file containing A2A server options | +| `--host` | `127.0.0.1` | HTTP server host | +| `--port` | `41242` | HTTP server port | +| `--transport` | `http` | Server transport: `http`, `stdio`, `unix`, `websocket`, `grpc`, `grpc-jsonrpc`, or `redis-streams` | +| `--debug`, `-d` | `false` | Enable debug logging | + +Example: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --debug +``` + +### YAML Configuration + +Use `--config` for authentication, storage, signing, transport-specific settings, push delivery, and other deployment details. Keys may use dashes or underscores. The common CLI flags `--host`, `--port`, and `--transport` override config-file values. + +```yaml +host: 127.0.0.1 +port: 41242 +transport: http +token: local-dev-token +persistence-dir: .iac-code-a2a/state +artifact-dir: .iac-code-a2a/artifacts +push-notifications: true +``` + +Run it with: + +```bash +iac-code a2a --config a2a-server.yml --port 41243 +``` + +### HTTP Authentication + +Authentication is optional. Configure server authentication in YAML or with environment variables. If no auth setting is configured, requests are unauthenticated. When one or more schemes are configured, a request may satisfy any configured scheme. + +| Config key | Environment Variable | Description | +|--------|----------------------|-------------| +| `token` | `IACCODE_A2A_HTTP_TOKEN` | Bearer token | +| `basic-username` | `IACCODE_A2A_BASIC_USERNAME` | Basic auth username | +| `basic-password` | `IACCODE_A2A_BASIC_PASSWORD` | Basic auth password | +| `api-key` | `IACCODE_A2A_API_KEY` | API key value | +| `api-key-header` | `IACCODE_A2A_API_KEY_HEADER` | API key header name | + +Bearer token: + +```yaml +token: local-dev-token +``` + +Basic auth: + +```yaml +basic-username: iac-code +basic-password: local-dev-password +``` + +API key: + +```yaml +api-key: local-dev-key +api-key-header: X-IAC-Code-Key +``` + +### Persistence and Artifacts + +| Config key | Default | Description | +|--------|---------|-------------| +| `persistence-dir` | `~/.iac-code/a2a` | Local JSON metadata for tasks, contexts, routes, and push configs | +| `artifact-dir` | `/artifacts` | Local artifact payload store | + +Persistence mirrors task and context snapshots for restoration metadata. It does not restart an in-flight asyncio task after a process crash. + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +``` + +### Agent Card Signing + +| Config key | Description | +|--------|-------------| +| `signing-secret` | HMAC secret used to sign the public Agent Card | + +The server emits A2A SDK `AgentCardSignature` JWS fields. The symmetric mode uses `HS256`. + +```yaml +signing-secret: local-card-signing-secret +``` + +### Push Notification Delivery + +| Config key | Default | Description | +|--------|---------|-------------| +| `push-notifications` | `false` | Enable A2A task push notification config methods and terminal-state delivery | +| `push-queue` | `local-file` | Push queue backend: `local-file` or `redis-streams` | +| `push-redis-url` | empty | Redis URL for the Redis-backed push queue | +| `push-stream` | `iac-code:a2a:push` | Redis stream for push jobs | +| `push-retry-key` | `iac-code:a2a:push:retry` | Redis sorted set for delayed retries | +| `push-dead-stream` | `iac-code:a2a:push:dead` | Redis stream for dead-letter jobs | +| `push-consumer-group` | `iac-code-push` | Redis consumer group for push workers | +| `push-consumer-name` | empty | Redis consumer name for this worker | +| `push-lease-timeout-ms` | `300000` | Redis pending lease timeout | + +Local file queue: + +```yaml +push-notifications: true +persistence-dir: ~/.iac-code/a2a +push-queue: local-file +``` + +Redis Streams queue: + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +``` + +Redis-backed push delivery requires the `a2a-redis` extra. + +### Transport Options + +| Transport | Command | Notes | +|-----------|---------|-------| +| HTTP JSON-RPC and REST | `iac-code a2a --transport http` | Default. Advertises `JSONRPC` and `HTTP+JSON` interfaces. | +| stdio | `iac-code a2a --transport stdio` | Experimental custom JSON-RPC frames over standard input/output. | +| Unix socket | `iac-code a2a --config a2a-server.yml --transport unix` | Requires `socket-path` in config. | +| WebSocket | `iac-code a2a --config a2a-server.yml --transport websocket` | Uses `ws-path` from config, defaulting to `/a2a`. | +| gRPC | `iac-code a2a --config a2a-server.yml --transport grpc` | Uses `grpc-host` and `grpc-port` from config. | +| gRPC JSON-RPC | `iac-code a2a --config a2a-server.yml --transport grpc-jsonrpc` | Custom JSON-RPC envelope over gRPC. | +| Redis Streams | `iac-code a2a --config a2a-server.yml --transport redis-streams` | Requires `redis-url` in config. | + +Redis Streams transport options: + +| Config key | Default | Description | +|--------|---------|-------------| +| `redis-url` | empty | Redis connection URL; required for `--transport redis-streams` | +| `request-stream` | `iac-code:a2a:requests` | Request stream name | +| `response-stream` | `iac-code:a2a:responses` | Response stream name | +| `consumer-group` | `iac-code` | Request stream consumer group | + +### Permission Behavior + +| Config key | Default | Description | +|--------|---------|-------------| +| `auto-approve-permissions` | `false` | Automatically approve tool permission requests raised during A2A turns | + +Without `auto-approve-permissions: true`, A2A mode rejects permission prompts and emits permission metadata. Use it only for trusted automation environments. + +## `iac-code a2a-client call` + +Discover an Agent Card, choose the advertised endpoint, and send a prompt. + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `--url` | empty | A2A agent base URL or JSON-RPC endpoint URL; may come from config | +| `--route` | repeatable | Route spec used when `--url` is omitted | +| `--route-name` | empty | Named route to select | +| `--prompt`, `-p` | required | Prompt text | +| `--cwd` | `.` | Workspace path sent as `message.metadata.iac_code.cwd` | +| `--context-id` | empty | Existing A2A context ID for a follow-up message | +| `--verify-card-secret`, `--signing-secret` | empty | HMAC secret for Agent Card verification | +| `--verify-card-jwks-url` | empty | Remote JWKS URL used for Agent Card verification | +| `--require-card-signature`, `--require-signature` | `false` | Reject unsigned or invalid Agent Cards | +| `--timeout` | `30.0` | Call timeout in seconds | +| `--stream` | `false` | Use `SendStreamingMessage` and print stream events | + +Follow-up in the same context: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --context-id ctx-123 \ + --prompt "Now add outputs for the VPC and vSwitch IDs." \ + --cwd "$PWD" +``` + +Streaming: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this Terraform module." \ + --cwd "$PWD" \ + --stream +``` + +Require a signed Agent Card: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a production VPC template." \ + --cwd "$PWD" +``` + +Verify using a remote JWKS URL: + +```bash +iac-code a2a-client --config jwks-client.yml call \ + --prompt "Review the ROS stack." +``` + +## `iac-code a2a-client discover` + +Fetch and print a remote Agent Card. + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +| Option | Description | +|--------|-------------| +| `--url` | A2A agent base URL; may come from config | +| `--verify-card-secret`, `--signing-secret` | HMAC secret for verification | +| `--verify-card-jwks-url` | Remote JWKS URL for verification | +| `--require-card-signature`, `--require-signature` | Require a valid signature | + +Authenticated discovery: + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +## Task Commands + +Task commands call JSON-RPC task methods directly. They are useful for operational tools, dashboards, and debugging. + +### `iac-code a2a-client task-get` + +```bash +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +| Option | Description | +|--------|-------------| +| `--url` | A2A JSON-RPC endpoint URL; may come from config | +| `--task-id` | Task ID; may come from config | +| `--history-length` | Maximum task history entries to return | + +### `iac-code a2a-client task-list` + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --context-id ctx-123 \ + --status TASK_STATE_INPUT_REQUIRED \ + --page-size 20 \ + --output table +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `--url` | empty | A2A JSON-RPC endpoint URL; may come from config | +| `--context-id` | empty | Filter by context ID | +| `--status` | empty | Filter by task state | +| `--page-size` | empty | Maximum tasks to return | +| `--page-token` | empty | Pagination token | +| `--include-artifacts` | `false` | Include task artifacts in the response | +| `--output` | `table` | `table` or `json` | + +JSON output: + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --include-artifacts \ + --output json +``` + +### `iac-code a2a-client task-cancel` + +```bash +iac-code a2a-client --config a2a-client.yml task-cancel \ + --task-id task-123 +``` + +Cancellation is cooperative. A completed, failed, canceled, or input-required task returns the standard A2A task-not-cancelable error. + +### `iac-code a2a-client task-subscribe` + +```bash +iac-code a2a-client --config a2a-client.yml task-subscribe \ + --task-id task-123 +``` + +The command streams events for active tasks. For a new turn, prefer `a2a-client call --stream`; it starts the task and streams updates in one command. + +## Push Notification Config Commands + +These commands require a server started with `push-notifications: true`. They manage standard A2A task push notification configs. + +### `iac-code a2a-client push-config-create` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +| Option | Description | +|--------|-------------| +| `--url` | A2A JSON-RPC endpoint URL; may come from config | +| `--task-id` | Task ID; may come from config | +| `--config-id` | Push config ID; may come from config | +| `--callback-url` | HTTP(S) callback URL; may come from config | +| `--notification-token` | Token sent as `X-A2A-Notification-Token` | +| `--auth-scheme` | Callback auth scheme, such as `bearer` or `basic` | +| `--auth-credentials` | Callback auth credentials | + +Callback URLs are validated before storage and dispatch. The default validator rejects non-HTTP(S) URLs, localhost names, and literal private/local IP addresses. + +### `iac-code a2a-client push-config-get` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-get \ + --task-id task-123 \ + --config-id webhook-1 +``` + +### `iac-code a2a-client push-config-list` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-list \ + --task-id task-123 \ + --page-size 10 +``` + +### `iac-code a2a-client push-config-delete` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-delete \ + --task-id task-123 \ + --config-id webhook-1 +``` + +## `iac-code a2a-client extended-card` + +Fetch the authenticated extended Agent Card. + +```bash +iac-code a2a-client --config a2a-client.yml extended-card \ + --token "$A2A_TOKEN" +``` + +The public Agent Card advertises `capabilities.extendedAgentCard=true`. The extended card adds authenticated runtime details, including task management and push configuration capability metadata. + +## `iac-code a2a-route-preview` + +Preview how `a2a-client call` resolves configured routes when `--url` is omitted. + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +| Option | Description | +|--------|-------------| +| `--route` | Repeatable route spec in `name=url;skills=a,b;tags=x,y` format | +| `--name` | Route name to resolve | +| `--skill` | Skill ID to resolve | +| `--prompt` | Prompt text used for name/tag matching | +| `--route-state-dir`, `--persistence-dir` | Directory used to persist route snapshots | +| `--save-routes` | Save provided routes to the route state directory | + +Save route snapshots: + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-state-dir ~/.iac-code/a2a \ + --save-routes +``` + +Call through routes: + +```bash +iac-code a2a-client call \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-name ros \ + --prompt "Create a ROS VPC template." \ + --cwd "$PWD" +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `IACCODE_A2A_HTTP_TOKEN` | Server/client Bearer token default | +| `IACCODE_A2A_BASIC_USERNAME` | Server/client Basic auth username default | +| `IACCODE_A2A_BASIC_PASSWORD` | Server/client Basic auth password default | +| `IACCODE_A2A_API_KEY` | Server/client API key default | +| `IACCODE_A2A_API_KEY_HEADER` | API key header name default | +| `IACCODE_A2A_ALLOWED_CWDS` | OS-path-separated list of allowed workspace roots for incoming message metadata and file URLs | +| `IACCODE_A2A_TEXT_MIME_TYPES` | Extra comma- or semicolon-separated text-like MIME types | +| `IACCODE_A2A_MULTIMODAL_MIME_TYPES` | Extra comma- or semicolon-separated multimodal MIME types | +| `IAC_CODE_A2A_PUSH_KEYRING` | Environment-managed encrypted push secret keyring | diff --git a/website/docs/a2a/examples.md b/website/docs/a2a/examples.md new file mode 100644 index 00000000..c11e69fe --- /dev/null +++ b/website/docs/a2a/examples.md @@ -0,0 +1,388 @@ +--- +title: Examples +description: Practical examples for integrating with the iac-code A2A server. +sidebar_position: 6 +--- + +# Examples + +This page provides ready-to-use A2A integration examples. + +## Prerequisites + +The examples assume: + +| Dependency | Version | Purpose | +|------------|---------|---------| +| Python | `3.12` | Matches the project runtime | +| `a2a-sdk` | `>=1.0.2,<2` | A2A client and protobuf types | +| `httpx` | `>=0.27.0` | HTTP client used by the SDK and direct examples | +| `iac-code` | current repo | Provides the `iac-code a2a` subcommand | + +Start the server: + +```bash +uv sync --extra a2a +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +## Python SDK — Streaming Session + +This example discovers the Agent Card, sends a message, prints assistant text chunks, and reports tool metadata. + +```python +"""Streaming iac-code A2A session using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path +from typing import Any + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest +from google.protobuf.json_format import MessageToDict + + +def metadata_to_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + return MessageToDict(value, preserving_proto_field_name=False) + + +async def main() -> None: + headers = {} + # For authenticated servers: + # headers["Authorization"] = "Bearer YOUR_TOKEN" + + async with httpx.AsyncClient(headers=headers, timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Create a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("task"): + print(f"[task] {event.task.id} context={event.task.context_id}") + + elif event.HasField("status_update"): + update = event.status_update + status = update.status + + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + metadata = metadata_to_dict(update.metadata) + tool = metadata.get("iac_code", {}).get("tool") + if tool: + print(f"\n[tool] {tool.get('status')} {tool.get('name', '')}".rstrip()) + + usage = metadata.get("iac_code", {}).get("usage") + if usage: + print(f"\n[usage] {usage['totalTokens']} total tokens") + + if status.state == "TASK_STATE_INPUT_REQUIRED": + print("\n[done]") + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## CLI — End-to-End Workflow + +Start a local server with persistence, artifacts, push notification support, and a signed Agent Card: + +```yaml +host: 127.0.0.1 +port: 41242 +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +```bash +iac-code a2a --config a2a-server.yml +``` + +Create a client config for the stable endpoint and card verification settings: + +```yaml +url: http://127.0.0.1:41242/ +verify-card-secret: local-card-signing-secret +require-card-signature: true +``` + +Discover and verify the Agent Card: + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +Send a streaming request: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" \ + --stream +``` + +List tasks and fetch one task as JSON: + +```bash +iac-code a2a-client --config a2a-client.yml task-list --output table + +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +Register a push callback for a task: + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +Preview route selection before calling a routed agent: + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +## Python SDK — Follow-up Message + +Follow-up messages reuse the same `context_id` and usually the same task ID. This keeps the internal iac-code runtime and conversation history alive. + +```python +import uuid +from pathlib import Path + +from a2a.types import Message, Part, Role, SendMessageRequest + + +def build_follow_up(task_id: str, context_id: str, text: str) -> SendMessageRequest: + return SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + task_id=task_id, + context_id=context_id, + role=Role.ROLE_USER, + parts=[Part(text=text)], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + +# Usage inside an async function: +# async for event in client.send_message( +# build_follow_up(task_id, context_id, "Add outputs for VPC and VSwitch IDs.") +# ): +# ... +``` + +The server rejects a reused `contextId` if the new message points at a different workspace. + +## Python SDK — Cancel a Task + +```python +from a2a.types import CancelTaskRequest + + +async def cancel_running_task(client, task_id: str) -> None: + task = await client.cancel_task(CancelTaskRequest(id=task_id)) + print(f"{task.id}: {task.status.state}") +``` + +## Direct HTTP — Minimal JSON-RPC Client + +Use this when you do not want the SDK dependency in the caller. + +```python +"""Direct A2A JSON-RPC client using httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=120.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template for missing parameters."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Direct HTTP — Streaming SSE + +```python +"""Read SendStreamingMessage events directly with httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=None) as client: + async with client.stream( + "POST", + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Generate a Terraform VPC example."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if line.startswith("data:"): + print(line.removeprefix("data:").strip()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Direct HTTP — Push Notification Config + +The push config methods are available when the server runs with `push-notifications: true`. + +```python +"""Create an A2A task push notification config.""" + +import asyncio + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "push-1", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-123", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token", + }, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Handling iac-code Metadata + +Tool and usage events arrive in `TaskStatusUpdateEvent.metadata.iac_code`. + +```python +from typing import Any + + +def handle_iac_metadata(metadata: dict[str, Any]) -> None: + iac = metadata.get("iac_code", {}) + + if tool := iac.get("tool"): + status = tool.get("status") + tool_id = tool.get("toolUseId") + name = tool.get("name", "") + print(f"tool={name} id={tool_id} status={status}") + + if permission := iac.get("permission"): + if permission.get("autoApproved") is False: + print(f"permission rejected for {permission.get('toolName')}") + + if usage := iac.get("usage"): + print( + "tokens=" + f"{usage.get('inputTokens', 0)}+{usage.get('outputTokens', 0)}" + f"={usage.get('totalTokens', 0)}" + ) +``` + +## Common Pitfalls + +| Symptom | Fix | +|---------|-----| +| HTTP `401` | Include a configured auth scheme, such as `Authorization: Bearer `, Basic auth, or `X-API-Key: `, on both Agent Card and JSON-RPC requests | +| `Invalid A2A workspace metadata.` | Use an existing absolute path in `metadata.iac_code.cwd` | +| `A2A server currently accepts text input only.` | Send at least one non-empty text part | +| `Task is already working.` | Wait for the current turn to finish before sending another message in the same context | +| Follow-up rejected as different workspace | Keep `metadata.iac_code.cwd` unchanged for a reused `contextId` | +| Local file URL rejected | Keep `file://` parts inside `metadata.iac_code.cwd` and inside `IACCODE_A2A_ALLOWED_CWDS` | +| Push callback rejected | Use an HTTP(S) callback URL that is not localhost or a literal private/local IP address | +| Redis push queue fails to start | Install the `a2a-redis` extra and provide `push-redis-url` in the A2A config | diff --git a/website/docs/a2a/getting-started.md b/website/docs/a2a/getting-started.md new file mode 100644 index 00000000..2b3a508e --- /dev/null +++ b/website/docs/a2a/getting-started.md @@ -0,0 +1,291 @@ +--- +sidebar_position: 2 +title: Getting Started +description: Start the A2A server and send your first message. +--- + +# Getting Started with A2A + +## Prerequisites + +1. **iac-code installed** — See the [Installation](/docs/getting-started/installation) guide. + +2. **LLM credentials configured** — See the [Authentication](/docs/configuration/authentication) guide to configure your model provider credentials. + +3. **A2A server dependencies** — Install iac-code with the `a2a` extra: + +```bash +uv sync --extra a2a +``` + +## Starting the A2A Server + +Start the server on the default local interface: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +Use a YAML config file when you need local state, artifact storage, push notification delivery, or signed Agent Cards: + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +Run it with: + +```bash +iac-code a2a --config a2a-server.yml +``` + +`push-notifications: true` enables A2A task push notification config methods and terminal-state delivery. Use `push-queue: redis-streams` with `push-redis-url` when multiple workers need to coordinate push delivery. + +The server exposes: + +| Route | Purpose | +|-------|---------| +| `GET /health` | Health check | +| `GET /.well-known/agent-card.json` | Agent Card discovery | +| `POST /` | A2A JSON-RPC endpoint | + +The HTTP server also registers the A2A SDK REST routes and advertises both `JSONRPC` and `HTTP+JSON` interfaces in the Agent Card. + +## Verify Discovery + +Fetch the Agent Card: + +```text +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +You should see `name: "iac-code"`, `JSONRPC` and `HTTP+JSON` interfaces, cache headers such as `ETag`, the optional `urn:iac-code:a2a:artifact-metadata:v1` extension, supported input modes, and skills such as `iac_generation`, `iac_review`, `aliyun_ros_operations`, and `terraform_ros_conversion`. + +Check the health endpoint: + +```bash +curl http://127.0.0.1:41242/health +``` + +Expected response: + +```json +{"status":"healthy"} +``` + +## Require Authentication + +Authentication is optional. If no A2A authentication options or environment variables are set, requests do not need auth. When any auth scheme is configured, every request, including Agent Card discovery, must satisfy one configured scheme. + +### Bearer Token + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +The equivalent YAML config key is `token`. + +```text +Authorization: Bearer +``` + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +The username and password must both be present. The equivalent YAML config keys are `basic-username` and `basic-password`. + +### API Key + +```bash +export IACCODE_A2A_API_KEY=your-api-key + +iac-code a2a +``` + +The default API key header is: + +```text +X-API-Key: +``` + +Override it with the `api-key-header` YAML config key or `IACCODE_A2A_API_KEY_HEADER`: + +```yaml +api-key: your-api-key +api-key-header: X-IAC-Code-Key +``` + +## Call a Remote A2A Agent + +Put stable client connection and auth settings in a YAML file: + +```yaml +url: http://127.0.0.1:41242/ +token: your-secret-token +verify-card-secret: your-card-signing-secret +require-card-signature: true +cwd: /path/to/workspace +``` + +Use `a2a-client call` for a direct Phase 1 client call: + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC with two vSwitches" --cwd "$PWD" +``` + +Use `--stream` when you want incremental events instead of one final response: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this template" \ + --cwd "$PWD" \ + --stream +``` + +Command-line options override config values when you need a one-off target or token: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --url https://other-agent.example.com/ \ + --prompt "Review this template" +``` + +For multi-agent routing, preview route selection before calling: + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --route-state-dir ~/.iac-code/a2a +``` + +See [Command Reference](./command-reference.md) for every A2A command, including task management, push config CRUD, extended Agent Cards, and transport options. + +## Send a First Message with curl + +Pass the workspace directory through `message.metadata.iac_code.cwd`; the path must be absolute, must already exist, and must be inside an allowed workspace root. By default, allowed roots are the server process directory and the system temp directory. Override them with `IACCODE_A2A_ALLOWED_CWDS`. + +The server accepts text-like parts, JSON data parts, raw UTF-8 text, local workspace `file://` text files, and bounded multimodal attachments. Remote URL ingestion is not supported; `url` parts must be local `file://` URLs inside the allowed workspace. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [ + {"text": "Generate a ROS VPC template with two vSwitches."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +For streaming output, use `SendStreamingMessage`: + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [ + {"text": "Review my Terraform files and suggest ROS equivalents."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## Minimal Python SDK Example + +The example below uses `a2a-sdk>=1.0.2,<2`, which is the version range used by the `a2a` extra. + +```python +"""Minimal iac-code A2A client using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest + + +async def main() -> None: + async with httpx.AsyncClient(timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Generate a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("status_update"): + status = event.status_update.status + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +:::tip +For authenticated servers, construct the `httpx.AsyncClient` with `headers={"Authorization": "Bearer "}` so both Agent Card discovery and JSON-RPC calls include the token. +::: + +## Next Steps + +- [Command Reference](./command-reference.md) — Complete CLI command and option reference. +- [Protocol Reference](./protocol-reference.md) — Method, route, state, and metadata details. +- [HTTP Transport](./http-transport.md) — JSON-RPC HTTP behavior, bearer auth, and curl workflows. +- [Examples](./examples.md) — SDK, direct HTTP, follow-up, cancellation, and metadata handling examples. diff --git a/website/docs/a2a/http-transport.md b/website/docs/a2a/http-transport.md new file mode 100644 index 00000000..e82c1eae --- /dev/null +++ b/website/docs/a2a/http-transport.md @@ -0,0 +1,269 @@ +--- +title: HTTP Transport +description: Run and call the iac-code A2A server over JSON-RPC HTTP. +sidebar_position: 5 +--- + +# HTTP Transport + +iac-code's default A2A server exposes JSON-RPC over HTTP, plus the A2A SDK REST routes. The server is built with Starlette and runs on Uvicorn. + +## Starting the Server + +```bash +# Default host and port +iac-code a2a + +# Explicit host and port +iac-code a2a --host 127.0.0.1 --port 41242 + +# Listen on all interfaces +iac-code a2a --host 0.0.0.0 --port 41242 +``` + +Install the optional server dependencies first: + +```bash +uv sync --extra a2a +``` + +## Endpoint Summary + +| Route | Method | Response | +|-------|--------|----------| +| `/health` | `GET` | Plain JSON health response | +| `/.well-known/agent-card.json` | `GET` | Agent Card JSON | +| `/` | `POST` | JSON-RPC response or SSE stream | +| SDK REST routes | mixed | A2A REST endpoints registered by the SDK | + +## Headers + +Recommended headers: + +```text +Content-Type: application/json +A2A-Version: 1.0 +``` + +When Bearer auth is enabled: + +```text +Authorization: Bearer +``` + +## Authentication + +The server supports optional Bearer token, Basic auth, and API key authentication. If no authentication options or environment variables are set, requests do not need auth. If one or more schemes are configured, a request can authenticate with any configured scheme. + +### Bearer Token + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +You can also set `token` in the A2A YAML config file. + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +Both username and password must be set for Basic auth to be enabled. + +### API Key + +```bash +export IACCODE_A2A_API_KEY=your-api-key +iac-code a2a +``` + +The default API key header is `X-API-Key`. You can change it in YAML: + +```yaml +api-key: ${IACCODE_A2A_API_KEY} +api-key-header: X-IAC-Code-Key +``` + +or with `IACCODE_A2A_API_KEY_HEADER`. + +| Scenario | Behavior | +|----------|----------| +| No auth scheme configured | No authentication required | +| One or more schemes configured, any one matches | Request proceeds | +| One or more schemes configured, no scheme matches | HTTP `401` with `{"error":"Unauthorized"}` | + +## Agent Card Discovery + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +Authenticated: + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "Authorization: Bearer $IACCODE_A2A_HTTP_TOKEN" +``` + +With API key authentication: + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "X-API-Key: $IACCODE_A2A_API_KEY" +``` + +The JSON-RPC endpoint URL is advertised in `supportedInterfaces[0].url`. HTTP mode also advertises an `HTTP+JSON` interface for REST-capable clients. + +## Non-streaming Message + +`SendMessage` returns a single JSON-RPC response after the agent turn finishes. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a Terraform VPC module for Alibaba Cloud."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## Streaming Message + +`SendStreamingMessage` returns Server-Sent Events. Use `curl -N` to print events as they arrive. + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Generate a ROS template for one VPC and two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +Each SSE `data:` line contains one JSON-RPC response whose `result` is an A2A `StreamResponse`. + +## Follow-up Message + +Use the `taskId` and `contextId` returned by the first response to continue the same conversation. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-2", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-3", + "taskId": "task-id-from-first-response", + "contextId": "context-id-from-first-response", + "role": "ROLE_USER", + "parts": [{"text": "Now add tags for environment and owner."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +The workspace must remain the same for the reused `contextId`. + +## Cancel a Running Task + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "cancel-1", + "method": "CancelTask", + "params": { + "id": "task-id" + } + }' +``` + +Cancellation is cooperative: iac-code cancels the active agent turn, emits a canceled state, and releases the context lock. Canceling an existing task that is no longer running returns the standard A2A `TaskNotCancelableError`. + +## CLI Equivalents + +Most HTTP workflows have a matching CLI command: + +```yaml +url: http://127.0.0.1:41242/ +``` + +```bash +# Discover the Agent Card +iac-code a2a-client --config a2a-client.yml discover + +# Send a non-streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a Terraform VPC module for Alibaba Cloud." \ + --cwd "$PWD" + +# Send a streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a ROS template for one VPC and two vSwitches." \ + --cwd "$PWD" \ + --stream + +# Inspect task state +iac-code a2a-client --config a2a-client.yml task-get --task-id task-id +iac-code a2a-client --config a2a-client.yml task-list --output table + +# Cancel an active task +iac-code a2a-client --config a2a-client.yml task-cancel --task-id task-id +``` + +For the full option list, see [Command Reference](./command-reference.md). + +## Operational Notes + +- Bind to `127.0.0.1` for local-only usage. +- Use `token` in the A2A config or `IACCODE_A2A_HTTP_TOKEN` before binding to a shared network interface. +- A2A mode rejects tool permission requests automatically; protect unauthenticated endpoints like local automation services. +- Active runtime state is in memory. Persistence mirrors task and context metadata, but restarting the process does not resume in-flight asyncio work. +- One context can run only one task at a time; separate contexts can run concurrently. diff --git a/website/docs/a2a/overview.md b/website/docs/a2a/overview.md new file mode 100644 index 00000000..20ee6188 --- /dev/null +++ b/website/docs/a2a/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: A2A Protocol +description: Overview of Agent2Agent support in iac-code. +--- + +# A2A Protocol + +## What is A2A + +[Agent2Agent (A2A)](https://github.com/a2aproject/A2A) is a protocol for discovering and calling remote agents. It lets an agent publish an Agent Card, accept structured messages, stream task updates, and expose cancellation and task lookup operations through standard transports. + +## iac-code as an A2A Server + +iac-code can run as an A2A 1.0 Server / Agent. Other A2A-compatible clients can discover it, send Infrastructure as Code requests, stream execution updates, and cancel active tasks. + +Use A2A when another agent, workflow engine, or service needs to call iac-code as an interoperable IaC specialist. Use ACP when an editor-style client needs session management, permission prompts, and local development integration. + +## Use Cases + +- **Agent orchestration** — A planner agent can delegate Alibaba Cloud ROS or Terraform work to iac-code. +- **Workflow automation** — Internal tools can submit IaC generation, review, or conversion tasks over HTTP. +- **Service discovery** — Clients can fetch the Agent Card and choose capabilities such as IaC generation or template review. +- **Streaming integrations** — A chatops or dashboard client can show model text, tool activity, usage metadata, and final task state as the turn runs. + +## Interaction Modes Comparison + +| Mode | Command | Best For | +|------|---------|----------| +| **Interactive REPL** | `iac-code` | Hands-on exploration and iterative template authoring | +| **Non-interactive CLI** | `iac-code --prompt "..."` or `--headless` | One-shot scripting and CI jobs | +| **ACP Server** | `iac-code acp` | IDE/editor integration and multi-session client control | +| **A2A Server** | `iac-code a2a` | Agent-to-agent interoperability over A2A transports | +| **A2A Client** | `iac-code a2a-client call` | Calling remote A2A agents from iac-code | + +## Core Capabilities + +- **Agent Card discovery** — Publishes `/.well-known/agent-card.json` with protocol binding, version, skills, input/output modes, and optional auth metadata. +- **HTTP JSON-RPC and REST** — Serves A2A JSON-RPC requests at `/` and registers the SDK REST routes. +- **Streaming responses** — Supports `SendStreamingMessage` for incremental task updates. +- **Task management** — Supports task lookup, authenticated task listing with cursor pagination, active task cancellation, and active task subscription. +- **Context reuse** — Reuses an iac-code runtime for follow-up messages in the same A2A `contextId`. +- **Workspace scoping** — Reads the project directory from message metadata at `iac_code.cwd`. +- **Tool metadata** — Emits iac-code-specific metadata for tool starts, input deltas, completed tool results, permission decisions, and token usage. +- **Input parts** — Accepts text-like parts, JSON data parts, raw UTF-8 text, local workspace `file://` text files, and bounded multimodal attachments represented as prompt manifests. +- **Client calls** — Discovers remote Agent Cards, verifies signatures when configured, and sends text prompts to remote agents. +- **Routing** — Selects configured remote agents by explicit name, skill, or prompt/tag matching. +- **Persistence metadata** — Mirrors local A2A task/context snapshots to JSON files for cross-process restoration metadata. +- **Artifacts** — Stores supported local text artifact payloads outside the streamed event body, emits standard `TaskArtifactUpdateEvent` events, and records task `artifacts`. +- **Extensions and caching** — Advertises the optional iac-code artifact metadata extension, validates required `A2A-Extensions`, and serves Agent Cards with cache headers. +- **Push notifications** — Supports A2A task push notification config methods when `push-notifications: true` is configured, with local-file or Redis-backed delivery queues. +- **Agent Card signing** — Adds optional A2A SDK JWS signatures for Agent Cards and supports `kid`-based verification with configured keys, local octet JWKS data, or a remote JWKS URL. +- **Multiple transports** — Runs over HTTP, stdio, Unix sockets, WebSocket, official gRPC, custom gRPC JSON-RPC, and Redis Streams transports. +- **CLI operations** — Provides commands for discovery, message sending, task lookup/list/cancel/subscribe, push config CRUD, extended cards, and route previews. + +## Phase 1 Support + +iac-code supports A2A server mode over HTTP JSON-RPC/REST and several optional transports, plus Phase 1 client mode for calling remote A2A agents. It can discover remote Agent Cards, select advertised endpoints, send A2A 1.0 prompts, query/list/cancel/subscribe to tasks, route to configured agents, persist local task/context restoration metadata, store local artifact payloads as standard task artifacts, validate required extensions, manage push notification configs, and sign or verify Agent Cards with HMAC or JWKS metadata. + +## Phase 1 Unsupported + +- stdio, Unix sockets, WebSocket, gRPC JSON-RPC envelope, and Redis Streams are experimental custom JSON-RPC transports. +- Official gRPC requires optional dependencies and uses an insecure local server binding by default. +- No distributed or shared task store. Persistence is local file storage under the iac-code runtime configuration area. +- No restoration of an in-flight asyncio task after process restart. +- No automatic background continuation of interrupted remote tasks. +- No OSS, S3, database, or external object-store artifact backend. +- No remote HTTP URL ingestion, large binary chunking, or resumable upload protocol. Local file URL parts must stay inside the allowed workspace roots. +- No default hard failure for unsigned Agent Cards. +- No asymmetric Agent Card signing from the server and no automatic signing key rotation. +- No autonomous planner DAG or complex multi-agent orchestration. +- Push delivery is at-least-once for Redis-backed queues; callback receivers must handle duplicates and enforce their own endpoint-side authorization policy. + +Tool permission requests are rejected automatically in A2A server mode. Run unauthenticated A2A mode only in trusted local environments or protect it with Bearer token, Basic auth, or API key authentication. diff --git a/website/docs/a2a/protocol-reference.md b/website/docs/a2a/protocol-reference.md new file mode 100644 index 00000000..2199b46f --- /dev/null +++ b/website/docs/a2a/protocol-reference.md @@ -0,0 +1,400 @@ +--- +title: Protocol Reference +description: Complete A2A protocol reference for iac-code integration. +sidebar_position: 4 +--- + +# Protocol Reference + +This document describes the A2A 1.0 surface exposed by the iac-code server and the Phase 1 client behavior used by `iac-code a2a-client call`. For exact CLI options, see [Command Reference](./command-reference.md). + +## Lifecycle Overview + +A typical A2A interaction follows this flow: + +```text +GET Agent Card -> SendMessage or SendStreamingMessage -> GetTask / follow-up / CancelTask +``` + +1. **Discover** — Fetch `/.well-known/agent-card.json`. +2. **Send** — Submit a text message to the JSON-RPC endpoint at `/`. +3. **Stream** — Receive `Task`, `Message`, and `TaskStatusUpdateEvent` payloads. +4. **Continue** — Send a follow-up message with the same `contextId`. +5. **Cancel or query** — Use `CancelTask`, `GetTask`, or `ListTasks`. + +## Agent Card + +The Agent Card is available at: + +```text +GET /.well-known/agent-card.json +``` + +Important fields: + +| Field | Value | Meaning | +|-------|-------|---------| +| `name` | `iac-code` | Agent name | +| `supportedInterfaces[0].protocolBinding` | `JSONRPC` | Transport binding | +| `supportedInterfaces[0].protocolVersion` | `1.0` | A2A protocol version | +| `supportedInterfaces[0].url` | `http://:/` | JSON-RPC endpoint | +| `capabilities.streaming` | `true` | Supports streaming task updates | +| `capabilities.pushNotifications` | `false` or `true` | `true` when `push-notifications: true` is configured | +| `capabilities.extendedAgentCard` | `true` | Authenticated callers can request extended runtime details | +| `capabilities.extensions` | `urn:iac-code:a2a:artifact-metadata:v1` | Optional iac-code metadata namespace for tool status and stored artifact metadata | +| `defaultInputModes` | text, JSON, YAML, image, audio, and binary MIME types | Accepted input MIME modes | +| `defaultOutputModes` | `["text/plain"]` | Text output only | + +Agent Card responses include `Cache-Control: public, max-age=60`, `ETag`, and `Last-Modified`. Clients may send `If-None-Match` and receive `304 Not Modified` when the card has not changed. + +Advertised skills: + +| Skill ID | Purpose | +|----------|---------| +| `iac_generation` | Generate Alibaba Cloud ROS and Terraform templates from natural language | +| `iac_review` | Inspect IaC templates and suggest fixes | +| `aliyun_ros_operations` | Assist with Alibaba Cloud ROS stack workflows | +| `terraform_ros_conversion` | Assist Terraform-to-ROS conversion using bundled skill resources | + +When authentication is enabled, the Agent Card advertises the configured security schemes: + +| Scheme | When advertised | +|--------|-----------------| +| `bearerAuth` | `token` or `IACCODE_A2A_HTTP_TOKEN` is set | +| `basicAuth` | Basic username and password are both set | +| `apiKeyAuth` | `api-key` or `IACCODE_A2A_API_KEY` is set | + +## Routes + +| Route | Method | Description | +|-------|--------|-------------| +| `/health` | `GET` | Returns `{"status":"healthy"}` | +| `/.well-known/agent-card.json` | `GET` | Returns the Agent Card | +| `/` | `POST` | Handles A2A JSON-RPC requests | +| REST routes | mixed | The A2A SDK REST routes registered by `create_rest_routes` | + +## Phase 1 Client and Transport Notes + +The default interoperable Phase 1 transport is JSON-RPC over HTTP. HTTP mode also advertises `HTTP+JSON` for the SDK REST routes. + +The server also has optional transports for stdio, Unix sockets, WebSocket, official gRPC, gRPC JSON-RPC envelope, and Redis Streams. stdio, Unix sockets, WebSocket, gRPC JSON-RPC, and Redis Streams are custom JSON-RPC transports. Official gRPC is advertised as `grpc` and requires optional gRPC dependencies. + +The built-in client uses Agent Card discovery (`GET /.well-known/agent-card.json`) before message calls, selects the first advertised runnable `supportedInterfaces[].url`, then sends JSON-RPC requests with `A2A-Version: 1.0` and A2A 1.0 method names such as `SendMessage`. + +`push-notifications: true` enables A2A push notification configuration methods and terminal-state delivery. + +Agent Card signing uses the A2A SDK signing utility and emits standard `AgentCardSignature` JWS fields. The symmetric-key mode uses `HS256`; verification can select a configured secret by protected-header `kid`, a local octet-key JWKS, or a remote JWKS URL. Server-side asymmetric signing and automatic key rotation are not implemented in Phase 1. + +For the canonical list of unsupported Phase 1 behavior, see [A2A Protocol](./overview.md#phase-1-unsupported). + +## Push Notification Delivery Backends + +`iac-code a2a --config a2a-server.yml` supports two push delivery queues: + +- `push-queue: local-file` stores jobs below the A2A persistence directory and is intended for local single-node use. +- `push-queue: redis-streams` stores jobs in Redis Streams and coordinates workers through a Redis consumer group. + +Redis-backed push delivery requires the optional `a2a-redis` extra and is at-least-once. Callback receivers should handle task updates idempotently because a job can be delivered again after worker crashes, lease expiry, reconnects, or retry races. + +Common Redis options: + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +push-lease-timeout-ms: 300000 +``` + +Callback URLs are validated before storage and again before dispatch. The default validator rejects non-HTTP(S) URLs, localhost hostnames, and literal private/local IP addresses. Callback receivers should still enforce their own authentication and idempotency policy. + +## JSON-RPC Methods + +### SendMessage + +Runs a non-streaming A2A message turn. The response contains a task or message after the turn has completed. + +**Request** + +```json +{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a VPC with two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +**Required message fields** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `messageId` | string | Yes | Unique client message ID | +| `role` | string | Yes | Use `ROLE_USER` for user input | +| `parts` | array | Yes | Text-like, JSON data, raw text, local file URL, or bounded multimodal parts | +| `metadata.iac_code.cwd` | string | Recommended | Absolute workspace path; defaults to the server process directory if omitted | + +`metadata.iac_code.cwd` must be an existing absolute directory when provided. It must be inside an allowed workspace root. By default, allowed roots are the server process directory and the system temp directory; `IACCODE_A2A_ALLOWED_CWDS` can provide an OS-path-separated allowlist. + +Supported input categories: + +| Category | Accepted Shape | Limits and Behavior | +|----------|----------------|---------------------| +| Text-like parts | `text` with `text/plain`, JSON, Markdown, YAML, or configured extra text MIME types | Appended directly to the prompt | +| JSON data parts | `data` with `application/json` | Serialized into compact JSON; max 1 MiB inline | +| Raw text parts | `raw` with a text-like MIME type | Must be valid UTF-8; max 1 MiB inline | +| Local text file URLs | `url` with `file://...` and text-like MIME type | File must exist inside `cwd` and allowed roots; max 1 MiB | +| Multimodal raw/data/file parts | image, audio, or configured multimodal MIME types | Converted into a prompt manifest with filename, media type, byte size, hash, and source; raw/data max 5 MiB, file URL max 25 MiB | + +Remote HTTP(S) URL ingestion is not supported. File URL parts must use local `file://` URLs and remain inside the allowed workspace. + +### SendStreamingMessage + +Runs a streaming A2A message turn. The request body has the same shape as `SendMessage`, but the server streams JSON-RPC responses as Server-Sent Events. + +```json +{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +### GetTask + +Returns the saved A2A task by ID. Use `historyLength` to limit returned history without mutating stored task history. Omit it to receive the server's current default history. + +```json +{ + "jsonrpc": "2.0", + "id": "3", + "method": "GetTask", + "params": { + "id": "task-id", + "historyLength": 10 + } +} +``` + +### ListTasks + +Returns known tasks visible to the authenticated caller. Results are sorted by status timestamp descending, then task ID descending for stable ordering. The server supports `contextId`, `status`, `pageSize`, `pageToken`, `historyLength`, and `includeArtifacts`. + +```json +{ + "jsonrpc": "2.0", + "id": "4", + "method": "ListTasks", + "params": { + "contextId": "ctx-id", + "status": "TASK_STATE_WORKING", + "pageSize": 20, + "includeArtifacts": false + } +} +``` + +`nextPageToken` is returned when another page is available. `includeArtifacts` defaults to `false`, so list responses omit task artifacts unless explicitly requested. + +### CancelTask + +Requests cancellation for a running task. + +```json +{ + "jsonrpc": "2.0", + "id": "5", + "method": "CancelTask", + "params": { + "id": "task-id" + } +} +``` + +If the task is active, the server cancels the running agent turn and emits a canceled task state. If the task exists but is not running, the server returns the standard A2A `TaskNotCancelableError`. + +### SubscribeToTask + +Subscribes to an active task update stream when supported by the client transport. + +```json +{ + "jsonrpc": "2.0", + "id": "6", + "method": "SubscribeToTask", + "params": { + "id": "task-id" + } +} +``` + +For active tasks, the stream starts with the current `Task`, then emits subsequent task events and closes when the active turn finishes. Subscribing to a completed, failed, canceled, or input-required task returns a task-not-found style error instead of waiting indefinitely. For new turns, prefer `SendStreamingMessage`; it starts execution and streams the response in one request. + +### Push Notification Config Methods + +When the server starts with `push-notifications: true`, it supports: + +| Method | Purpose | +|--------|---------| +| `CreateTaskPushNotificationConfig` | Store a callback config for a task | +| `GetTaskPushNotificationConfig` | Fetch one callback config | +| `ListTaskPushNotificationConfigs` | List callback configs for a task | +| `DeleteTaskPushNotificationConfig` | Delete a callback config | + +Example create request: + +```json +{ + "jsonrpc": "2.0", + "id": "7", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-id", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token" + } + } +} +``` + +The server encrypts stored notification tokens and callback authentication credentials when the local push keyring is available. + +### GetExtendedAgentCard + +Authenticated clients can request the extended Agent Card: + +```json +{ + "jsonrpc": "2.0", + "id": "8", + "method": "GetExtendedAgentCard", + "params": {} +} +``` + +The extended card includes the public card plus authenticated runtime details. + +## Task and Context Behavior + +iac-code maps A2A contexts to internal agent runtimes: + +| Concept | Behavior | +|---------|----------| +| `contextId` omitted | The SDK/server generates a new context ID | +| Same `contextId` | Reuses the same internal iac-code session and conversation state | +| Same `contextId`, different `cwd` | Rejected as a different workspace | +| Same `contextId`, concurrent message | Rejected with `Task is already working.` | +| Different `contextId` values | Can execute concurrently | +| Idle context | Evicted from memory after the configured idle timeout | + +Task and context IDs must be non-empty, at most 128 characters, and contain only letters, digits, `_`, `.`, `:`, or `-`. + +## Task States + +| State | Meaning | +|-------|---------| +| `TASK_STATE_SUBMITTED` | The task was accepted | +| `TASK_STATE_WORKING` | iac-code is running the agent turn | +| `TASK_STATE_INPUT_REQUIRED` | The turn completed and the agent is ready for follow-up input | +| `TASK_STATE_CANCELED` | Cancellation was requested and applied | +| `TASK_STATE_FAILED` | The task failed validation or execution | + +iac-code uses `TASK_STATE_INPUT_REQUIRED` as the normal completed state because the context remains available for follow-up messages. + +## Streaming Updates + +During execution, iac-code emits `TaskStatusUpdateEvent` updates. + +Assistant text is delivered as a status message: + +```json +{ + "statusUpdate": { + "taskId": "task-1", + "contextId": "ctx-1", + "status": { + "state": "TASK_STATE_WORKING", + "message": { + "role": "ROLE_AGENT", + "parts": [{"text": "Here is the ROS template..."}] + } + } + } +} +``` + +Tool and usage details are delivered through `metadata.iac_code`: + +| Metadata Path | Description | +|---------------|-------------| +| `iac_code.tool.status` | `started`, `input_delta`, `input_complete`, `completed`, or `failed` | +| `iac_code.tool.toolUseId` | Stable tool-use ID for correlating tool events | +| `iac_code.tool.name` | Tool name when available | +| `iac_code.tool.input` | Completed tool input, truncated to 4000 characters per field | +| `iac_code.tool.result` | Tool result, truncated to 4000 characters per field | +| `iac_code.permission.autoApproved` | `false` when a tool permission request was rejected by A2A server mode | +| `iac_code.usage.inputTokens` | Input token count for the turn | +| `iac_code.usage.outputTokens` | Output token count for the turn | +| `iac_code.usage.totalTokens` | Total token count for the turn | + +When a tool result includes a supported text artifact payload, the server stores the payload locally, emits a standard `TaskArtifactUpdateEvent`, and records the artifact in the task `artifacts` field. The artifact part uses a `file://` URL plus metadata such as `mediaType`, `byteSize`, and `sha256`; the original artifact content is not duplicated inside tool metadata. + +## Extensions + +The Agent Card advertises the optional iac-code artifact metadata extension: + +```text +urn:iac-code:a2a:artifact-metadata:v1 +``` + +This extension identifies the `metadata.iac_code` namespace used for tool progress, permission decisions, token usage, and local artifact metadata. If the server is configured with any required extension, clients must include its URI in the `A2A-Extensions` header. Missing required extensions return the standard A2A `ExtensionSupportRequiredError`. + +## Error Handling + +| Scenario | Result | +|----------|--------| +| Empty text input | `TASK_STATE_FAILED` with `A2A server currently accepts text input only.` | +| Unsupported media type | Validation error or standard A2A content-type error, depending on where the SDK rejects the request | +| Remote URL part | Validation error because URL parts must use local `file://` URLs | +| File URL outside allowed workspace | Validation error | +| Missing required A2A extension | Standard A2A `ExtensionSupportRequiredError` | +| Invalid workspace metadata | `TASK_STATE_FAILED` with an invalid workspace message | +| Missing or invalid authentication | HTTP `401` with `{"error":"Unauthorized"}` | +| Missing A2A server dependencies | CLI exits with an install hint for the `a2a` extra | +| Provider credentials missing | Sanitized authentication error | +| Unexpected runtime error | Sanitized internal error | + +The server avoids returning local paths, secrets, and provider details in unexpected error messages. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/command-reference.md new file mode 100644 index 00000000..ad92cb40 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -0,0 +1,504 @@ +--- +title: Befehlsreferenz +description: Vollstaendige CLI-Befehlsreferenz fuer das Ausfuehren und Aufrufen von iac-code ueber A2A. +sidebar_position: 3 +--- + +# A2A-Befehlsreferenz + +Diese Seite dokumentiert jeden A2A-bezogenen `iac-code`-Befehl. Verwenden Sie sie, wenn Sie exakte Optionsnamen, gaengige Befehlsmuster und die betriebliche Bedeutung jedes Flags benoetigen. + +## Befehlsuebersicht + +| Befehl | Zweck | +|---------|---------| +| `iac-code a2a` | iac-code als A2A-Server ausfuehren | +| `iac-code a2a-client call` | Eine entfernte Agent Card entdecken und einen Prompt senden | +| `iac-code a2a-client discover` | Eine Agent Card abrufen und optional verifizieren | +| `iac-code a2a-client task-get` | Einen Task per ID abrufen | +| `iac-code a2a-client task-list` | Tasks mit Filtern und Paginierung auflisten | +| `iac-code a2a-client task-cancel` | Einen aktiven Task abbrechen | +| `iac-code a2a-client task-subscribe` | Einen aktiven Task-Event-Stream abonnieren | +| `iac-code a2a-client push-config-create` | Eine Task-Push-Notification-Config erstellen | +| `iac-code a2a-client push-config-get` | Eine Task-Push-Notification-Config abrufen | +| `iac-code a2a-client push-config-list` | Task-Push-Notification-Configs auflisten | +| `iac-code a2a-client push-config-delete` | Eine Task-Push-Notification-Config loeschen | +| `iac-code a2a-client extended-card` | Die authentifizierte erweiterte Agent Card abrufen | +| `iac-code a2a-route-preview` | Lokale Routenauswahl fuer `a2a-client call` voranzeigen | + +Alle HTTP-Clientbefehle akzeptieren dieselben Authentifizierungsoptionen: + +| Option | Beschreibung | +|--------|-------------| +| `--token` | Bearer Token, gesendet als `Authorization: Bearer ` | +| `--basic-username` | Benutzername fuer Basic Auth | +| `--basic-password` | Passwort fuer Basic Auth | +| `--api-key` | API-Key-Wert | +| `--api-key-header` | API-Key-Headername; standardmaessig `X-API-Key` | + +## A2A-Client-Konfiguration + +Alle `a2a-client`-Unterbefehle akzeptieren eine YAML-Konfigurationsdatei auf Gruppenebene: + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC" +``` + +CLI-Optionen ueberschreiben Konfigurationswerte. Verwenden Sie Konfiguration fuer stabile Verbindung, Auth, Verifikation, Routing und wiederholte Task- oder Push-Einstellungen; behalten Sie einmaligen Prompt-Text auf der Befehlszeile. + +```yaml +url: http://127.0.0.1:41242/ +token: your-bearer-token +basic-username: iac-code +basic-password: your-password +api-key: your-api-key +api-key-header: X-IAC-Code-Key +verify-card-secret: your-card-signing-secret +verify-card-jwks-url: https://a2a.example.com/.well-known/jwks.json +require-card-signature: true +timeout: 30 +cwd: /path/to/workspace +context-id: ctx-123 +task-id: task-123 +config-id: webhook-1 +callback-url: https://hooks.example.com/a2a +notification-token: notification-token +auth-scheme: bearer +auth-credentials: callback-token +routes: + - name: ros + url: http://127.0.0.1:41242/ + skills: + - iac_generation + tags: + - ros + - template +``` + +## `iac-code a2a` + +Fuehren Sie iac-code als A2A-Server aus. + +```bash +iac-code a2a +``` + +Standardmaessig bindet der Server an `127.0.0.1:41242` und stellt JSON-RPC ueber HTTP bereit. Port `41242` ist der iac-code-Standard; er ist kein registrierter A2A-Port. + +### Grundlegende Serveroptionen + +| Option | Standard | Beschreibung | +|--------|---------|-------------| +| `--config` | leer | YAML-Konfigurationsdatei mit A2A-Serveroptionen | +| `--host` | `127.0.0.1` | HTTP-Serverhost | +| `--port` | `41242` | HTTP-Serverport | +| `--transport` | `http` | Server-Transport: `http`, `stdio`, `unix`, `websocket`, `grpc`, `grpc-jsonrpc` oder `redis-streams` | +| `--debug`, `-d` | `false` | Debug-Logging aktivieren | + +Beispiel: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --debug +``` + +### YAML-Konfiguration + +Verwenden Sie `--config` fuer Authentifizierung, Speicherung, Signierung, transportspezifische Einstellungen, Push-Zustellung und andere Deployment-Details. Schluessel koennen Bindestriche oder Unterstriche verwenden. Die gaengigen CLI-Flags `--host`, `--port` und `--transport` ueberschreiben Werte aus der Konfigurationsdatei. + +```yaml +host: 127.0.0.1 +port: 41242 +transport: http +token: local-dev-token +persistence-dir: .iac-code-a2a/state +artifact-dir: .iac-code-a2a/artifacts +push-notifications: true +``` + +Fuehren Sie ihn aus mit: + +```bash +iac-code a2a --config a2a-server.yml --port 41243 +``` + +### HTTP-Authentifizierung + +Authentifizierung ist optional. Konfigurieren Sie Serverauthentifizierung in YAML oder mit Umgebungsvariablen. Wenn keine Auth-Einstellung konfiguriert ist, sind Anfragen unauthentifiziert. Wenn ein oder mehrere Schemas konfiguriert sind, kann eine Anfrage jedes konfigurierte Schema erfuellen. + +| Konfigurationsschluessel | Umgebungsvariable | Beschreibung | +|--------|----------------------|-------------| +| `token` | `IACCODE_A2A_HTTP_TOKEN` | Bearer Token | +| `basic-username` | `IACCODE_A2A_BASIC_USERNAME` | Benutzername fuer Basic Auth | +| `basic-password` | `IACCODE_A2A_BASIC_PASSWORD` | Passwort fuer Basic Auth | +| `api-key` | `IACCODE_A2A_API_KEY` | API-Key-Wert | +| `api-key-header` | `IACCODE_A2A_API_KEY_HEADER` | API-Key-Headername | + +Bearer Token: + +```yaml +token: local-dev-token +``` + +Basic Auth: + +```yaml +basic-username: iac-code +basic-password: local-dev-password +``` + +API Key: + +```yaml +api-key: local-dev-key +api-key-header: X-IAC-Code-Key +``` + +### Persistenz und Artifacts + +| Konfigurationsschluessel | Standard | Beschreibung | +|--------|---------|-------------| +| `persistence-dir` | `~/.iac-code/a2a` | Lokale JSON-Metadaten fuer Tasks, Kontexte, Routen und Push-Configs | +| `artifact-dir` | `/artifacts` | Lokaler Artifact-Payload-Speicher | + +Persistenz spiegelt Task- und Kontext-Snapshots fuer Wiederherstellungsmetadaten. Sie startet einen laufenden asyncio-Task nach einem Prozessabsturz nicht neu. + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +``` + +### Agent-Card-Signierung + +| Konfigurationsschluessel | Beschreibung | +|--------|-------------| +| `signing-secret` | HMAC-Secret zum Signieren der oeffentlichen Agent Card | + +Der Server gibt A2A-SDK-`AgentCardSignature`-JWS-Felder aus. Der symmetrische Modus verwendet `HS256`. + +```yaml +signing-secret: local-card-signing-secret +``` + +### Push-Notification-Zustellung + +| Konfigurationsschluessel | Standard | Beschreibung | +|--------|---------|-------------| +| `push-notifications` | `false` | A2A-Task-Push-Notification-Config-Methoden und Terminalzustands-Zustellung aktivieren | +| `push-queue` | `local-file` | Push-Queue-Backend: `local-file` oder `redis-streams` | +| `push-redis-url` | leer | Redis-URL fuer die Redis-gestuetzte Push-Queue | +| `push-stream` | `iac-code:a2a:push` | Redis Stream fuer Push-Jobs | +| `push-retry-key` | `iac-code:a2a:push:retry` | Redis Sorted Set fuer verzoegerte Wiederholungen | +| `push-dead-stream` | `iac-code:a2a:push:dead` | Redis Stream fuer Dead-Letter-Jobs | +| `push-consumer-group` | `iac-code-push` | Redis Consumer Group fuer Push-Worker | +| `push-consumer-name` | leer | Redis Consumer-Name fuer diesen Worker | +| `push-lease-timeout-ms` | `300000` | Redis Pending-Lease-Timeout | + +Lokale Datei-Queue: + +```yaml +push-notifications: true +persistence-dir: ~/.iac-code/a2a +push-queue: local-file +``` + +Redis-Streams-Queue: + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +``` + +Redis-gestuetzte Push-Zustellung erfordert das Extra `a2a-redis`. + +### Transportoptionen + +| Transport | Befehl | Hinweise | +|-----------|---------|-------| +| HTTP JSON-RPC und REST | `iac-code a2a --transport http` | Standard. Bewirbt `JSONRPC`- und `HTTP+JSON`-Schnittstellen. | +| stdio | `iac-code a2a --transport stdio` | Experimentelle benutzerdefinierte JSON-RPC-Frames ueber Standardeingabe/-ausgabe. | +| Unix socket | `iac-code a2a --config a2a-server.yml --transport unix` | Erfordert `socket-path` in der Konfiguration. | +| WebSocket | `iac-code a2a --config a2a-server.yml --transport websocket` | Verwendet `ws-path` aus der Konfiguration, standardmaessig `/a2a`. | +| gRPC | `iac-code a2a --config a2a-server.yml --transport grpc` | Verwendet `grpc-host` und `grpc-port` aus der Konfiguration. | +| gRPC JSON-RPC | `iac-code a2a --config a2a-server.yml --transport grpc-jsonrpc` | Benutzerdefinierter JSON-RPC Envelope ueber gRPC. | +| Redis Streams | `iac-code a2a --config a2a-server.yml --transport redis-streams` | Erfordert `redis-url` in der Konfiguration. | + +Redis-Streams-Transportoptionen: + +| Konfigurationsschluessel | Standard | Beschreibung | +|--------|---------|-------------| +| `redis-url` | leer | Redis-Verbindungs-URL; erforderlich fuer `--transport redis-streams` | +| `request-stream` | `iac-code:a2a:requests` | Name des Request Streams | +| `response-stream` | `iac-code:a2a:responses` | Name des Response Streams | +| `consumer-group` | `iac-code` | Consumer Group des Request Streams | + +### Berechtigungsverhalten + +| Konfigurationsschluessel | Standard | Beschreibung | +|--------|---------|-------------| +| `auto-approve-permissions` | `false` | Tool-Berechtigungsanfragen, die waehrend A2A-Turns entstehen, automatisch genehmigen | + +Ohne `auto-approve-permissions: true` lehnt der A2A-Modus Berechtigungsabfragen ab und gibt Berechtigungsmetadaten aus. Verwenden Sie es nur fuer vertrauenswuerdige Automatisierungsumgebungen. + +## `iac-code a2a-client call` + +Entdeckt eine Agent Card, waehlt den beworbenen Endpunkt und sendet einen Prompt. + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" +``` + +| Option | Standard | Beschreibung | +|--------|---------|-------------| +| `--url` | leer | A2A-Agent-Basis-URL oder JSON-RPC-Endpunkt-URL; kann aus der Konfiguration kommen | +| `--route` | wiederholbar | Route-Spec, die verwendet wird, wenn `--url` ausgelassen ist | +| `--route-name` | leer | Auszuwaehlende benannte Route | +| `--prompt`, `-p` | erforderlich | Prompt-Text | +| `--cwd` | `.` | Workspace-Pfad, gesendet als `message.metadata.iac_code.cwd` | +| `--context-id` | leer | Vorhandene A2A-Kontext-ID fuer eine Follow-up-Nachricht | +| `--verify-card-secret`, `--signing-secret` | leer | HMAC-Secret fuer Agent-Card-Verifikation | +| `--verify-card-jwks-url` | leer | Entfernte JWKS-URL fuer Agent-Card-Verifikation | +| `--require-card-signature`, `--require-signature` | `false` | Unsignierte oder ungueltige Agent Cards ablehnen | +| `--timeout` | `30.0` | Aufruf-Timeout in Sekunden | +| `--stream` | `false` | `SendStreamingMessage` verwenden und Stream-Events ausgeben | + +Follow-up im selben Kontext: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --context-id ctx-123 \ + --prompt "Now add outputs for the VPC and vSwitch IDs." \ + --cwd "$PWD" +``` + +Streaming: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this Terraform module." \ + --cwd "$PWD" \ + --stream +``` + +Signierte Agent Card verlangen: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a production VPC template." \ + --cwd "$PWD" +``` + +Mit einer entfernten JWKS-URL verifizieren: + +```bash +iac-code a2a-client --config jwks-client.yml call \ + --prompt "Review the ROS stack." +``` + +## `iac-code a2a-client discover` + +Eine entfernte Agent Card abrufen und ausgeben. + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +| Option | Beschreibung | +|--------|-------------| +| `--url` | A2A-Agent-Basis-URL; kann aus der Konfiguration kommen | +| `--verify-card-secret`, `--signing-secret` | HMAC-Secret fuer Verifikation | +| `--verify-card-jwks-url` | Entfernte JWKS-URL fuer Verifikation | +| `--require-card-signature`, `--require-signature` | Eine gueltige Signatur verlangen | + +Authentifizierte Discovery: + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +## Task-Befehle + +Task-Befehle rufen JSON-RPC-Task-Methoden direkt auf. Sie sind fuer Betriebswerkzeuge, Dashboards und Debugging nuetzlich. + +### `iac-code a2a-client task-get` + +```bash +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +| Option | Beschreibung | +|--------|-------------| +| `--url` | A2A-JSON-RPC-Endpunkt-URL; kann aus der Konfiguration kommen | +| `--task-id` | Task-ID; kann aus der Konfiguration kommen | +| `--history-length` | Maximale Anzahl zurueckzugebender Task-Historieneintraege | + +### `iac-code a2a-client task-list` + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --context-id ctx-123 \ + --status TASK_STATE_INPUT_REQUIRED \ + --page-size 20 \ + --output table +``` + +| Option | Standard | Beschreibung | +|--------|---------|-------------| +| `--url` | leer | A2A-JSON-RPC-Endpunkt-URL; kann aus der Konfiguration kommen | +| `--context-id` | leer | Nach Kontext-ID filtern | +| `--status` | leer | Nach Task-Zustand filtern | +| `--page-size` | leer | Maximale Anzahl zurueckzugebender Tasks | +| `--page-token` | leer | Paginierungstoken | +| `--include-artifacts` | `false` | Task-Artifacts in die Antwort einschliessen | +| `--output` | `table` | `table` oder `json` | + +JSON-Ausgabe: + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --include-artifacts \ + --output json +``` + +### `iac-code a2a-client task-cancel` + +```bash +iac-code a2a-client --config a2a-client.yml task-cancel \ + --task-id task-123 +``` + +Der Abbruch ist kooperativ. Ein abgeschlossener, fehlgeschlagener, abgebrochener oder input-required Task gibt den standardmaessigen A2A-Task-not-cancelable-Fehler zurueck. + +### `iac-code a2a-client task-subscribe` + +```bash +iac-code a2a-client --config a2a-client.yml task-subscribe \ + --task-id task-123 +``` + +Der Befehl streamt Events fuer aktive Tasks. Fuer einen neuen Turn bevorzugen Sie `a2a-client call --stream`; dies startet den Task und streamt Aktualisierungen in einem Befehl. + +## Push-Notification-Config-Befehle + +Diese Befehle erfordern einen Server, der mit `push-notifications: true` gestartet wurde. Sie verwalten standardmaessige A2A-Task-Push-Notification-Configs. + +### `iac-code a2a-client push-config-create` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +| Option | Beschreibung | +|--------|-------------| +| `--url` | A2A-JSON-RPC-Endpunkt-URL; kann aus der Konfiguration kommen | +| `--task-id` | Task-ID; kann aus der Konfiguration kommen | +| `--config-id` | Push-Config-ID; kann aus der Konfiguration kommen | +| `--callback-url` | HTTP(S)-Callback-URL; kann aus der Konfiguration kommen | +| `--notification-token` | Token, gesendet als `X-A2A-Notification-Token` | +| `--auth-scheme` | Callback-Auth-Schema, zum Beispiel `bearer` oder `basic` | +| `--auth-credentials` | Callback-Auth-Zugangsdaten | + +Callback-URLs werden vor Speicherung und Versand validiert. Der Standardvalidator lehnt Nicht-HTTP(S)-URLs, localhost-Namen und literale private/lokale IP-Adressen ab. + +### `iac-code a2a-client push-config-get` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-get \ + --task-id task-123 \ + --config-id webhook-1 +``` + +### `iac-code a2a-client push-config-list` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-list \ + --task-id task-123 \ + --page-size 10 +``` + +### `iac-code a2a-client push-config-delete` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-delete \ + --task-id task-123 \ + --config-id webhook-1 +``` + +## `iac-code a2a-client extended-card` + +Die authentifizierte erweiterte Agent Card abrufen. + +```bash +iac-code a2a-client --config a2a-client.yml extended-card \ + --token "$A2A_TOKEN" +``` + +Die oeffentliche Agent Card bewirbt `capabilities.extendedAgentCard=true`. Die erweiterte Karte fuegt authentifizierte Laufzeitdetails hinzu, einschliesslich Task-Verwaltung und Push-Konfigurationsfaehigkeitsmetadaten. + +## `iac-code a2a-route-preview` + +Vorschau, wie `a2a-client call` konfigurierte Routen aufloest, wenn `--url` ausgelassen ist. + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +| Option | Beschreibung | +|--------|-------------| +| `--route` | Wiederholbare Route-Spec im Format `name=url;skills=a,b;tags=x,y` | +| `--name` | Aufzuloesender Routenname | +| `--skill` | Aufzuloesende Skill-ID | +| `--prompt` | Prompt-Text fuer Name-/Tag-Abgleich | +| `--route-state-dir`, `--persistence-dir` | Verzeichnis zum Persistieren von Routen-Snapshots | +| `--save-routes` | Angegebene Routen im Routen-Zustandsverzeichnis speichern | + +Routen-Snapshots speichern: + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-state-dir ~/.iac-code/a2a \ + --save-routes +``` + +Aufruf ueber Routen: + +```bash +iac-code a2a-client call \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-name ros \ + --prompt "Create a ROS VPC template." \ + --cwd "$PWD" +``` + +## Umgebungsvariablen + +| Variable | Beschreibung | +|----------|-------------| +| `IACCODE_A2A_HTTP_TOKEN` | Standard fuer Server-/Client-Bearer-Token | +| `IACCODE_A2A_BASIC_USERNAME` | Standard fuer Server-/Client-Basic-Auth-Benutzername | +| `IACCODE_A2A_BASIC_PASSWORD` | Standard fuer Server-/Client-Basic-Auth-Passwort | +| `IACCODE_A2A_API_KEY` | Standard fuer Server-/Client-API-Key | +| `IACCODE_A2A_API_KEY_HEADER` | Standard fuer API-Key-Headername | +| `IACCODE_A2A_ALLOWED_CWDS` | OS-pfadgetrennte Liste erlaubter Workspace-Roots fuer eingehende Nachrichtenmetadaten und File-URLs | +| `IACCODE_A2A_TEXT_MIME_TYPES` | Zusaetzliche komma- oder semikolongetrennte textartige MIME-Typen | +| `IACCODE_A2A_MULTIMODAL_MIME_TYPES` | Zusaetzliche komma- oder semikolongetrennte multimodale MIME-Typen | +| `IAC_CODE_A2A_PUSH_KEYRING` | Umgebungsgesteuerter verschluesselter Push-Secret-Keyring | diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/examples.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/examples.md new file mode 100644 index 00000000..7844453c --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/examples.md @@ -0,0 +1,388 @@ +--- +title: Beispiele +description: Praktische Beispiele fuer die Integration mit dem iac-code-A2A-Server. +sidebar_position: 6 +--- + +# Beispiele + +Diese Seite bietet sofort verwendbare A2A-Integrationsbeispiele. + +## Voraussetzungen + +Die Beispiele setzen voraus: + +| Abhaengigkeit | Version | Zweck | +|------------|---------|---------| +| Python | `3.12` | Entspricht der Projektlaufzeit | +| `a2a-sdk` | `>=1.0.2,<2` | A2A-Client und Protobuf-Typen | +| `httpx` | `>=0.27.0` | HTTP-Client, der vom SDK und direkten Beispielen verwendet wird | +| `iac-code` | aktuelles Repo | Stellt den Unterbefehl `iac-code a2a` bereit | + +Starten Sie den Server: + +```bash +uv sync --extra a2a +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +## Python SDK - Streaming-Sitzung + +Dieses Beispiel entdeckt die Agent Card, sendet eine Nachricht, gibt Assistant-Text-Chunks aus und meldet Tool-Metadaten. + +```python +"""Streaming iac-code A2A session using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path +from typing import Any + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest +from google.protobuf.json_format import MessageToDict + + +def metadata_to_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + return MessageToDict(value, preserving_proto_field_name=False) + + +async def main() -> None: + headers = {} + # For authenticated servers: + # headers["Authorization"] = "Bearer YOUR_TOKEN" + + async with httpx.AsyncClient(headers=headers, timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Create a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("task"): + print(f"[task] {event.task.id} context={event.task.context_id}") + + elif event.HasField("status_update"): + update = event.status_update + status = update.status + + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + metadata = metadata_to_dict(update.metadata) + tool = metadata.get("iac_code", {}).get("tool") + if tool: + print(f"\n[tool] {tool.get('status')} {tool.get('name', '')}".rstrip()) + + usage = metadata.get("iac_code", {}).get("usage") + if usage: + print(f"\n[usage] {usage['totalTokens']} total tokens") + + if status.state == "TASK_STATE_INPUT_REQUIRED": + print("\n[done]") + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## CLI - End-to-End-Workflow + +Starten Sie einen lokalen Server mit Persistenz, Artifacts, Push-Notification-Unterstuetzung und einer signierten Agent Card: + +```yaml +host: 127.0.0.1 +port: 41242 +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +```bash +iac-code a2a --config a2a-server.yml +``` + +Erstellen Sie eine Client-Konfiguration fuer den stabilen Endpunkt und die Kartenverifikationseinstellungen: + +```yaml +url: http://127.0.0.1:41242/ +verify-card-secret: local-card-signing-secret +require-card-signature: true +``` + +Entdecken und verifizieren Sie die Agent Card: + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +Senden Sie eine Streaming-Anfrage: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" \ + --stream +``` + +Listen Sie Tasks auf und rufen Sie einen Task als JSON ab: + +```bash +iac-code a2a-client --config a2a-client.yml task-list --output table + +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +Registrieren Sie einen Push-Callback fuer einen Task: + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +Zeigen Sie vor dem Aufrufen eines gerouteten Agent die Routenauswahl in der Vorschau an: + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +## Python SDK - Follow-up-Nachricht + +Follow-up-Nachrichten verwenden dieselbe `context_id` und normalerweise dieselbe Task-ID wieder. Dadurch bleiben die interne iac-code-Laufzeit und der Unterhaltungsverlauf erhalten. + +```python +import uuid +from pathlib import Path + +from a2a.types import Message, Part, Role, SendMessageRequest + + +def build_follow_up(task_id: str, context_id: str, text: str) -> SendMessageRequest: + return SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + task_id=task_id, + context_id=context_id, + role=Role.ROLE_USER, + parts=[Part(text=text)], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + +# Usage inside an async function: +# async for event in client.send_message( +# build_follow_up(task_id, context_id, "Add outputs for VPC and VSwitch IDs.") +# ): +# ... +``` + +Der Server lehnt ein wiederverwendetes `contextId` ab, wenn die neue Nachricht auf einen anderen Workspace zeigt. + +## Python SDK - Task abbrechen + +```python +from a2a.types import CancelTaskRequest + + +async def cancel_running_task(client, task_id: str) -> None: + task = await client.cancel_task(CancelTaskRequest(id=task_id)) + print(f"{task.id}: {task.status.state}") +``` + +## Direktes HTTP - Minimaler JSON-RPC-Client + +Verwenden Sie dies, wenn der Aufrufer die SDK-Abhaengigkeit nicht haben soll. + +```python +"""Direct A2A JSON-RPC client using httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=120.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template for missing parameters."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Direktes HTTP - Streaming-SSE + +```python +"""Read SendStreamingMessage events directly with httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=None) as client: + async with client.stream( + "POST", + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Generate a Terraform VPC example."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if line.startswith("data:"): + print(line.removeprefix("data:").strip()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Direktes HTTP - Push-Notification-Config + +Die Push-Config-Methoden sind verfuegbar, wenn der Server mit `push-notifications: true` laeuft. + +```python +"""Create an A2A task push notification config.""" + +import asyncio + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "push-1", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-123", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token", + }, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## iac-code-Metadaten behandeln + +Tool- und Nutzungs-Events treffen in `TaskStatusUpdateEvent.metadata.iac_code` ein. + +```python +from typing import Any + + +def handle_iac_metadata(metadata: dict[str, Any]) -> None: + iac = metadata.get("iac_code", {}) + + if tool := iac.get("tool"): + status = tool.get("status") + tool_id = tool.get("toolUseId") + name = tool.get("name", "") + print(f"tool={name} id={tool_id} status={status}") + + if permission := iac.get("permission"): + if permission.get("autoApproved") is False: + print(f"permission rejected for {permission.get('toolName')}") + + if usage := iac.get("usage"): + print( + "tokens=" + f"{usage.get('inputTokens', 0)}+{usage.get('outputTokens', 0)}" + f"={usage.get('totalTokens', 0)}" + ) +``` + +## Haeufige Stolperfallen + +| Symptom | Behebung | +|---------|-----| +| HTTP `401` | Fuegen Sie ein konfiguriertes Auth-Schema wie `Authorization: Bearer `, Basic Auth oder `X-API-Key: ` sowohl bei Agent-Card- als auch JSON-RPC-Anfragen hinzu | +| `Invalid A2A workspace metadata.` | Verwenden Sie einen vorhandenen absoluten Pfad in `metadata.iac_code.cwd` | +| `A2A server currently accepts text input only.` | Senden Sie mindestens einen nicht leeren Textteil | +| `Task is already working.` | Warten Sie, bis der aktuelle Turn abgeschlossen ist, bevor Sie eine weitere Nachricht im selben Kontext senden | +| Follow-up als anderer Workspace abgelehnt | Lassen Sie `metadata.iac_code.cwd` fuer ein wiederverwendetes `contextId` unveraendert | +| Lokale File-URL abgelehnt | Halten Sie `file://`-Teile innerhalb von `metadata.iac_code.cwd` und innerhalb von `IACCODE_A2A_ALLOWED_CWDS` | +| Push-Callback abgelehnt | Verwenden Sie eine HTTP(S)-Callback-URL, die nicht localhost und keine literale private/lokale IP-Adresse ist | +| Redis-Push-Queue startet nicht | Installieren Sie das Extra `a2a-redis` und geben Sie `push-redis-url` in der A2A-Konfiguration an | diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/getting-started.md new file mode 100644 index 00000000..76b64ef3 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -0,0 +1,291 @@ +--- +sidebar_position: 2 +title: Erste Schritte +description: Starten Sie den A2A-Server und senden Sie Ihre erste Nachricht. +--- + +# Erste Schritte mit A2A + +## Voraussetzungen + +1. **iac-code installiert** - Siehe die Anleitung [Installation](/docs/getting-started/installation). + +2. **LLM-Zugangsdaten konfiguriert** - Siehe die Anleitung [Authentication](/docs/configuration/authentication), um die Zugangsdaten Ihres Modellproviders zu konfigurieren. + +3. **A2A-Serverabhaengigkeiten** - Installieren Sie iac-code mit dem Extra `a2a`: + +```bash +uv sync --extra a2a +``` + +## A2A-Server starten + +Starten Sie den Server auf der lokalen Standardschnittstelle: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +Verwenden Sie eine YAML-Konfigurationsdatei, wenn Sie lokalen Zustand, Artifact-Speicherung, Push-Notification-Zustellung oder signierte Agent Cards benoetigen: + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +Fuehren Sie ihn aus mit: + +```bash +iac-code a2a --config a2a-server.yml +``` + +`push-notifications: true` aktiviert A2A-Task-Push-Notification-Config-Methoden und Zustellung fuer Terminalzustaende. Verwenden Sie `push-queue: redis-streams` mit `push-redis-url`, wenn mehrere Worker die Push-Zustellung koordinieren muessen. + +Der Server stellt bereit: + +| Route | Zweck | +|-------|---------| +| `GET /health` | Health Check | +| `GET /.well-known/agent-card.json` | Agent-Card-Erkennung | +| `POST /` | A2A JSON-RPC-Endpunkt | + +Der HTTP-Server registriert ausserdem die A2A-SDK-REST-Routen und bewirbt sowohl `JSONRPC`- als auch `HTTP+JSON`-Schnittstellen in der Agent Card. + +## Discovery pruefen + +Rufen Sie die Agent Card ab: + +```text +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +Sie sollten `name: "iac-code"`, `JSONRPC`- und `HTTP+JSON`-Schnittstellen, Cache-Header wie `ETag`, die optionale Extension `urn:iac-code:a2a:artifact-metadata:v1`, unterstuetzte Eingabemodi und Skills wie `iac_generation`, `iac_review`, `aliyun_ros_operations` und `terraform_ros_conversion` sehen. + +Pruefen Sie den Health-Endpunkt: + +```bash +curl http://127.0.0.1:41242/health +``` + +Erwartete Antwort: + +```json +{"status":"healthy"} +``` + +## Authentifizierung verlangen + +Authentifizierung ist optional. Wenn keine A2A-Authentifizierungsoptionen oder Umgebungsvariablen gesetzt sind, benoetigen Anfragen keine Authentifizierung. Sobald ein Auth-Schema konfiguriert ist, muss jede Anfrage, einschliesslich Agent-Card-Discovery, ein konfiguriertes Schema erfuellen. + +### Bearer Token + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +Der entsprechende YAML-Konfigurationsschluessel ist `token`. + +```text +Authorization: Bearer +``` + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +Benutzername und Passwort muessen beide vorhanden sein. Die entsprechenden YAML-Konfigurationsschluessel sind `basic-username` und `basic-password`. + +### API Key + +```bash +export IACCODE_A2A_API_KEY=your-api-key + +iac-code a2a +``` + +Der Standard-API-Key-Header ist: + +```text +X-API-Key: +``` + +Ueberschreiben Sie ihn mit dem YAML-Konfigurationsschluessel `api-key-header` oder `IACCODE_A2A_API_KEY_HEADER`: + +```yaml +api-key: your-api-key +api-key-header: X-IAC-Code-Key +``` + +## Entfernten A2A-Agent aufrufen + +Legen Sie stabile Client-Verbindungs- und Auth-Einstellungen in einer YAML-Datei ab: + +```yaml +url: http://127.0.0.1:41242/ +token: your-secret-token +verify-card-secret: your-card-signing-secret +require-card-signature: true +cwd: /path/to/workspace +``` + +Verwenden Sie `a2a-client call` fuer einen direkten Phase-1-Clientaufruf: + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC with two vSwitches" --cwd "$PWD" +``` + +Verwenden Sie `--stream`, wenn Sie inkrementelle Events statt einer finalen Antwort moechten: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this template" \ + --cwd "$PWD" \ + --stream +``` + +Befehlszeilenoptionen ueberschreiben Konfigurationswerte, wenn Sie ein einmaliges Ziel oder Token benoetigen: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --url https://other-agent.example.com/ \ + --prompt "Review this template" +``` + +Zeigen Sie bei Multi-Agent-Routing die Routenauswahl vor dem Aufruf in der Vorschau an: + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --route-state-dir ~/.iac-code/a2a +``` + +Siehe [Befehlsreferenz](./command-reference.md) fuer jeden A2A-Befehl, einschliesslich Task-Verwaltung, Push-Config-CRUD, erweiterter Agent Cards und Transportoptionen. + +## Erste Nachricht mit curl senden + +Uebergeben Sie das Workspace-Verzeichnis ueber `message.metadata.iac_code.cwd`; der Pfad muss absolut sein, bereits existieren und innerhalb eines erlaubten Workspace-Roots liegen. Standardmaessig sind die erlaubten Roots das Server-Prozessverzeichnis und das System-Temp-Verzeichnis. Ueberschreiben Sie sie mit `IACCODE_A2A_ALLOWED_CWDS`. + +Der Server akzeptiert textartige Teile, JSON-Datenteile, rohen UTF-8-Text, lokale Workspace-Textdateien mit `file://` und begrenzte multimodale Anhaenge. Die Aufnahme entfernter URLs wird nicht unterstuetzt; `url`-Teile muessen lokale `file://`-URLs innerhalb des erlaubten Workspace sein. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [ + {"text": "Generate a ROS VPC template with two vSwitches."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +Verwenden Sie fuer Streaming-Ausgabe `SendStreamingMessage`: + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [ + {"text": "Review my Terraform files and suggest ROS equivalents."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## Minimales Python-SDK-Beispiel + +Das folgende Beispiel verwendet `a2a-sdk>=1.0.2,<2`, den Versionsbereich, der vom Extra `a2a` verwendet wird. + +```python +"""Minimal iac-code A2A client using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest + + +async def main() -> None: + async with httpx.AsyncClient(timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Generate a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("status_update"): + status = event.status_update.status + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +:::tip +Erstellen Sie fuer authentifizierte Server den `httpx.AsyncClient` mit `headers={"Authorization": "Bearer "}`, damit sowohl Agent-Card-Discovery als auch JSON-RPC-Aufrufe das Token einschliessen. +::: + +## Naechste Schritte + +- [Befehlsreferenz](./command-reference.md) - Vollstaendige CLI-Befehls- und Optionsreferenz. +- [Protokollreferenz](./protocol-reference.md) - Details zu Methoden, Routen, Zustaenden und Metadaten. +- [HTTP-Transport](./http-transport.md) - JSON-RPC-HTTP-Verhalten, Bearer Auth und curl-Workflows. +- [Beispiele](./examples.md) - Beispiele fuer SDK, direktes HTTP, Follow-up, Abbruch und Metadatenbehandlung. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/http-transport.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/http-transport.md new file mode 100644 index 00000000..348a55fb --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/http-transport.md @@ -0,0 +1,269 @@ +--- +title: HTTP-Transport +description: Fuehren Sie den iac-code-A2A-Server ueber JSON-RPC HTTP aus und rufen Sie ihn auf. +sidebar_position: 5 +--- + +# HTTP-Transport + +Der standardmaessige A2A-Server von iac-code stellt JSON-RPC ueber HTTP sowie die A2A-SDK-REST-Routen bereit. Der Server ist mit Starlette gebaut und laeuft auf Uvicorn. + +## Server starten + +```bash +# Default host and port +iac-code a2a + +# Explicit host and port +iac-code a2a --host 127.0.0.1 --port 41242 + +# Listen on all interfaces +iac-code a2a --host 0.0.0.0 --port 41242 +``` + +Installieren Sie zuerst die optionalen Serverabhaengigkeiten: + +```bash +uv sync --extra a2a +``` + +## Endpunktuebersicht + +| Route | Methode | Antwort | +|-------|--------|----------| +| `/health` | `GET` | Einfache JSON-Health-Antwort | +| `/.well-known/agent-card.json` | `GET` | Agent-Card-JSON | +| `/` | `POST` | JSON-RPC-Antwort oder SSE-Stream | +| SDK-REST-Routen | gemischt | Vom SDK registrierte A2A-REST-Endpunkte | + +## Header + +Empfohlene Header: + +```text +Content-Type: application/json +A2A-Version: 1.0 +``` + +Wenn Bearer Auth aktiviert ist: + +```text +Authorization: Bearer +``` + +## Authentifizierung + +Der Server unterstuetzt optionale Bearer-Token-, Basic-Auth- und API-Key-Authentifizierung. Wenn keine Authentifizierungsoptionen oder Umgebungsvariablen gesetzt sind, benoetigen Anfragen keine Authentifizierung. Wenn ein oder mehrere Schemas konfiguriert sind, kann sich eine Anfrage mit jedem konfigurierten Schema authentifizieren. + +### Bearer Token + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +Sie koennen `token` auch in der A2A-YAML-Konfigurationsdatei setzen. + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +Sowohl Benutzername als auch Passwort muessen gesetzt sein, damit Basic Auth aktiviert wird. + +### API Key + +```bash +export IACCODE_A2A_API_KEY=your-api-key +iac-code a2a +``` + +Der Standard-API-Key-Header ist `X-API-Key`. Sie koennen ihn in YAML aendern: + +```yaml +api-key: ${IACCODE_A2A_API_KEY} +api-key-header: X-IAC-Code-Key +``` + +oder mit `IACCODE_A2A_API_KEY_HEADER`. + +| Szenario | Verhalten | +|----------|----------| +| Kein Auth-Schema konfiguriert | Keine Authentifizierung erforderlich | +| Ein oder mehrere Schemas konfiguriert, eines passt | Anfrage wird fortgesetzt | +| Ein oder mehrere Schemas konfiguriert, kein Schema passt | HTTP `401` mit `{"error":"Unauthorized"}` | + +## Agent-Card-Discovery + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +Authentifiziert: + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "Authorization: Bearer $IACCODE_A2A_HTTP_TOKEN" +``` + +Mit API-Key-Authentifizierung: + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "X-API-Key: $IACCODE_A2A_API_KEY" +``` + +Die JSON-RPC-Endpunkt-URL wird in `supportedInterfaces[0].url` beworben. Der HTTP-Modus bewirbt ausserdem eine `HTTP+JSON`-Schnittstelle fuer REST-faehige Clients. + +## Nicht streamende Nachricht + +`SendMessage` gibt eine einzelne JSON-RPC-Antwort zurueck, nachdem der Agent-Turn abgeschlossen ist. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a Terraform VPC module for Alibaba Cloud."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## Streaming-Nachricht + +`SendStreamingMessage` gibt Server-Sent Events zurueck. Verwenden Sie `curl -N`, um Events auszugeben, sobald sie eintreffen. + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Generate a ROS template for one VPC and two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +Jede SSE-`data:`-Zeile enthaelt eine JSON-RPC-Antwort, deren `result` eine A2A-`StreamResponse` ist. + +## Follow-up-Nachricht + +Verwenden Sie das von der ersten Antwort zurueckgegebene `taskId` und `contextId`, um dieselbe Unterhaltung fortzusetzen. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-2", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-3", + "taskId": "task-id-from-first-response", + "contextId": "context-id-from-first-response", + "role": "ROLE_USER", + "parts": [{"text": "Now add tags for environment and owner."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +Der Workspace muss fuer das wiederverwendete `contextId` gleich bleiben. + +## Laufenden Task abbrechen + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "cancel-1", + "method": "CancelTask", + "params": { + "id": "task-id" + } + }' +``` + +Der Abbruch ist kooperativ: iac-code bricht den aktiven Agent-Turn ab, gibt einen abgebrochenen Zustand aus und gibt die Kontext-Sperre frei. Das Abbrechen eines vorhandenen Tasks, der nicht mehr laeuft, gibt den standardmaessigen A2A-`TaskNotCancelableError` zurueck. + +## CLI-Aequivalente + +Die meisten HTTP-Workflows haben einen passenden CLI-Befehl: + +```yaml +url: http://127.0.0.1:41242/ +``` + +```bash +# Discover the Agent Card +iac-code a2a-client --config a2a-client.yml discover + +# Send a non-streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a Terraform VPC module for Alibaba Cloud." \ + --cwd "$PWD" + +# Send a streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a ROS template for one VPC and two vSwitches." \ + --cwd "$PWD" \ + --stream + +# Inspect task state +iac-code a2a-client --config a2a-client.yml task-get --task-id task-id +iac-code a2a-client --config a2a-client.yml task-list --output table + +# Cancel an active task +iac-code a2a-client --config a2a-client.yml task-cancel --task-id task-id +``` + +Die vollstaendige Optionsliste finden Sie in der [Befehlsreferenz](./command-reference.md). + +## Betriebshinweise + +- Binden Sie fuer rein lokale Nutzung an `127.0.0.1`. +- Verwenden Sie `token` in der A2A-Konfiguration oder `IACCODE_A2A_HTTP_TOKEN`, bevor Sie an eine gemeinsam genutzte Netzwerkschnittstelle binden. +- Der A2A-Modus lehnt Tool-Berechtigungsanfragen automatisch ab; schuetzen Sie unauthentifizierte Endpunkte wie lokale Automatisierungsservices. +- Aktiver Laufzeitzustand liegt im Speicher. Persistenz spiegelt Task- und Kontextmetadaten, aber ein Prozessneustart setzt laufende asyncio-Arbeit nicht fort. +- Ein Kontext kann jeweils nur einen Task ausfuehren; getrennte Kontexte koennen parallel laufen. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/overview.md new file mode 100644 index 00000000..76d7e331 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: A2A-Protokoll +description: Uebersicht ueber die Agent2Agent-Unterstuetzung in iac-code. +--- + +# A2A-Protokoll + +## Was ist A2A + +[Agent2Agent (A2A)](https://github.com/a2aproject/A2A) ist ein Protokoll zum Entdecken und Aufrufen entfernter Agents. Es ermoeglicht einem Agent, eine Agent Card zu veroeffentlichen, strukturierte Nachrichten anzunehmen, Task-Aktualisierungen zu streamen und Abbruch- sowie Task-Abfrageoperationen ueber Standard-Transports bereitzustellen. + +## iac-code als A2A-Server + +iac-code kann als A2A 1.0 Server / Agent ausgefuehrt werden. Andere A2A-kompatible Clients koennen ihn entdecken, Infrastructure-as-Code-Anfragen senden, Ausfuehrungsaktualisierungen streamen und aktive Tasks abbrechen. + +Verwenden Sie A2A, wenn ein anderer Agent, eine Workflow-Engine oder ein Service iac-code als interoperablen IaC-Spezialisten aufrufen muss. Verwenden Sie ACP, wenn ein editorartiger Client Sitzungsverwaltung, Berechtigungsabfragen und lokale Entwicklungsintegration benoetigt. + +## Anwendungsfaelle + +- **Agent-Orchestrierung** - Ein Planner-Agent kann Alibaba Cloud ROS- oder Terraform-Arbeit an iac-code delegieren. +- **Workflow-Automatisierung** - Interne Tools koennen IaC-Generierungs-, Review- oder Konvertierungs-Tasks ueber HTTP einreichen. +- **Service Discovery** - Clients koennen die Agent Card abrufen und Faehigkeiten wie IaC-Generierung oder Template-Review auswaehlen. +- **Streaming-Integrationen** - Ein ChatOps- oder Dashboard-Client kann Modelltext, Tool-Aktivitaet, Nutzungsmetadaten und den finalen Task-Zustand anzeigen, waehrend der Turn laeuft. + +## Vergleich der Interaktionsmodi + +| Modus | Befehl | Am besten fuer | +|------|---------|----------| +| **Interaktive REPL** | `iac-code` | Praktische Erkundung und iterative Template-Erstellung | +| **Nicht interaktive CLI** | `iac-code --prompt "..."` oder `--headless` | Einmalige Skripte und CI-Jobs | +| **ACP-Server** | `iac-code acp` | IDE-/Editor-Integration und Multi-Session-Clientsteuerung | +| **A2A-Server** | `iac-code a2a` | Agent-zu-Agent-Interoperabilitaet ueber A2A-Transports | +| **A2A-Client** | `iac-code a2a-client call` | Aufrufen entfernter A2A-Agents aus iac-code | + +## Kernfaehigkeiten + +- **Agent-Card-Erkennung** - Veroeffentlicht `/.well-known/agent-card.json` mit Protocol Binding, Version, Skills, Eingabe-/Ausgabemodi und optionalen Auth-Metadaten. +- **HTTP JSON-RPC und REST** - Bedient A2A JSON-RPC-Anfragen unter `/` und registriert die SDK-REST-Routen. +- **Streaming-Antworten** - Unterstuetzt `SendStreamingMessage` fuer inkrementelle Task-Aktualisierungen. +- **Task-Verwaltung** - Unterstuetzt Task-Abfrage, authentifizierte Task-Auflistung mit Cursor-Paginierung, Abbruch aktiver Tasks und Abonnement aktiver Tasks. +- **Kontextwiederverwendung** - Verwendet eine iac-code-Laufzeit fuer Follow-up-Nachrichten im selben A2A `contextId` wieder. +- **Workspace-Eingrenzung** - Liest das Projektverzeichnis aus Nachrichtenmetadaten unter `iac_code.cwd`. +- **Tool-Metadaten** - Gibt iac-code-spezifische Metadaten fuer Tool-Starts, Eingabedeltas, abgeschlossene Tool-Ergebnisse, Berechtigungsentscheidungen und Token-Nutzung aus. +- **Eingabeteile** - Akzeptiert textartige Teile, JSON-Datenteile, rohen UTF-8-Text, lokale Workspace-Textdateien mit `file://` und begrenzte multimodale Anhaenge, die als Prompt-Manifeste dargestellt werden. +- **Client-Aufrufe** - Entdeckt entfernte Agent Cards, prueft Signaturen bei entsprechender Konfiguration und sendet Text-Prompts an entfernte Agents. +- **Routing** - Waehlt konfigurierte entfernte Agents nach explizitem Namen, Skill oder Prompt-/Tag-Abgleich aus. +- **Persistenzmetadaten** - Spiegelt lokale A2A-Task-/Kontext-Snapshots in JSON-Dateien fuer prozessuebergreifende Wiederherstellungsmetadaten. +- **Artifacts** - Speichert unterstuetzte lokale Text-Artifact-Payloads ausserhalb des gestreamten Event-Bodys, gibt standardmaessige `TaskArtifactUpdateEvent`-Events aus und zeichnet Task-`artifacts` auf. +- **Extensions und Caching** - Bewirbt die optionale iac-code-Artifact-Metadaten-Extension, validiert erforderliche `A2A-Extensions` und liefert Agent Cards mit Cache-Headern aus. +- **Push-Benachrichtigungen** - Unterstuetzt A2A-Task-Push-Notification-Config-Methoden, wenn `push-notifications: true` konfiguriert ist, mit lokalen dateibasierten oder Redis-gestuetzten Zustellqueues. +- **Agent-Card-Signierung** - Fuegt optionale A2A-SDK-JWS-Signaturen fuer Agent Cards hinzu und unterstuetzt `kid`-basierte Verifikation mit konfigurierten Schluesseln, lokalen Octet-JWKS-Daten oder einer entfernten JWKS-URL. +- **Mehrere Transports** - Laeuft ueber HTTP, stdio, Unix-Sockets, WebSocket, offizielles gRPC, benutzerdefiniertes gRPC JSON-RPC und Redis Streams-Transports. +- **CLI-Operationen** - Bietet Befehle fuer Discovery, Nachrichtensenden, Task-Abfrage/-Liste/-Abbruch/-Abo, Push-Config-CRUD, erweiterte Karten und Routenvorschauen. + +## Phase-1-Unterstuetzung + +iac-code unterstuetzt A2A-Servermodus ueber HTTP JSON-RPC/REST und mehrere optionale Transports sowie Phase-1-Clientmodus fuer das Aufrufen entfernter A2A-Agents. Es kann entfernte Agent Cards entdecken, beworbene Endpunkte auswaehlen, A2A 1.0-Prompts senden, Tasks abfragen/auflisten/abbrechen/abonnieren, zu konfigurierten Agents routen, lokale Task-/Kontext-Wiederherstellungsmetadaten persistieren, lokale Artifact-Payloads als Standard-Task-Artifacts speichern, erforderliche Extensions validieren, Push-Notification-Configs verwalten und Agent Cards mit HMAC- oder JWKS-Metadaten signieren oder verifizieren. + +## In Phase 1 nicht unterstuetzt {#phase-1-unsupported} + +- stdio, Unix-Sockets, WebSocket, gRPC JSON-RPC Envelope und Redis Streams sind experimentelle benutzerdefinierte JSON-RPC-Transports. +- Offizielles gRPC erfordert optionale Abhaengigkeiten und verwendet standardmaessig ein unsicheres lokales Server-Binding. +- Kein verteilter oder gemeinsamer Task-Store. Persistenz ist lokale Dateispeicherung im Laufzeit-Konfigurationsbereich von iac-code. +- Keine Wiederherstellung eines laufenden asyncio-Tasks nach einem Prozessneustart. +- Keine automatische Hintergrundfortsetzung unterbrochener entfernter Tasks. +- Kein OSS-, S3-, Datenbank- oder externer Object-Store-Artifact-Backend. +- Keine Aufnahme entfernter HTTP-URLs, kein Chunking grosser Binaerdaten und kein fortsetzbares Upload-Protokoll. Lokale File-URL-Teile muessen innerhalb der erlaubten Workspace-Roots bleiben. +- Kein standardmaessiger harter Fehler fuer unsignierte Agent Cards. +- Keine asymmetrische Agent-Card-Signierung vom Server und keine automatische Rotation von Signierschluesseln. +- Kein autonomer Planner-DAG und keine komplexe Multi-Agent-Orchestrierung. +- Push-Zustellung ist fuer Redis-gestuetzte Queues mindestens einmal; Callback-Empfaenger muessen Duplikate behandeln und ihre eigene autorisierungsseitige Endpoint-Policy erzwingen. + +Tool-Berechtigungsanfragen werden im A2A-Servermodus automatisch abgelehnt. Fuehren Sie den unauthentifizierten A2A-Modus nur in vertrauenswuerdigen lokalen Umgebungen aus oder schuetzen Sie ihn mit Bearer-Token-, Basic-Auth- oder API-Key-Authentifizierung. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md new file mode 100644 index 00000000..92b8b016 --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -0,0 +1,400 @@ +--- +title: Protokollreferenz +description: Vollstaendige A2A-Protokollreferenz fuer die iac-code-Integration. +sidebar_position: 4 +--- + +# Protokollreferenz + +Dieses Dokument beschreibt die vom iac-code-Server bereitgestellte A2A 1.0-Oberflaeche und das Phase-1-Clientverhalten, das von `iac-code a2a-client call` verwendet wird. Exakte CLI-Optionen finden Sie in der [Befehlsreferenz](./command-reference.md). + +## Lifecycle-Uebersicht + +Eine typische A2A-Interaktion folgt diesem Ablauf: + +```text +GET Agent Card -> SendMessage or SendStreamingMessage -> GetTask / follow-up / CancelTask +``` + +1. **Entdecken** - `/.well-known/agent-card.json` abrufen. +2. **Senden** - Eine Textnachricht an den JSON-RPC-Endpunkt unter `/` einreichen. +3. **Streamen** - `Task`-, `Message`- und `TaskStatusUpdateEvent`-Payloads empfangen. +4. **Fortsetzen** - Eine Follow-up-Nachricht mit derselben `contextId` senden. +5. **Abbrechen oder abfragen** - `CancelTask`, `GetTask` oder `ListTasks` verwenden. + +## Agent Card + +Die Agent Card ist verfuegbar unter: + +```text +GET /.well-known/agent-card.json +``` + +Wichtige Felder: + +| Feld | Wert | Bedeutung | +|-------|-------|---------| +| `name` | `iac-code` | Agent-Name | +| `supportedInterfaces[0].protocolBinding` | `JSONRPC` | Transport-Binding | +| `supportedInterfaces[0].protocolVersion` | `1.0` | A2A-Protokollversion | +| `supportedInterfaces[0].url` | `http://:/` | JSON-RPC-Endpunkt | +| `capabilities.streaming` | `true` | Unterstuetzt Streaming-Task-Aktualisierungen | +| `capabilities.pushNotifications` | `false` oder `true` | `true`, wenn `push-notifications: true` konfiguriert ist | +| `capabilities.extendedAgentCard` | `true` | Authentifizierte Aufrufer koennen erweiterte Laufzeitdetails anfordern | +| `capabilities.extensions` | `urn:iac-code:a2a:artifact-metadata:v1` | Optionaler iac-code-Metadaten-Namespace fuer Tool-Status und gespeicherte Artifact-Metadaten | +| `defaultInputModes` | Text-, JSON-, YAML-, Bild-, Audio- und Binaer-MIME-Typen | Akzeptierte Eingabe-MIME-Modi | +| `defaultOutputModes` | `["text/plain"]` | Nur Textausgabe | + +Agent-Card-Antworten enthalten `Cache-Control: public, max-age=60`, `ETag` und `Last-Modified`. Clients koennen `If-None-Match` senden und `304 Not Modified` erhalten, wenn die Karte unveraendert ist. + +Beworbene Skills: + +| Skill ID | Zweck | +|----------|---------| +| `iac_generation` | Alibaba Cloud ROS- und Terraform-Templates aus natuerlicher Sprache generieren | +| `iac_review` | IaC-Templates pruefen und Korrekturen vorschlagen | +| `aliyun_ros_operations` | Bei Alibaba Cloud ROS Stack-Workflows unterstuetzen | +| `terraform_ros_conversion` | Terraform-zu-ROS-Konvertierung mit gebuendelten Skill-Ressourcen unterstuetzen | + +Wenn Authentifizierung aktiviert ist, bewirbt die Agent Card die konfigurierten Sicherheitsschemas: + +| Schema | Wann beworben | +|--------|-----------------| +| `bearerAuth` | `token` oder `IACCODE_A2A_HTTP_TOKEN` ist gesetzt | +| `basicAuth` | Basic-Benutzername und Passwort sind beide gesetzt | +| `apiKeyAuth` | `api-key` oder `IACCODE_A2A_API_KEY` ist gesetzt | + +## Routen + +| Route | Methode | Beschreibung | +|-------|--------|-------------| +| `/health` | `GET` | Gibt `{"status":"healthy"}` zurueck | +| `/.well-known/agent-card.json` | `GET` | Gibt die Agent Card zurueck | +| `/` | `POST` | Verarbeitet A2A JSON-RPC-Anfragen | +| REST-Routen | gemischt | Die von `create_rest_routes` registrierten A2A-SDK-REST-Routen | + +## Phase-1-Client- und Transporthinweise + +Der standardmaessige interoperable Phase-1-Transport ist JSON-RPC ueber HTTP. Der HTTP-Modus bewirbt ausserdem `HTTP+JSON` fuer die SDK-REST-Routen. + +Der Server hat auch optionale Transports fuer stdio, Unix-Sockets, WebSocket, offizielles gRPC, gRPC JSON-RPC Envelope und Redis Streams. stdio, Unix-Sockets, WebSocket, gRPC JSON-RPC und Redis Streams sind benutzerdefinierte JSON-RPC-Transports. Offizielles gRPC wird als `grpc` beworben und erfordert optionale gRPC-Abhaengigkeiten. + +Der eingebaute Client verwendet Agent-Card-Discovery (`GET /.well-known/agent-card.json`) vor Nachrichtenaufrufen, waehlt die erste beworbene ausfuehrbare `supportedInterfaces[].url` und sendet dann JSON-RPC-Anfragen mit `A2A-Version: 1.0` und A2A 1.0-Methodennamen wie `SendMessage`. + +`push-notifications: true` aktiviert A2A-Push-Notification-Konfigurationsmethoden und Zustellung fuer Terminalzustaende. + +Agent-Card-Signierung verwendet das A2A-SDK-Signing-Utility und gibt standardmaessige `AgentCardSignature`-JWS-Felder aus. Der symmetrische Schluesselmodus verwendet `HS256`; die Verifikation kann anhand des Protected-Header-`kid` ein konfiguriertes Secret, ein lokales Octet-Key-JWKS oder eine entfernte JWKS-URL auswaehlen. Serverseitige asymmetrische Signierung und automatische Schluesselrotation sind in Phase 1 nicht implementiert. + +Die kanonische Liste des in Phase 1 nicht unterstuetzten Verhaltens finden Sie unter [A2A-Protokoll](./overview.md#phase-1-unsupported). + +## Push-Notification-Zustell-Backends + +`iac-code a2a --config a2a-server.yml` unterstuetzt zwei Push-Zustellqueues: + +- `push-queue: local-file` speichert Jobs unterhalb des A2A-Persistenzverzeichnisses und ist fuer lokale Single-Node-Nutzung gedacht. +- `push-queue: redis-streams` speichert Jobs in Redis Streams und koordiniert Worker ueber eine Redis Consumer Group. + +Redis-gestuetzte Push-Zustellung erfordert das optionale Extra `a2a-redis` und ist mindestens einmal. Callback-Empfaenger sollten Task-Aktualisierungen idempotent behandeln, da ein Job nach Worker-Crashes, Lease-Ablauf, Reconnects oder Retry-Races erneut zugestellt werden kann. + +Haeufige Redis-Optionen: + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +push-lease-timeout-ms: 300000 +``` + +Callback-URLs werden vor dem Speichern und erneut vor dem Versand validiert. Der Standardvalidator lehnt Nicht-HTTP(S)-URLs, localhost-Hostnamen und literale private/lokale IP-Adressen ab. Callback-Empfaenger sollten dennoch ihre eigene Authentifizierungs- und Idempotenz-Policy erzwingen. + +## JSON-RPC-Methoden + +### SendMessage + +Fuehrt einen nicht streamenden A2A-Nachrichten-Turn aus. Die Antwort enthaelt einen Task oder eine Nachricht, nachdem der Turn abgeschlossen ist. + +**Anfrage** + +```json +{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a VPC with two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +**Erforderliche Nachrichtenfelder** + +| Feld | Typ | Erforderlich | Beschreibung | +|-------|------|----------|-------------| +| `messageId` | string | Ja | Eindeutige Client-Nachrichten-ID | +| `role` | string | Ja | `ROLE_USER` fuer Benutzereingaben verwenden | +| `parts` | array | Ja | Textartige, JSON-Daten-, Rohtext-, lokale File-URL- oder begrenzte multimodale Teile | +| `metadata.iac_code.cwd` | string | Empfohlen | Absoluter Workspace-Pfad; faellt auf das Server-Prozessverzeichnis zurueck, wenn ausgelassen | + +`metadata.iac_code.cwd` muss, wenn angegeben, ein vorhandenes absolutes Verzeichnis sein. Es muss innerhalb eines erlaubten Workspace-Roots liegen. Standardmaessig sind die erlaubten Roots das Server-Prozessverzeichnis und das System-Temp-Verzeichnis; `IACCODE_A2A_ALLOWED_CWDS` kann eine OS-pfadgetrennte Allowlist bereitstellen. + +Unterstuetzte Eingabekategorien: + +| Kategorie | Akzeptierte Form | Grenzen und Verhalten | +|----------|----------------|---------------------| +| Textartige Teile | `text` mit `text/plain`, JSON, Markdown, YAML oder konfigurierten zusaetzlichen Text-MIME-Typen | Direkt an den Prompt angehaengt | +| JSON-Datenteile | `data` mit `application/json` | In kompaktes JSON serialisiert; max. 1 MiB inline | +| Rohtextteile | `raw` mit einem textartigen MIME-Typ | Muss gueltiges UTF-8 sein; max. 1 MiB inline | +| Lokale Textdatei-URLs | `url` mit `file://...` und textartigem MIME-Typ | Datei muss innerhalb von `cwd` und erlaubten Roots existieren; max. 1 MiB | +| Multimodale Raw-/Data-/File-Teile | Bild-, Audio- oder konfigurierte multimodale MIME-Typen | In ein Prompt-Manifest mit Dateiname, Medientyp, Bytegroesse, Hash und Quelle konvertiert; Raw/Data max. 5 MiB, File-URL max. 25 MiB | + +Die Aufnahme entfernter HTTP(S)-URLs wird nicht unterstuetzt. File-URL-Teile muessen lokale `file://`-URLs verwenden und innerhalb des erlaubten Workspace bleiben. + +### SendStreamingMessage + +Fuehrt einen streamenden A2A-Nachrichten-Turn aus. Der Anfragebody hat dieselbe Form wie `SendMessage`, aber der Server streamt JSON-RPC-Antworten als Server-Sent Events. + +```json +{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +### GetTask + +Gibt den gespeicherten A2A-Task per ID zurueck. Verwenden Sie `historyLength`, um die zurueckgegebene Historie zu begrenzen, ohne die gespeicherte Task-Historie zu veraendern. Lassen Sie es aus, um die aktuelle Standardhistorie des Servers zu erhalten. + +```json +{ + "jsonrpc": "2.0", + "id": "3", + "method": "GetTask", + "params": { + "id": "task-id", + "historyLength": 10 + } +} +``` + +### ListTasks + +Gibt bekannte Tasks zurueck, die fuer den authentifizierten Aufrufer sichtbar sind. Ergebnisse werden nach Status-Zeitstempel absteigend und dann nach Task-ID absteigend fuer stabile Reihenfolge sortiert. Der Server unterstuetzt `contextId`, `status`, `pageSize`, `pageToken`, `historyLength` und `includeArtifacts`. + +```json +{ + "jsonrpc": "2.0", + "id": "4", + "method": "ListTasks", + "params": { + "contextId": "ctx-id", + "status": "TASK_STATE_WORKING", + "pageSize": 20, + "includeArtifacts": false + } +} +``` + +`nextPageToken` wird zurueckgegeben, wenn eine weitere Seite verfuegbar ist. `includeArtifacts` ist standardmaessig `false`, sodass Listenantworten Task-Artifacts auslassen, sofern sie nicht explizit angefordert werden. + +### CancelTask + +Fordert den Abbruch eines laufenden Tasks an. + +```json +{ + "jsonrpc": "2.0", + "id": "5", + "method": "CancelTask", + "params": { + "id": "task-id" + } +} +``` + +Wenn der Task aktiv ist, bricht der Server den laufenden Agent-Turn ab und gibt einen abgebrochenen Task-Zustand aus. Wenn der Task existiert, aber nicht laeuft, gibt der Server den standardmaessigen A2A-`TaskNotCancelableError` zurueck. + +### SubscribeToTask + +Abonniert einen aktiven Task-Aktualisierungsstream, wenn der Client-Transport dies unterstuetzt. + +```json +{ + "jsonrpc": "2.0", + "id": "6", + "method": "SubscribeToTask", + "params": { + "id": "task-id" + } +} +``` + +Fuer aktive Tasks beginnt der Stream mit dem aktuellen `Task`, gibt dann nachfolgende Task-Events aus und schliesst, wenn der aktive Turn beendet ist. Das Abonnieren eines abgeschlossenen, fehlgeschlagenen, abgebrochenen oder input-required Tasks gibt einen task-not-found-artigen Fehler zurueck, statt unbegrenzt zu warten. Fuer neue Turns bevorzugen Sie `SendStreamingMessage`; es startet die Ausfuehrung und streamt die Antwort in einer Anfrage. + +### Push-Notification-Config-Methoden + +Wenn der Server mit `push-notifications: true` startet, unterstuetzt er: + +| Methode | Zweck | +|--------|---------| +| `CreateTaskPushNotificationConfig` | Eine Callback-Config fuer einen Task speichern | +| `GetTaskPushNotificationConfig` | Eine Callback-Config abrufen | +| `ListTaskPushNotificationConfigs` | Callback-Configs fuer einen Task auflisten | +| `DeleteTaskPushNotificationConfig` | Eine Callback-Config loeschen | + +Beispiel fuer eine Create-Anfrage: + +```json +{ + "jsonrpc": "2.0", + "id": "7", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-id", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token" + } + } +} +``` + +Der Server verschluesselt gespeicherte Notification-Tokens und Callback-Authentifizierungszugangsdaten, wenn der lokale Push-Keyring verfuegbar ist. + +### GetExtendedAgentCard + +Authentifizierte Clients koennen die erweiterte Agent Card anfordern: + +```json +{ + "jsonrpc": "2.0", + "id": "8", + "method": "GetExtendedAgentCard", + "params": {} +} +``` + +Die erweiterte Karte enthaelt die oeffentliche Karte plus authentifizierte Laufzeitdetails. + +## Task- und Kontextverhalten + +iac-code bildet A2A-Kontexte auf interne Agent-Laufzeiten ab: + +| Konzept | Verhalten | +|---------|----------| +| `contextId` omitted | Das SDK/der Server generiert eine neue Kontext-ID | +| Same `contextId` | Verwendet dieselbe interne iac-code-Sitzung und denselben Unterhaltungszustand wieder | +| Same `contextId`, different `cwd` | Wird als anderer Workspace abgelehnt | +| Same `contextId`, concurrent message | Wird mit `Task is already working.` abgelehnt | +| Different `contextId` values | Koennen parallel ausgefuehrt werden | +| Idle context | Wird nach dem konfigurierten Idle-Timeout aus dem Speicher entfernt | + +Task- und Kontext-IDs muessen nicht leer sein, hoechstens 128 Zeichen haben und duerfen nur Buchstaben, Ziffern, `_`, `.`, `:` oder `-` enthalten. + +## Task-Zustaende + +| Zustand | Bedeutung | +|-------|---------| +| `TASK_STATE_SUBMITTED` | Der Task wurde akzeptiert | +| `TASK_STATE_WORKING` | iac-code fuehrt den Agent-Turn aus | +| `TASK_STATE_INPUT_REQUIRED` | Der Turn wurde abgeschlossen und der Agent ist bereit fuer Follow-up-Eingaben | +| `TASK_STATE_CANCELED` | Abbruch wurde angefordert und angewendet | +| `TASK_STATE_FAILED` | Der Task ist bei Validierung oder Ausfuehrung fehlgeschlagen | + +iac-code verwendet `TASK_STATE_INPUT_REQUIRED` als normalen abgeschlossenen Zustand, da der Kontext fuer Follow-up-Nachrichten verfuegbar bleibt. + +## Streaming-Aktualisierungen + +Waehrend der Ausfuehrung gibt iac-code `TaskStatusUpdateEvent`-Aktualisierungen aus. + +Assistant-Text wird als Statusnachricht geliefert: + +```json +{ + "statusUpdate": { + "taskId": "task-1", + "contextId": "ctx-1", + "status": { + "state": "TASK_STATE_WORKING", + "message": { + "role": "ROLE_AGENT", + "parts": [{"text": "Here is the ROS template..."}] + } + } + } +} +``` + +Tool- und Nutzungsdetails werden ueber `metadata.iac_code` geliefert: + +| Metadatenpfad | Beschreibung | +|---------------|-------------| +| `iac_code.tool.status` | `started`, `input_delta`, `input_complete`, `completed` oder `failed` | +| `iac_code.tool.toolUseId` | Stabile Tool-Use-ID zur Korrelation von Tool-Events | +| `iac_code.tool.name` | Tool-Name, wenn verfuegbar | +| `iac_code.tool.input` | Abgeschlossene Tool-Eingabe, pro Feld auf 4000 Zeichen gekuerzt | +| `iac_code.tool.result` | Tool-Ergebnis, pro Feld auf 4000 Zeichen gekuerzt | +| `iac_code.permission.autoApproved` | `false`, wenn eine Tool-Berechtigungsanfrage vom A2A-Servermodus abgelehnt wurde | +| `iac_code.usage.inputTokens` | Anzahl der Eingabetoken fuer den Turn | +| `iac_code.usage.outputTokens` | Anzahl der Ausgabetoken fuer den Turn | +| `iac_code.usage.totalTokens` | Gesamtzahl der Token fuer den Turn | + +Wenn ein Tool-Ergebnis eine unterstuetzte Text-Artifact-Payload enthaelt, speichert der Server die Payload lokal, gibt ein standardmaessiges `TaskArtifactUpdateEvent` aus und zeichnet das Artifact im Task-Feld `artifacts` auf. Der Artifact-Teil verwendet eine `file://`-URL plus Metadaten wie `mediaType`, `byteSize` und `sha256`; der urspruengliche Artifact-Inhalt wird nicht innerhalb der Tool-Metadaten dupliziert. + +## Extensions + +Die Agent Card bewirbt die optionale iac-code-Artifact-Metadaten-Extension: + +```text +urn:iac-code:a2a:artifact-metadata:v1 +``` + +Diese Extension identifiziert den Namespace `metadata.iac_code`, der fuer Tool-Fortschritt, Berechtigungsentscheidungen, Token-Nutzung und lokale Artifact-Metadaten verwendet wird. Wenn der Server mit einer erforderlichen Extension konfiguriert ist, muessen Clients ihre URI im Header `A2A-Extensions` einschliessen. Fehlende erforderliche Extensions geben den standardmaessigen A2A-`ExtensionSupportRequiredError` zurueck. + +## Fehlerbehandlung + +| Szenario | Ergebnis | +|----------|--------| +| Leere Texteingabe | `TASK_STATE_FAILED` mit `A2A server currently accepts text input only.` | +| Nicht unterstuetzter Medientyp | Validierungsfehler oder standardmaessiger A2A-Content-Type-Fehler, je nachdem, wo das SDK die Anfrage ablehnt | +| Remote-URL-Teil | Validierungsfehler, weil URL-Teile lokale `file://`-URLs verwenden muessen | +| File-URL ausserhalb des erlaubten Workspace | Validierungsfehler | +| Fehlende erforderliche A2A-Extension | Standardmaessiger A2A-`ExtensionSupportRequiredError` | +| Ungueltige Workspace-Metadaten | `TASK_STATE_FAILED` mit einer Meldung zu ungueltigem Workspace | +| Fehlende oder ungueltige Authentifizierung | HTTP `401` mit `{"error":"Unauthorized"}` | +| Fehlende A2A-Serverabhaengigkeiten | CLI beendet sich mit einem Installationshinweis fuer das Extra `a2a` | +| Provider-Zugangsdaten fehlen | Bereinigter Authentifizierungsfehler | +| Unerwarteter Laufzeitfehler | Bereinigter interner Fehler | + +Der Server vermeidet es, lokale Pfade, Secrets und Provider-Details in unerwarteten Fehlermeldungen zurueckzugeben. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/command-reference.md new file mode 100644 index 00000000..bf7fe0ca --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -0,0 +1,504 @@ +--- +title: Referencia de comandos +description: Referencia completa de comandos CLI para ejecutar y llamar a iac-code sobre A2A. +sidebar_position: 3 +--- + +# Referencia de comandos A2A + +Esta página documenta todos los comandos de `iac-code` relacionados con A2A. Úsala cuando necesites nombres exactos de opciones, patrones comunes de comandos y el significado operativo de cada flag. + +## Resumen de comandos + +| Comando | Propósito | +|---------|-----------| +| `iac-code a2a` | Ejecutar iac-code como servidor A2A | +| `iac-code a2a-client call` | Descubrir una Agent Card remota y enviar un prompt | +| `iac-code a2a-client discover` | Obtener y opcionalmente verificar una Agent Card | +| `iac-code a2a-client task-get` | Obtener una tarea por ID | +| `iac-code a2a-client task-list` | Listar tareas con filtros y paginación | +| `iac-code a2a-client task-cancel` | Cancelar una tarea activa | +| `iac-code a2a-client task-subscribe` | Suscribirse a un stream de eventos de una tarea activa | +| `iac-code a2a-client push-config-create` | Crear una configuración de notificación push de tarea | +| `iac-code a2a-client push-config-get` | Obtener una configuración de notificación push de tarea | +| `iac-code a2a-client push-config-list` | Listar configuraciones de notificación push de tarea | +| `iac-code a2a-client push-config-delete` | Eliminar una configuración de notificación push de tarea | +| `iac-code a2a-client extended-card` | Obtener la Agent Card extendida autenticada | +| `iac-code a2a-route-preview` | Previsualizar la selección local de ruta para `a2a-client call` | + +Todos los comandos de cliente HTTP aceptan las mismas opciones de autenticación: + +| Opción | Descripción | +|--------|-------------| +| `--token` | Token Bearer enviado como `Authorization: Bearer ` | +| `--basic-username` | Nombre de usuario de Basic auth | +| `--basic-password` | Contraseña de Basic auth | +| `--api-key` | Valor de clave de API | +| `--api-key-header` | Nombre del encabezado de clave de API; por defecto es `X-API-Key` | + +## Configuración del cliente A2A + +Todos los subcomandos `a2a-client` aceptan un archivo de configuración YAML a nivel de grupo: + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC" +``` + +Las opciones de CLI sobrescriben los valores de configuración. Usa la configuración para conexión estable, autenticación, verificación, enrutamiento y ajustes repetidos de tareas o push; mantén el texto de prompts puntuales en la línea de comandos. + +```yaml +url: http://127.0.0.1:41242/ +token: your-bearer-token +basic-username: iac-code +basic-password: your-password +api-key: your-api-key +api-key-header: X-IAC-Code-Key +verify-card-secret: your-card-signing-secret +verify-card-jwks-url: https://a2a.example.com/.well-known/jwks.json +require-card-signature: true +timeout: 30 +cwd: /path/to/workspace +context-id: ctx-123 +task-id: task-123 +config-id: webhook-1 +callback-url: https://hooks.example.com/a2a +notification-token: notification-token +auth-scheme: bearer +auth-credentials: callback-token +routes: + - name: ros + url: http://127.0.0.1:41242/ + skills: + - iac_generation + tags: + - ros + - template +``` + +## `iac-code a2a` + +Ejecuta iac-code como servidor A2A. + +```bash +iac-code a2a +``` + +De forma predeterminada, el servidor se enlaza a `127.0.0.1:41242` y sirve JSON-RPC sobre HTTP. El puerto `41242` es el predeterminado de iac-code; no es un puerto A2A registrado. + +### Opciones básicas del servidor + +| Opción | Predeterminado | Descripción | +|--------|----------------|-------------| +| `--config` | vacío | Archivo de configuración YAML que contiene opciones del servidor A2A | +| `--host` | `127.0.0.1` | Host del servidor HTTP | +| `--port` | `41242` | Puerto del servidor HTTP | +| `--transport` | `http` | Transporte del servidor: `http`, `stdio`, `unix`, `websocket`, `grpc`, `grpc-jsonrpc` o `redis-streams` | +| `--debug`, `-d` | `false` | Habilitar logging de depuración | + +Ejemplo: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --debug +``` + +### Configuración YAML + +Usa `--config` para autenticación, almacenamiento, firma, ajustes específicos de transporte, entrega push y otros detalles de despliegue. Las claves pueden usar guiones o guiones bajos. Los flags comunes de CLI `--host`, `--port` y `--transport` sobrescriben los valores del archivo de configuración. + +```yaml +host: 127.0.0.1 +port: 41242 +transport: http +token: local-dev-token +persistence-dir: .iac-code-a2a/state +artifact-dir: .iac-code-a2a/artifacts +push-notifications: true +``` + +Ejecútalo con: + +```bash +iac-code a2a --config a2a-server.yml --port 41243 +``` + +### Autenticación HTTP + +La autenticación es opcional. Configura la autenticación del servidor en YAML o con variables de entorno. Si no se configura ningún ajuste de autenticación, las solicitudes no están autenticadas. Cuando se configuran uno o más esquemas, una solicitud puede satisfacer cualquier esquema configurado. + +| Clave de configuración | Variable de entorno | Descripción | +|--------|----------------------|-------------| +| `token` | `IACCODE_A2A_HTTP_TOKEN` | Token Bearer | +| `basic-username` | `IACCODE_A2A_BASIC_USERNAME` | Nombre de usuario de Basic auth | +| `basic-password` | `IACCODE_A2A_BASIC_PASSWORD` | Contraseña de Basic auth | +| `api-key` | `IACCODE_A2A_API_KEY` | Valor de clave de API | +| `api-key-header` | `IACCODE_A2A_API_KEY_HEADER` | Nombre del encabezado de clave de API | + +Token Bearer: + +```yaml +token: local-dev-token +``` + +Basic auth: + +```yaml +basic-username: iac-code +basic-password: local-dev-password +``` + +Clave de API: + +```yaml +api-key: local-dev-key +api-key-header: X-IAC-Code-Key +``` + +### Persistencia y artefactos + +| Clave de configuración | Predeterminado | Descripción | +|--------|---------|-------------| +| `persistence-dir` | `~/.iac-code/a2a` | Metadatos JSON locales para tareas, contextos, rutas y configuraciones push | +| `artifact-dir` | `/artifacts` | Almacén local de payloads de artefactos | + +La persistencia refleja instantáneas de tareas y contextos como metadatos de restauración. No reinicia una tarea asyncio en curso después de un fallo del proceso. + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +``` + +### Firma de Agent Card + +| Clave de configuración | Descripción | +|--------|-------------| +| `signing-secret` | Secreto HMAC usado para firmar la Agent Card pública | + +El servidor emite campos JWS `AgentCardSignature` del SDK de A2A. El modo simétrico usa `HS256`. + +```yaml +signing-secret: local-card-signing-secret +``` + +### Entrega de notificaciones push + +| Clave de configuración | Predeterminado | Descripción | +|--------|---------|-------------| +| `push-notifications` | `false` | Habilitar métodos de configuración de notificaciones push de tareas A2A y entrega de estados terminales | +| `push-queue` | `local-file` | Backend de cola push: `local-file` o `redis-streams` | +| `push-redis-url` | vacío | URL de Redis para la cola push respaldada por Redis | +| `push-stream` | `iac-code:a2a:push` | Stream de Redis para trabajos push | +| `push-retry-key` | `iac-code:a2a:push:retry` | Conjunto ordenado de Redis para reintentos retrasados | +| `push-dead-stream` | `iac-code:a2a:push:dead` | Stream de Redis para trabajos de dead-letter | +| `push-consumer-group` | `iac-code-push` | Grupo de consumidores Redis para workers push | +| `push-consumer-name` | vacío | Nombre de consumidor Redis para este worker | +| `push-lease-timeout-ms` | `300000` | Timeout de lease pendiente de Redis | + +Cola de archivo local: + +```yaml +push-notifications: true +persistence-dir: ~/.iac-code/a2a +push-queue: local-file +``` + +Cola Redis Streams: + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +``` + +La entrega push respaldada por Redis requiere el extra `a2a-redis`. + +### Opciones de transporte + +| Transporte | Comando | Notas | +|------------|---------|-------| +| HTTP JSON-RPC y REST | `iac-code a2a --transport http` | Predeterminado. Anuncia interfaces `JSONRPC` y `HTTP+JSON`. | +| stdio | `iac-code a2a --transport stdio` | Frames JSON-RPC personalizados experimentales sobre entrada/salida estándar. | +| Socket Unix | `iac-code a2a --config a2a-server.yml --transport unix` | Requiere `socket-path` en la configuración. | +| WebSocket | `iac-code a2a --config a2a-server.yml --transport websocket` | Usa `ws-path` desde la configuración, con valor predeterminado `/a2a`. | +| gRPC | `iac-code a2a --config a2a-server.yml --transport grpc` | Usa `grpc-host` y `grpc-port` desde la configuración. | +| gRPC JSON-RPC | `iac-code a2a --config a2a-server.yml --transport grpc-jsonrpc` | Envoltorio JSON-RPC personalizado sobre gRPC. | +| Redis Streams | `iac-code a2a --config a2a-server.yml --transport redis-streams` | Requiere `redis-url` en la configuración. | + +Opciones de transporte Redis Streams: + +| Clave de configuración | Predeterminado | Descripción | +|--------|---------|-------------| +| `redis-url` | vacío | URL de conexión Redis; requerida para `--transport redis-streams` | +| `request-stream` | `iac-code:a2a:requests` | Nombre del stream de solicitudes | +| `response-stream` | `iac-code:a2a:responses` | Nombre del stream de respuestas | +| `consumer-group` | `iac-code` | Grupo de consumidores del stream de solicitudes | + +### Comportamiento de permisos + +| Clave de configuración | Predeterminado | Descripción | +|--------|---------|-------------| +| `auto-approve-permissions` | `false` | Aprobar automáticamente solicitudes de permisos de herramientas generadas durante turnos A2A | + +Sin `auto-approve-permissions: true`, el modo A2A rechaza solicitudes de permisos y emite metadatos de permisos. Úsalo solo para entornos de automatización de confianza. + +## `iac-code a2a-client call` + +Descubre una Agent Card, elige el endpoint anunciado y envía un prompt. + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" +``` + +| Opción | Predeterminado | Descripción | +|--------|----------------|-------------| +| `--url` | vacío | URL base del agente A2A o URL del endpoint JSON-RPC; puede venir de la configuración | +| `--route` | repetible | Especificación de ruta usada cuando `--url` se omite | +| `--route-name` | vacío | Ruta con nombre que seleccionar | +| `--prompt`, `-p` | requerido | Texto del prompt | +| `--cwd` | `.` | Ruta de espacio de trabajo enviada como `message.metadata.iac_code.cwd` | +| `--context-id` | vacío | ID de contexto A2A existente para un mensaje de seguimiento | +| `--verify-card-secret`, `--signing-secret` | vacío | Secreto HMAC para verificación de Agent Card | +| `--verify-card-jwks-url` | vacío | URL JWKS remota usada para verificación de Agent Card | +| `--require-card-signature`, `--require-signature` | `false` | Rechazar Agent Cards sin firmar o inválidas | +| `--timeout` | `30.0` | Timeout de llamada en segundos | +| `--stream` | `false` | Usar `SendStreamingMessage` e imprimir eventos de stream | + +Seguimiento en el mismo contexto: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --context-id ctx-123 \ + --prompt "Now add outputs for the VPC and vSwitch IDs." \ + --cwd "$PWD" +``` + +Streaming: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this Terraform module." \ + --cwd "$PWD" \ + --stream +``` + +Requerir una Agent Card firmada: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a production VPC template." \ + --cwd "$PWD" +``` + +Verificar usando una URL JWKS remota: + +```bash +iac-code a2a-client --config jwks-client.yml call \ + --prompt "Review the ROS stack." +``` + +## `iac-code a2a-client discover` + +Obtiene e imprime una Agent Card remota. + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +| Opción | Descripción | +|--------|-------------| +| `--url` | URL base del agente A2A; puede venir de la configuración | +| `--verify-card-secret`, `--signing-secret` | Secreto HMAC para verificación | +| `--verify-card-jwks-url` | URL JWKS remota para verificación | +| `--require-card-signature`, `--require-signature` | Requerir una firma válida | + +Descubrimiento autenticado: + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +## Comandos de tareas + +Los comandos de tareas llaman directamente a métodos de tarea JSON-RPC. Son útiles para herramientas operativas, paneles y depuración. + +### `iac-code a2a-client task-get` + +```bash +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +| Opción | Descripción | +|--------|-------------| +| `--url` | URL del endpoint A2A JSON-RPC; puede venir de la configuración | +| `--task-id` | ID de tarea; puede venir de la configuración | +| `--history-length` | Entradas máximas de historial de tarea que devolver | + +### `iac-code a2a-client task-list` + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --context-id ctx-123 \ + --status TASK_STATE_INPUT_REQUIRED \ + --page-size 20 \ + --output table +``` + +| Opción | Predeterminado | Descripción | +|--------|----------------|-------------| +| `--url` | vacío | URL del endpoint A2A JSON-RPC; puede venir de la configuración | +| `--context-id` | vacío | Filtrar por ID de contexto | +| `--status` | vacío | Filtrar por estado de tarea | +| `--page-size` | vacío | Máximo de tareas que devolver | +| `--page-token` | vacío | Token de paginación | +| `--include-artifacts` | `false` | Incluir artefactos de tarea en la respuesta | +| `--output` | `table` | `table` o `json` | + +Salida JSON: + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --include-artifacts \ + --output json +``` + +### `iac-code a2a-client task-cancel` + +```bash +iac-code a2a-client --config a2a-client.yml task-cancel \ + --task-id task-123 +``` + +La cancelación es cooperativa. Una tarea completada, fallida, cancelada o que requiere entrada devuelve el error estándar A2A de tarea no cancelable. + +### `iac-code a2a-client task-subscribe` + +```bash +iac-code a2a-client --config a2a-client.yml task-subscribe \ + --task-id task-123 +``` + +El comando transmite eventos para tareas activas. Para un nuevo turno, prefiere `a2a-client call --stream`; inicia la tarea y transmite actualizaciones en un solo comando. + +## Comandos de configuración de notificaciones push + +Estos comandos requieren un servidor iniciado con `push-notifications: true`. Gestionan configuraciones estándar de notificaciones push de tareas A2A. + +### `iac-code a2a-client push-config-create` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +| Opción | Descripción | +|--------|-------------| +| `--url` | URL del endpoint A2A JSON-RPC; puede venir de la configuración | +| `--task-id` | ID de tarea; puede venir de la configuración | +| `--config-id` | ID de configuración push; puede venir de la configuración | +| `--callback-url` | URL de callback HTTP(S); puede venir de la configuración | +| `--notification-token` | Token enviado como `X-A2A-Notification-Token` | +| `--auth-scheme` | Esquema de autenticación del callback, como `bearer` o `basic` | +| `--auth-credentials` | Credenciales de autenticación del callback | + +Las URL de callback se validan antes del almacenamiento y del despacho. El validador predeterminado rechaza URL que no sean HTTP(S), nombres localhost y direcciones IP literales privadas/locales. + +### `iac-code a2a-client push-config-get` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-get \ + --task-id task-123 \ + --config-id webhook-1 +``` + +### `iac-code a2a-client push-config-list` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-list \ + --task-id task-123 \ + --page-size 10 +``` + +### `iac-code a2a-client push-config-delete` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-delete \ + --task-id task-123 \ + --config-id webhook-1 +``` + +## `iac-code a2a-client extended-card` + +Obtiene la Agent Card extendida autenticada. + +```bash +iac-code a2a-client --config a2a-client.yml extended-card \ + --token "$A2A_TOKEN" +``` + +La Agent Card pública anuncia `capabilities.extendedAgentCard=true`. La tarjeta extendida agrega detalles autenticados del runtime, incluidos metadatos de capacidades de gestión de tareas y configuración push. + +## `iac-code a2a-route-preview` + +Previsualiza cómo `a2a-client call` resuelve rutas configuradas cuando `--url` se omite. + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +| Opción | Descripción | +|--------|-------------| +| `--route` | Especificación de ruta repetible en formato `name=url;skills=a,b;tags=x,y` | +| `--name` | Nombre de ruta que resolver | +| `--skill` | ID de skill que resolver | +| `--prompt` | Texto de prompt usado para coincidencia de nombre/etiqueta | +| `--route-state-dir`, `--persistence-dir` | Directorio usado para persistir instantáneas de rutas | +| `--save-routes` | Guardar las rutas proporcionadas en el directorio de estado de rutas | + +Guardar instantáneas de rutas: + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-state-dir ~/.iac-code/a2a \ + --save-routes +``` + +Llamar mediante rutas: + +```bash +iac-code a2a-client call \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-name ros \ + --prompt "Create a ROS VPC template." \ + --cwd "$PWD" +``` + +## Variables de entorno + +| Variable | Descripción | +|----------|-------------| +| `IACCODE_A2A_HTTP_TOKEN` | Valor predeterminado del token Bearer de servidor/cliente | +| `IACCODE_A2A_BASIC_USERNAME` | Valor predeterminado del nombre de usuario de Basic auth de servidor/cliente | +| `IACCODE_A2A_BASIC_PASSWORD` | Valor predeterminado de la contraseña de Basic auth de servidor/cliente | +| `IACCODE_A2A_API_KEY` | Valor predeterminado de clave de API de servidor/cliente | +| `IACCODE_A2A_API_KEY_HEADER` | Nombre predeterminado del encabezado de clave de API | +| `IACCODE_A2A_ALLOWED_CWDS` | Lista separada por rutas del sistema operativo de raíces de espacio de trabajo permitidas para metadatos de mensajes entrantes y URL de archivos | +| `IACCODE_A2A_TEXT_MIME_TYPES` | Tipos MIME extra similares a texto separados por comas o punto y coma | +| `IACCODE_A2A_MULTIMODAL_MIME_TYPES` | Tipos MIME multimodales extra separados por comas o punto y coma | +| `IAC_CODE_A2A_PUSH_KEYRING` | Keyring de secretos push cifrados gestionado por el entorno | diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/examples.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/examples.md new file mode 100644 index 00000000..52adc92b --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/examples.md @@ -0,0 +1,388 @@ +--- +title: Ejemplos +description: Ejemplos prácticos para integrarse con el servidor A2A de iac-code. +sidebar_position: 6 +--- + +# Ejemplos + +Esta página proporciona ejemplos de integración A2A listos para usar. + +## Requisitos previos + +Los ejemplos asumen: + +| Dependencia | Versión | Propósito | +|-------------|---------|-----------| +| Python | `3.12` | Coincide con el runtime del proyecto | +| `a2a-sdk` | `>=1.0.2,<2` | Cliente A2A y tipos protobuf | +| `httpx` | `>=0.27.0` | Cliente HTTP usado por el SDK y ejemplos directos | +| `iac-code` | current repo | Proporciona el subcomando `iac-code a2a` | + +Inicia el servidor: + +```bash +uv sync --extra a2a +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +## SDK de Python — Sesión en streaming + +Este ejemplo descubre la Agent Card, envía un mensaje, imprime fragmentos de texto del asistente y reporta metadatos de herramientas. + +```python +"""Streaming iac-code A2A session using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path +from typing import Any + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest +from google.protobuf.json_format import MessageToDict + + +def metadata_to_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + return MessageToDict(value, preserving_proto_field_name=False) + + +async def main() -> None: + headers = {} + # For authenticated servers: + # headers["Authorization"] = "Bearer YOUR_TOKEN" + + async with httpx.AsyncClient(headers=headers, timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Create a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("task"): + print(f"[task] {event.task.id} context={event.task.context_id}") + + elif event.HasField("status_update"): + update = event.status_update + status = update.status + + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + metadata = metadata_to_dict(update.metadata) + tool = metadata.get("iac_code", {}).get("tool") + if tool: + print(f"\n[tool] {tool.get('status')} {tool.get('name', '')}".rstrip()) + + usage = metadata.get("iac_code", {}).get("usage") + if usage: + print(f"\n[usage] {usage['totalTokens']} total tokens") + + if status.state == "TASK_STATE_INPUT_REQUIRED": + print("\n[done]") + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## CLI — Flujo de trabajo completo + +Inicia un servidor local con persistencia, artefactos, soporte de notificaciones push y una Agent Card firmada: + +```yaml +host: 127.0.0.1 +port: 41242 +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +```bash +iac-code a2a --config a2a-server.yml +``` + +Crea una configuración de cliente para el endpoint estable y los ajustes de verificación de tarjeta: + +```yaml +url: http://127.0.0.1:41242/ +verify-card-secret: local-card-signing-secret +require-card-signature: true +``` + +Descubre y verifica la Agent Card: + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +Envía una solicitud en streaming: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" \ + --stream +``` + +Lista tareas y obtiene una tarea como JSON: + +```bash +iac-code a2a-client --config a2a-client.yml task-list --output table + +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +Registra un callback push para una tarea: + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +Previsualiza la selección de ruta antes de llamar a un agente enrutado: + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +## SDK de Python — Mensaje de seguimiento + +Los mensajes de seguimiento reutilizan el mismo `context_id` y normalmente el mismo ID de tarea. Esto mantiene vivos el runtime interno de iac-code y el historial de conversación. + +```python +import uuid +from pathlib import Path + +from a2a.types import Message, Part, Role, SendMessageRequest + + +def build_follow_up(task_id: str, context_id: str, text: str) -> SendMessageRequest: + return SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + task_id=task_id, + context_id=context_id, + role=Role.ROLE_USER, + parts=[Part(text=text)], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + +# Usage inside an async function: +# async for event in client.send_message( +# build_follow_up(task_id, context_id, "Add outputs for VPC and VSwitch IDs.") +# ): +# ... +``` + +El servidor rechaza un `contextId` reutilizado si el nuevo mensaje apunta a un espacio de trabajo diferente. + +## SDK de Python — Cancelar una tarea + +```python +from a2a.types import CancelTaskRequest + + +async def cancel_running_task(client, task_id: str) -> None: + task = await client.cancel_task(CancelTaskRequest(id=task_id)) + print(f"{task.id}: {task.status.state}") +``` + +## HTTP directo — Cliente JSON-RPC mínimo + +Usa esto cuando no quieras la dependencia del SDK en el llamador. + +```python +"""Direct A2A JSON-RPC client using httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=120.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template for missing parameters."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## HTTP directo — SSE en streaming + +```python +"""Read SendStreamingMessage events directly with httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=None) as client: + async with client.stream( + "POST", + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Generate a Terraform VPC example."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if line.startswith("data:"): + print(line.removeprefix("data:").strip()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## HTTP directo — Configuración de notificaciones push + +Los métodos de configuración push están disponibles cuando el servidor se ejecuta con `push-notifications: true`. + +```python +"""Create an A2A task push notification config.""" + +import asyncio + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "push-1", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-123", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token", + }, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Manejo de metadatos de iac-code + +Los eventos de herramientas y uso llegan en `TaskStatusUpdateEvent.metadata.iac_code`. + +```python +from typing import Any + + +def handle_iac_metadata(metadata: dict[str, Any]) -> None: + iac = metadata.get("iac_code", {}) + + if tool := iac.get("tool"): + status = tool.get("status") + tool_id = tool.get("toolUseId") + name = tool.get("name", "") + print(f"tool={name} id={tool_id} status={status}") + + if permission := iac.get("permission"): + if permission.get("autoApproved") is False: + print(f"permission rejected for {permission.get('toolName')}") + + if usage := iac.get("usage"): + print( + "tokens=" + f"{usage.get('inputTokens', 0)}+{usage.get('outputTokens', 0)}" + f"={usage.get('totalTokens', 0)}" + ) +``` + +## Errores comunes + +| Síntoma | Solución | +|---------|----------| +| HTTP `401` | Incluye un esquema de autenticación configurado, como `Authorization: Bearer `, Basic auth o `X-API-Key: `, tanto en las solicitudes de Agent Card como en las JSON-RPC | +| `Invalid A2A workspace metadata.` | Usa una ruta absoluta existente en `metadata.iac_code.cwd` | +| `A2A server currently accepts text input only.` | Envía al menos una parte de texto no vacía | +| `Task is already working.` | Espera a que termine el turno actual antes de enviar otro mensaje en el mismo contexto | +| Seguimiento rechazado como espacio de trabajo diferente | Mantén `metadata.iac_code.cwd` sin cambios para un `contextId` reutilizado | +| URL de archivo local rechazada | Mantén las partes `file://` dentro de `metadata.iac_code.cwd` y dentro de `IACCODE_A2A_ALLOWED_CWDS` | +| Callback push rechazado | Usa una URL de callback HTTP(S) que no sea localhost ni una dirección IP literal privada/local | +| La cola push de Redis no inicia | Instala el extra `a2a-redis` y proporciona `push-redis-url` en la configuración A2A | diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/getting-started.md new file mode 100644 index 00000000..0dacf7dd --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -0,0 +1,291 @@ +--- +sidebar_position: 2 +title: Primeros pasos +description: Inicia el servidor A2A y envía tu primer mensaje. +--- + +# Primeros pasos con A2A + +## Requisitos previos + +1. **iac-code instalado** — Consulta la guía de [instalación](/docs/getting-started/installation). + +2. **Credenciales de LLM configuradas** — Consulta la guía de [autenticación](/docs/configuration/authentication) para configurar las credenciales de tu proveedor de modelo. + +3. **Dependencias del servidor A2A** — Instala iac-code con el extra `a2a`: + +```bash +uv sync --extra a2a +``` + +## Iniciar el servidor A2A + +Inicia el servidor en la interfaz local predeterminada: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +Usa un archivo de configuración YAML cuando necesites estado local, almacenamiento de artefactos, entrega de notificaciones push o Agent Cards firmadas: + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +Ejecútalo con: + +```bash +iac-code a2a --config a2a-server.yml +``` + +`push-notifications: true` habilita los métodos de configuración de notificaciones push de tareas A2A y la entrega de estados terminales. Usa `push-queue: redis-streams` con `push-redis-url` cuando varios workers necesiten coordinar la entrega push. + +El servidor expone: + +| Ruta | Propósito | +|------|-----------| +| `GET /health` | Comprobación de salud | +| `GET /.well-known/agent-card.json` | Descubrimiento de Agent Card | +| `POST /` | Endpoint A2A JSON-RPC | + +El servidor HTTP también registra las rutas REST del SDK de A2A y anuncia interfaces `JSONRPC` y `HTTP+JSON` en la Agent Card. + +## Verificar el descubrimiento + +Obtén la Agent Card: + +```text +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +Deberías ver `name: "iac-code"`, interfaces `JSONRPC` y `HTTP+JSON`, encabezados de caché como `ETag`, la extensión opcional `urn:iac-code:a2a:artifact-metadata:v1`, modos de entrada soportados y skills como `iac_generation`, `iac_review`, `aliyun_ros_operations` y `terraform_ros_conversion`. + +Comprueba el endpoint de salud: + +```bash +curl http://127.0.0.1:41242/health +``` + +Respuesta esperada: + +```json +{"status":"healthy"} +``` + +## Requerir autenticación + +La autenticación es opcional. Si no se establecen opciones de autenticación A2A ni variables de entorno, las solicitudes no necesitan autenticación. Cuando se configura cualquier esquema de autenticación, cada solicitud, incluido el descubrimiento de Agent Card, debe satisfacer uno de los esquemas configurados. + +### Token Bearer + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +La clave equivalente de configuración YAML es `token`. + +```text +Authorization: Bearer +``` + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +El nombre de usuario y la contraseña deben estar presentes. Las claves equivalentes de configuración YAML son `basic-username` y `basic-password`. + +### Clave de API + +```bash +export IACCODE_A2A_API_KEY=your-api-key + +iac-code a2a +``` + +El encabezado predeterminado de clave de API es: + +```text +X-API-Key: +``` + +Sobrescríbelo con la clave de configuración YAML `api-key-header` o `IACCODE_A2A_API_KEY_HEADER`: + +```yaml +api-key: your-api-key +api-key-header: X-IAC-Code-Key +``` + +## Llamar a un agente A2A remoto + +Pon los ajustes estables de conexión del cliente y autenticación en un archivo YAML: + +```yaml +url: http://127.0.0.1:41242/ +token: your-secret-token +verify-card-secret: your-card-signing-secret +require-card-signature: true +cwd: /path/to/workspace +``` + +Usa `a2a-client call` para una llamada directa de cliente de Fase 1: + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC with two vSwitches" --cwd "$PWD" +``` + +Usa `--stream` cuando quieras eventos incrementales en lugar de una única respuesta final: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this template" \ + --cwd "$PWD" \ + --stream +``` + +Las opciones de línea de comandos sobrescriben los valores de configuración cuando necesitas un destino o token puntual: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --url https://other-agent.example.com/ \ + --prompt "Review this template" +``` + +Para enrutamiento multiagente, previsualiza la selección de ruta antes de llamar: + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --route-state-dir ~/.iac-code/a2a +``` + +Consulta la [referencia de comandos](./command-reference.md) para todos los comandos A2A, incluida la gestión de tareas, CRUD de configuración push, Agent Cards extendidas y opciones de transporte. + +## Enviar un primer mensaje con curl + +Pasa el directorio del espacio de trabajo mediante `message.metadata.iac_code.cwd`; la ruta debe ser absoluta, ya debe existir y debe estar dentro de una raíz de espacio de trabajo permitida. De forma predeterminada, las raíces permitidas son el directorio del proceso del servidor y el directorio temporal del sistema. Sobrescríbelas con `IACCODE_A2A_ALLOWED_CWDS`. + +El servidor acepta partes similares a texto, partes de datos JSON, texto UTF-8 sin procesar, archivos de texto locales `file://` del espacio de trabajo y adjuntos multimodales acotados. La ingesta de URL remotas no está soportada; las partes `url` deben ser URL locales `file://` dentro del espacio de trabajo permitido. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [ + {"text": "Generate a ROS VPC template with two vSwitches."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +Para salida en streaming, usa `SendStreamingMessage`: + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [ + {"text": "Review my Terraform files and suggest ROS equivalents."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## Ejemplo mínimo con el SDK de Python + +El ejemplo siguiente usa `a2a-sdk>=1.0.2,<2`, que es el rango de versiones utilizado por el extra `a2a`. + +```python +"""Minimal iac-code A2A client using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest + + +async def main() -> None: + async with httpx.AsyncClient(timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Generate a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("status_update"): + status = event.status_update.status + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +:::tip +Para servidores autenticados, construye el `httpx.AsyncClient` con `headers={"Authorization": "Bearer "}` para que tanto el descubrimiento de Agent Card como las llamadas JSON-RPC incluyan el token. +::: + +## Próximos pasos + +- [Referencia de comandos](./command-reference.md) — Referencia completa de comandos y opciones de CLI. +- [Referencia del protocolo](./protocol-reference.md) — Detalles de métodos, rutas, estados y metadatos. +- [Transporte HTTP](./http-transport.md) — Comportamiento HTTP JSON-RPC, autenticación bearer y flujos de trabajo con curl. +- [Ejemplos](./examples.md) — Ejemplos de SDK, HTTP directo, seguimiento, cancelación y manejo de metadatos. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/http-transport.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/http-transport.md new file mode 100644 index 00000000..f96e607e --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/http-transport.md @@ -0,0 +1,269 @@ +--- +title: Transporte HTTP +description: Ejecuta y llama al servidor A2A de iac-code sobre HTTP JSON-RPC. +sidebar_position: 5 +--- + +# Transporte HTTP + +El servidor A2A predeterminado de iac-code expone JSON-RPC sobre HTTP, además de las rutas REST del SDK de A2A. El servidor está construido con Starlette y se ejecuta en Uvicorn. + +## Iniciar el servidor + +```bash +# Default host and port +iac-code a2a + +# Explicit host and port +iac-code a2a --host 127.0.0.1 --port 41242 + +# Listen on all interfaces +iac-code a2a --host 0.0.0.0 --port 41242 +``` + +Instala primero las dependencias opcionales del servidor: + +```bash +uv sync --extra a2a +``` + +## Resumen de endpoints + +| Ruta | Método | Respuesta | +|------|--------|-----------| +| `/health` | `GET` | Respuesta de salud JSON simple | +| `/.well-known/agent-card.json` | `GET` | JSON de Agent Card | +| `/` | `POST` | Respuesta JSON-RPC o stream SSE | +| Rutas REST del SDK | mixto | Endpoints REST de A2A registrados por el SDK | + +## Encabezados + +Encabezados recomendados: + +```text +Content-Type: application/json +A2A-Version: 1.0 +``` + +Cuando la autenticación Bearer está habilitada: + +```text +Authorization: Bearer +``` + +## Autenticación + +El servidor soporta autenticación opcional mediante token Bearer, Basic auth y clave de API. Si no se establecen opciones de autenticación ni variables de entorno, las solicitudes no necesitan autenticación. Si se configuran uno o más esquemas, una solicitud puede autenticarse con cualquier esquema configurado. + +### Token Bearer + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +También puedes establecer `token` en el archivo de configuración YAML de A2A. + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +Tanto el nombre de usuario como la contraseña deben establecerse para que Basic auth se habilite. + +### Clave de API + +```bash +export IACCODE_A2A_API_KEY=your-api-key +iac-code a2a +``` + +El encabezado predeterminado de clave de API es `X-API-Key`. Puedes cambiarlo en YAML: + +```yaml +api-key: ${IACCODE_A2A_API_KEY} +api-key-header: X-IAC-Code-Key +``` + +o con `IACCODE_A2A_API_KEY_HEADER`. + +| Escenario | Comportamiento | +|-----------|----------------| +| Ningún esquema de autenticación configurado | No se requiere autenticación | +| Uno o más esquemas configurados, cualquiera coincide | La solicitud continúa | +| Uno o más esquemas configurados, ningún esquema coincide | HTTP `401` con `{"error":"Unauthorized"}` | + +## Descubrimiento de Agent Card + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +Autenticado: + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "Authorization: Bearer $IACCODE_A2A_HTTP_TOKEN" +``` + +Con autenticación por clave de API: + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "X-API-Key: $IACCODE_A2A_API_KEY" +``` + +La URL del endpoint JSON-RPC se anuncia en `supportedInterfaces[0].url`. El modo HTTP también anuncia una interfaz `HTTP+JSON` para clientes compatibles con REST. + +## Mensaje sin streaming + +`SendMessage` devuelve una única respuesta JSON-RPC después de que termina el turno del agente. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a Terraform VPC module for Alibaba Cloud."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## Mensaje en streaming + +`SendStreamingMessage` devuelve Server-Sent Events. Usa `curl -N` para imprimir los eventos a medida que llegan. + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Generate a ROS template for one VPC and two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +Cada línea SSE `data:` contiene una respuesta JSON-RPC cuyo `result` es una `StreamResponse` de A2A. + +## Mensaje de seguimiento + +Usa el `taskId` y el `contextId` devueltos por la primera respuesta para continuar la misma conversación. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-2", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-3", + "taskId": "task-id-from-first-response", + "contextId": "context-id-from-first-response", + "role": "ROLE_USER", + "parts": [{"text": "Now add tags for environment and owner."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +El espacio de trabajo debe seguir siendo el mismo para el `contextId` reutilizado. + +## Cancelar una tarea en ejecución + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "cancel-1", + "method": "CancelTask", + "params": { + "id": "task-id" + } + }' +``` + +La cancelación es cooperativa: iac-code cancela el turno activo del agente, emite un estado cancelado y libera el bloqueo del contexto. Cancelar una tarea existente que ya no está en ejecución devuelve el `TaskNotCancelableError` estándar de A2A. + +## Equivalentes de CLI + +La mayoría de los flujos de trabajo HTTP tienen un comando CLI equivalente: + +```yaml +url: http://127.0.0.1:41242/ +``` + +```bash +# Discover the Agent Card +iac-code a2a-client --config a2a-client.yml discover + +# Send a non-streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a Terraform VPC module for Alibaba Cloud." \ + --cwd "$PWD" + +# Send a streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a ROS template for one VPC and two vSwitches." \ + --cwd "$PWD" \ + --stream + +# Inspect task state +iac-code a2a-client --config a2a-client.yml task-get --task-id task-id +iac-code a2a-client --config a2a-client.yml task-list --output table + +# Cancel an active task +iac-code a2a-client --config a2a-client.yml task-cancel --task-id task-id +``` + +Para la lista completa de opciones, consulta la [referencia de comandos](./command-reference.md). + +## Notas operativas + +- Enlaza a `127.0.0.1` para uso solo local. +- Usa `token` en la configuración A2A o `IACCODE_A2A_HTTP_TOKEN` antes de enlazar a una interfaz de red compartida. +- El modo A2A rechaza automáticamente las solicitudes de permisos de herramientas; protege los endpoints sin autenticación como servicios de automatización local. +- El estado activo del runtime está en memoria. La persistencia refleja metadatos de tareas y contextos, pero reiniciar el proceso no reanuda trabajo asyncio en curso. +- Un contexto solo puede ejecutar una tarea a la vez; los contextos separados pueden ejecutarse de forma concurrente. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/overview.md new file mode 100644 index 00000000..5e3cd92d --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: Protocolo A2A +description: Descripción general del soporte de Agent2Agent en iac-code. +--- + +# Protocolo A2A + +## Qué es A2A + +[Agent2Agent (A2A)](https://github.com/a2aproject/A2A) es un protocolo para descubrir y llamar agentes remotos. Permite que un agente publique una Agent Card, acepte mensajes estructurados, transmita actualizaciones de tareas y exponga operaciones de cancelación y consulta de tareas mediante transportes estándar. + +## iac-code como servidor A2A + +iac-code puede ejecutarse como un servidor / agente A2A 1.0. Otros clientes compatibles con A2A pueden descubrirlo, enviar solicitudes de infraestructura como código, transmitir actualizaciones de ejecución y cancelar tareas activas. + +Usa A2A cuando otro agente, motor de flujos de trabajo o servicio necesite llamar a iac-code como especialista de IaC interoperable. Usa ACP cuando un cliente de estilo editor necesite gestión de sesiones, solicitudes de permisos e integración con el desarrollo local. + +## Casos de uso + +- **Orquestación de agentes** — Un agente planificador puede delegar trabajo de Alibaba Cloud ROS o Terraform a iac-code. +- **Automatización de flujos de trabajo** — Las herramientas internas pueden enviar tareas de generación, revisión o conversión de IaC por HTTP. +- **Descubrimiento de servicios** — Los clientes pueden obtener la Agent Card y elegir capacidades como generación de IaC o revisión de plantillas. +- **Integraciones con streaming** — Un cliente de chatops o panel puede mostrar texto del modelo, actividad de herramientas, metadatos de uso y el estado final de la tarea mientras se ejecuta el turno. + +## Comparación de modos de interacción + +| Modo | Comando | Ideal para | +|------|---------|------------| +| **REPL interactivo** | `iac-code` | Exploración práctica y creación iterativa de plantillas | +| **CLI no interactiva** | `iac-code --prompt "..."` o `--headless` | Scripts de una sola ejecución y trabajos de CI | +| **Servidor ACP** | `iac-code acp` | Integración con IDE/editor y control de clientes multisesión | +| **Servidor A2A** | `iac-code a2a` | Interoperabilidad agente a agente sobre transportes A2A | +| **Cliente A2A** | `iac-code a2a-client call` | Llamar a agentes A2A remotos desde iac-code | + +## Capacidades principales + +- **Descubrimiento de Agent Card** — Publica `/.well-known/agent-card.json` con enlace de protocolo, versión, skills, modos de entrada/salida y metadatos opcionales de autenticación. +- **HTTP JSON-RPC y REST** — Sirve solicitudes A2A JSON-RPC en `/` y registra las rutas REST del SDK. +- **Respuestas en streaming** — Soporta `SendStreamingMessage` para actualizaciones incrementales de tareas. +- **Gestión de tareas** — Soporta consulta de tareas, listado autenticado de tareas con paginación por cursor, cancelación de tareas activas y suscripción a tareas activas. +- **Reutilización de contexto** — Reutiliza un runtime de iac-code para mensajes de seguimiento en el mismo `contextId` de A2A. +- **Ámbito del espacio de trabajo** — Lee el directorio del proyecto desde los metadatos del mensaje en `iac_code.cwd`. +- **Metadatos de herramientas** — Emite metadatos específicos de iac-code para inicios de herramientas, deltas de entrada, resultados completados de herramientas, decisiones de permisos y uso de tokens. +- **Partes de entrada** — Acepta partes similares a texto, partes de datos JSON, texto UTF-8 sin procesar, archivos de texto locales `file://` del espacio de trabajo y adjuntos multimodales acotados representados como manifiestos de prompt. +- **Llamadas de cliente** — Descubre Agent Cards remotas, verifica firmas cuando está configurado y envía prompts de texto a agentes remotos. +- **Enrutamiento** — Selecciona agentes remotos configurados por nombre explícito, skill o coincidencia de prompt/etiqueta. +- **Metadatos de persistencia** — Refleja instantáneas locales de tareas/contextos A2A en archivos JSON como metadatos de restauración entre procesos. +- **Artefactos** — Almacena payloads de artefactos de texto locales soportados fuera del cuerpo del evento transmitido, emite eventos estándar `TaskArtifactUpdateEvent` y registra los `artifacts` de la tarea. +- **Extensiones y caché** — Anuncia la extensión opcional de metadatos de artefactos de iac-code, valida `A2A-Extensions` requeridas y sirve Agent Cards con encabezados de caché. +- **Notificaciones push** — Soporta métodos de configuración de notificaciones push de tareas A2A cuando `push-notifications: true` está configurado, con colas de entrega basadas en archivo local o Redis. +- **Firma de Agent Card** — Agrega firmas JWS opcionales del SDK de A2A para Agent Cards y soporta verificación basada en `kid` con claves configuradas, datos JWKS octet locales o una URL JWKS remota. +- **Múltiples transportes** — Se ejecuta sobre HTTP, stdio, sockets Unix, WebSocket, gRPC oficial, gRPC JSON-RPC personalizado y transportes Redis Streams. +- **Operaciones CLI** — Proporciona comandos para descubrimiento, envío de mensajes, consulta/listado/cancelación/suscripción de tareas, CRUD de configuración push, tarjetas extendidas y vistas previas de rutas. + +## Soporte de Fase 1 + +iac-code soporta el modo servidor A2A sobre HTTP JSON-RPC/REST y varios transportes opcionales, además del modo cliente de Fase 1 para llamar a agentes A2A remotos. Puede descubrir Agent Cards remotas, seleccionar endpoints anunciados, enviar prompts A2A 1.0, consultar/listar/cancelar/suscribirse a tareas, enrutar a agentes configurados, persistir metadatos locales de restauración de tareas/contextos, almacenar payloads de artefactos locales como artefactos de tarea estándar, validar extensiones requeridas, gestionar configuraciones de notificaciones push y firmar o verificar Agent Cards con metadatos HMAC o JWKS. + +## No soportado en Fase 1 {#phase-1-unsupported} + +- stdio, sockets Unix, WebSocket, envoltorio gRPC JSON-RPC y Redis Streams son transportes JSON-RPC personalizados experimentales. +- gRPC oficial requiere dependencias opcionales y usa de forma predeterminada un enlace de servidor local inseguro. +- No hay almacén de tareas distribuido ni compartido. La persistencia es almacenamiento de archivos local bajo el área de configuración de runtime de iac-code. +- No se restaura una tarea asyncio en curso después de reiniciar el proceso. +- No hay continuación automática en segundo plano de tareas remotas interrumpidas. +- No hay backend de artefactos para OSS, S3, base de datos ni almacén de objetos externo. +- No hay ingesta de URL HTTP remota, fragmentación de binarios grandes ni protocolo de carga reanudable. Las partes de URL de archivo local deben permanecer dentro de las raíces permitidas del espacio de trabajo. +- No hay fallo estricto predeterminado para Agent Cards sin firmar. +- No hay firma asimétrica de Agent Card desde el servidor ni rotación automática de claves de firma. +- No hay DAG de planificador autónomo ni orquestación multiagente compleja. +- La entrega push es al menos una vez para colas respaldadas por Redis; los receptores de callback deben manejar duplicados y aplicar su propia política de autorización del lado del endpoint. + +Las solicitudes de permisos de herramientas se rechazan automáticamente en modo servidor A2A. Ejecuta el modo A2A sin autenticación solo en entornos locales de confianza o protégelo con autenticación mediante token Bearer, Basic auth o clave de API. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md new file mode 100644 index 00000000..024be0d4 --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -0,0 +1,400 @@ +--- +title: Referencia del protocolo +description: Referencia completa del protocolo A2A para la integración con iac-code. +sidebar_position: 4 +--- + +# Referencia del protocolo + +Este documento describe la superficie A2A 1.0 expuesta por el servidor iac-code y el comportamiento del cliente de Fase 1 usado por `iac-code a2a-client call`. Para opciones exactas de CLI, consulta la [referencia de comandos](./command-reference.md). + +## Resumen del ciclo de vida + +Una interacción A2A típica sigue este flujo: + +```text +GET Agent Card -> SendMessage or SendStreamingMessage -> GetTask / follow-up / CancelTask +``` + +1. **Descubrir** — Obtén `/.well-known/agent-card.json`. +2. **Enviar** — Envía un mensaje de texto al endpoint JSON-RPC en `/`. +3. **Transmitir** — Recibe payloads `Task`, `Message` y `TaskStatusUpdateEvent`. +4. **Continuar** — Envía un mensaje de seguimiento con el mismo `contextId`. +5. **Cancelar o consultar** — Usa `CancelTask`, `GetTask` o `ListTasks`. + +## Agent Card + +La Agent Card está disponible en: + +```text +GET /.well-known/agent-card.json +``` + +Campos importantes: + +| Campo | Valor | Significado | +|-------|-------|-------------| +| `name` | `iac-code` | Nombre del agente | +| `supportedInterfaces[0].protocolBinding` | `JSONRPC` | Enlace de transporte | +| `supportedInterfaces[0].protocolVersion` | `1.0` | Versión del protocolo A2A | +| `supportedInterfaces[0].url` | `http://:/` | Endpoint JSON-RPC | +| `capabilities.streaming` | `true` | Soporta actualizaciones de tareas en streaming | +| `capabilities.pushNotifications` | `false` o `true` | `true` cuando `push-notifications: true` está configurado | +| `capabilities.extendedAgentCard` | `true` | Los llamadores autenticados pueden solicitar detalles extendidos del runtime | +| `capabilities.extensions` | `urn:iac-code:a2a:artifact-metadata:v1` | Namespace opcional de metadatos de iac-code para estado de herramientas y metadatos de artefactos almacenados | +| `defaultInputModes` | tipos MIME text, JSON, YAML, image, audio y binary | Modos MIME de entrada aceptados | +| `defaultOutputModes` | `["text/plain"]` | Solo salida de texto | + +Las respuestas de Agent Card incluyen `Cache-Control: public, max-age=60`, `ETag` y `Last-Modified`. Los clientes pueden enviar `If-None-Match` y recibir `304 Not Modified` cuando la tarjeta no ha cambiado. + +Skills anunciadas: + +| Skill ID | Propósito | +|----------|-----------| +| `iac_generation` | Generar plantillas Alibaba Cloud ROS y Terraform a partir de lenguaje natural | +| `iac_review` | Inspeccionar plantillas IaC y sugerir correcciones | +| `aliyun_ros_operations` | Ayudar con flujos de trabajo de stacks de Alibaba Cloud ROS | +| `terraform_ros_conversion` | Ayudar en la conversión de Terraform a ROS usando recursos de skills integrados | + +Cuando la autenticación está habilitada, la Agent Card anuncia los esquemas de seguridad configurados: + +| Esquema | Cuándo se anuncia | +|---------|-------------------| +| `bearerAuth` | `token` o `IACCODE_A2A_HTTP_TOKEN` está establecido | +| `basicAuth` | El usuario y la contraseña de Basic están establecidos | +| `apiKeyAuth` | `api-key` o `IACCODE_A2A_API_KEY` está establecido | + +## Rutas + +| Ruta | Método | Descripción | +|------|--------|-------------| +| `/health` | `GET` | Devuelve `{"status":"healthy"}` | +| `/.well-known/agent-card.json` | `GET` | Devuelve la Agent Card | +| `/` | `POST` | Maneja solicitudes A2A JSON-RPC | +| Rutas REST | mixto | Las rutas REST del SDK de A2A registradas por `create_rest_routes` | + +## Cliente de Fase 1 y notas de transporte + +El transporte interoperable predeterminado de Fase 1 es JSON-RPC sobre HTTP. El modo HTTP también anuncia `HTTP+JSON` para las rutas REST del SDK. + +El servidor también tiene transportes opcionales para stdio, sockets Unix, WebSocket, gRPC oficial, envoltorio gRPC JSON-RPC y Redis Streams. stdio, sockets Unix, WebSocket, gRPC JSON-RPC y Redis Streams son transportes JSON-RPC personalizados. gRPC oficial se anuncia como `grpc` y requiere dependencias gRPC opcionales. + +El cliente integrado usa el descubrimiento de Agent Card (`GET /.well-known/agent-card.json`) antes de las llamadas de mensaje, selecciona el primer `supportedInterfaces[].url` ejecutable anunciado y luego envía solicitudes JSON-RPC con `A2A-Version: 1.0` y nombres de métodos A2A 1.0 como `SendMessage`. + +`push-notifications: true` habilita los métodos de configuración de notificaciones push de A2A y la entrega de estados terminales. + +La firma de Agent Card usa la utilidad de firma del SDK de A2A y emite campos JWS estándar `AgentCardSignature`. El modo de clave simétrica usa `HS256`; la verificación puede seleccionar un secreto configurado por `kid` del encabezado protegido, un JWKS local de clave octet o una URL JWKS remota. La firma asimétrica del lado del servidor y la rotación automática de claves no están implementadas en la Fase 1. + +Para la lista canónica de comportamientos no soportados en Fase 1, consulta [Protocolo A2A](./overview.md#phase-1-unsupported). + +## Backends de entrega de notificaciones push + +`iac-code a2a --config a2a-server.yml` soporta dos colas de entrega push: + +- `push-queue: local-file` almacena trabajos debajo del directorio de persistencia A2A y está pensado para uso local de un solo nodo. +- `push-queue: redis-streams` almacena trabajos en Redis Streams y coordina workers mediante un grupo de consumidores de Redis. + +La entrega push respaldada por Redis requiere el extra opcional `a2a-redis` y es al menos una vez. Los receptores de callback deben manejar actualizaciones de tareas de forma idempotente porque un trabajo puede entregarse de nuevo después de fallos de workers, expiración de leases, reconexiones o carreras de reintento. + +Opciones comunes de Redis: + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +push-lease-timeout-ms: 300000 +``` + +Las URL de callback se validan antes de almacenarlas y nuevamente antes del despacho. El validador predeterminado rechaza URL que no sean HTTP(S), nombres de host localhost y direcciones IP literales privadas/locales. Los receptores de callback aun así deben aplicar su propia política de autenticación e idempotencia. + +## Métodos JSON-RPC + +### SendMessage + +Ejecuta un turno de mensaje A2A sin streaming. La respuesta contiene una tarea o mensaje después de que el turno se haya completado. + +**Solicitud** + +```json +{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a VPC with two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +**Campos de mensaje requeridos** + +| Campo | Tipo | Requerido | Descripción | +|-------|------|-----------|-------------| +| `messageId` | string | Sí | ID de mensaje de cliente único | +| `role` | string | Sí | Usa `ROLE_USER` para entrada de usuario | +| `parts` | array | Sí | Partes similares a texto, datos JSON, texto sin procesar, URL de archivo local o partes multimodales acotadas | +| `metadata.iac_code.cwd` | string | Recomendado | Ruta absoluta del espacio de trabajo; si se omite, toma por defecto el directorio del proceso del servidor | + +`metadata.iac_code.cwd` debe ser un directorio absoluto existente cuando se proporciona. Debe estar dentro de una raíz de espacio de trabajo permitida. De forma predeterminada, las raíces permitidas son el directorio del proceso del servidor y el directorio temporal del sistema; `IACCODE_A2A_ALLOWED_CWDS` puede proporcionar una lista permitida separada por rutas del sistema operativo. + +Categorías de entrada soportadas: + +| Categoría | Forma aceptada | Límites y comportamiento | +|-----------|----------------|--------------------------| +| Partes similares a texto | `text` con `text/plain`, JSON, Markdown, YAML o tipos MIME de texto extra configurados | Se agregan directamente al prompt | +| Partes de datos JSON | `data` con `application/json` | Serializadas como JSON compacto; máximo 1 MiB inline | +| Partes de texto sin procesar | `raw` con un tipo MIME similar a texto | Deben ser UTF-8 válido; máximo 1 MiB inline | +| URL de archivos de texto locales | `url` con `file://...` y tipo MIME similar a texto | El archivo debe existir dentro de `cwd` y de las raíces permitidas; máximo 1 MiB | +| Partes multimodales raw/data/file | image, audio o tipos MIME multimodales configurados | Convertidas en un manifiesto de prompt con nombre de archivo, tipo de medio, tamaño en bytes, hash y origen; raw/data máximo 5 MiB, URL de archivo máximo 25 MiB | + +La ingesta de URL HTTP(S) remotas no está soportada. Las partes de URL de archivo deben usar URL locales `file://` y permanecer dentro del espacio de trabajo permitido. + +### SendStreamingMessage + +Ejecuta un turno de mensaje A2A en streaming. El cuerpo de la solicitud tiene la misma forma que `SendMessage`, pero el servidor transmite respuestas JSON-RPC como Server-Sent Events. + +```json +{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +### GetTask + +Devuelve la tarea A2A guardada por ID. Usa `historyLength` para limitar el historial devuelto sin mutar el historial de tarea almacenado. Omítelo para recibir el historial predeterminado actual del servidor. + +```json +{ + "jsonrpc": "2.0", + "id": "3", + "method": "GetTask", + "params": { + "id": "task-id", + "historyLength": 10 + } +} +``` + +### ListTasks + +Devuelve las tareas conocidas visibles para el llamador autenticado. Los resultados se ordenan por marca de tiempo de estado descendente y luego por ID de tarea descendente para un orden estable. El servidor soporta `contextId`, `status`, `pageSize`, `pageToken`, `historyLength` e `includeArtifacts`. + +```json +{ + "jsonrpc": "2.0", + "id": "4", + "method": "ListTasks", + "params": { + "contextId": "ctx-id", + "status": "TASK_STATE_WORKING", + "pageSize": 20, + "includeArtifacts": false + } +} +``` + +`nextPageToken` se devuelve cuando hay otra página disponible. `includeArtifacts` toma por defecto `false`, por lo que las respuestas de listado omiten los artefactos de tarea salvo que se soliciten explícitamente. + +### CancelTask + +Solicita la cancelación de una tarea en ejecución. + +```json +{ + "jsonrpc": "2.0", + "id": "5", + "method": "CancelTask", + "params": { + "id": "task-id" + } +} +``` + +Si la tarea está activa, el servidor cancela el turno de agente en ejecución y emite un estado de tarea cancelado. Si la tarea existe pero no está en ejecución, el servidor devuelve el `TaskNotCancelableError` estándar de A2A. + +### SubscribeToTask + +Se suscribe a un stream de actualizaciones de una tarea activa cuando el transporte del cliente lo soporta. + +```json +{ + "jsonrpc": "2.0", + "id": "6", + "method": "SubscribeToTask", + "params": { + "id": "task-id" + } +} +``` + +Para tareas activas, el stream comienza con el `Task` actual, luego emite eventos de tarea posteriores y se cierra cuando termina el turno activo. Suscribirse a una tarea completada, fallida, cancelada o que requiere entrada devuelve un error de estilo tarea no encontrada en lugar de esperar indefinidamente. Para turnos nuevos, prefiere `SendStreamingMessage`; inicia la ejecución y transmite la respuesta en una solicitud. + +### Métodos de configuración de notificaciones push + +Cuando el servidor inicia con `push-notifications: true`, soporta: + +| Método | Propósito | +|--------|-----------| +| `CreateTaskPushNotificationConfig` | Almacenar una configuración de callback para una tarea | +| `GetTaskPushNotificationConfig` | Obtener una configuración de callback | +| `ListTaskPushNotificationConfigs` | Listar configuraciones de callback de una tarea | +| `DeleteTaskPushNotificationConfig` | Eliminar una configuración de callback | + +Ejemplo de solicitud de creación: + +```json +{ + "jsonrpc": "2.0", + "id": "7", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-id", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token" + } + } +} +``` + +El servidor cifra los tokens de notificación almacenados y las credenciales de autenticación de callback cuando el keyring push local está disponible. + +### GetExtendedAgentCard + +Los clientes autenticados pueden solicitar la Agent Card extendida: + +```json +{ + "jsonrpc": "2.0", + "id": "8", + "method": "GetExtendedAgentCard", + "params": {} +} +``` + +La tarjeta extendida incluye la tarjeta pública más detalles autenticados del runtime. + +## Comportamiento de tareas y contextos + +iac-code asigna contextos A2A a runtimes internos de agente: + +| Concepto | Comportamiento | +|----------|----------------| +| `contextId` omitido | El SDK/servidor genera un nuevo ID de contexto | +| Mismo `contextId` | Reutiliza la misma sesión interna de iac-code y el estado de conversación | +| Mismo `contextId`, distinto `cwd` | Rechazado como un espacio de trabajo diferente | +| Mismo `contextId`, mensaje concurrente | Rechazado con `Task is already working.` | +| Valores de `contextId` diferentes | Pueden ejecutarse concurrentemente | +| Contexto inactivo | Expulsado de memoria después del timeout de inactividad configurado | + +Los IDs de tarea y contexto no deben estar vacíos, pueden tener como máximo 128 caracteres y solo pueden contener letras, dígitos, `_`, `.`, `:` o `-`. + +## Estados de tarea + +| Estado | Significado | +|--------|-------------| +| `TASK_STATE_SUBMITTED` | La tarea fue aceptada | +| `TASK_STATE_WORKING` | iac-code está ejecutando el turno del agente | +| `TASK_STATE_INPUT_REQUIRED` | El turno se completó y el agente está listo para entrada de seguimiento | +| `TASK_STATE_CANCELED` | Se solicitó y aplicó la cancelación | +| `TASK_STATE_FAILED` | La tarea falló en validación o ejecución | + +iac-code usa `TASK_STATE_INPUT_REQUIRED` como estado completado normal porque el contexto queda disponible para mensajes de seguimiento. + +## Actualizaciones en streaming + +Durante la ejecución, iac-code emite actualizaciones `TaskStatusUpdateEvent`. + +El texto del asistente se entrega como un mensaje de estado: + +```json +{ + "statusUpdate": { + "taskId": "task-1", + "contextId": "ctx-1", + "status": { + "state": "TASK_STATE_WORKING", + "message": { + "role": "ROLE_AGENT", + "parts": [{"text": "Here is the ROS template..."}] + } + } + } +} +``` + +Los detalles de herramientas y uso se entregan mediante `metadata.iac_code`: + +| Ruta de metadatos | Descripción | +|-------------------|-------------| +| `iac_code.tool.status` | `started`, `input_delta`, `input_complete`, `completed` o `failed` | +| `iac_code.tool.toolUseId` | ID estable de uso de herramienta para correlacionar eventos de herramienta | +| `iac_code.tool.name` | Nombre de la herramienta cuando está disponible | +| `iac_code.tool.input` | Entrada completada de la herramienta, truncada a 4000 caracteres por campo | +| `iac_code.tool.result` | Resultado de la herramienta, truncado a 4000 caracteres por campo | +| `iac_code.permission.autoApproved` | `false` cuando una solicitud de permiso de herramienta fue rechazada por el modo servidor A2A | +| `iac_code.usage.inputTokens` | Recuento de tokens de entrada del turno | +| `iac_code.usage.outputTokens` | Recuento de tokens de salida del turno | +| `iac_code.usage.totalTokens` | Recuento total de tokens del turno | + +Cuando un resultado de herramienta incluye un payload de artefacto de texto soportado, el servidor almacena el payload localmente, emite un `TaskArtifactUpdateEvent` estándar y registra el artefacto en el campo `artifacts` de la tarea. La parte de artefacto usa una URL `file://` más metadatos como `mediaType`, `byteSize` y `sha256`; el contenido original del artefacto no se duplica dentro de los metadatos de herramienta. + +## Extensiones + +La Agent Card anuncia la extensión opcional de metadatos de artefactos de iac-code: + +```text +urn:iac-code:a2a:artifact-metadata:v1 +``` + +Esta extensión identifica el namespace `metadata.iac_code` usado para progreso de herramientas, decisiones de permisos, uso de tokens y metadatos de artefactos locales. Si el servidor está configurado con alguna extensión requerida, los clientes deben incluir su URI en el encabezado `A2A-Extensions`. Las extensiones requeridas ausentes devuelven el `ExtensionSupportRequiredError` estándar de A2A. + +## Manejo de errores + +| Escenario | Resultado | +|-----------|-----------| +| Entrada de texto vacía | `TASK_STATE_FAILED` con `A2A server currently accepts text input only.` | +| Tipo de medio no soportado | Error de validación o error estándar de tipo de contenido de A2A, según dónde el SDK rechace la solicitud | +| Parte de URL remota | Error de validación porque las partes de URL deben usar URL locales `file://` | +| URL de archivo fuera del espacio de trabajo permitido | Error de validación | +| Extensión A2A requerida ausente | `ExtensionSupportRequiredError` estándar de A2A | +| Metadatos de espacio de trabajo no válidos | `TASK_STATE_FAILED` con un mensaje de espacio de trabajo no válido | +| Autenticación ausente o no válida | HTTP `401` con `{"error":"Unauthorized"}` | +| Dependencias del servidor A2A ausentes | La CLI sale con una pista de instalación para el extra `a2a` | +| Credenciales de proveedor ausentes | Error de autenticación saneado | +| Error inesperado de runtime | Error interno saneado | + +El servidor evita devolver rutas locales, secretos y detalles del proveedor en mensajes de error inesperados. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/command-reference.md new file mode 100644 index 00000000..fac563cc --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -0,0 +1,504 @@ +--- +title: Référence des commandes +description: Référence complète des commandes CLI pour exécuter et appeler iac-code via A2A. +sidebar_position: 3 +--- + +# Référence des commandes A2A + +Cette page documente chaque commande `iac-code` liée à A2A. Utilisez-la lorsque vous avez besoin des noms exacts des options, des motifs de commandes courants et du sens opérationnel de chaque indicateur. + +## Vue d'ensemble des commandes + +| Commande | Objectif | +|---------|---------| +| `iac-code a2a` | Exécuter iac-code comme serveur A2A | +| `iac-code a2a-client call` | Découvrir une Agent Card distante et envoyer un prompt | +| `iac-code a2a-client discover` | Récupérer et vérifier optionnellement une Agent Card | +| `iac-code a2a-client task-get` | Récupérer une tâche par ID | +| `iac-code a2a-client task-list` | Lister les tâches avec filtres et pagination | +| `iac-code a2a-client task-cancel` | Annuler une tâche active | +| `iac-code a2a-client task-subscribe` | S'abonner au flux d'événements d'une tâche active | +| `iac-code a2a-client push-config-create` | Créer une configuration de notification push de tâche | +| `iac-code a2a-client push-config-get` | Récupérer une configuration de notification push de tâche | +| `iac-code a2a-client push-config-list` | Lister les configurations de notification push de tâche | +| `iac-code a2a-client push-config-delete` | Supprimer une configuration de notification push de tâche | +| `iac-code a2a-client extended-card` | Récupérer l'Agent Card étendue authentifiée | +| `iac-code a2a-route-preview` | Prévisualiser la sélection de route locale pour `a2a-client call` | + +Toutes les commandes client HTTP acceptent les mêmes options d'authentification : + +| Option | Description | +|--------|-------------| +| `--token` | Jeton Bearer envoyé comme `Authorization: Bearer ` | +| `--basic-username` | Nom d'utilisateur Basic auth | +| `--basic-password` | Mot de passe Basic auth | +| `--api-key` | Valeur de clé API | +| `--api-key-header` | Nom de l'en-tête de clé API ; vaut `X-API-Key` par défaut | + +## Configuration client A2A + +Toutes les sous-commandes `a2a-client` acceptent un fichier de configuration YAML au niveau du groupe : + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC" +``` + +Les options CLI remplacent les valeurs de configuration. Utilisez la configuration pour les paramètres stables de connexion, d'authentification, de vérification, de routage et les paramètres répétés de tâche ou de push ; gardez le texte de prompt ponctuel sur la ligne de commande. + +```yaml +url: http://127.0.0.1:41242/ +token: your-bearer-token +basic-username: iac-code +basic-password: your-password +api-key: your-api-key +api-key-header: X-IAC-Code-Key +verify-card-secret: your-card-signing-secret +verify-card-jwks-url: https://a2a.example.com/.well-known/jwks.json +require-card-signature: true +timeout: 30 +cwd: /path/to/workspace +context-id: ctx-123 +task-id: task-123 +config-id: webhook-1 +callback-url: https://hooks.example.com/a2a +notification-token: notification-token +auth-scheme: bearer +auth-credentials: callback-token +routes: + - name: ros + url: http://127.0.0.1:41242/ + skills: + - iac_generation + tags: + - ros + - template +``` + +## `iac-code a2a` + +Exécute iac-code comme serveur A2A. + +```bash +iac-code a2a +``` + +Par défaut, le serveur se lie à `127.0.0.1:41242` et sert JSON-RPC via HTTP. Le port `41242` est la valeur par défaut d'iac-code ; ce n'est pas un port A2A enregistré. + +### Options serveur de base + +| Option | Défaut | Description | +|--------|---------|-------------| +| `--config` | vide | Fichier de configuration YAML contenant les options du serveur A2A | +| `--host` | `127.0.0.1` | Hôte du serveur HTTP | +| `--port` | `41242` | Port du serveur HTTP | +| `--transport` | `http` | Transport serveur : `http`, `stdio`, `unix`, `websocket`, `grpc`, `grpc-jsonrpc` ou `redis-streams` | +| `--debug`, `-d` | `false` | Activer la journalisation de débogage | + +Exemple : + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --debug +``` + +### Configuration YAML + +Utilisez `--config` pour l'authentification, le stockage, la signature, les paramètres propres aux transports, la livraison push et d'autres détails de déploiement. Les clés peuvent utiliser des tirets ou des underscores. Les indicateurs CLI communs `--host`, `--port` et `--transport` remplacent les valeurs du fichier de configuration. + +```yaml +host: 127.0.0.1 +port: 41242 +transport: http +token: local-dev-token +persistence-dir: .iac-code-a2a/state +artifact-dir: .iac-code-a2a/artifacts +push-notifications: true +``` + +Exécutez-le avec : + +```bash +iac-code a2a --config a2a-server.yml --port 41243 +``` + +### Authentification HTTP + +L'authentification est optionnelle. Configurez l'authentification du serveur en YAML ou avec des variables d'environnement. Si aucun paramètre d'authentification n'est configuré, les requêtes ne sont pas authentifiées. Lorsqu'un ou plusieurs schémas sont configurés, une requête peut satisfaire n'importe lequel des schémas configurés. + +| Clé de configuration | Variable d'environnement | Description | +|--------|----------------------|-------------| +| `token` | `IACCODE_A2A_HTTP_TOKEN` | Jeton Bearer | +| `basic-username` | `IACCODE_A2A_BASIC_USERNAME` | Nom d'utilisateur Basic auth | +| `basic-password` | `IACCODE_A2A_BASIC_PASSWORD` | Mot de passe Basic auth | +| `api-key` | `IACCODE_A2A_API_KEY` | Valeur de clé API | +| `api-key-header` | `IACCODE_A2A_API_KEY_HEADER` | Nom de l'en-tête de clé API | + +Jeton Bearer : + +```yaml +token: local-dev-token +``` + +Basic auth : + +```yaml +basic-username: iac-code +basic-password: local-dev-password +``` + +Clé API : + +```yaml +api-key: local-dev-key +api-key-header: X-IAC-Code-Key +``` + +### Persistance et artefacts + +| Clé de configuration | Défaut | Description | +|--------|---------|-------------| +| `persistence-dir` | `~/.iac-code/a2a` | Métadonnées JSON locales pour les tâches, contextes, routes et configurations push | +| `artifact-dir` | `/artifacts` | Magasin local de charges utiles d'artefacts | + +La persistance duplique les instantanés de tâches et de contextes pour les métadonnées de restauration. Elle ne redémarre pas une tâche asyncio en cours après un crash de processus. + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +``` + +### Signature d'Agent Card + +| Clé de configuration | Description | +|--------|-------------| +| `signing-secret` | Secret HMAC utilisé pour signer l'Agent Card publique | + +Le serveur émet les champs JWS `AgentCardSignature` du SDK A2A. Le mode symétrique utilise `HS256`. + +```yaml +signing-secret: local-card-signing-secret +``` + +### Livraison des notifications push + +| Clé de configuration | Défaut | Description | +|--------|---------|-------------| +| `push-notifications` | `false` | Activer les méthodes de configuration des notifications push de tâche A2A et la livraison des états terminaux | +| `push-queue` | `local-file` | Backend de file push : `local-file` ou `redis-streams` | +| `push-redis-url` | vide | URL Redis pour la file push adossée à Redis | +| `push-stream` | `iac-code:a2a:push` | Stream Redis pour les tâches push | +| `push-retry-key` | `iac-code:a2a:push:retry` | Ensemble trié Redis pour les nouvelles tentatives différées | +| `push-dead-stream` | `iac-code:a2a:push:dead` | Stream Redis pour les tâches en dead-letter | +| `push-consumer-group` | `iac-code-push` | Groupe de consommateurs Redis pour les workers push | +| `push-consumer-name` | vide | Nom de consommateur Redis pour ce worker | +| `push-lease-timeout-ms` | `300000` | Délai de bail pending Redis | + +File locale : + +```yaml +push-notifications: true +persistence-dir: ~/.iac-code/a2a +push-queue: local-file +``` + +File Redis Streams : + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +``` + +La livraison push adossée à Redis nécessite l'extra `a2a-redis`. + +### Options de transport + +| Transport | Commande | Notes | +|-----------|---------|-------| +| HTTP JSON-RPC et REST | `iac-code a2a --transport http` | Par défaut. Annonce les interfaces `JSONRPC` et `HTTP+JSON`. | +| stdio | `iac-code a2a --transport stdio` | Trames JSON-RPC personnalisées expérimentales via entrée/sortie standard. | +| Socket Unix | `iac-code a2a --config a2a-server.yml --transport unix` | Nécessite `socket-path` dans la configuration. | +| WebSocket | `iac-code a2a --config a2a-server.yml --transport websocket` | Utilise `ws-path` depuis la configuration, avec `/a2a` par défaut. | +| gRPC | `iac-code a2a --config a2a-server.yml --transport grpc` | Utilise `grpc-host` et `grpc-port` depuis la configuration. | +| gRPC JSON-RPC | `iac-code a2a --config a2a-server.yml --transport grpc-jsonrpc` | Enveloppe JSON-RPC personnalisée via gRPC. | +| Redis Streams | `iac-code a2a --config a2a-server.yml --transport redis-streams` | Nécessite `redis-url` dans la configuration. | + +Options du transport Redis Streams : + +| Clé de configuration | Défaut | Description | +|--------|---------|-------------| +| `redis-url` | vide | URL de connexion Redis ; requise pour `--transport redis-streams` | +| `request-stream` | `iac-code:a2a:requests` | Nom du stream de requêtes | +| `response-stream` | `iac-code:a2a:responses` | Nom du stream de réponses | +| `consumer-group` | `iac-code` | Groupe de consommateurs du stream de requêtes | + +### Comportement des autorisations + +| Clé de configuration | Défaut | Description | +|--------|---------|-------------| +| `auto-approve-permissions` | `false` | Approuver automatiquement les demandes d'autorisation d'outil levées pendant les tours A2A | + +Sans `auto-approve-permissions: true`, le mode A2A rejette les prompts d'autorisation et émet des métadonnées d'autorisation. Utilisez-le seulement pour les environnements d'automatisation de confiance. + +## `iac-code a2a-client call` + +Découvre une Agent Card, choisit l'endpoint annoncé et envoie un prompt. + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" +``` + +| Option | Défaut | Description | +|--------|---------|-------------| +| `--url` | vide | URL de base de l'agent A2A ou URL de l'endpoint JSON-RPC ; peut venir de la configuration | +| `--route` | répétable | Spécification de route utilisée lorsque `--url` est omis | +| `--route-name` | vide | Route nommée à sélectionner | +| `--prompt`, `-p` | obligatoire | Texte du prompt | +| `--cwd` | `.` | Chemin d'espace de travail envoyé comme `message.metadata.iac_code.cwd` | +| `--context-id` | vide | ID de contexte A2A existant pour un message de suivi | +| `--verify-card-secret`, `--signing-secret` | vide | Secret HMAC pour la vérification de l'Agent Card | +| `--verify-card-jwks-url` | vide | URL JWKS distante utilisée pour la vérification de l'Agent Card | +| `--require-card-signature`, `--require-signature` | `false` | Rejeter les Agent Cards non signées ou invalides | +| `--timeout` | `30.0` | Délai d'appel en secondes | +| `--stream` | `false` | Utiliser `SendStreamingMessage` et afficher les événements du flux | + +Suivi dans le même contexte : + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --context-id ctx-123 \ + --prompt "Now add outputs for the VPC and vSwitch IDs." \ + --cwd "$PWD" +``` + +Streaming : + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this Terraform module." \ + --cwd "$PWD" \ + --stream +``` + +Exiger une Agent Card signée : + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a production VPC template." \ + --cwd "$PWD" +``` + +Vérifier avec une URL JWKS distante : + +```bash +iac-code a2a-client --config jwks-client.yml call \ + --prompt "Review the ROS stack." +``` + +## `iac-code a2a-client discover` + +Récupère et affiche une Agent Card distante. + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +| Option | Description | +|--------|-------------| +| `--url` | URL de base de l'agent A2A ; peut venir de la configuration | +| `--verify-card-secret`, `--signing-secret` | Secret HMAC pour la vérification | +| `--verify-card-jwks-url` | URL JWKS distante pour la vérification | +| `--require-card-signature`, `--require-signature` | Exiger une signature valide | + +Découverte authentifiée : + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +## Commandes de tâche + +Les commandes de tâche appellent directement les méthodes de tâche JSON-RPC. Elles sont utiles pour les outils opérationnels, les tableaux de bord et le débogage. + +### `iac-code a2a-client task-get` + +```bash +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +| Option | Description | +|--------|-------------| +| `--url` | URL de l'endpoint A2A JSON-RPC ; peut venir de la configuration | +| `--task-id` | ID de tâche ; peut venir de la configuration | +| `--history-length` | Nombre maximal d'entrées d'historique de tâche à renvoyer | + +### `iac-code a2a-client task-list` + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --context-id ctx-123 \ + --status TASK_STATE_INPUT_REQUIRED \ + --page-size 20 \ + --output table +``` + +| Option | Défaut | Description | +|--------|---------|-------------| +| `--url` | vide | URL de l'endpoint A2A JSON-RPC ; peut venir de la configuration | +| `--context-id` | vide | Filtrer par ID de contexte | +| `--status` | vide | Filtrer par état de tâche | +| `--page-size` | vide | Nombre maximal de tâches à renvoyer | +| `--page-token` | vide | Jeton de pagination | +| `--include-artifacts` | `false` | Inclure les artefacts de tâche dans la réponse | +| `--output` | `table` | `table` ou `json` | + +Sortie JSON : + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --include-artifacts \ + --output json +``` + +### `iac-code a2a-client task-cancel` + +```bash +iac-code a2a-client --config a2a-client.yml task-cancel \ + --task-id task-123 +``` + +L'annulation est coopérative. Une tâche terminée, échouée, annulée ou nécessitant une entrée renvoie l'erreur A2A standard de tâche non annulable. + +### `iac-code a2a-client task-subscribe` + +```bash +iac-code a2a-client --config a2a-client.yml task-subscribe \ + --task-id task-123 +``` + +La commande diffuse les événements des tâches actives. Pour un nouveau tour, préférez `a2a-client call --stream` ; il démarre la tâche et diffuse les mises à jour en une seule commande. + +## Commandes de configuration des notifications push + +Ces commandes nécessitent un serveur démarré avec `push-notifications: true`. Elles gèrent les configurations standard de notifications push de tâche A2A. + +### `iac-code a2a-client push-config-create` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +| Option | Description | +|--------|-------------| +| `--url` | URL de l'endpoint A2A JSON-RPC ; peut venir de la configuration | +| `--task-id` | ID de tâche ; peut venir de la configuration | +| `--config-id` | ID de configuration push ; peut venir de la configuration | +| `--callback-url` | URL de callback HTTP(S) ; peut venir de la configuration | +| `--notification-token` | Jeton envoyé comme `X-A2A-Notification-Token` | +| `--auth-scheme` | Schéma d'authentification du callback, comme `bearer` ou `basic` | +| `--auth-credentials` | Identifiants d'authentification du callback | + +Les URL de callback sont validées avant le stockage et l'envoi. Le validateur par défaut rejette les URL non HTTP(S), les noms localhost et les adresses IP littérales privées/locales. + +### `iac-code a2a-client push-config-get` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-get \ + --task-id task-123 \ + --config-id webhook-1 +``` + +### `iac-code a2a-client push-config-list` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-list \ + --task-id task-123 \ + --page-size 10 +``` + +### `iac-code a2a-client push-config-delete` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-delete \ + --task-id task-123 \ + --config-id webhook-1 +``` + +## `iac-code a2a-client extended-card` + +Récupère l'Agent Card étendue authentifiée. + +```bash +iac-code a2a-client --config a2a-client.yml extended-card \ + --token "$A2A_TOKEN" +``` + +L'Agent Card publique annonce `capabilities.extendedAgentCard=true`. La carte étendue ajoute des détails runtime authentifiés, y compris les métadonnées de capacités de gestion des tâches et de configuration push. + +## `iac-code a2a-route-preview` + +Prévisualise la manière dont `a2a-client call` résout les routes configurées lorsque `--url` est omis. + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +| Option | Description | +|--------|-------------| +| `--route` | Spécification de route répétable au format `name=url;skills=a,b;tags=x,y` | +| `--name` | Nom de route à résoudre | +| `--skill` | ID de compétence à résoudre | +| `--prompt` | Texte de prompt utilisé pour la correspondance nom/tag | +| `--route-state-dir`, `--persistence-dir` | Répertoire utilisé pour persister les instantanés de route | +| `--save-routes` | Enregistrer les routes fournies dans le répertoire d'état des routes | + +Enregistrer les instantanés de route : + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-state-dir ~/.iac-code/a2a \ + --save-routes +``` + +Appeler via les routes : + +```bash +iac-code a2a-client call \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-name ros \ + --prompt "Create a ROS VPC template." \ + --cwd "$PWD" +``` + +## Variables d'environnement + +| Variable | Description | +|----------|-------------| +| `IACCODE_A2A_HTTP_TOKEN` | Valeur par défaut du jeton Bearer serveur/client | +| `IACCODE_A2A_BASIC_USERNAME` | Valeur par défaut du nom d'utilisateur Basic auth serveur/client | +| `IACCODE_A2A_BASIC_PASSWORD` | Valeur par défaut du mot de passe Basic auth serveur/client | +| `IACCODE_A2A_API_KEY` | Valeur par défaut de la clé API serveur/client | +| `IACCODE_A2A_API_KEY_HEADER` | Valeur par défaut du nom de l'en-tête de clé API | +| `IACCODE_A2A_ALLOWED_CWDS` | Liste, séparée par le séparateur de chemins du système d'exploitation, des racines d'espace de travail autorisées pour les métadonnées de message entrantes et les URL de fichier | +| `IACCODE_A2A_TEXT_MIME_TYPES` | Types MIME de type texte supplémentaires séparés par des virgules ou points-virgules | +| `IACCODE_A2A_MULTIMODAL_MIME_TYPES` | Types MIME multimodaux supplémentaires séparés par des virgules ou points-virgules | +| `IAC_CODE_A2A_PUSH_KEYRING` | Trousseau de clés secret push chiffré géré par l'environnement | diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/examples.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/examples.md new file mode 100644 index 00000000..43d78a32 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/examples.md @@ -0,0 +1,388 @@ +--- +title: Exemples +description: Exemples pratiques pour l'intégration avec le serveur A2A iac-code. +sidebar_position: 6 +--- + +# Exemples + +Cette page fournit des exemples d'intégration A2A prêts à l'emploi. + +## Prérequis + +Les exemples supposent : + +| Dépendance | Version | Objectif | +|------------|---------|---------| +| Python | `3.12` | Correspond au runtime du projet | +| `a2a-sdk` | `>=1.0.2,<2` | Client A2A et types protobuf | +| `httpx` | `>=0.27.0` | Client HTTP utilisé par le SDK et les exemples directs | +| `iac-code` | dépôt actuel | Fournit la sous-commande `iac-code a2a` | + +Démarrez le serveur : + +```bash +uv sync --extra a2a +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +## SDK Python — Session en streaming + +Cet exemple découvre l'Agent Card, envoie un message, affiche les fragments de texte de l'assistant et rapporte les métadonnées d'outil. + +```python +"""Streaming iac-code A2A session using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path +from typing import Any + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest +from google.protobuf.json_format import MessageToDict + + +def metadata_to_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + return MessageToDict(value, preserving_proto_field_name=False) + + +async def main() -> None: + headers = {} + # For authenticated servers: + # headers["Authorization"] = "Bearer YOUR_TOKEN" + + async with httpx.AsyncClient(headers=headers, timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Create a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("task"): + print(f"[task] {event.task.id} context={event.task.context_id}") + + elif event.HasField("status_update"): + update = event.status_update + status = update.status + + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + metadata = metadata_to_dict(update.metadata) + tool = metadata.get("iac_code", {}).get("tool") + if tool: + print(f"\n[tool] {tool.get('status')} {tool.get('name', '')}".rstrip()) + + usage = metadata.get("iac_code", {}).get("usage") + if usage: + print(f"\n[usage] {usage['totalTokens']} total tokens") + + if status.state == "TASK_STATE_INPUT_REQUIRED": + print("\n[done]") + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## CLI — Workflow de bout en bout + +Démarrez un serveur local avec persistance, artefacts, prise en charge des notifications push et Agent Card signée : + +```yaml +host: 127.0.0.1 +port: 41242 +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +```bash +iac-code a2a --config a2a-server.yml +``` + +Créez une configuration client pour l'endpoint stable et les paramètres de vérification de carte : + +```yaml +url: http://127.0.0.1:41242/ +verify-card-secret: local-card-signing-secret +require-card-signature: true +``` + +Découvrez et vérifiez l'Agent Card : + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +Envoyez une requête en streaming : + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" \ + --stream +``` + +Listez les tâches et récupérez une tâche en JSON : + +```bash +iac-code a2a-client --config a2a-client.yml task-list --output table + +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +Enregistrez un callback push pour une tâche : + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +Prévisualisez la sélection de route avant d'appeler un agent routé : + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +## SDK Python — Message de suivi + +Les messages de suivi réutilisent le même `context_id` et généralement le même ID de tâche. Cela garde en vie le runtime iac-code interne et l'historique de conversation. + +```python +import uuid +from pathlib import Path + +from a2a.types import Message, Part, Role, SendMessageRequest + + +def build_follow_up(task_id: str, context_id: str, text: str) -> SendMessageRequest: + return SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + task_id=task_id, + context_id=context_id, + role=Role.ROLE_USER, + parts=[Part(text=text)], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + +# Usage inside an async function: +# async for event in client.send_message( +# build_follow_up(task_id, context_id, "Add outputs for VPC and VSwitch IDs.") +# ): +# ... +``` + +Le serveur rejette un `contextId` réutilisé si le nouveau message pointe vers un espace de travail différent. + +## SDK Python — Annuler une tâche + +```python +from a2a.types import CancelTaskRequest + + +async def cancel_running_task(client, task_id: str) -> None: + task = await client.cancel_task(CancelTaskRequest(id=task_id)) + print(f"{task.id}: {task.status.state}") +``` + +## HTTP direct — Client JSON-RPC minimal + +Utilisez ceci lorsque vous ne voulez pas de dépendance au SDK dans l'appelant. + +```python +"""Direct A2A JSON-RPC client using httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=120.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template for missing parameters."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## HTTP direct — Streaming SSE + +```python +"""Read SendStreamingMessage events directly with httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=None) as client: + async with client.stream( + "POST", + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Generate a Terraform VPC example."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if line.startswith("data:"): + print(line.removeprefix("data:").strip()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## HTTP direct — Configuration des notifications push + +Les méthodes de configuration push sont disponibles lorsque le serveur s'exécute avec `push-notifications: true`. + +```python +"""Create an A2A task push notification config.""" + +import asyncio + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "push-1", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-123", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token", + }, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Gérer les métadonnées iac-code + +Les événements d'outils et d'utilisation arrivent dans `TaskStatusUpdateEvent.metadata.iac_code`. + +```python +from typing import Any + + +def handle_iac_metadata(metadata: dict[str, Any]) -> None: + iac = metadata.get("iac_code", {}) + + if tool := iac.get("tool"): + status = tool.get("status") + tool_id = tool.get("toolUseId") + name = tool.get("name", "") + print(f"tool={name} id={tool_id} status={status}") + + if permission := iac.get("permission"): + if permission.get("autoApproved") is False: + print(f"permission rejected for {permission.get('toolName')}") + + if usage := iac.get("usage"): + print( + "tokens=" + f"{usage.get('inputTokens', 0)}+{usage.get('outputTokens', 0)}" + f"={usage.get('totalTokens', 0)}" + ) +``` + +## Pièges courants + +| Symptôme | Correction | +|---------|-----| +| HTTP `401` | Incluez un schéma d'authentification configuré, comme `Authorization: Bearer `, Basic auth ou `X-API-Key: `, sur les requêtes Agent Card et JSON-RPC | +| `Invalid A2A workspace metadata.` | Utilisez un chemin absolu existant dans `metadata.iac_code.cwd` | +| `A2A server currently accepts text input only.` | Envoyez au moins une partie texte non vide | +| `Task is already working.` | Attendez que le tour actuel se termine avant d'envoyer un autre message dans le même contexte | +| Suivi rejeté comme espace de travail différent | Gardez `metadata.iac_code.cwd` inchangé pour un `contextId` réutilisé | +| URL de fichier local rejetée | Gardez les parties `file://` dans `metadata.iac_code.cwd` et dans `IACCODE_A2A_ALLOWED_CWDS` | +| Callback push rejeté | Utilisez une URL de callback HTTP(S) qui n'est pas localhost ni une adresse IP littérale privée/locale | +| Échec du démarrage de la file push Redis | Installez l'extra `a2a-redis` et fournissez `push-redis-url` dans la configuration A2A | diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/getting-started.md new file mode 100644 index 00000000..d7cbe7cb --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -0,0 +1,291 @@ +--- +sidebar_position: 2 +title: Bien démarrer +description: Démarrez le serveur A2A et envoyez votre premier message. +--- + +# Bien démarrer avec A2A + +## Prérequis + +1. **iac-code installé** — Consultez le guide [Installation](/docs/getting-started/installation). + +2. **Identifiants LLM configurés** — Consultez le guide [Authentication](/docs/configuration/authentication) pour configurer les identifiants de votre fournisseur de modèle. + +3. **Dépendances du serveur A2A** — Installez iac-code avec l'extra `a2a` : + +```bash +uv sync --extra a2a +``` + +## Démarrer le serveur A2A + +Démarrez le serveur sur l'interface locale par défaut : + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +Utilisez un fichier de configuration YAML lorsque vous avez besoin d'état local, de stockage d'artefacts, de livraison de notifications push ou d'Agent Cards signées : + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +Exécutez-le avec : + +```bash +iac-code a2a --config a2a-server.yml +``` + +`push-notifications: true` active les méthodes de configuration des notifications push de tâche A2A et la livraison des états terminaux. Utilisez `push-queue: redis-streams` avec `push-redis-url` lorsque plusieurs workers doivent coordonner la livraison push. + +Le serveur expose : + +| Route | Objectif | +|-------|---------| +| `GET /health` | Vérification de santé | +| `GET /.well-known/agent-card.json` | Découverte de l'Agent Card | +| `POST /` | Endpoint A2A JSON-RPC | + +Le serveur HTTP enregistre également les routes REST du SDK A2A et annonce les interfaces `JSONRPC` et `HTTP+JSON` dans l'Agent Card. + +## Vérifier la découverte + +Récupérez l'Agent Card : + +```text +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +Vous devriez voir `name: "iac-code"`, les interfaces `JSONRPC` et `HTTP+JSON`, des en-têtes de cache comme `ETag`, l'extension optionnelle `urn:iac-code:a2a:artifact-metadata:v1`, les modes d'entrée pris en charge, et des compétences comme `iac_generation`, `iac_review`, `aliyun_ros_operations` et `terraform_ros_conversion`. + +Vérifiez l'endpoint de santé : + +```bash +curl http://127.0.0.1:41242/health +``` + +Réponse attendue : + +```json +{"status":"healthy"} +``` + +## Exiger l'authentification + +L'authentification est optionnelle. Si aucune option d'authentification A2A ni variable d'environnement n'est définie, les requêtes n'ont pas besoin d'authentification. Lorsqu'un schéma d'authentification est configuré, chaque requête, y compris la découverte de l'Agent Card, doit satisfaire l'un des schémas configurés. + +### Jeton Bearer + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +La clé de configuration YAML équivalente est `token`. + +```text +Authorization: Bearer +``` + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +Le nom d'utilisateur et le mot de passe doivent tous deux être présents. Les clés de configuration YAML équivalentes sont `basic-username` et `basic-password`. + +### Clé API + +```bash +export IACCODE_A2A_API_KEY=your-api-key + +iac-code a2a +``` + +L'en-tête de clé API par défaut est : + +```text +X-API-Key: +``` + +Remplacez-le avec la clé de configuration YAML `api-key-header` ou `IACCODE_A2A_API_KEY_HEADER` : + +```yaml +api-key: your-api-key +api-key-header: X-IAC-Code-Key +``` + +## Appeler un agent A2A distant + +Placez les paramètres stables de connexion client et d'authentification dans un fichier YAML : + +```yaml +url: http://127.0.0.1:41242/ +token: your-secret-token +verify-card-secret: your-card-signing-secret +require-card-signature: true +cwd: /path/to/workspace +``` + +Utilisez `a2a-client call` pour un appel client Phase 1 direct : + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC with two vSwitches" --cwd "$PWD" +``` + +Utilisez `--stream` lorsque vous voulez des événements incrémentaux au lieu d'une réponse finale unique : + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this template" \ + --cwd "$PWD" \ + --stream +``` + +Les options de ligne de commande remplacent les valeurs de configuration lorsque vous avez besoin d'une cible ou d'un jeton ponctuel : + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --url https://other-agent.example.com/ \ + --prompt "Review this template" +``` + +Pour le routage multi-agent, prévisualisez la sélection de route avant l'appel : + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --route-state-dir ~/.iac-code/a2a +``` + +Consultez la [référence des commandes](./command-reference.md) pour toutes les commandes A2A, y compris la gestion des tâches, le CRUD de configuration push, les Agent Cards étendues et les options de transport. + +## Envoyer un premier message avec curl + +Passez le répertoire de l'espace de travail via `message.metadata.iac_code.cwd` ; le chemin doit être absolu, exister déjà et se trouver dans une racine d'espace de travail autorisée. Par défaut, les racines autorisées sont le répertoire du processus serveur et le répertoire temporaire système. Remplacez-les avec `IACCODE_A2A_ALLOWED_CWDS`. + +Le serveur accepte les parties de type texte, les parties de données JSON, le texte UTF-8 brut, les fichiers texte locaux `file://` de l'espace de travail et les pièces jointes multimodales bornées. L'ingestion d'URL distante n'est pas prise en charge ; les parties `url` doivent être des URL locales `file://` dans l'espace de travail autorisé. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [ + {"text": "Generate a ROS VPC template with two vSwitches."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +Pour la sortie en streaming, utilisez `SendStreamingMessage` : + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [ + {"text": "Review my Terraform files and suggest ROS equivalents."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## Exemple minimal avec le SDK Python + +L'exemple ci-dessous utilise `a2a-sdk>=1.0.2,<2`, qui est la plage de versions utilisée par l'extra `a2a`. + +```python +"""Minimal iac-code A2A client using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest + + +async def main() -> None: + async with httpx.AsyncClient(timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Generate a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("status_update"): + status = event.status_update.status + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +:::tip +Pour les serveurs authentifiés, construisez le `httpx.AsyncClient` avec `headers={"Authorization": "Bearer "}` afin que la découverte de l'Agent Card et les appels JSON-RPC incluent tous deux le jeton. +::: + +## Étapes suivantes + +- [Référence des commandes](./command-reference.md) — Référence complète des commandes et options CLI. +- [Référence du protocole](./protocol-reference.md) — Détails des méthodes, routes, états et métadonnées. +- [Transport HTTP](./http-transport.md) — Comportement HTTP JSON-RPC, authentification bearer et workflows curl. +- [Exemples](./examples.md) — Exemples SDK, HTTP direct, suivi, annulation et gestion des métadonnées. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/http-transport.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/http-transport.md new file mode 100644 index 00000000..9f0270a2 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/http-transport.md @@ -0,0 +1,269 @@ +--- +title: Transport HTTP +description: Exécutez et appelez le serveur A2A iac-code via JSON-RPC HTTP. +sidebar_position: 5 +--- + +# Transport HTTP + +Le serveur A2A par défaut d'iac-code expose JSON-RPC via HTTP, ainsi que les routes REST du SDK A2A. Le serveur est construit avec Starlette et s'exécute sur Uvicorn. + +## Démarrer le serveur + +```bash +# Default host and port +iac-code a2a + +# Explicit host and port +iac-code a2a --host 127.0.0.1 --port 41242 + +# Listen on all interfaces +iac-code a2a --host 0.0.0.0 --port 41242 +``` + +Installez d'abord les dépendances serveur optionnelles : + +```bash +uv sync --extra a2a +``` + +## Résumé des endpoints + +| Route | Méthode | Réponse | +|-------|--------|----------| +| `/health` | `GET` | Réponse de santé JSON simple | +| `/.well-known/agent-card.json` | `GET` | JSON de l'Agent Card | +| `/` | `POST` | Réponse JSON-RPC ou flux SSE | +| Routes REST du SDK | mixte | Endpoints REST A2A enregistrés par le SDK | + +## En-têtes + +En-têtes recommandés : + +```text +Content-Type: application/json +A2A-Version: 1.0 +``` + +Lorsque l'authentification Bearer est activée : + +```text +Authorization: Bearer +``` + +## Authentification + +Le serveur prend en charge l'authentification optionnelle par jeton Bearer, Basic auth et clé API. Si aucune option d'authentification ni variable d'environnement n'est définie, les requêtes n'ont pas besoin d'authentification. Si un ou plusieurs schémas sont configurés, une requête peut s'authentifier avec n'importe lequel des schémas configurés. + +### Jeton Bearer + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +Vous pouvez aussi définir `token` dans le fichier de configuration YAML A2A. + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +Le nom d'utilisateur et le mot de passe doivent tous deux être définis pour activer Basic auth. + +### Clé API + +```bash +export IACCODE_A2A_API_KEY=your-api-key +iac-code a2a +``` + +L'en-tête de clé API par défaut est `X-API-Key`. Vous pouvez le modifier en YAML : + +```yaml +api-key: ${IACCODE_A2A_API_KEY} +api-key-header: X-IAC-Code-Key +``` + +ou avec `IACCODE_A2A_API_KEY_HEADER`. + +| Scénario | Comportement | +|----------|----------| +| Aucun schéma d'authentification configuré | Aucune authentification requise | +| Un ou plusieurs schémas configurés, l'un correspond | La requête continue | +| Un ou plusieurs schémas configurés, aucun ne correspond | HTTP `401` avec `{"error":"Unauthorized"}` | + +## Découverte de l'Agent Card + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +Authentifié : + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "Authorization: Bearer $IACCODE_A2A_HTTP_TOKEN" +``` + +Avec authentification par clé API : + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "X-API-Key: $IACCODE_A2A_API_KEY" +``` + +L'URL de l'endpoint JSON-RPC est annoncée dans `supportedInterfaces[0].url`. Le mode HTTP annonce également une interface `HTTP+JSON` pour les clients capables d'utiliser REST. + +## Message non streaming + +`SendMessage` renvoie une réponse JSON-RPC unique une fois le tour de l'agent terminé. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a Terraform VPC module for Alibaba Cloud."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## Message en streaming + +`SendStreamingMessage` renvoie des Server-Sent Events. Utilisez `curl -N` pour afficher les événements à mesure qu'ils arrivent. + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Generate a ROS template for one VPC and two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +Chaque ligne SSE `data:` contient une réponse JSON-RPC dont le `result` est une `StreamResponse` A2A. + +## Message de suivi + +Utilisez les `taskId` et `contextId` renvoyés par la première réponse pour continuer la même conversation. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-2", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-3", + "taskId": "task-id-from-first-response", + "contextId": "context-id-from-first-response", + "role": "ROLE_USER", + "parts": [{"text": "Now add tags for environment and owner."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +L'espace de travail doit rester le même pour le `contextId` réutilisé. + +## Annuler une tâche en cours + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "cancel-1", + "method": "CancelTask", + "params": { + "id": "task-id" + } + }' +``` + +L'annulation est coopérative : iac-code annule le tour actif de l'agent, émet un état annulé et libère le verrou de contexte. Annuler une tâche existante qui n'est plus en cours renvoie l'erreur A2A standard `TaskNotCancelableError`. + +## Équivalents CLI + +La plupart des workflows HTTP ont une commande CLI correspondante : + +```yaml +url: http://127.0.0.1:41242/ +``` + +```bash +# Discover the Agent Card +iac-code a2a-client --config a2a-client.yml discover + +# Send a non-streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a Terraform VPC module for Alibaba Cloud." \ + --cwd "$PWD" + +# Send a streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a ROS template for one VPC and two vSwitches." \ + --cwd "$PWD" \ + --stream + +# Inspect task state +iac-code a2a-client --config a2a-client.yml task-get --task-id task-id +iac-code a2a-client --config a2a-client.yml task-list --output table + +# Cancel an active task +iac-code a2a-client --config a2a-client.yml task-cancel --task-id task-id +``` + +Pour la liste complète des options, consultez la [référence des commandes](./command-reference.md). + +## Notes opérationnelles + +- Liez à `127.0.0.1` pour une utilisation locale uniquement. +- Utilisez `token` dans la configuration A2A ou `IACCODE_A2A_HTTP_TOKEN` avant de lier le serveur à une interface réseau partagée. +- Le mode A2A rejette automatiquement les demandes d'autorisation d'outil ; protégez les endpoints non authentifiés comme des services d'automatisation locaux. +- L'état runtime actif est en mémoire. La persistance duplique les métadonnées de tâche et de contexte, mais le redémarrage du processus ne reprend pas le travail asyncio en cours. +- Un contexte ne peut exécuter qu'une seule tâche à la fois ; des contextes séparés peuvent s'exécuter simultanément. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/overview.md new file mode 100644 index 00000000..10034616 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: Protocole A2A +description: Vue d'ensemble de la prise en charge d'Agent2Agent dans iac-code. +--- + +# Protocole A2A + +## Qu'est-ce qu'A2A + +[Agent2Agent (A2A)](https://github.com/a2aproject/A2A) est un protocole qui permet de découvrir et d'appeler des agents distants. Il permet à un agent de publier une Agent Card, d'accepter des messages structurés, de diffuser des mises à jour de tâche en continu, et d'exposer des opérations d'annulation et de consultation de tâches via des transports standard. + +## iac-code comme serveur A2A + +iac-code peut s'exécuter comme serveur / agent A2A 1.0. Les autres clients compatibles A2A peuvent le découvrir, envoyer des demandes d'Infrastructure as Code, diffuser les mises à jour d'exécution en continu et annuler des tâches actives. + +Utilisez A2A lorsqu'un autre agent, moteur de workflow ou service doit appeler iac-code comme spécialiste IaC interopérable. Utilisez ACP lorsqu'un client de type éditeur a besoin de gestion de session, de demandes d'autorisation et d'une intégration au développement local. + +## Cas d'utilisation + +- **Orchestration d'agents** — Un agent planificateur peut déléguer du travail Alibaba Cloud ROS ou Terraform à iac-code. +- **Automatisation de workflows** — Des outils internes peuvent soumettre des tâches de génération, de revue ou de conversion IaC via HTTP. +- **Découverte de service** — Les clients peuvent récupérer l'Agent Card et choisir des capacités comme la génération IaC ou la revue de modèles. +- **Intégrations en streaming** — Un client chatops ou tableau de bord peut afficher le texte du modèle, l'activité des outils, les métadonnées d'utilisation et l'état final de la tâche pendant l'exécution du tour. + +## Comparaison des modes d'interaction + +| Mode | Commande | Idéal pour | +|------|---------|----------| +| **REPL interactif** | `iac-code` | Exploration pratique et création itérative de modèles | +| **CLI non interactif** | `iac-code --prompt "..."` ou `--headless` | Scripts ponctuels et tâches CI | +| **Serveur ACP** | `iac-code acp` | Intégration IDE/éditeur et contrôle client multi-session | +| **Serveur A2A** | `iac-code a2a` | Interopérabilité agent-à-agent via les transports A2A | +| **Client A2A** | `iac-code a2a-client call` | Appel d'agents A2A distants depuis iac-code | + +## Capacités principales + +- **Découverte de l'Agent Card** — Publie `/.well-known/agent-card.json` avec le binding de protocole, la version, les compétences, les modes d'entrée/sortie et les métadonnées d'authentification optionnelles. +- **HTTP JSON-RPC et REST** — Sert les requêtes A2A JSON-RPC sur `/` et enregistre les routes REST du SDK. +- **Réponses en streaming** — Prend en charge `SendStreamingMessage` pour les mises à jour de tâche incrémentales. +- **Gestion des tâches** — Prend en charge la consultation des tâches, la liste authentifiée des tâches avec pagination par curseur, l'annulation des tâches actives et l'abonnement aux tâches actives. +- **Réutilisation du contexte** — Réutilise un runtime iac-code pour les messages de suivi dans le même `contextId` A2A. +- **Portée de l'espace de travail** — Lit le répertoire du projet depuis les métadonnées de message à `iac_code.cwd`. +- **Métadonnées d'outil** — Émet des métadonnées propres à iac-code pour les démarrages d'outils, les deltas d'entrée, les résultats d'outils terminés, les décisions d'autorisation et l'utilisation des jetons. +- **Parties d'entrée** — Accepte les parties de type texte, les parties de données JSON, le texte UTF-8 brut, les fichiers texte locaux `file://` de l'espace de travail et les pièces jointes multimodales bornées représentées comme manifestes de prompt. +- **Appels client** — Découvre les Agent Cards distantes, vérifie les signatures lorsqu'elles sont configurées, et envoie des prompts texte à des agents distants. +- **Routage** — Sélectionne les agents distants configurés par nom explicite, compétence ou correspondance prompt/tag. +- **Métadonnées de persistance** — Duplique les instantanés locaux de tâches/contextes A2A vers des fichiers JSON pour les métadonnées de restauration interprocessus. +- **Artefacts** — Stocke les charges utiles d'artefacts texte locaux pris en charge hors du corps de l'événement diffusé, émet des événements standard `TaskArtifactUpdateEvent` et enregistre les `artifacts` de la tâche. +- **Extensions et mise en cache** — Annonce l'extension optionnelle de métadonnées d'artefact iac-code, valide les `A2A-Extensions` obligatoires et sert les Agent Cards avec des en-têtes de cache. +- **Notifications push** — Prend en charge les méthodes de configuration des notifications push de tâche A2A lorsque `push-notifications: true` est configuré, avec des files de livraison adossées à des fichiers locaux ou à Redis. +- **Signature d'Agent Card** — Ajoute des signatures JWS optionnelles du SDK A2A pour les Agent Cards et prend en charge la vérification basée sur `kid` avec des clés configurées, des données JWKS octet locales ou une URL JWKS distante. +- **Transports multiples** — Fonctionne via HTTP, stdio, sockets Unix, WebSocket, gRPC officiel, gRPC JSON-RPC personnalisé et transports Redis Streams. +- **Opérations CLI** — Fournit des commandes pour la découverte, l'envoi de messages, la consultation/liste/annulation/abonnement aux tâches, le CRUD de configuration push, les cartes étendues et les aperçus de routage. + +## Prise en charge Phase 1 + +iac-code prend en charge le mode serveur A2A via HTTP JSON-RPC/REST et plusieurs transports optionnels, ainsi que le mode client Phase 1 pour appeler des agents A2A distants. Il peut découvrir des Agent Cards distantes, sélectionner les endpoints annoncés, envoyer des prompts A2A 1.0, interroger/lister/annuler/s'abonner aux tâches, router vers des agents configurés, persister les métadonnées locales de restauration de tâches/contextes, stocker les charges utiles d'artefacts locaux comme artefacts de tâche standard, valider les extensions obligatoires, gérer les configurations de notifications push, et signer ou vérifier les Agent Cards avec des métadonnées HMAC ou JWKS. + +## Non pris en charge en Phase 1 {#phase-1-unsupported} + +- stdio, les sockets Unix, WebSocket, l'enveloppe gRPC JSON-RPC et Redis Streams sont des transports JSON-RPC personnalisés expérimentaux. +- Le gRPC officiel nécessite des dépendances optionnelles et utilise par défaut un binding serveur local non sécurisé. +- Pas de magasin de tâches distribué ou partagé. La persistance est un stockage de fichiers local sous la zone de configuration runtime d'iac-code. +- Pas de restauration d'une tâche asyncio en cours après le redémarrage du processus. +- Pas de continuation automatique en arrière-plan des tâches distantes interrompues. +- Pas de backend d'artefacts OSS, S3, base de données ou magasin d'objets externe. +- Pas d'ingestion d'URL HTTP distante, de découpage de gros binaires ou de protocole de téléversement reprenable. Les parties d'URL de fichier local doivent rester dans les racines d'espace de travail autorisées. +- Pas d'échec strict par défaut pour les Agent Cards non signées. +- Pas de signature asymétrique d'Agent Card côté serveur et pas de rotation automatique des clés de signature. +- Pas de DAG de planificateur autonome ni d'orchestration multi-agent complexe. +- La livraison push est au moins une fois pour les files adossées à Redis ; les récepteurs de callback doivent gérer les doublons et appliquer leur propre politique d'autorisation côté endpoint. + +Les demandes d'autorisation d'outil sont rejetées automatiquement en mode serveur A2A. N'exécutez le mode A2A non authentifié que dans des environnements locaux de confiance, ou protégez-le avec une authentification par jeton Bearer, Basic auth ou clé API. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md new file mode 100644 index 00000000..c4630d5e --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -0,0 +1,400 @@ +--- +title: Référence du protocole +description: Référence complète du protocole A2A pour l'intégration d'iac-code. +sidebar_position: 4 +--- + +# Référence du protocole + +Ce document décrit la surface A2A 1.0 exposée par le serveur iac-code et le comportement du client Phase 1 utilisé par `iac-code a2a-client call`. Pour les options CLI exactes, consultez la [référence des commandes](./command-reference.md). + +## Vue d'ensemble du cycle de vie + +Une interaction A2A typique suit ce flux : + +```text +GET Agent Card -> SendMessage or SendStreamingMessage -> GetTask / follow-up / CancelTask +``` + +1. **Découvrir** — Récupérer `/.well-known/agent-card.json`. +2. **Envoyer** — Soumettre un message texte à l'endpoint JSON-RPC sur `/`. +3. **Diffuser** — Recevoir des charges utiles `Task`, `Message` et `TaskStatusUpdateEvent`. +4. **Continuer** — Envoyer un message de suivi avec le même `contextId`. +5. **Annuler ou interroger** — Utiliser `CancelTask`, `GetTask` ou `ListTasks`. + +## Agent Card + +L'Agent Card est disponible à : + +```text +GET /.well-known/agent-card.json +``` + +Champs importants : + +| Champ | Valeur | Signification | +|-------|-------|---------| +| `name` | `iac-code` | Nom de l'agent | +| `supportedInterfaces[0].protocolBinding` | `JSONRPC` | Binding de transport | +| `supportedInterfaces[0].protocolVersion` | `1.0` | Version du protocole A2A | +| `supportedInterfaces[0].url` | `http://:/` | Endpoint JSON-RPC | +| `capabilities.streaming` | `true` | Prend en charge les mises à jour de tâche en streaming | +| `capabilities.pushNotifications` | `false` ou `true` | `true` lorsque `push-notifications: true` est configuré | +| `capabilities.extendedAgentCard` | `true` | Les appelants authentifiés peuvent demander des détails runtime étendus | +| `capabilities.extensions` | `urn:iac-code:a2a:artifact-metadata:v1` | Espace de noms optionnel de métadonnées iac-code pour l'état des outils et les métadonnées d'artefacts stockés | +| `defaultInputModes` | types MIME texte, JSON, YAML, image, audio et binaires | Modes MIME d'entrée acceptés | +| `defaultOutputModes` | `["text/plain"]` | Sortie texte uniquement | + +Les réponses d'Agent Card incluent `Cache-Control: public, max-age=60`, `ETag` et `Last-Modified`. Les clients peuvent envoyer `If-None-Match` et recevoir `304 Not Modified` lorsque la carte n'a pas changé. + +Compétences annoncées : + +| ID de compétence | Objectif | +|----------|---------| +| `iac_generation` | Générer des modèles Alibaba Cloud ROS et Terraform à partir du langage naturel | +| `iac_review` | Inspecter les modèles IaC et suggérer des corrections | +| `aliyun_ros_operations` | Aider aux workflows de piles Alibaba Cloud ROS | +| `terraform_ros_conversion` | Aider à la conversion Terraform-vers-ROS avec les ressources de compétences groupées | + +Lorsque l'authentification est activée, l'Agent Card annonce les schémas de sécurité configurés : + +| Schéma | Quand il est annoncé | +|--------|-----------------| +| `bearerAuth` | `token` ou `IACCODE_A2A_HTTP_TOKEN` est défini | +| `basicAuth` | Le nom d'utilisateur et le mot de passe Basic sont tous deux définis | +| `apiKeyAuth` | `api-key` ou `IACCODE_A2A_API_KEY` est défini | + +## Routes + +| Route | Méthode | Description | +|-------|--------|-------------| +| `/health` | `GET` | Renvoie `{"status":"healthy"}` | +| `/.well-known/agent-card.json` | `GET` | Renvoie l'Agent Card | +| `/` | `POST` | Gère les requêtes A2A JSON-RPC | +| Routes REST | mixte | Les routes REST du SDK A2A enregistrées par `create_rest_routes` | + +## Notes sur le client et les transports Phase 1 + +Le transport Phase 1 interopérable par défaut est JSON-RPC via HTTP. Le mode HTTP annonce également `HTTP+JSON` pour les routes REST du SDK. + +Le serveur dispose aussi de transports optionnels pour stdio, les sockets Unix, WebSocket, le gRPC officiel, l'enveloppe gRPC JSON-RPC et Redis Streams. stdio, les sockets Unix, WebSocket, gRPC JSON-RPC et Redis Streams sont des transports JSON-RPC personnalisés. Le gRPC officiel est annoncé comme `grpc` et nécessite des dépendances gRPC optionnelles. + +Le client intégré utilise la découverte d'Agent Card (`GET /.well-known/agent-card.json`) avant les appels de message, sélectionne le premier `supportedInterfaces[].url` exécutable annoncé, puis envoie des requêtes JSON-RPC avec `A2A-Version: 1.0` et des noms de méthodes A2A 1.0 comme `SendMessage`. + +`push-notifications: true` active les méthodes de configuration des notifications push A2A et la livraison des états terminaux. + +La signature d'Agent Card utilise l'utilitaire de signature du SDK A2A et émet les champs JWS standard `AgentCardSignature`. Le mode à clé symétrique utilise `HS256` ; la vérification peut sélectionner un secret configuré par `kid` d'en-tête protégé, un JWKS local à clé octet ou une URL JWKS distante. La signature asymétrique côté serveur et la rotation automatique des clés ne sont pas implémentées en Phase 1. + +Pour la liste canonique des comportements non pris en charge en Phase 1, consultez [Protocole A2A](./overview.md#phase-1-unsupported). + +## Backends de livraison des notifications push + +`iac-code a2a --config a2a-server.yml` prend en charge deux files de livraison push : + +- `push-queue: local-file` stocke les tâches sous le répertoire de persistance A2A et est destiné à une utilisation locale sur un seul noeud. +- `push-queue: redis-streams` stocke les tâches dans Redis Streams et coordonne les workers via un groupe de consommateurs Redis. + +La livraison push adossée à Redis nécessite l'extra optionnel `a2a-redis` et est au moins une fois. Les récepteurs de callback doivent gérer les mises à jour de tâche de manière idempotente, car une tâche peut être livrée à nouveau après des crashs de workers, l'expiration d'un bail, des reconnexions ou des courses de nouvelle tentative. + +Options Redis courantes : + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +push-lease-timeout-ms: 300000 +``` + +Les URL de callback sont validées avant le stockage puis de nouveau avant l'envoi. Le validateur par défaut rejette les URL non HTTP(S), les noms d'hôte localhost et les adresses IP littérales privées/locales. Les récepteurs de callback doivent tout de même appliquer leur propre politique d'authentification et d'idempotence. + +## Méthodes JSON-RPC + +### SendMessage + +Exécute un tour de message A2A non streaming. La réponse contient une tâche ou un message une fois le tour terminé. + +**Requête** + +```json +{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a VPC with two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +**Champs de message requis** + +| Champ | Type | Requis | Description | +|-------|------|----------|-------------| +| `messageId` | string | Oui | ID de message client unique | +| `role` | string | Oui | Utilisez `ROLE_USER` pour l'entrée utilisateur | +| `parts` | array | Oui | Parties de type texte, données JSON, texte brut, URL de fichier local ou parties multimodales bornées | +| `metadata.iac_code.cwd` | string | Recommandé | Chemin absolu de l'espace de travail ; utilise par défaut le répertoire du processus serveur si omis | + +`metadata.iac_code.cwd` doit être un répertoire absolu existant lorsqu'il est fourni. Il doit se trouver dans une racine d'espace de travail autorisée. Par défaut, les racines autorisées sont le répertoire du processus serveur et le répertoire temporaire système ; `IACCODE_A2A_ALLOWED_CWDS` peut fournir une liste d'autorisation séparée par le séparateur de chemins du système d'exploitation. + +Catégories d'entrée prises en charge : + +| Catégorie | Forme acceptée | Limites et comportement | +|----------|----------------|---------------------| +| Parties de type texte | `text` avec `text/plain`, JSON, Markdown, YAML ou des types MIME texte supplémentaires configurés | Ajoutées directement au prompt | +| Parties de données JSON | `data` avec `application/json` | Sérialisées en JSON compact ; max 1 MiB en ligne | +| Parties de texte brut | `raw` avec un type MIME de type texte | Doit être UTF-8 valide ; max 1 MiB en ligne | +| URL de fichiers texte locaux | `url` avec `file://...` et un type MIME de type texte | Le fichier doit exister dans `cwd` et les racines autorisées ; max 1 MiB | +| Parties multimodales raw/data/file | image, audio ou types MIME multimodaux configurés | Converties en manifeste de prompt avec nom de fichier, type média, taille en octets, hash et source ; raw/data max 5 MiB, URL de fichier max 25 MiB | + +L'ingestion d'URL HTTP(S) distante n'est pas prise en charge. Les parties d'URL de fichier doivent utiliser des URL locales `file://` et rester dans l'espace de travail autorisé. + +### SendStreamingMessage + +Exécute un tour de message A2A en streaming. Le corps de requête a la même forme que `SendMessage`, mais le serveur diffuse les réponses JSON-RPC comme Server-Sent Events. + +```json +{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +### GetTask + +Renvoie la tâche A2A sauvegardée par ID. Utilisez `historyLength` pour limiter l'historique renvoyé sans modifier l'historique de tâche stocké. Omettez-le pour recevoir l'historique par défaut actuel du serveur. + +```json +{ + "jsonrpc": "2.0", + "id": "3", + "method": "GetTask", + "params": { + "id": "task-id", + "historyLength": 10 + } +} +``` + +### ListTasks + +Renvoie les tâches connues visibles par l'appelant authentifié. Les résultats sont triés par horodatage de statut décroissant, puis par ID de tâche décroissant pour un ordre stable. Le serveur prend en charge `contextId`, `status`, `pageSize`, `pageToken`, `historyLength` et `includeArtifacts`. + +```json +{ + "jsonrpc": "2.0", + "id": "4", + "method": "ListTasks", + "params": { + "contextId": "ctx-id", + "status": "TASK_STATE_WORKING", + "pageSize": 20, + "includeArtifacts": false + } +} +``` + +`nextPageToken` est renvoyé lorsqu'une autre page est disponible. `includeArtifacts` vaut `false` par défaut, donc les réponses de liste omettent les artefacts de tâche sauf demande explicite. + +### CancelTask + +Demande l'annulation d'une tâche en cours. + +```json +{ + "jsonrpc": "2.0", + "id": "5", + "method": "CancelTask", + "params": { + "id": "task-id" + } +} +``` + +Si la tâche est active, le serveur annule le tour de l'agent en cours et émet un état de tâche annulé. Si la tâche existe mais n'est pas en cours, le serveur renvoie l'erreur A2A standard `TaskNotCancelableError`. + +### SubscribeToTask + +S'abonne à un flux de mises à jour de tâche active lorsque le transport client le prend en charge. + +```json +{ + "jsonrpc": "2.0", + "id": "6", + "method": "SubscribeToTask", + "params": { + "id": "task-id" + } +} +``` + +Pour les tâches actives, le flux commence par la `Task` actuelle, puis émet les événements de tâche suivants et se ferme lorsque le tour actif se termine. S'abonner à une tâche terminée, échouée, annulée ou nécessitant une entrée renvoie une erreur de type tâche introuvable au lieu d'attendre indéfiniment. Pour les nouveaux tours, préférez `SendStreamingMessage` ; il démarre l'exécution et diffuse la réponse en une seule requête. + +### Méthodes de configuration des notifications push + +Lorsque le serveur démarre avec `push-notifications: true`, il prend en charge : + +| Méthode | Objectif | +|--------|---------| +| `CreateTaskPushNotificationConfig` | Stocker une configuration de callback pour une tâche | +| `GetTaskPushNotificationConfig` | Récupérer une configuration de callback | +| `ListTaskPushNotificationConfigs` | Lister les configurations de callback d'une tâche | +| `DeleteTaskPushNotificationConfig` | Supprimer une configuration de callback | + +Exemple de requête de création : + +```json +{ + "jsonrpc": "2.0", + "id": "7", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-id", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token" + } + } +} +``` + +Le serveur chiffre les jetons de notification stockés et les identifiants d'authentification de callback lorsque le trousseau de clés push local est disponible. + +### GetExtendedAgentCard + +Les clients authentifiés peuvent demander l'Agent Card étendue : + +```json +{ + "jsonrpc": "2.0", + "id": "8", + "method": "GetExtendedAgentCard", + "params": {} +} +``` + +La carte étendue inclut la carte publique plus les détails runtime authentifiés. + +## Comportement des tâches et des contextes + +iac-code mappe les contextes A2A vers des runtimes d'agent internes : + +| Concept | Comportement | +|---------|----------| +| `contextId` omis | Le SDK/serveur génère un nouvel ID de contexte | +| Même `contextId` | Réutilise la même session iac-code interne et l'état de conversation | +| Même `contextId`, `cwd` différent | Rejeté comme espace de travail différent | +| Même `contextId`, message concurrent | Rejeté avec `Task is already working.` | +| Valeurs `contextId` différentes | Peuvent s'exécuter simultanément | +| Contexte inactif | Évincé de la mémoire après le délai d'inactivité configuré | + +Les ID de tâche et de contexte doivent être non vides, comporter au plus 128 caractères et contenir uniquement des lettres, des chiffres, `_`, `.`, `:` ou `-`. + +## États de tâche + +| État | Signification | +|-------|---------| +| `TASK_STATE_SUBMITTED` | La tâche a été acceptée | +| `TASK_STATE_WORKING` | iac-code exécute le tour de l'agent | +| `TASK_STATE_INPUT_REQUIRED` | Le tour est terminé et l'agent est prêt pour une entrée de suivi | +| `TASK_STATE_CANCELED` | L'annulation a été demandée et appliquée | +| `TASK_STATE_FAILED` | La tâche a échoué lors de la validation ou de l'exécution | + +iac-code utilise `TASK_STATE_INPUT_REQUIRED` comme état terminé normal, car le contexte reste disponible pour les messages de suivi. + +## Mises à jour en streaming + +Pendant l'exécution, iac-code émet des mises à jour `TaskStatusUpdateEvent`. + +Le texte de l'assistant est livré comme message de statut : + +```json +{ + "statusUpdate": { + "taskId": "task-1", + "contextId": "ctx-1", + "status": { + "state": "TASK_STATE_WORKING", + "message": { + "role": "ROLE_AGENT", + "parts": [{"text": "Here is the ROS template..."}] + } + } + } +} +``` + +Les détails d'outils et d'utilisation sont livrés via `metadata.iac_code` : + +| Chemin de métadonnées | Description | +|---------------|-------------| +| `iac_code.tool.status` | `started`, `input_delta`, `input_complete`, `completed` ou `failed` | +| `iac_code.tool.toolUseId` | ID d'utilisation d'outil stable pour corréler les événements d'outil | +| `iac_code.tool.name` | Nom de l'outil lorsqu'il est disponible | +| `iac_code.tool.input` | Entrée d'outil terminée, tronquée à 4000 caractères par champ | +| `iac_code.tool.result` | Résultat d'outil, tronqué à 4000 caractères par champ | +| `iac_code.permission.autoApproved` | `false` lorsqu'une demande d'autorisation d'outil a été rejetée par le mode serveur A2A | +| `iac_code.usage.inputTokens` | Nombre de jetons d'entrée pour le tour | +| `iac_code.usage.outputTokens` | Nombre de jetons de sortie pour le tour | +| `iac_code.usage.totalTokens` | Nombre total de jetons pour le tour | + +Lorsqu'un résultat d'outil inclut une charge utile d'artefact texte prise en charge, le serveur stocke la charge utile localement, émet un `TaskArtifactUpdateEvent` standard et enregistre l'artefact dans le champ `artifacts` de la tâche. La partie d'artefact utilise une URL `file://` plus des métadonnées comme `mediaType`, `byteSize` et `sha256` ; le contenu original de l'artefact n'est pas dupliqué dans les métadonnées d'outil. + +## Extensions + +L'Agent Card annonce l'extension optionnelle de métadonnées d'artefact iac-code : + +```text +urn:iac-code:a2a:artifact-metadata:v1 +``` + +Cette extension identifie l'espace de noms `metadata.iac_code` utilisé pour la progression des outils, les décisions d'autorisation, l'utilisation des jetons et les métadonnées d'artefacts locaux. Si le serveur est configuré avec une extension obligatoire, les clients doivent inclure son URI dans l'en-tête `A2A-Extensions`. Les extensions obligatoires manquantes renvoient l'erreur A2A standard `ExtensionSupportRequiredError`. + +## Gestion des erreurs + +| Scénario | Résultat | +|----------|--------| +| Entrée texte vide | `TASK_STATE_FAILED` avec `A2A server currently accepts text input only.` | +| Type média non pris en charge | Erreur de validation ou erreur de type de contenu A2A standard, selon l'endroit où le SDK rejette la requête | +| Partie d'URL distante | Erreur de validation, car les parties d'URL doivent utiliser des URL locales `file://` | +| URL de fichier hors de l'espace de travail autorisé | Erreur de validation | +| Extension A2A obligatoire manquante | `ExtensionSupportRequiredError` A2A standard | +| Métadonnées d'espace de travail invalides | `TASK_STATE_FAILED` avec un message d'espace de travail invalide | +| Authentification manquante ou invalide | HTTP `401` avec `{"error":"Unauthorized"}` | +| Dépendances serveur A2A manquantes | La CLI quitte avec une indication d'installation pour l'extra `a2a` | +| Identifiants fournisseur manquants | Erreur d'authentification nettoyée | +| Erreur runtime inattendue | Erreur interne nettoyée | + +Le serveur évite de renvoyer des chemins locaux, des secrets et des détails de fournisseur dans les messages d'erreur inattendus. diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/command-reference.md new file mode 100644 index 00000000..920deefb --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -0,0 +1,504 @@ +--- +title: コマンドリファレンス +description: A2A 経由で iac-code を実行して呼び出すための完全な CLI コマンドリファレンス。 +sidebar_position: 3 +--- + +# A2A コマンドリファレンス + +このページでは、A2A 関連のすべての `iac-code` コマンドを説明します。正確なオプション名、一般的なコマンドパターン、各フラグの運用上の意味が必要な場合に使用してください。 + +## コマンド概要 + +| Command | 用途 | +|---------|---------| +| `iac-code a2a` | iac-code を A2A サーバーとして実行 | +| `iac-code a2a-client call` | リモート Agent Card を発見してプロンプトを送信 | +| `iac-code a2a-client discover` | Agent Card を取得し、任意で検証 | +| `iac-code a2a-client task-get` | ID で 1 つのタスクを取得 | +| `iac-code a2a-client task-list` | フィルターとページネーションでタスクを一覧表示 | +| `iac-code a2a-client task-cancel` | アクティブなタスクをキャンセル | +| `iac-code a2a-client task-subscribe` | アクティブなタスクイベントストリームを購読 | +| `iac-code a2a-client push-config-create` | タスクプッシュ通知設定を作成 | +| `iac-code a2a-client push-config-get` | 1 つのタスクプッシュ通知設定を取得 | +| `iac-code a2a-client push-config-list` | タスクプッシュ通知設定を一覧表示 | +| `iac-code a2a-client push-config-delete` | タスクプッシュ通知設定を削除 | +| `iac-code a2a-client extended-card` | 認証済みの拡張 Agent Card を取得 | +| `iac-code a2a-route-preview` | `a2a-client call` のローカルルート選択をプレビュー | + +すべての HTTP クライアントコマンドは、同じ認証オプションを受け付けます。 + +| Option | 説明 | +|--------|-------------| +| `--token` | `Authorization: Bearer ` として送信される Bearer token | +| `--basic-username` | Basic auth のユーザー名 | +| `--basic-password` | Basic auth のパスワード | +| `--api-key` | API key 値 | +| `--api-key-header` | API key ヘッダー名。デフォルトは `X-API-Key` | + +## A2A クライアント設定 + +すべての `a2a-client` サブコマンドは、グループレベルで YAML 設定ファイルを受け付けます。 + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC" +``` + +CLI オプションは設定値を上書きします。安定した接続、認証、検証、ルーティング、繰り返し使うタスクまたはプッシュ設定には config を使用し、一度きりのプロンプトテキストはコマンドラインに置いてください。 + +```yaml +url: http://127.0.0.1:41242/ +token: your-bearer-token +basic-username: iac-code +basic-password: your-password +api-key: your-api-key +api-key-header: X-IAC-Code-Key +verify-card-secret: your-card-signing-secret +verify-card-jwks-url: https://a2a.example.com/.well-known/jwks.json +require-card-signature: true +timeout: 30 +cwd: /path/to/workspace +context-id: ctx-123 +task-id: task-123 +config-id: webhook-1 +callback-url: https://hooks.example.com/a2a +notification-token: notification-token +auth-scheme: bearer +auth-credentials: callback-token +routes: + - name: ros + url: http://127.0.0.1:41242/ + skills: + - iac_generation + tags: + - ros + - template +``` + +## `iac-code a2a` + +iac-code を A2A サーバーとして実行します。 + +```bash +iac-code a2a +``` + +デフォルトでは、サーバーは `127.0.0.1:41242` にバインドし、HTTP 上の JSON-RPC を提供します。ポート `41242` は iac-code のデフォルトであり、登録済み A2A ポートではありません。 + +### 基本サーバーオプション + +| Option | Default | 説明 | +|--------|---------|-------------| +| `--config` | empty | A2A サーバーオプションを含む YAML 設定ファイル | +| `--host` | `127.0.0.1` | HTTP サーバーホスト | +| `--port` | `41242` | HTTP サーバーポート | +| `--transport` | `http` | サーバートランスポート: `http`, `stdio`, `unix`, `websocket`, `grpc`, `grpc-jsonrpc`, or `redis-streams` | +| `--debug`, `-d` | `false` | デバッグログを有効化 | + +例: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --debug +``` + +### YAML 設定 + +認証、ストレージ、署名、トランスポート固有設定、プッシュ配信、その他のデプロイ詳細には `--config` を使用します。キーにはハイフンまたはアンダースコアを使用できます。共通 CLI フラグ `--host`、`--port`、`--transport` は設定ファイルの値を上書きします。 + +```yaml +host: 127.0.0.1 +port: 41242 +transport: http +token: local-dev-token +persistence-dir: .iac-code-a2a/state +artifact-dir: .iac-code-a2a/artifacts +push-notifications: true +``` + +次のように実行します。 + +```bash +iac-code a2a --config a2a-server.yml --port 41243 +``` + +### HTTP 認証 + +認証は任意です。サーバー認証は YAML または環境変数で設定します。認証設定がない場合、リクエストは未認証です。1 つ以上の方式が設定されている場合、リクエストはいずれかの設定済み方式を満たせます。 + +| Config key | Environment Variable | 説明 | +|--------|----------------------|-------------| +| `token` | `IACCODE_A2A_HTTP_TOKEN` | Bearer token | +| `basic-username` | `IACCODE_A2A_BASIC_USERNAME` | Basic auth username | +| `basic-password` | `IACCODE_A2A_BASIC_PASSWORD` | Basic auth password | +| `api-key` | `IACCODE_A2A_API_KEY` | API key value | +| `api-key-header` | `IACCODE_A2A_API_KEY_HEADER` | API key header name | + +Bearer token: + +```yaml +token: local-dev-token +``` + +Basic auth: + +```yaml +basic-username: iac-code +basic-password: local-dev-password +``` + +API key: + +```yaml +api-key: local-dev-key +api-key-header: X-IAC-Code-Key +``` + +### 永続化とアーティファクト + +| Config key | Default | 説明 | +|--------|---------|-------------| +| `persistence-dir` | `~/.iac-code/a2a` | タスク、コンテキスト、ルート、プッシュ設定のローカル JSON メタデータ | +| `artifact-dir` | `/artifacts` | ローカルアーティファクトペイロードストア | + +永続化は復元メタデータのためにタスクとコンテキストのスナップショットをミラーします。プロセスクラッシュ後に実行中の asyncio タスクを再開するものではありません。 + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +``` + +### Agent Card 署名 + +| Config key | 説明 | +|--------|-------------| +| `signing-secret` | 公開 Agent Card の署名に使用される HMAC secret | + +サーバーは A2A SDK `AgentCardSignature` JWS フィールドを出力します。対称モードは `HS256` を使用します。 + +```yaml +signing-secret: local-card-signing-secret +``` + +### プッシュ通知配信 + +| Config key | Default | 説明 | +|--------|---------|-------------| +| `push-notifications` | `false` | A2A タスクプッシュ通知設定メソッドと終端状態の配信を有効化 | +| `push-queue` | `local-file` | プッシュキューバックエンド: `local-file` または `redis-streams` | +| `push-redis-url` | empty | Redis ベースのプッシュキュー用 Redis URL | +| `push-stream` | `iac-code:a2a:push` | プッシュジョブ用 Redis stream | +| `push-retry-key` | `iac-code:a2a:push:retry` | 遅延リトライ用 Redis sorted set | +| `push-dead-stream` | `iac-code:a2a:push:dead` | dead-letter ジョブ用 Redis stream | +| `push-consumer-group` | `iac-code-push` | プッシュワーカー用 Redis consumer group | +| `push-consumer-name` | empty | このワーカーの Redis consumer name | +| `push-lease-timeout-ms` | `300000` | Redis pending lease timeout | + +ローカルファイルキュー: + +```yaml +push-notifications: true +persistence-dir: ~/.iac-code/a2a +push-queue: local-file +``` + +Redis Streams キュー: + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +``` + +Redis ベースのプッシュ配信には `a2a-redis` extra が必要です。 + +### トランスポートオプション + +| Transport | Command | 注意 | +|-----------|---------|-------| +| HTTP JSON-RPC and REST | `iac-code a2a --transport http` | デフォルト。`JSONRPC` と `HTTP+JSON` インターフェイスを広告します。 | +| stdio | `iac-code a2a --transport stdio` | 標準入出力上の実験的なカスタム JSON-RPC フレーム。 | +| Unix socket | `iac-code a2a --config a2a-server.yml --transport unix` | config に `socket-path` が必要。 | +| WebSocket | `iac-code a2a --config a2a-server.yml --transport websocket` | config の `ws-path` を使用し、デフォルトは `/a2a`。 | +| gRPC | `iac-code a2a --config a2a-server.yml --transport grpc` | config の `grpc-host` と `grpc-port` を使用。 | +| gRPC JSON-RPC | `iac-code a2a --config a2a-server.yml --transport grpc-jsonrpc` | gRPC 上のカスタム JSON-RPC エンベロープ。 | +| Redis Streams | `iac-code a2a --config a2a-server.yml --transport redis-streams` | config に `redis-url` が必要。 | + +Redis Streams トランスポートオプション: + +| Config key | Default | 説明 | +|--------|---------|-------------| +| `redis-url` | empty | Redis 接続 URL。`--transport redis-streams` では必須 | +| `request-stream` | `iac-code:a2a:requests` | リクエスト stream 名 | +| `response-stream` | `iac-code:a2a:responses` | レスポンス stream 名 | +| `consumer-group` | `iac-code` | リクエスト stream consumer group | + +### 権限の挙動 + +| Config key | Default | 説明 | +|--------|---------|-------------| +| `auto-approve-permissions` | `false` | A2A ターン中に発生したツール権限リクエストを自動承認 | + +`auto-approve-permissions: true` がない場合、A2A モードは権限プロンプトを拒否し、権限メタデータを出力します。信頼できる自動化環境でのみ使用してください。 + +## `iac-code a2a-client call` + +Agent Card を発見し、広告されたエンドポイントを選択して、プロンプトを送信します。 + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" +``` + +| Option | Default | 説明 | +|--------|---------|-------------| +| `--url` | empty | A2A エージェントのベース URL または JSON-RPC エンドポイント URL。config 由来でも可 | +| `--route` | repeatable | `--url` が省略された場合に使用される route spec | +| `--route-name` | empty | 選択する名前付きルート | +| `--prompt`, `-p` | required | プロンプトテキスト | +| `--cwd` | `.` | `message.metadata.iac_code.cwd` として送信されるワークスペースパス | +| `--context-id` | empty | フォローアップメッセージ用の既存 A2A context ID | +| `--verify-card-secret`, `--signing-secret` | empty | Agent Card 検証用の HMAC secret | +| `--verify-card-jwks-url` | empty | Agent Card 検証に使用されるリモート JWKS URL | +| `--require-card-signature`, `--require-signature` | `false` | 署名なしまたは無効な Agent Card を拒否 | +| `--timeout` | `30.0` | 呼び出しタイムアウト秒数 | +| `--stream` | `false` | `SendStreamingMessage` を使用し、ストリームイベントを出力 | + +同じコンテキスト内のフォローアップ: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --context-id ctx-123 \ + --prompt "Now add outputs for the VPC and vSwitch IDs." \ + --cwd "$PWD" +``` + +ストリーミング: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this Terraform module." \ + --cwd "$PWD" \ + --stream +``` + +署名済み Agent Card を必須にする: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a production VPC template." \ + --cwd "$PWD" +``` + +リモート JWKS URL を使って検証する: + +```bash +iac-code a2a-client --config jwks-client.yml call \ + --prompt "Review the ROS stack." +``` + +## `iac-code a2a-client discover` + +リモート Agent Card を取得して出力します。 + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +| Option | 説明 | +|--------|-------------| +| `--url` | A2A エージェントのベース URL。config 由来でも可 | +| `--verify-card-secret`, `--signing-secret` | 検証用 HMAC secret | +| `--verify-card-jwks-url` | 検証用リモート JWKS URL | +| `--require-card-signature`, `--require-signature` | 有効な署名を必須にする | + +認証済みディスカバリー: + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +## タスクコマンド + +タスクコマンドは JSON-RPC タスクメソッドを直接呼び出します。運用ツール、ダッシュボード、デバッグに便利です。 + +### `iac-code a2a-client task-get` + +```bash +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +| Option | 説明 | +|--------|-------------| +| `--url` | A2A JSON-RPC エンドポイント URL。config 由来でも可 | +| `--task-id` | タスク ID。config 由来でも可 | +| `--history-length` | 返すタスク履歴エントリの最大数 | + +### `iac-code a2a-client task-list` + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --context-id ctx-123 \ + --status TASK_STATE_INPUT_REQUIRED \ + --page-size 20 \ + --output table +``` + +| Option | Default | 説明 | +|--------|---------|-------------| +| `--url` | empty | A2A JSON-RPC エンドポイント URL。config 由来でも可 | +| `--context-id` | empty | context ID でフィルター | +| `--status` | empty | タスク状態でフィルター | +| `--page-size` | empty | 返すタスクの最大数 | +| `--page-token` | empty | ページネーショントークン | +| `--include-artifacts` | `false` | 応答にタスクアーティファクトを含める | +| `--output` | `table` | `table` または `json` | + +JSON 出力: + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --include-artifacts \ + --output json +``` + +### `iac-code a2a-client task-cancel` + +```bash +iac-code a2a-client --config a2a-client.yml task-cancel \ + --task-id task-123 +``` + +キャンセルは協調的です。完了済み、失敗済み、キャンセル済み、または input-required のタスクは、標準 A2A task-not-cancelable エラーを返します。 + +### `iac-code a2a-client task-subscribe` + +```bash +iac-code a2a-client --config a2a-client.yml task-subscribe \ + --task-id task-123 +``` + +このコマンドはアクティブタスクのイベントをストリーミングします。新しいターンでは `a2a-client call --stream` を優先してください。1 つのコマンドでタスクを開始し、更新をストリーミングします。 + +## プッシュ通知設定コマンド + +これらのコマンドには、`push-notifications: true` で起動されたサーバーが必要です。標準 A2A タスクプッシュ通知設定を管理します。 + +### `iac-code a2a-client push-config-create` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +| Option | 説明 | +|--------|-------------| +| `--url` | A2A JSON-RPC エンドポイント URL。config 由来でも可 | +| `--task-id` | タスク ID。config 由来でも可 | +| `--config-id` | Push config ID。config 由来でも可 | +| `--callback-url` | HTTP(S) callback URL。config 由来でも可 | +| `--notification-token` | `X-A2A-Notification-Token` として送信されるトークン | +| `--auth-scheme` | `bearer` や `basic` などの callback auth scheme | +| `--auth-credentials` | callback auth credentials | + +Callback URL は保存前と配送前に検証されます。デフォルトのバリデーターは、非 HTTP(S) URL、localhost 名、リテラルな private/local IP アドレスを拒否します。 + +### `iac-code a2a-client push-config-get` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-get \ + --task-id task-123 \ + --config-id webhook-1 +``` + +### `iac-code a2a-client push-config-list` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-list \ + --task-id task-123 \ + --page-size 10 +``` + +### `iac-code a2a-client push-config-delete` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-delete \ + --task-id task-123 \ + --config-id webhook-1 +``` + +## `iac-code a2a-client extended-card` + +認証済みの拡張 Agent Card を取得します。 + +```bash +iac-code a2a-client --config a2a-client.yml extended-card \ + --token "$A2A_TOKEN" +``` + +公開 Agent Card は `capabilities.extendedAgentCard=true` を広告します。拡張カードは、タスク管理やプッシュ設定機能メタデータを含む認証済みランタイム詳細を追加します。 + +## `iac-code a2a-route-preview` + +`a2a-client call` が、`--url` が省略された場合に設定済みルートをどのように解決するかをプレビューします。 + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +| Option | 説明 | +|--------|-------------| +| `--route` | `name=url;skills=a,b;tags=x,y` 形式の繰り返し可能な route spec | +| `--name` | 解決するルート名 | +| `--skill` | 解決する Skill ID | +| `--prompt` | 名前/タグ一致に使用されるプロンプトテキスト | +| `--route-state-dir`, `--persistence-dir` | ルートスナップショットの永続化に使用されるディレクトリ | +| `--save-routes` | 指定されたルートをルート状態ディレクトリに保存 | + +ルートスナップショットを保存する: + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-state-dir ~/.iac-code/a2a \ + --save-routes +``` + +ルート経由で呼び出す: + +```bash +iac-code a2a-client call \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-name ros \ + --prompt "Create a ROS VPC template." \ + --cwd "$PWD" +``` + +## 環境変数 + +| Variable | 説明 | +|----------|-------------| +| `IACCODE_A2A_HTTP_TOKEN` | サーバー/クライアント Bearer token のデフォルト | +| `IACCODE_A2A_BASIC_USERNAME` | サーバー/クライアント Basic auth username のデフォルト | +| `IACCODE_A2A_BASIC_PASSWORD` | サーバー/クライアント Basic auth password のデフォルト | +| `IACCODE_A2A_API_KEY` | サーバー/クライアント API key のデフォルト | +| `IACCODE_A2A_API_KEY_HEADER` | API key header name のデフォルト | +| `IACCODE_A2A_ALLOWED_CWDS` | 受信メッセージメタデータと file URL に許可されるワークスペースルートの OS パス区切りリスト | +| `IACCODE_A2A_TEXT_MIME_TYPES` | 追加のカンマ区切りまたはセミコロン区切りのテキスト風 MIME types | +| `IACCODE_A2A_MULTIMODAL_MIME_TYPES` | 追加のカンマ区切りまたはセミコロン区切りのマルチモーダル MIME types | +| `IAC_CODE_A2A_PUSH_KEYRING` | 環境管理の暗号化プッシュシークレット keyring | diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/examples.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/examples.md new file mode 100644 index 00000000..f6bc673c --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/examples.md @@ -0,0 +1,388 @@ +--- +title: 例 +description: iac-code A2A サーバーと統合するための実用例。 +sidebar_position: 6 +--- + +# 例 + +このページでは、すぐに使える A2A 統合例を提供します。 + +## 前提条件 + +これらの例は次を前提としています。 + +| 依存関係 | バージョン | 用途 | +|------------|---------|---------| +| Python | `3.12` | プロジェクトランタイムに一致 | +| `a2a-sdk` | `>=1.0.2,<2` | A2A クライアントと protobuf 型 | +| `httpx` | `>=0.27.0` | SDK と直接例で使用される HTTP クライアント | +| `iac-code` | current repo | `iac-code a2a` サブコマンドを提供 | + +サーバーを起動します。 + +```bash +uv sync --extra a2a +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +## Python SDK — ストリーミングセッション + +この例は Agent Card を発見し、メッセージを送信し、アシスタントのテキストチャンクを出力し、ツールメタデータを報告します。 + +```python +"""Streaming iac-code A2A session using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path +from typing import Any + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest +from google.protobuf.json_format import MessageToDict + + +def metadata_to_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + return MessageToDict(value, preserving_proto_field_name=False) + + +async def main() -> None: + headers = {} + # For authenticated servers: + # headers["Authorization"] = "Bearer YOUR_TOKEN" + + async with httpx.AsyncClient(headers=headers, timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Create a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("task"): + print(f"[task] {event.task.id} context={event.task.context_id}") + + elif event.HasField("status_update"): + update = event.status_update + status = update.status + + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + metadata = metadata_to_dict(update.metadata) + tool = metadata.get("iac_code", {}).get("tool") + if tool: + print(f"\n[tool] {tool.get('status')} {tool.get('name', '')}".rstrip()) + + usage = metadata.get("iac_code", {}).get("usage") + if usage: + print(f"\n[usage] {usage['totalTokens']} total tokens") + + if status.state == "TASK_STATE_INPUT_REQUIRED": + print("\n[done]") + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## CLI — エンドツーエンドワークフロー + +永続化、アーティファクト、プッシュ通知サポート、署名済み Agent Card を備えたローカルサーバーを起動します。 + +```yaml +host: 127.0.0.1 +port: 41242 +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +```bash +iac-code a2a --config a2a-server.yml +``` + +安定したエンドポイントとカード検証設定のクライアント設定を作成します。 + +```yaml +url: http://127.0.0.1:41242/ +verify-card-secret: local-card-signing-secret +require-card-signature: true +``` + +Agent Card を発見して検証します。 + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +ストリーミングリクエストを送信します。 + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" \ + --stream +``` + +タスクを一覧表示し、1 つのタスクを JSON として取得します。 + +```bash +iac-code a2a-client --config a2a-client.yml task-list --output table + +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +タスクのプッシュコールバックを登録します。 + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +ルーティングされたエージェントを呼び出す前に、ルート選択をプレビューします。 + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +## Python SDK — フォローアップメッセージ + +フォローアップメッセージは同じ `context_id` と、通常は同じタスク ID を再利用します。これにより、内部 iac-code ランタイムと会話履歴が維持されます。 + +```python +import uuid +from pathlib import Path + +from a2a.types import Message, Part, Role, SendMessageRequest + + +def build_follow_up(task_id: str, context_id: str, text: str) -> SendMessageRequest: + return SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + task_id=task_id, + context_id=context_id, + role=Role.ROLE_USER, + parts=[Part(text=text)], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + +# Usage inside an async function: +# async for event in client.send_message( +# build_follow_up(task_id, context_id, "Add outputs for VPC and VSwitch IDs.") +# ): +# ... +``` + +サーバーは再利用された `contextId` を、新しいメッセージが別のワークスペースを指している場合に拒否します。 + +## Python SDK — タスクをキャンセルする + +```python +from a2a.types import CancelTaskRequest + + +async def cancel_running_task(client, task_id: str) -> None: + task = await client.cancel_task(CancelTaskRequest(id=task_id)) + print(f"{task.id}: {task.status.state}") +``` + +## 直接 HTTP — 最小 JSON-RPC クライアント + +呼び出し側に SDK 依存関係を持たせたくない場合に使用します。 + +```python +"""Direct A2A JSON-RPC client using httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=120.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template for missing parameters."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## 直接 HTTP — ストリーミング SSE + +```python +"""Read SendStreamingMessage events directly with httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=None) as client: + async with client.stream( + "POST", + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Generate a Terraform VPC example."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if line.startswith("data:"): + print(line.removeprefix("data:").strip()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## 直接 HTTP — プッシュ通知設定 + +プッシュ設定メソッドは、サーバーが `push-notifications: true` で実行されている場合に利用できます。 + +```python +"""Create an A2A task push notification config.""" + +import asyncio + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "push-1", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-123", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token", + }, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## iac-code メタデータの処理 + +ツールと使用量イベントは `TaskStatusUpdateEvent.metadata.iac_code` に届きます。 + +```python +from typing import Any + + +def handle_iac_metadata(metadata: dict[str, Any]) -> None: + iac = metadata.get("iac_code", {}) + + if tool := iac.get("tool"): + status = tool.get("status") + tool_id = tool.get("toolUseId") + name = tool.get("name", "") + print(f"tool={name} id={tool_id} status={status}") + + if permission := iac.get("permission"): + if permission.get("autoApproved") is False: + print(f"permission rejected for {permission.get('toolName')}") + + if usage := iac.get("usage"): + print( + "tokens=" + f"{usage.get('inputTokens', 0)}+{usage.get('outputTokens', 0)}" + f"={usage.get('totalTokens', 0)}" + ) +``` + +## よくある落とし穴 + +| 症状 | 修正 | +|---------|-----| +| HTTP `401` | Agent Card と JSON-RPC リクエストの両方で、`Authorization: Bearer `、Basic auth、または `X-API-Key: ` などの設定済み認証方式を含める | +| `Invalid A2A workspace metadata.` | `metadata.iac_code.cwd` に既存の絶対パスを使用する | +| `A2A server currently accepts text input only.` | 少なくとも 1 つの空でないテキストパーツを送信する | +| `Task is already working.` | 同じコンテキストで別のメッセージを送信する前に、現在のターンの完了を待つ | +| フォローアップが別ワークスペースとして拒否される | `metadata.iac_code.cwd` を、再利用する `contextId` では変更しない | +| ローカルファイル URL が拒否される | `file://` パーツを `metadata.iac_code.cwd` 内かつ `IACCODE_A2A_ALLOWED_CWDS` 内に保つ | +| プッシュコールバックが拒否される | localhost またはリテラルな private/local IP アドレスではない HTTP(S) コールバック URL を使用する | +| Redis プッシュキューの起動に失敗する | `a2a-redis` extra をインストールし、A2A 設定で `push-redis-url` を指定する | diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/getting-started.md new file mode 100644 index 00000000..d3a5bc35 --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -0,0 +1,291 @@ +--- +sidebar_position: 2 +title: はじめに +description: A2A サーバーを起動して最初のメッセージを送信します。 +--- + +# A2A を始める + +## 前提条件 + +1. **iac-code がインストール済み** — [インストール](/docs/getting-started/installation)ガイドを参照してください。 + +2. **LLM 認証情報が設定済み** — モデルプロバイダーの認証情報を設定するには、[認証](/docs/configuration/authentication)ガイドを参照してください。 + +3. **A2A サーバー依存関係** — `a2a` extra 付きで iac-code をインストールします。 + +```bash +uv sync --extra a2a +``` + +## A2A サーバーの起動 + +デフォルトのローカルインターフェイスでサーバーを起動します。 + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +ローカル状態、アーティファクトストレージ、プッシュ通知配信、または署名済み Agent Card が必要な場合は YAML 設定ファイルを使用します。 + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +次のように実行します。 + +```bash +iac-code a2a --config a2a-server.yml +``` + +`push-notifications: true` は、A2A タスクプッシュ通知設定メソッドと終端状態の配信を有効にします。複数のワーカーがプッシュ配信を調整する必要がある場合は、`push-queue: redis-streams` と `push-redis-url` を使用してください。 + +サーバーは次を公開します。 + +| Route | 用途 | +|-------|---------| +| `GET /health` | ヘルスチェック | +| `GET /.well-known/agent-card.json` | Agent Card ディスカバリー | +| `POST /` | A2A JSON-RPC エンドポイント | + +HTTP サーバーは A2A SDK REST ルートも登録し、Agent Card で `JSONRPC` と `HTTP+JSON` の両方のインターフェイスを広告します。 + +## ディスカバリーの検証 + +Agent Card を取得します。 + +```text +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +`name: "iac-code"`、`JSONRPC` と `HTTP+JSON` のインターフェイス、`ETag` などのキャッシュヘッダー、任意の `urn:iac-code:a2a:artifact-metadata:v1` 拡張、サポートされる入力モード、`iac_generation`、`iac_review`、`aliyun_ros_operations`、`terraform_ros_conversion` などのスキルが表示されるはずです。 + +ヘルスエンドポイントを確認します。 + +```bash +curl http://127.0.0.1:41242/health +``` + +期待される応答: + +```json +{"status":"healthy"} +``` + +## 認証を必須にする + +認証は任意です。A2A 認証オプションや環境変数が設定されていない場合、リクエストに認証は不要です。何らかの認証方式が設定されている場合、Agent Card ディスカバリーを含むすべてのリクエストは、設定された方式のいずれかを満たす必要があります。 + +### Bearer Token + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +対応する YAML 設定キーは `token` です。 + +```text +Authorization: Bearer +``` + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +ユーザー名とパスワードは両方とも存在する必要があります。対応する YAML 設定キーは `basic-username` と `basic-password` です。 + +### API Key + +```bash +export IACCODE_A2A_API_KEY=your-api-key + +iac-code a2a +``` + +デフォルトの API key ヘッダーは次のとおりです。 + +```text +X-API-Key: +``` + +`api-key-header` YAML 設定キーまたは `IACCODE_A2A_API_KEY_HEADER` で上書きします。 + +```yaml +api-key: your-api-key +api-key-header: X-IAC-Code-Key +``` + +## リモート A2A エージェントを呼び出す + +安定したクライアント接続と認証設定を YAML ファイルに入れます。 + +```yaml +url: http://127.0.0.1:41242/ +token: your-secret-token +verify-card-secret: your-card-signing-secret +require-card-signature: true +cwd: /path/to/workspace +``` + +直接の Phase 1 クライアント呼び出しには `a2a-client call` を使用します。 + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC with two vSwitches" --cwd "$PWD" +``` + +最終応答だけでなく増分イベントが必要な場合は `--stream` を使用します。 + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this template" \ + --cwd "$PWD" \ + --stream +``` + +一度きりのターゲットやトークンが必要な場合、コマンドラインオプションは設定値を上書きします。 + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --url https://other-agent.example.com/ \ + --prompt "Review this template" +``` + +マルチエージェントルーティングでは、呼び出し前にルート選択をプレビューします。 + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --route-state-dir ~/.iac-code/a2a +``` + +タスク管理、プッシュ設定 CRUD、拡張 Agent Card、トランスポートオプションを含むすべての A2A コマンドについては、[コマンドリファレンス](./command-reference.md)を参照してください。 + +## curl で最初のメッセージを送信する + +ワークスペースディレクトリは `message.metadata.iac_code.cwd` を通じて渡します。パスは絶対パスで、既に存在し、許可されたワークスペースルート内にある必要があります。デフォルトでは、許可されるルートはサーバープロセスディレクトリとシステム一時ディレクトリです。`IACCODE_A2A_ALLOWED_CWDS` で上書きできます。 + +サーバーはテキスト風パーツ、JSON データパーツ、生の UTF-8 テキスト、ローカルワークスペースの `file://` テキストファイル、制限付きマルチモーダル添付を受け付けます。リモート URL 取り込みはサポートされません。`url` パーツは、許可されたワークスペース内のローカル `file://` URL でなければなりません。 + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [ + {"text": "Generate a ROS VPC template with two vSwitches."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +ストリーミング出力には `SendStreamingMessage` を使用します。 + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [ + {"text": "Review my Terraform files and suggest ROS equivalents."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## 最小 Python SDK 例 + +以下の例では、`a2a-sdk>=1.0.2,<2` を使用します。これは `a2a` extra で使用されるバージョン範囲です。 + +```python +"""Minimal iac-code A2A client using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest + + +async def main() -> None: + async with httpx.AsyncClient(timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Generate a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("status_update"): + status = event.status_update.status + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +:::tip +認証済みサーバーでは、`httpx.AsyncClient` を `headers={"Authorization": "Bearer "}` 付きで構築し、Agent Card ディスカバリーと JSON-RPC 呼び出しの両方にトークンが含まれるようにしてください。 +::: + +## 次のステップ + +- [コマンドリファレンス](./command-reference.md) — CLI コマンドとオプションの完全なリファレンス。 +- [プロトコルリファレンス](./protocol-reference.md) — メソッド、ルート、状態、メタデータの詳細。 +- [HTTP トランスポート](./http-transport.md) — JSON-RPC HTTP の動作、bearer auth、curl ワークフロー。 +- [例](./examples.md) — SDK、直接 HTTP、フォローアップ、キャンセル、メタデータ処理の例。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/http-transport.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/http-transport.md new file mode 100644 index 00000000..0a0e889b --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/http-transport.md @@ -0,0 +1,269 @@ +--- +title: HTTP トランスポート +description: JSON-RPC HTTP 経由で iac-code A2A サーバーを実行して呼び出します。 +sidebar_position: 5 +--- + +# HTTP トランスポート + +iac-code のデフォルト A2A サーバーは、HTTP 上の JSON-RPC と A2A SDK REST ルートを公開します。サーバーは Starlette で構築され、Uvicorn 上で実行されます。 + +## サーバーの起動 + +```bash +# Default host and port +iac-code a2a + +# Explicit host and port +iac-code a2a --host 127.0.0.1 --port 41242 + +# Listen on all interfaces +iac-code a2a --host 0.0.0.0 --port 41242 +``` + +最初に任意のサーバー依存関係をインストールします。 + +```bash +uv sync --extra a2a +``` + +## エンドポイント概要 + +| Route | Method | Response | +|-------|--------|----------| +| `/health` | `GET` | プレーン JSON のヘルス応答 | +| `/.well-known/agent-card.json` | `GET` | Agent Card JSON | +| `/` | `POST` | JSON-RPC 応答または SSE ストリーム | +| SDK REST routes | mixed | SDK によって登録される A2A REST エンドポイント | + +## ヘッダー + +推奨ヘッダー: + +```text +Content-Type: application/json +A2A-Version: 1.0 +``` + +Bearer auth が有効な場合: + +```text +Authorization: Bearer +``` + +## 認証 + +サーバーは任意の Bearer token、Basic auth、API key 認証をサポートします。認証オプションや環境変数が設定されていない場合、リクエストに認証は不要です。1 つ以上の方式が設定されている場合、リクエストはいずれかの設定済み方式で認証できます。 + +### Bearer Token + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +A2A YAML 設定ファイルで `token` を設定することもできます。 + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +Basic auth を有効にするには、ユーザー名とパスワードの両方を設定する必要があります。 + +### API Key + +```bash +export IACCODE_A2A_API_KEY=your-api-key +iac-code a2a +``` + +デフォルトの API key ヘッダーは `X-API-Key` です。YAML で変更できます。 + +```yaml +api-key: ${IACCODE_A2A_API_KEY} +api-key-header: X-IAC-Code-Key +``` + +または `IACCODE_A2A_API_KEY_HEADER` を使用します。 + +| シナリオ | 挙動 | +|----------|----------| +| 認証方式が設定されていない | 認証は不要 | +| 1 つ以上の方式が設定され、いずれか 1 つが一致する | リクエストは続行される | +| 1 つ以上の方式が設定され、どの方式も一致しない | HTTP `401` と `{"error":"Unauthorized"}` | + +## Agent Card ディスカバリー + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +認証あり: + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "Authorization: Bearer $IACCODE_A2A_HTTP_TOKEN" +``` + +API key 認証あり: + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "X-API-Key: $IACCODE_A2A_API_KEY" +``` + +JSON-RPC エンドポイント URL は `supportedInterfaces[0].url` で広告されます。HTTP モードは REST 対応クライアント向けに `HTTP+JSON` インターフェイスも広告します。 + +## 非ストリーミングメッセージ + +`SendMessage` は、エージェントターンが完了した後に単一の JSON-RPC 応答を返します。 + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a Terraform VPC module for Alibaba Cloud."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## ストリーミングメッセージ + +`SendStreamingMessage` は Server-Sent Events を返します。イベントが到着したタイミングで出力するには `curl -N` を使用します。 + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Generate a ROS template for one VPC and two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +各 SSE `data:` 行には、`result` が A2A `StreamResponse` である 1 つの JSON-RPC 応答が含まれます。 + +## フォローアップメッセージ + +最初の応答で返された `taskId` と `contextId` を使用して、同じ会話を継続します。 + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-2", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-3", + "taskId": "task-id-from-first-response", + "contextId": "context-id-from-first-response", + "role": "ROLE_USER", + "parts": [{"text": "Now add tags for environment and owner."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +再利用される `contextId` では、ワークスペースが同じままである必要があります。 + +## 実行中タスクをキャンセルする + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "cancel-1", + "method": "CancelTask", + "params": { + "id": "task-id" + } + }' +``` + +キャンセルは協調的です。iac-code はアクティブなエージェントターンをキャンセルし、キャンセル済み状態を出力し、コンテキストロックを解放します。既に実行中でない既存タスクをキャンセルすると、標準 A2A `TaskNotCancelableError` が返されます。 + +## 対応する CLI + +ほとんどの HTTP ワークフローには対応する CLI コマンドがあります。 + +```yaml +url: http://127.0.0.1:41242/ +``` + +```bash +# Discover the Agent Card +iac-code a2a-client --config a2a-client.yml discover + +# Send a non-streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a Terraform VPC module for Alibaba Cloud." \ + --cwd "$PWD" + +# Send a streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a ROS template for one VPC and two vSwitches." \ + --cwd "$PWD" \ + --stream + +# Inspect task state +iac-code a2a-client --config a2a-client.yml task-get --task-id task-id +iac-code a2a-client --config a2a-client.yml task-list --output table + +# Cancel an active task +iac-code a2a-client --config a2a-client.yml task-cancel --task-id task-id +``` + +完全なオプション一覧は[コマンドリファレンス](./command-reference.md)を参照してください。 + +## 運用上の注意 + +- ローカル専用の利用では `127.0.0.1` にバインドしてください。 +- 共有ネットワークインターフェイスにバインドする前に、A2A 設定の `token` または `IACCODE_A2A_HTTP_TOKEN` を使用してください。 +- A2A モードはツール権限リクエストを自動的に拒否します。ローカル自動化サービスのような認証なしエンドポイントは保護してください。 +- アクティブなランタイム状態はメモリ内にあります。永続化はタスクとコンテキストのメタデータをミラーしますが、プロセスを再起動しても実行中の asyncio 作業は再開されません。 +- 1 つのコンテキストでは同時に 1 つのタスクだけを実行できます。別々のコンテキストは並行して実行できます。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/overview.md new file mode 100644 index 00000000..7d84c0aa --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: A2A プロトコル +description: iac-code における Agent2Agent 対応の概要。 +--- + +# A2A プロトコル + +## A2A とは + +[Agent2Agent (A2A)](https://github.com/a2aproject/A2A) は、リモートエージェントを発見して呼び出すためのプロトコルです。エージェントは Agent Card を公開し、構造化メッセージを受け取り、タスク更新をストリーミングし、標準トランスポートを通じてキャンセルやタスク検索の操作を公開できます。 + +## A2A サーバーとしての iac-code + +iac-code は A2A 1.0 Server / Agent として実行できます。他の A2A 互換クライアントは iac-code を発見し、Infrastructure as Code のリクエストを送信し、実行更新をストリーミングし、アクティブなタスクをキャンセルできます。 + +別のエージェント、ワークフローエンジン、またはサービスが、相互運用可能な IaC スペシャリストとして iac-code を呼び出す必要がある場合は A2A を使用します。エディタースタイルのクライアントがセッション管理、権限プロンプト、ローカル開発統合を必要とする場合は ACP を使用します。 + +## ユースケース + +- **エージェントオーケストレーション** — プランナーエージェントは Alibaba Cloud ROS または Terraform の作業を iac-code に委任できます。 +- **ワークフロー自動化** — 内部ツールは IaC の生成、レビュー、変換タスクを HTTP 経由で送信できます。 +- **サービスディスカバリー** — クライアントは Agent Card を取得し、IaC 生成やテンプレートレビューなどの機能を選択できます。 +- **ストリーミング統合** — chatops やダッシュボードクライアントは、ターンの実行中にモデルテキスト、ツールアクティビティ、使用量メタデータ、最終タスク状態を表示できます。 + +## インタラクションモードの比較 + +| モード | コマンド | 最適な用途 | +|------|---------|----------| +| **Interactive REPL** | `iac-code` | 手元での探索と反復的なテンプレート作成 | +| **Non-interactive CLI** | `iac-code --prompt "..."` or `--headless` | 1 回限りのスクリプト実行と CI ジョブ | +| **ACP Server** | `iac-code acp` | IDE/エディター統合と複数セッションのクライアント制御 | +| **A2A Server** | `iac-code a2a` | A2A トランスポート上のエージェント間相互運用 | +| **A2A Client** | `iac-code a2a-client call` | iac-code からリモート A2A エージェントを呼び出す | + +## 中核機能 + +- **Agent Card ディスカバリー** — `/.well-known/agent-card.json` を公開し、プロトコルバインディング、バージョン、スキル、入出力モード、任意の認証メタデータを含めます。 +- **HTTP JSON-RPC and REST** — A2A JSON-RPC リクエストを `/` で提供し、SDK REST ルートを登録します。 +- **ストリーミング応答** — 増分タスク更新のために `SendStreamingMessage` をサポートします。 +- **タスク管理** — タスク検索、カーソルページネーション付きの認証済みタスク一覧、アクティブタスクのキャンセル、アクティブタスクの購読をサポートします。 +- **コンテキスト再利用** — 同じ A2A `contextId` 内のフォローアップメッセージで iac-code ランタイムを再利用します。 +- **ワークスペーススコープ** — メッセージメタデータの `iac_code.cwd` からプロジェクトディレクトリを読み取ります。 +- **ツールメタデータ** — ツール開始、入力差分、完了したツール結果、権限判断、トークン使用量について、iac-code 固有のメタデータを出力します。 +- **入力パーツ** — テキスト風パーツ、JSON データパーツ、生の UTF-8 テキスト、ローカルワークスペースの `file://` テキストファイル、プロンプトマニフェストとして表現された制限付きマルチモーダル添付を受け付けます。 +- **クライアント呼び出し** — リモート Agent Card を発見し、設定されている場合は署名を検証し、テキストプロンプトをリモートエージェントへ送信します。 +- **ルーティング** — 明示的な名前、スキル、またはプロンプト/タグの一致により、設定済みリモートエージェントを選択します。 +- **永続化メタデータ** — プロセスをまたぐ復元メタデータのために、ローカル A2A タスク/コンテキストスナップショットを JSON ファイルへミラーします。 +- **アーティファクト** — サポートされるローカルテキストアーティファクトペイロードをストリーミングイベント本文の外に保存し、標準の `TaskArtifactUpdateEvent` イベントを出力し、タスクの `artifacts` を記録します。 +- **拡張とキャッシュ** — 任意の iac-code アーティファクトメタデータ拡張を広告し、必須 `A2A-Extensions` を検証し、キャッシュヘッダー付きで Agent Card を提供します。 +- **プッシュ通知** — `push-notifications: true` が設定されている場合、ローカルファイルまたは Redis ベースの配信キューを使って、A2A タスクプッシュ通知設定メソッドをサポートします。 +- **Agent Card 署名** — Agent Card に任意の A2A SDK JWS 署名を追加し、設定済みキー、ローカル octet JWKS データ、またはリモート JWKS URL による `kid` ベースの検証をサポートします。 +- **複数トランスポート** — HTTP、stdio、Unix ソケット、WebSocket、公式 gRPC、カスタム gRPC JSON-RPC、Redis Streams トランスポートで実行します。 +- **CLI 操作** — ディスカバリー、メッセージ送信、タスク検索/一覧/キャンセル/購読、プッシュ設定 CRUD、拡張カード、ルートプレビューのコマンドを提供します。 + +## Phase 1 のサポート + +iac-code は HTTP JSON-RPC/REST と複数の任意トランスポート上の A2A サーバーモードに加え、リモート A2A エージェントを呼び出す Phase 1 クライアントモードをサポートします。リモート Agent Card の発見、広告されたエンドポイントの選択、A2A 1.0 プロンプトの送信、タスクの照会/一覧/キャンセル/購読、設定済みエージェントへのルーティング、ローカルタスク/コンテキスト復元メタデータの永続化、ローカルアーティファクトペイロードの標準タスクアーティファクトとしての保存、必須拡張の検証、プッシュ通知設定の管理、HMAC または JWKS メタデータによる Agent Card の署名または検証ができます。 + +## Phase 1 で未サポート {#phase-1-unsupported} + +- stdio、Unix ソケット、WebSocket、gRPC JSON-RPC エンベロープ、Redis Streams は実験的なカスタム JSON-RPC トランスポートです。 +- 公式 gRPC には任意依存関係が必要で、デフォルトでは安全でないローカルサーバーバインディングを使用します。 +- 分散または共有タスクストアはありません。永続化は iac-code ランタイム設定領域配下のローカルファイルストレージです。 +- プロセス再起動後、実行中の asyncio タスクは復元されません。 +- 中断されたリモートタスクの自動バックグラウンド継続はありません。 +- OSS、S3、データベース、外部オブジェクトストアのアーティファクトバックエンドはありません。 +- リモート HTTP URL 取り込み、大きなバイナリのチャンク化、再開可能アップロードプロトコルはありません。ローカルファイル URL パーツは許可されたワークスペースルート内に留まる必要があります。 +- 署名されていない Agent Card に対するデフォルトのハード失敗はありません。 +- サーバーからの非対称 Agent Card 署名と、自動署名キーローテーションはありません。 +- 自律プランナー DAG や複雑なマルチエージェントオーケストレーションはありません。 +- Redis ベースのキューではプッシュ配信は at-least-once です。コールバック受信側は重複を処理し、エンドポイント側の認可ポリシーを自分で適用する必要があります。 + +A2A サーバーモードでは、ツール権限リクエストは自動的に拒否されます。認証なしの A2A モードは信頼できるローカル環境でのみ実行するか、Bearer token、Basic auth、または API key 認証で保護してください。 diff --git a/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md new file mode 100644 index 00000000..43d82a41 --- /dev/null +++ b/website/i18n/ja/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -0,0 +1,400 @@ +--- +title: プロトコルリファレンス +description: iac-code 統合のための完全な A2A プロトコルリファレンス。 +sidebar_position: 4 +--- + +# プロトコルリファレンス + +このドキュメントでは、iac-code サーバーが公開する A2A 1.0 の範囲と、`iac-code a2a-client call` で使用される Phase 1 クライアントの挙動を説明します。正確な CLI オプションについては、[コマンドリファレンス](./command-reference.md)を参照してください。 + +## ライフサイクル概要 + +典型的な A2A インタラクションは次の流れに従います。 + +```text +GET Agent Card -> SendMessage or SendStreamingMessage -> GetTask / follow-up / CancelTask +``` + +1. **発見** — `/.well-known/agent-card.json` を取得します。 +2. **送信** — `/` の JSON-RPC エンドポイントへテキストメッセージを送信します。 +3. **ストリーム** — `Task`、`Message`、`TaskStatusUpdateEvent` ペイロードを受信します。 +4. **継続** — 同じ `contextId` でフォローアップメッセージを送信します。 +5. **キャンセルまたは照会** — `CancelTask`、`GetTask`、または `ListTasks` を使用します。 + +## Agent Card + +Agent Card は次の場所で利用できます。 + +```text +GET /.well-known/agent-card.json +``` + +重要なフィールド: + +| Field | Value | 意味 | +|-------|-------|---------| +| `name` | `iac-code` | エージェント名 | +| `supportedInterfaces[0].protocolBinding` | `JSONRPC` | トランスポートバインディング | +| `supportedInterfaces[0].protocolVersion` | `1.0` | A2A プロトコルバージョン | +| `supportedInterfaces[0].url` | `http://:/` | JSON-RPC エンドポイント | +| `capabilities.streaming` | `true` | ストリーミングタスク更新をサポート | +| `capabilities.pushNotifications` | `false` or `true` | `true` は `push-notifications: true` が設定されている場合 | +| `capabilities.extendedAgentCard` | `true` | 認証済み呼び出し元は拡張ランタイム詳細をリクエスト可能 | +| `capabilities.extensions` | `urn:iac-code:a2a:artifact-metadata:v1` | ツール状態と保存済みアーティファクトメタデータのための任意の iac-code メタデータ名前空間 | +| `defaultInputModes` | text, JSON, YAML, image, audio, and binary MIME types | 受け付ける入力 MIME モード | +| `defaultOutputModes` | `["text/plain"]` | テキスト出力のみ | + +Agent Card 応答には `Cache-Control: public, max-age=60`、`ETag`、`Last-Modified` が含まれます。クライアントは `If-None-Match` を送信でき、カードが変更されていない場合は `304 Not Modified` を受け取ります。 + +広告されるスキル: + +| Skill ID | 用途 | +|----------|---------| +| `iac_generation` | 自然言語から Alibaba Cloud ROS と Terraform テンプレートを生成 | +| `iac_review` | IaC テンプレートを検査し修正を提案 | +| `aliyun_ros_operations` | Alibaba Cloud ROS スタックワークフローを支援 | +| `terraform_ros_conversion` | バンドルされたスキルリソースを使用して Terraform から ROS への変換を支援 | + +認証が有効な場合、Agent Card は設定済みのセキュリティ方式を広告します。 + +| Scheme | 広告される条件 | +|--------|-----------------| +| `bearerAuth` | `token` または `IACCODE_A2A_HTTP_TOKEN` が設定されている | +| `basicAuth` | Basic username と password の両方が設定されている | +| `apiKeyAuth` | `api-key` または `IACCODE_A2A_API_KEY` が設定されている | + +## ルート + +| Route | Method | 説明 | +|-------|--------|-------------| +| `/health` | `GET` | `{"status":"healthy"}` を返す | +| `/.well-known/agent-card.json` | `GET` | Agent Card を返す | +| `/` | `POST` | A2A JSON-RPC リクエストを処理 | +| REST routes | mixed | `create_rest_routes` によって登録される A2A SDK REST ルート | + +## Phase 1 クライアントとトランスポートの注意点 + +デフォルトの相互運用可能な Phase 1 トランスポートは、HTTP 上の JSON-RPC です。HTTP モードは SDK REST ルート向けに `HTTP+JSON` も広告します。 + +サーバーには、stdio、Unix ソケット、WebSocket、公式 gRPC、gRPC JSON-RPC エンベロープ、Redis Streams の任意トランスポートもあります。stdio、Unix ソケット、WebSocket、gRPC JSON-RPC、Redis Streams はカスタム JSON-RPC トランスポートです。公式 gRPC は `grpc` として広告され、任意の gRPC 依存関係が必要です。 + +組み込みクライアントは、メッセージ呼び出しの前に Agent Card ディスカバリー (`GET /.well-known/agent-card.json`) を使用し、最初に広告された実行可能な `supportedInterfaces[].url` を選択してから、`A2A-Version: 1.0` と `SendMessage` などの A2A 1.0 メソッド名を使って JSON-RPC リクエストを送信します。 + +`push-notifications: true` は A2A プッシュ通知設定メソッドと終端状態の配信を有効にします。 + +Agent Card 署名は A2A SDK の署名ユーティリティを使用し、標準の `AgentCardSignature` JWS フィールドを出力します。対称キーモードは `HS256` を使用します。検証では、保護ヘッダー `kid` による設定済みシークレット、ローカル octet-key JWKS、またはリモート JWKS URL を選択できます。サーバー側の非対称署名と自動キーローテーションは Phase 1 では実装されていません。 + +Phase 1 で未サポートの挙動の標準的な一覧は、[A2A プロトコル](./overview.md#phase-1-unsupported)を参照してください。 + +## プッシュ通知配信バックエンド + +`iac-code a2a --config a2a-server.yml` は 2 つのプッシュ配信キューをサポートします。 + +- `push-queue: local-file` は A2A 永続化ディレクトリの下にジョブを保存し、ローカルの単一ノード利用を想定しています。 +- `push-queue: redis-streams` は Redis Streams にジョブを保存し、Redis consumer group を通じてワーカーを調整します。 + +Redis ベースのプッシュ配信には任意の `a2a-redis` extra が必要で、at-least-once です。ワーカーのクラッシュ、リース期限切れ、再接続、リトライ競合の後にジョブが再配信される可能性があるため、コールバック受信側はタスク更新を冪等に処理する必要があります。 + +一般的な Redis オプション: + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +push-lease-timeout-ms: 300000 +``` + +Callback URL は保存前と配送前に検証されます。デフォルトのバリデーターは、非 HTTP(S) URL、localhost ホスト名、リテラルな private/local IP アドレスを拒否します。コールバック受信側は、それでも独自の認証と冪等性ポリシーを適用する必要があります。 + +## JSON-RPC メソッド + +### SendMessage + +非ストリーミング A2A メッセージターンを実行します。応答には、ターン完了後のタスクまたはメッセージが含まれます。 + +**リクエスト** + +```json +{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a VPC with two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +**必須メッセージフィールド** + +| Field | Type | Required | 説明 | +|-------|------|----------|-------------| +| `messageId` | string | Yes | 一意のクライアントメッセージ ID | +| `role` | string | Yes | ユーザー入力には `ROLE_USER` を使用 | +| `parts` | array | Yes | テキスト風、JSON データ、生テキスト、ローカルファイル URL、または制限付きマルチモーダルパーツ | +| `metadata.iac_code.cwd` | string | Recommended | 絶対ワークスペースパス。省略時はサーバープロセスディレクトリがデフォルト | + +`metadata.iac_code.cwd` が指定された場合、既存の絶対ディレクトリである必要があります。許可されたワークスペースルート内になければなりません。デフォルトでは、許可されるルートはサーバープロセスディレクトリとシステム一時ディレクトリです。`IACCODE_A2A_ALLOWED_CWDS` で、OS パス区切りの許可リストを指定できます。 + +サポートされる入力カテゴリ: + +| Category | Accepted Shape | 制限と挙動 | +|----------|----------------|---------------------| +| テキスト風パーツ | `text` with `text/plain`, JSON, Markdown, YAML, or configured extra text MIME types | プロンプトへ直接追加 | +| JSON データパーツ | `data` with `application/json` | コンパクト JSON にシリアライズ。インライン最大 1 MiB | +| 生テキストパーツ | `raw` with a text-like MIME type | 有効な UTF-8 である必要あり。インライン最大 1 MiB | +| ローカルテキストファイル URL | `url` with `file://...` and text-like MIME type | ファイルは `cwd` と許可済みルート内に存在する必要あり。最大 1 MiB | +| マルチモーダル raw/data/file パーツ | image, audio, or configured multimodal MIME types | ファイル名、メディアタイプ、バイトサイズ、ハッシュ、ソースを含むプロンプトマニフェストに変換。raw/data は最大 5 MiB、file URL は最大 25 MiB | + +リモート HTTP(S) URL 取り込みはサポートされません。File URL パーツはローカル `file://` URL を使用し、許可されたワークスペース内に留まる必要があります。 + +### SendStreamingMessage + +ストリーミング A2A メッセージターンを実行します。リクエスト本文は `SendMessage` と同じ形ですが、サーバーは JSON-RPC 応答を Server-Sent Events としてストリーミングします。 + +```json +{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +### GetTask + +保存された A2A タスクを ID で返します。保存済みタスク履歴を変更せずに返却履歴を制限するには `historyLength` を使用します。省略すると、サーバーの現在のデフォルト履歴を受け取ります。 + +```json +{ + "jsonrpc": "2.0", + "id": "3", + "method": "GetTask", + "params": { + "id": "task-id", + "historyLength": 10 + } +} +``` + +### ListTasks + +認証済み呼び出し元に見える既知のタスクを返します。結果は、安定した順序になるように、ステータスタイムスタンプの降順、次にタスク ID の降順でソートされます。サーバーは `contextId`、`status`、`pageSize`、`pageToken`、`historyLength`、`includeArtifacts` をサポートします。 + +```json +{ + "jsonrpc": "2.0", + "id": "4", + "method": "ListTasks", + "params": { + "contextId": "ctx-id", + "status": "TASK_STATE_WORKING", + "pageSize": 20, + "includeArtifacts": false + } +} +``` + +別のページが利用可能な場合、`nextPageToken` が返されます。`includeArtifacts` のデフォルトは `false` のため、明示的に要求しない限り、一覧応答ではタスクアーティファクトが省略されます。 + +### CancelTask + +実行中タスクのキャンセルをリクエストします。 + +```json +{ + "jsonrpc": "2.0", + "id": "5", + "method": "CancelTask", + "params": { + "id": "task-id" + } +} +``` + +タスクがアクティブな場合、サーバーは実行中のエージェントターンをキャンセルし、キャンセル済みタスク状態を出力します。タスクは存在するが実行中でない場合、サーバーは標準 A2A `TaskNotCancelableError` を返します。 + +### SubscribeToTask + +クライアントトランスポートでサポートされる場合、アクティブタスク更新ストリームを購読します。 + +```json +{ + "jsonrpc": "2.0", + "id": "6", + "method": "SubscribeToTask", + "params": { + "id": "task-id" + } +} +``` + +アクティブなタスクでは、ストリームは現在の `Task` で開始し、その後のタスクイベントを出力し、アクティブターンが終了すると閉じます。完了済み、失敗済み、キャンセル済み、または input-required のタスクを購読すると、無期限に待つのではなく task-not-found 風のエラーが返されます。新しいターンでは、`SendStreamingMessage` を優先してください。1 つのリクエストで実行を開始し、応答をストリーミングします。 + +### プッシュ通知設定メソッド + +サーバーが `push-notifications: true` で起動した場合、次をサポートします。 + +| Method | 用途 | +|--------|---------| +| `CreateTaskPushNotificationConfig` | タスクのコールバック設定を保存 | +| `GetTaskPushNotificationConfig` | 1 つのコールバック設定を取得 | +| `ListTaskPushNotificationConfigs` | タスクのコールバック設定を一覧表示 | +| `DeleteTaskPushNotificationConfig` | コールバック設定を削除 | + +作成リクエストの例: + +```json +{ + "jsonrpc": "2.0", + "id": "7", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-id", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token" + } + } +} +``` + +ローカルプッシュ keyring が利用可能な場合、サーバーは保存された通知トークンとコールバック認証情報を暗号化します。 + +### GetExtendedAgentCard + +認証済みクライアントは拡張 Agent Card をリクエストできます。 + +```json +{ + "jsonrpc": "2.0", + "id": "8", + "method": "GetExtendedAgentCard", + "params": {} +} +``` + +拡張カードには、公開カードに加えて認証済みランタイム詳細が含まれます。 + +## タスクとコンテキストの挙動 + +iac-code は A2A コンテキストを内部エージェントランタイムにマッピングします。 + +| Concept | 挙動 | +|---------|----------| +| `contextId` omitted | SDK/サーバーが新しいコンテキスト ID を生成 | +| Same `contextId` | 同じ内部 iac-code セッションと会話状態を再利用 | +| Same `contextId`, different `cwd` | 異なるワークスペースとして拒否 | +| Same `contextId`, concurrent message | `Task is already working.` で拒否 | +| Different `contextId` values | 並行実行が可能 | +| Idle context | 設定されたアイドルタイムアウト後にメモリから削除 | + +タスク ID とコンテキスト ID は空でなく、最大 128 文字で、文字、数字、`_`、`.`、`:`、`-` のみを含む必要があります。 + +## タスク状態 + +| State | 意味 | +|-------|---------| +| `TASK_STATE_SUBMITTED` | タスクが受理された | +| `TASK_STATE_WORKING` | iac-code がエージェントターンを実行中 | +| `TASK_STATE_INPUT_REQUIRED` | ターンが完了し、エージェントがフォローアップ入力を受けられる状態 | +| `TASK_STATE_CANCELED` | キャンセルが要求され適用された | +| `TASK_STATE_FAILED` | タスクが検証または実行に失敗した | + +コンテキストがフォローアップメッセージで利用可能なまま残るため、iac-code は通常の完了状態として `TASK_STATE_INPUT_REQUIRED` を使用します。 + +## ストリーミング更新 + +実行中、iac-code は `TaskStatusUpdateEvent` 更新を出力します。 + +アシスタントのテキストはステータスメッセージとして配信されます。 + +```json +{ + "statusUpdate": { + "taskId": "task-1", + "contextId": "ctx-1", + "status": { + "state": "TASK_STATE_WORKING", + "message": { + "role": "ROLE_AGENT", + "parts": [{"text": "Here is the ROS template..."}] + } + } + } +} +``` + +ツールと使用量の詳細は `metadata.iac_code` を通じて配信されます。 + +| Metadata Path | 説明 | +|---------------|-------------| +| `iac_code.tool.status` | `started`, `input_delta`, `input_complete`, `completed`, or `failed` | +| `iac_code.tool.toolUseId` | ツールイベントを関連付ける安定した tool-use ID | +| `iac_code.tool.name` | 利用可能な場合のツール名 | +| `iac_code.tool.input` | 完了したツール入力。フィールドごとに 4000 文字へ切り詰め | +| `iac_code.tool.result` | ツール結果。フィールドごとに 4000 文字へ切り詰め | +| `iac_code.permission.autoApproved` | A2A サーバーモードによってツール権限リクエストが拒否された場合は `false` | +| `iac_code.usage.inputTokens` | ターンの入力トークン数 | +| `iac_code.usage.outputTokens` | ターンの出力トークン数 | +| `iac_code.usage.totalTokens` | ターンの合計トークン数 | + +ツール結果にサポート対象のテキストアーティファクトペイロードが含まれる場合、サーバーはペイロードをローカルに保存し、標準の `TaskArtifactUpdateEvent` を出力し、タスクの `artifacts` フィールドにアーティファクトを記録します。アーティファクトパーツは、`file://` URL と `mediaType`、`byteSize`、`sha256` などのメタデータを使用します。元のアーティファクト内容はツールメタデータ内に重複して含まれません。 + +## 拡張 + +Agent Card は任意の iac-code アーティファクトメタデータ拡張を広告します。 + +```text +urn:iac-code:a2a:artifact-metadata:v1 +``` + +この拡張は、ツール進行状況、権限判断、トークン使用量、ローカルアーティファクトメタデータに使用される `metadata.iac_code` 名前空間を識別します。サーバーが必須拡張を設定している場合、クライアントはその URI を `A2A-Extensions` ヘッダーに含める必要があります。必須拡張がない場合、標準 A2A `ExtensionSupportRequiredError` が返されます。 + +## エラー処理 + +| シナリオ | 結果 | +|----------|--------| +| 空のテキスト入力 | `TASK_STATE_FAILED` with `A2A server currently accepts text input only.` | +| サポートされないメディアタイプ | SDK がリクエストを拒否する場所に応じて、検証エラーまたは標準 A2A content-type エラー | +| リモート URL パーツ | URL パーツはローカル `file://` URL を使用する必要があるため検証エラー | +| 許可されたワークスペース外の File URL | 検証エラー | +| 必須 A2A 拡張がない | 標準 A2A `ExtensionSupportRequiredError` | +| 不正なワークスペースメタデータ | invalid workspace メッセージ付きの `TASK_STATE_FAILED` | +| 認証がない、または不正 | HTTP `401` with `{"error":"Unauthorized"}` | +| A2A サーバー依存関係がない | CLI は `a2a` extra のインストールヒントを表示して終了 | +| プロバイダー認証情報がない | サニタイズされた認証エラー | +| 予期しないランタイムエラー | サニタイズされた内部エラー | + +サーバーは、予期しないエラーメッセージでローカルパス、シークレット、プロバイダー詳細を返さないようにします。 diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/command-reference.md new file mode 100644 index 00000000..b616590b --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -0,0 +1,504 @@ +--- +title: Referência de comandos +description: Referência completa de comandos da CLI para executar e chamar iac-code sobre A2A. +sidebar_position: 3 +--- + +# Referência de comandos A2A + +Esta página documenta todos os comandos `iac-code` relacionados a A2A. Use-a quando precisar dos nomes exatos das opções, padrões comuns de comandos e o significado operacional de cada flag. + +## Visão geral dos comandos + +| Comando | Finalidade | +|---------|------------| +| `iac-code a2a` | Executar o iac-code como servidor A2A | +| `iac-code a2a-client call` | Descobrir um Agent Card remoto e enviar um prompt | +| `iac-code a2a-client discover` | Buscar e opcionalmente verificar um Agent Card | +| `iac-code a2a-client task-get` | Buscar uma tarefa por ID | +| `iac-code a2a-client task-list` | Listar tarefas com filtros e paginação | +| `iac-code a2a-client task-cancel` | Cancelar uma tarefa ativa | +| `iac-code a2a-client task-subscribe` | Assinar um stream de eventos de uma tarefa ativa | +| `iac-code a2a-client push-config-create` | Criar uma configuração de notificação push de tarefa | +| `iac-code a2a-client push-config-get` | Buscar uma configuração de notificação push de tarefa | +| `iac-code a2a-client push-config-list` | Listar configurações de notificação push de tarefa | +| `iac-code a2a-client push-config-delete` | Excluir uma configuração de notificação push de tarefa | +| `iac-code a2a-client extended-card` | Buscar o Agent Card estendido autenticado | +| `iac-code a2a-route-preview` | Pré-visualizar a seleção local de rota para `a2a-client call` | + +Todos os comandos de cliente HTTP aceitam as mesmas opções de autenticação: + +| Opção | Descrição | +|-------|-----------| +| `--token` | Bearer token enviado como `Authorization: Bearer ` | +| `--basic-username` | Nome de usuário Basic auth | +| `--basic-password` | Senha Basic auth | +| `--api-key` | Valor da API key | +| `--api-key-header` | Nome do cabeçalho da API key; padrão `X-API-Key` | + +## Configuração do cliente A2A + +Todos os subcomandos `a2a-client` aceitam um arquivo de configuração YAML no nível do grupo: + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC" +``` + +Opções da CLI substituem valores de configuração. Use configuração para conexão estável, autenticação, verificação, roteamento e configurações repetidas de tarefas ou push; mantenha texto de prompt pontual na linha de comando. + +```yaml +url: http://127.0.0.1:41242/ +token: your-bearer-token +basic-username: iac-code +basic-password: your-password +api-key: your-api-key +api-key-header: X-IAC-Code-Key +verify-card-secret: your-card-signing-secret +verify-card-jwks-url: https://a2a.example.com/.well-known/jwks.json +require-card-signature: true +timeout: 30 +cwd: /path/to/workspace +context-id: ctx-123 +task-id: task-123 +config-id: webhook-1 +callback-url: https://hooks.example.com/a2a +notification-token: notification-token +auth-scheme: bearer +auth-credentials: callback-token +routes: + - name: ros + url: http://127.0.0.1:41242/ + skills: + - iac_generation + tags: + - ros + - template +``` + +## `iac-code a2a` + +Execute o iac-code como um servidor A2A. + +```bash +iac-code a2a +``` + +Por padrão, o servidor faz bind em `127.0.0.1:41242` e serve JSON-RPC sobre HTTP. A porta `41242` é o padrão do iac-code; ela não é uma porta A2A registrada. + +### Opções básicas do servidor + +| Opção | Padrão | Descrição | +|-------|--------|-----------| +| `--config` | vazio | Arquivo de configuração YAML contendo opções do servidor A2A | +| `--host` | `127.0.0.1` | Host do servidor HTTP | +| `--port` | `41242` | Porta do servidor HTTP | +| `--transport` | `http` | Transport do servidor: `http`, `stdio`, `unix`, `websocket`, `grpc`, `grpc-jsonrpc` ou `redis-streams` | +| `--debug`, `-d` | `false` | Habilitar logs de debug | + +Exemplo: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --debug +``` + +### Configuração YAML + +Use `--config` para autenticação, armazenamento, assinatura, configurações específicas de transport, entrega push e outros detalhes de implantação. Chaves podem usar hífens ou underscores. As flags comuns da CLI `--host`, `--port` e `--transport` substituem valores do arquivo de configuração. + +```yaml +host: 127.0.0.1 +port: 41242 +transport: http +token: local-dev-token +persistence-dir: .iac-code-a2a/state +artifact-dir: .iac-code-a2a/artifacts +push-notifications: true +``` + +Execute com: + +```bash +iac-code a2a --config a2a-server.yml --port 41243 +``` + +### Autenticação HTTP + +A autenticação é opcional. Configure a autenticação do servidor em YAML ou com variáveis de ambiente. Se nenhuma configuração de autenticação estiver definida, as requisições não são autenticadas. Quando um ou mais esquemas estiverem configurados, uma requisição pode satisfazer qualquer esquema configurado. + +| Chave de configuração | Variável de ambiente | Descrição | +|--------|----------------------|-----------| +| `token` | `IACCODE_A2A_HTTP_TOKEN` | Bearer token | +| `basic-username` | `IACCODE_A2A_BASIC_USERNAME` | Nome de usuário Basic auth | +| `basic-password` | `IACCODE_A2A_BASIC_PASSWORD` | Senha Basic auth | +| `api-key` | `IACCODE_A2A_API_KEY` | Valor da API key | +| `api-key-header` | `IACCODE_A2A_API_KEY_HEADER` | Nome do cabeçalho da API key | + +Bearer token: + +```yaml +token: local-dev-token +``` + +Basic auth: + +```yaml +basic-username: iac-code +basic-password: local-dev-password +``` + +API key: + +```yaml +api-key: local-dev-key +api-key-header: X-IAC-Code-Key +``` + +### Persistência e artefatos + +| Chave de configuração | Padrão | Descrição | +|--------|--------|-----------| +| `persistence-dir` | `~/.iac-code/a2a` | Metadados JSON locais para tarefas, contextos, rotas e configurações push | +| `artifact-dir` | `/artifacts` | Armazenamento local de payloads de artefatos | + +A persistência espelha snapshots de tarefas e contextos para metadados de restauração. Ela não reinicia uma tarefa asyncio em andamento após uma falha do processo. + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +``` + +### Assinatura de Agent Card + +| Chave de configuração | Descrição | +|--------|-----------| +| `signing-secret` | Segredo HMAC usado para assinar o Agent Card público | + +O servidor emite campos JWS `AgentCardSignature` do SDK A2A. O modo simétrico usa `HS256`. + +```yaml +signing-secret: local-card-signing-secret +``` + +### Entrega de notificações push + +| Chave de configuração | Padrão | Descrição | +|--------|--------|-----------| +| `push-notifications` | `false` | Habilitar métodos de configuração de notificação push de tarefas A2A e entrega de estados terminais | +| `push-queue` | `local-file` | Backend de fila push: `local-file` ou `redis-streams` | +| `push-redis-url` | vazio | URL Redis para a fila push baseada em Redis | +| `push-stream` | `iac-code:a2a:push` | Stream Redis para jobs push | +| `push-retry-key` | `iac-code:a2a:push:retry` | Sorted set Redis para retries atrasados | +| `push-dead-stream` | `iac-code:a2a:push:dead` | Stream Redis para jobs dead-letter | +| `push-consumer-group` | `iac-code-push` | Consumer group Redis para workers push | +| `push-consumer-name` | vazio | Nome do consumidor Redis para este worker | +| `push-lease-timeout-ms` | `300000` | Timeout de lease pendente no Redis | + +Fila de arquivo local: + +```yaml +push-notifications: true +persistence-dir: ~/.iac-code/a2a +push-queue: local-file +``` + +Fila Redis Streams: + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +``` + +A entrega push baseada em Redis exige o extra `a2a-redis`. + +### Opções de transport + +| Transport | Comando | Observações | +|-----------|---------|-------------| +| HTTP JSON-RPC e REST | `iac-code a2a --transport http` | Padrão. Anuncia interfaces `JSONRPC` e `HTTP+JSON`. | +| stdio | `iac-code a2a --transport stdio` | Frames JSON-RPC customizados experimentais sobre entrada/saída padrão. | +| Unix socket | `iac-code a2a --config a2a-server.yml --transport unix` | Exige `socket-path` na configuração. | +| WebSocket | `iac-code a2a --config a2a-server.yml --transport websocket` | Usa `ws-path` da configuração, com padrão `/a2a`. | +| gRPC | `iac-code a2a --config a2a-server.yml --transport grpc` | Usa `grpc-host` e `grpc-port` da configuração. | +| gRPC JSON-RPC | `iac-code a2a --config a2a-server.yml --transport grpc-jsonrpc` | Envelope JSON-RPC customizado sobre gRPC. | +| Redis Streams | `iac-code a2a --config a2a-server.yml --transport redis-streams` | Exige `redis-url` na configuração. | + +Opções de transport Redis Streams: + +| Chave de configuração | Padrão | Descrição | +|--------|--------|-----------| +| `redis-url` | vazio | URL de conexão Redis; obrigatória para `--transport redis-streams` | +| `request-stream` | `iac-code:a2a:requests` | Nome do stream de requisições | +| `response-stream` | `iac-code:a2a:responses` | Nome do stream de respostas | +| `consumer-group` | `iac-code` | Consumer group do stream de requisições | + +### Comportamento de permissões + +| Chave de configuração | Padrão | Descrição | +|--------|--------|-----------| +| `auto-approve-permissions` | `false` | Aprovar automaticamente solicitações de permissão de ferramentas levantadas durante turnos A2A | + +Sem `auto-approve-permissions: true`, o modo A2A rejeita prompts de permissão e emite metadados de permissão. Use-o apenas em ambientes de automação confiáveis. + +## `iac-code a2a-client call` + +Descobre um Agent Card, escolhe o endpoint anunciado e envia um prompt. + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" +``` + +| Opção | Padrão | Descrição | +|-------|--------|-----------| +| `--url` | vazio | URL base do agente A2A ou URL do endpoint JSON-RPC; pode vir da configuração | +| `--route` | repetível | Especificação de rota usada quando `--url` é omitido | +| `--route-name` | vazio | Rota nomeada a selecionar | +| `--prompt`, `-p` | obrigatório | Texto do prompt | +| `--cwd` | `.` | Caminho do workspace enviado como `message.metadata.iac_code.cwd` | +| `--context-id` | vazio | ID de contexto A2A existente para uma mensagem de acompanhamento | +| `--verify-card-secret`, `--signing-secret` | vazio | Segredo HMAC para verificação do Agent Card | +| `--verify-card-jwks-url` | vazio | URL JWKS remota usada para verificação do Agent Card | +| `--require-card-signature`, `--require-signature` | `false` | Rejeitar Agent Cards não assinados ou inválidos | +| `--timeout` | `30.0` | Timeout da chamada em segundos | +| `--stream` | `false` | Usar `SendStreamingMessage` e imprimir eventos do stream | + +Acompanhamento no mesmo contexto: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --context-id ctx-123 \ + --prompt "Now add outputs for the VPC and vSwitch IDs." \ + --cwd "$PWD" +``` + +Streaming: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this Terraform module." \ + --cwd "$PWD" \ + --stream +``` + +Exigir um Agent Card assinado: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a production VPC template." \ + --cwd "$PWD" +``` + +Verificar usando uma URL JWKS remota: + +```bash +iac-code a2a-client --config jwks-client.yml call \ + --prompt "Review the ROS stack." +``` + +## `iac-code a2a-client discover` + +Busca e imprime um Agent Card remoto. + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +| Opção | Descrição | +|-------|-----------| +| `--url` | URL base do agente A2A; pode vir da configuração | +| `--verify-card-secret`, `--signing-secret` | Segredo HMAC para verificação | +| `--verify-card-jwks-url` | URL JWKS remota para verificação | +| `--require-card-signature`, `--require-signature` | Exigir uma assinatura válida | + +Descoberta autenticada: + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +## Comandos de tarefa + +Comandos de tarefa chamam métodos JSON-RPC de tarefa diretamente. Eles são úteis para ferramentas operacionais, dashboards e depuração. + +### `iac-code a2a-client task-get` + +```bash +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +| Opção | Descrição | +|-------|-----------| +| `--url` | URL do endpoint A2A JSON-RPC; pode vir da configuração | +| `--task-id` | ID da tarefa; pode vir da configuração | +| `--history-length` | Máximo de entradas de histórico de tarefa a retornar | + +### `iac-code a2a-client task-list` + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --context-id ctx-123 \ + --status TASK_STATE_INPUT_REQUIRED \ + --page-size 20 \ + --output table +``` + +| Opção | Padrão | Descrição | +|-------|--------|-----------| +| `--url` | vazio | URL do endpoint A2A JSON-RPC; pode vir da configuração | +| `--context-id` | vazio | Filtrar por ID de contexto | +| `--status` | vazio | Filtrar por estado da tarefa | +| `--page-size` | vazio | Máximo de tarefas a retornar | +| `--page-token` | vazio | Token de paginação | +| `--include-artifacts` | `false` | Incluir artefatos de tarefas na resposta | +| `--output` | `table` | `table` ou `json` | + +Saída JSON: + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --include-artifacts \ + --output json +``` + +### `iac-code a2a-client task-cancel` + +```bash +iac-code a2a-client --config a2a-client.yml task-cancel \ + --task-id task-123 +``` + +O cancelamento é cooperativo. Uma tarefa concluída, falha, cancelada ou que exige entrada retorna o erro A2A padrão task-not-cancelable. + +### `iac-code a2a-client task-subscribe` + +```bash +iac-code a2a-client --config a2a-client.yml task-subscribe \ + --task-id task-123 +``` + +O comando transmite eventos para tarefas ativas. Para um novo turno, prefira `a2a-client call --stream`; ele inicia a tarefa e transmite atualizações em um único comando. + +## Comandos de configuração de notificações push + +Estes comandos exigem um servidor iniciado com `push-notifications: true`. Eles gerenciam configurações padrão de notificação push de tarefas A2A. + +### `iac-code a2a-client push-config-create` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +| Opção | Descrição | +|-------|-----------| +| `--url` | URL do endpoint A2A JSON-RPC; pode vir da configuração | +| `--task-id` | ID da tarefa; pode vir da configuração | +| `--config-id` | ID da configuração push; pode vir da configuração | +| `--callback-url` | URL de callback HTTP(S); pode vir da configuração | +| `--notification-token` | Token enviado como `X-A2A-Notification-Token` | +| `--auth-scheme` | Esquema de autenticação do callback, como `bearer` ou `basic` | +| `--auth-credentials` | Credenciais de autenticação do callback | + +URLs de callback são validadas antes do armazenamento e envio. O validador padrão rejeita URLs que não sejam HTTP(S), nomes localhost e endereços IP literais privados/locais. + +### `iac-code a2a-client push-config-get` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-get \ + --task-id task-123 \ + --config-id webhook-1 +``` + +### `iac-code a2a-client push-config-list` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-list \ + --task-id task-123 \ + --page-size 10 +``` + +### `iac-code a2a-client push-config-delete` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-delete \ + --task-id task-123 \ + --config-id webhook-1 +``` + +## `iac-code a2a-client extended-card` + +Busca o Agent Card estendido autenticado. + +```bash +iac-code a2a-client --config a2a-client.yml extended-card \ + --token "$A2A_TOKEN" +``` + +O Agent Card público anuncia `capabilities.extendedAgentCard=true`. O card estendido adiciona detalhes autenticados do runtime, incluindo gerenciamento de tarefas e metadados de capacidade de configuração push. + +## `iac-code a2a-route-preview` + +Pré-visualize como `a2a-client call` resolve rotas configuradas quando `--url` é omitido. + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +| Opção | Descrição | +|-------|-----------| +| `--route` | Especificação de rota repetível no formato `name=url;skills=a,b;tags=x,y` | +| `--name` | Nome da rota a resolver | +| `--skill` | ID da skill a resolver | +| `--prompt` | Texto de prompt usado para correspondência de nome/tag | +| `--route-state-dir`, `--persistence-dir` | Diretório usado para persistir snapshots de rotas | +| `--save-routes` | Salvar rotas fornecidas no diretório de estado de rotas | + +Salvar snapshots de rotas: + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-state-dir ~/.iac-code/a2a \ + --save-routes +``` + +Chamar por rotas: + +```bash +iac-code a2a-client call \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-name ros \ + --prompt "Create a ROS VPC template." \ + --cwd "$PWD" +``` + +## Variáveis de ambiente + +| Variável | Descrição | +|----------|-----------| +| `IACCODE_A2A_HTTP_TOKEN` | Padrão de Bearer token do servidor/cliente | +| `IACCODE_A2A_BASIC_USERNAME` | Padrão de nome de usuário Basic auth do servidor/cliente | +| `IACCODE_A2A_BASIC_PASSWORD` | Padrão de senha Basic auth do servidor/cliente | +| `IACCODE_A2A_API_KEY` | Padrão de API key do servidor/cliente | +| `IACCODE_A2A_API_KEY_HEADER` | Padrão de nome do cabeçalho da API key | +| `IACCODE_A2A_ALLOWED_CWDS` | Lista separada pelo separador de caminhos do sistema operacional de raízes de workspace permitidas para metadados de mensagens recebidas e URLs de arquivos | +| `IACCODE_A2A_TEXT_MIME_TYPES` | Tipos MIME extras semelhantes a texto, separados por vírgula ou ponto e vírgula | +| `IACCODE_A2A_MULTIMODAL_MIME_TYPES` | Tipos MIME multimodais extras, separados por vírgula ou ponto e vírgula | +| `IAC_CODE_A2A_PUSH_KEYRING` | Keyring criptografado de segredos push gerenciado pelo ambiente | diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/examples.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/examples.md new file mode 100644 index 00000000..16a669a7 --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/examples.md @@ -0,0 +1,388 @@ +--- +title: Exemplos +description: Exemplos práticos para integrar com o servidor A2A do iac-code. +sidebar_position: 6 +--- + +# Exemplos + +Esta página fornece exemplos prontos para uso de integração A2A. + +## Pré-requisitos + +Os exemplos assumem: + +| Dependência | Versão | Finalidade | +|-------------|--------|------------| +| Python | `3.12` | Corresponde ao runtime do projeto | +| `a2a-sdk` | `>=1.0.2,<2` | Cliente A2A e tipos protobuf | +| `httpx` | `>=0.27.0` | Cliente HTTP usado pelo SDK e pelos exemplos diretos | +| `iac-code` | repositório atual | Fornece o subcomando `iac-code a2a` | + +Inicie o servidor: + +```bash +uv sync --extra a2a +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +## SDK Python — Sessão em streaming + +Este exemplo descobre o Agent Card, envia uma mensagem, imprime chunks de texto do assistente e relata metadados de ferramentas. + +```python +"""Streaming iac-code A2A session using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path +from typing import Any + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest +from google.protobuf.json_format import MessageToDict + + +def metadata_to_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + return MessageToDict(value, preserving_proto_field_name=False) + + +async def main() -> None: + headers = {} + # For authenticated servers: + # headers["Authorization"] = "Bearer YOUR_TOKEN" + + async with httpx.AsyncClient(headers=headers, timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Create a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("task"): + print(f"[task] {event.task.id} context={event.task.context_id}") + + elif event.HasField("status_update"): + update = event.status_update + status = update.status + + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + metadata = metadata_to_dict(update.metadata) + tool = metadata.get("iac_code", {}).get("tool") + if tool: + print(f"\n[tool] {tool.get('status')} {tool.get('name', '')}".rstrip()) + + usage = metadata.get("iac_code", {}).get("usage") + if usage: + print(f"\n[usage] {usage['totalTokens']} total tokens") + + if status.state == "TASK_STATE_INPUT_REQUIRED": + print("\n[done]") + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## CLI — Workflow de ponta a ponta + +Inicie um servidor local com persistência, artefatos, suporte a notificações push e um Agent Card assinado: + +```yaml +host: 127.0.0.1 +port: 41242 +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +```bash +iac-code a2a --config a2a-server.yml +``` + +Crie uma configuração de cliente para o endpoint estável e as configurações de verificação do card: + +```yaml +url: http://127.0.0.1:41242/ +verify-card-secret: local-card-signing-secret +require-card-signature: true +``` + +Descubra e verifique o Agent Card: + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +Envie uma requisição em streaming: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" \ + --stream +``` + +Liste tarefas e busque uma tarefa como JSON: + +```bash +iac-code a2a-client --config a2a-client.yml task-list --output table + +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +Registre um callback push para uma tarefa: + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +Pré-visualize a seleção de rota antes de chamar um agente roteado: + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +## SDK Python — Mensagem de acompanhamento + +Mensagens de acompanhamento reutilizam o mesmo `context_id` e geralmente o mesmo ID de tarefa. Isso mantém vivo o runtime interno do iac-code e o histórico da conversa. + +```python +import uuid +from pathlib import Path + +from a2a.types import Message, Part, Role, SendMessageRequest + + +def build_follow_up(task_id: str, context_id: str, text: str) -> SendMessageRequest: + return SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + task_id=task_id, + context_id=context_id, + role=Role.ROLE_USER, + parts=[Part(text=text)], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + +# Usage inside an async function: +# async for event in client.send_message( +# build_follow_up(task_id, context_id, "Add outputs for VPC and VSwitch IDs.") +# ): +# ... +``` + +O servidor rejeita um `contextId` reutilizado se a nova mensagem apontar para um workspace diferente. + +## SDK Python — Cancelar uma tarefa + +```python +from a2a.types import CancelTaskRequest + + +async def cancel_running_task(client, task_id: str) -> None: + task = await client.cancel_task(CancelTaskRequest(id=task_id)) + print(f"{task.id}: {task.status.state}") +``` + +## HTTP direto — Cliente JSON-RPC mínimo + +Use isto quando você não quiser a dependência do SDK no chamador. + +```python +"""Direct A2A JSON-RPC client using httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=120.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template for missing parameters."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## HTTP direto — Streaming SSE + +```python +"""Read SendStreamingMessage events directly with httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=None) as client: + async with client.stream( + "POST", + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Generate a Terraform VPC example."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if line.startswith("data:"): + print(line.removeprefix("data:").strip()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## HTTP direto — Configuração de notificação push + +Os métodos de configuração push estão disponíveis quando o servidor executa com `push-notifications: true`. + +```python +"""Create an A2A task push notification config.""" + +import asyncio + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "push-1", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-123", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token", + }, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Tratando metadados do iac-code + +Eventos de ferramentas e uso chegam em `TaskStatusUpdateEvent.metadata.iac_code`. + +```python +from typing import Any + + +def handle_iac_metadata(metadata: dict[str, Any]) -> None: + iac = metadata.get("iac_code", {}) + + if tool := iac.get("tool"): + status = tool.get("status") + tool_id = tool.get("toolUseId") + name = tool.get("name", "") + print(f"tool={name} id={tool_id} status={status}") + + if permission := iac.get("permission"): + if permission.get("autoApproved") is False: + print(f"permission rejected for {permission.get('toolName')}") + + if usage := iac.get("usage"): + print( + "tokens=" + f"{usage.get('inputTokens', 0)}+{usage.get('outputTokens', 0)}" + f"={usage.get('totalTokens', 0)}" + ) +``` + +## Armadilhas comuns + +| Sintoma | Correção | +|---------|----------| +| HTTP `401` | Inclua um esquema de autenticação configurado, como `Authorization: Bearer `, Basic auth ou `X-API-Key: `, tanto nas requisições de Agent Card quanto nas JSON-RPC | +| `Invalid A2A workspace metadata.` | Use um caminho absoluto existente em `metadata.iac_code.cwd` | +| `A2A server currently accepts text input only.` | Envie pelo menos uma parte de texto não vazia | +| `Task is already working.` | Aguarde o turno atual terminar antes de enviar outra mensagem no mesmo contexto | +| Acompanhamento rejeitado como workspace diferente | Mantenha `metadata.iac_code.cwd` inalterado para um `contextId` reutilizado | +| URL de arquivo local rejeitada | Mantenha partes `file://` dentro de `metadata.iac_code.cwd` e dentro de `IACCODE_A2A_ALLOWED_CWDS` | +| Callback push rejeitado | Use uma URL de callback HTTP(S) que não seja localhost nem um endereço IP literal privado/local | +| Fila push Redis falha ao iniciar | Instale o extra `a2a-redis` e forneça `push-redis-url` na configuração A2A | diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/getting-started.md new file mode 100644 index 00000000..2fea830f --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -0,0 +1,291 @@ +--- +sidebar_position: 2 +title: Primeiros passos +description: Inicie o servidor A2A e envie sua primeira mensagem. +--- + +# Primeiros passos com A2A + +## Pré-requisitos + +1. **iac-code instalado** — Consulte o guia de [Installation](/docs/getting-started/installation). + +2. **Credenciais de LLM configuradas** — Consulte o guia de [Authentication](/docs/configuration/authentication) para configurar as credenciais do seu provedor de modelo. + +3. **Dependências do servidor A2A** — Instale o iac-code com o extra `a2a`: + +```bash +uv sync --extra a2a +``` + +## Iniciando o servidor A2A + +Inicie o servidor na interface local padrão: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +Use um arquivo de configuração YAML quando precisar de estado local, armazenamento de artefatos, entrega de notificações push ou Agent Cards assinados: + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +Execute com: + +```bash +iac-code a2a --config a2a-server.yml +``` + +`push-notifications: true` habilita os métodos de configuração de notificações push de tarefas A2A e a entrega de estados terminais. Use `push-queue: redis-streams` com `push-redis-url` quando vários workers precisarem coordenar a entrega push. + +O servidor expõe: + +| Rota | Finalidade | +|------|------------| +| `GET /health` | Verificação de saúde | +| `GET /.well-known/agent-card.json` | Descoberta do Agent Card | +| `POST /` | Endpoint A2A JSON-RPC | + +O servidor HTTP também registra as rotas REST do SDK A2A e anuncia as interfaces `JSONRPC` e `HTTP+JSON` no Agent Card. + +## Verificar descoberta + +Busque o Agent Card: + +```text +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +Você deve ver `name: "iac-code"`, interfaces `JSONRPC` e `HTTP+JSON`, cabeçalhos de cache como `ETag`, a extensão opcional `urn:iac-code:a2a:artifact-metadata:v1`, modos de entrada suportados e skills como `iac_generation`, `iac_review`, `aliyun_ros_operations` e `terraform_ros_conversion`. + +Verifique o endpoint de saúde: + +```bash +curl http://127.0.0.1:41242/health +``` + +Resposta esperada: + +```json +{"status":"healthy"} +``` + +## Exigir autenticação + +A autenticação é opcional. Se nenhuma opção de autenticação A2A ou variável de ambiente estiver definida, as requisições não precisam de autenticação. Quando qualquer esquema de autenticação estiver configurado, todas as requisições, incluindo a descoberta do Agent Card, devem satisfazer um dos esquemas configurados. + +### Bearer Token + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +A chave equivalente de configuração YAML é `token`. + +```text +Authorization: Bearer +``` + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +O nome de usuário e a senha devem estar presentes. As chaves equivalentes de configuração YAML são `basic-username` e `basic-password`. + +### API Key + +```bash +export IACCODE_A2A_API_KEY=your-api-key + +iac-code a2a +``` + +O cabeçalho padrão de API key é: + +```text +X-API-Key: +``` + +Substitua-o com a chave de configuração YAML `api-key-header` ou `IACCODE_A2A_API_KEY_HEADER`: + +```yaml +api-key: your-api-key +api-key-header: X-IAC-Code-Key +``` + +## Chamar um agente A2A remoto + +Coloque as configurações estáveis de conexão e autenticação do cliente em um arquivo YAML: + +```yaml +url: http://127.0.0.1:41242/ +token: your-secret-token +verify-card-secret: your-card-signing-secret +require-card-signature: true +cwd: /path/to/workspace +``` + +Use `a2a-client call` para uma chamada direta de cliente Fase 1: + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC with two vSwitches" --cwd "$PWD" +``` + +Use `--stream` quando quiser eventos incrementais em vez de uma resposta final única: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this template" \ + --cwd "$PWD" \ + --stream +``` + +As opções de linha de comando substituem os valores de configuração quando você precisa de um alvo ou token pontual: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --url https://other-agent.example.com/ \ + --prompt "Review this template" +``` + +Para roteamento multiagente, pré-visualize a seleção de rota antes de chamar: + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --route-state-dir ~/.iac-code/a2a +``` + +Consulte a [Referência de comandos](./command-reference.md) para todos os comandos A2A, incluindo gerenciamento de tarefas, CRUD de configuração push, Agent Cards estendidos e opções de transport. + +## Enviar uma primeira mensagem com curl + +Passe o diretório do workspace por `message.metadata.iac_code.cwd`; o caminho deve ser absoluto, já deve existir e deve estar dentro de uma raiz de workspace permitida. Por padrão, as raízes permitidas são o diretório do processo do servidor e o diretório temporário do sistema. Substitua-as com `IACCODE_A2A_ALLOWED_CWDS`. + +O servidor aceita partes semelhantes a texto, partes de dados JSON, texto UTF-8 bruto, arquivos de texto locais `file://` do workspace e anexos multimodais limitados. Ingestão de URLs remotas não é suportada; partes `url` devem ser URLs locais `file://` dentro do workspace permitido. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [ + {"text": "Generate a ROS VPC template with two vSwitches."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +Para saída em streaming, use `SendStreamingMessage`: + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [ + {"text": "Review my Terraform files and suggest ROS equivalents."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## Exemplo mínimo com o SDK Python + +O exemplo abaixo usa `a2a-sdk>=1.0.2,<2`, que é o intervalo de versões usado pelo extra `a2a`. + +```python +"""Minimal iac-code A2A client using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest + + +async def main() -> None: + async with httpx.AsyncClient(timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Generate a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("status_update"): + status = event.status_update.status + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +:::tip +Para servidores autenticados, construa o `httpx.AsyncClient` com `headers={"Authorization": "Bearer "}` para que tanto a descoberta do Agent Card quanto as chamadas JSON-RPC incluam o token. +::: + +## Próximos passos + +- [Referência de comandos](./command-reference.md) — Referência completa de comandos e opções da CLI. +- [Referência do protocolo](./protocol-reference.md) — Detalhes de método, rota, estado e metadados. +- [Transport HTTP](./http-transport.md) — Comportamento HTTP JSON-RPC, autenticação Bearer e workflows com curl. +- [Exemplos](./examples.md) — Exemplos de SDK, HTTP direto, acompanhamento, cancelamento e tratamento de metadados. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/http-transport.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/http-transport.md new file mode 100644 index 00000000..a3b742ab --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/http-transport.md @@ -0,0 +1,269 @@ +--- +title: Transport HTTP +description: Execute e chame o servidor A2A do iac-code por HTTP JSON-RPC. +sidebar_position: 5 +--- + +# Transport HTTP + +O servidor A2A padrão do iac-code expõe JSON-RPC sobre HTTP, além das rotas REST do SDK A2A. O servidor é construído com Starlette e executa no Uvicorn. + +## Iniciando o servidor + +```bash +# Default host and port +iac-code a2a + +# Explicit host and port +iac-code a2a --host 127.0.0.1 --port 41242 + +# Listen on all interfaces +iac-code a2a --host 0.0.0.0 --port 41242 +``` + +Instale primeiro as dependências opcionais do servidor: + +```bash +uv sync --extra a2a +``` + +## Resumo dos endpoints + +| Rota | Método | Resposta | +|------|--------|----------| +| `/health` | `GET` | Resposta de saúde em JSON simples | +| `/.well-known/agent-card.json` | `GET` | JSON do Agent Card | +| `/` | `POST` | Resposta JSON-RPC ou stream SSE | +| Rotas REST do SDK | misto | Endpoints REST A2A registrados pelo SDK | + +## Cabeçalhos + +Cabeçalhos recomendados: + +```text +Content-Type: application/json +A2A-Version: 1.0 +``` + +Quando a autenticação Bearer está habilitada: + +```text +Authorization: Bearer +``` + +## Autenticação + +O servidor suporta autenticação opcional por Bearer token, Basic auth e API key. Se nenhuma opção de autenticação ou variável de ambiente estiver definida, as requisições não precisam de autenticação. Se um ou mais esquemas estiverem configurados, uma requisição poderá se autenticar com qualquer esquema configurado. + +### Bearer Token + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +Você também pode definir `token` no arquivo de configuração YAML A2A. + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +O nome de usuário e a senha devem estar definidos para que Basic auth seja habilitado. + +### API Key + +```bash +export IACCODE_A2A_API_KEY=your-api-key +iac-code a2a +``` + +O cabeçalho padrão de API key é `X-API-Key`. Você pode alterá-lo em YAML: + +```yaml +api-key: ${IACCODE_A2A_API_KEY} +api-key-header: X-IAC-Code-Key +``` + +ou com `IACCODE_A2A_API_KEY_HEADER`. + +| Cenário | Comportamento | +|---------|---------------| +| Nenhum esquema de autenticação configurado | Nenhuma autenticação necessária | +| Um ou mais esquemas configurados, qualquer um corresponde | A requisição prossegue | +| Um ou mais esquemas configurados, nenhum esquema corresponde | HTTP `401` com `{"error":"Unauthorized"}` | + +## Descoberta do Agent Card + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +Autenticado: + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "Authorization: Bearer $IACCODE_A2A_HTTP_TOKEN" +``` + +Com autenticação por API key: + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "X-API-Key: $IACCODE_A2A_API_KEY" +``` + +A URL do endpoint JSON-RPC é anunciada em `supportedInterfaces[0].url`. O modo HTTP também anuncia uma interface `HTTP+JSON` para clientes compatíveis com REST. + +## Mensagem sem streaming + +`SendMessage` retorna uma única resposta JSON-RPC depois que o turno do agente termina. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a Terraform VPC module for Alibaba Cloud."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## Mensagem em streaming + +`SendStreamingMessage` retorna Server-Sent Events. Use `curl -N` para imprimir eventos à medida que chegam. + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Generate a ROS template for one VPC and two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +Cada linha SSE `data:` contém uma resposta JSON-RPC cujo `result` é um `StreamResponse` A2A. + +## Mensagem de acompanhamento + +Use o `taskId` e o `contextId` retornados pela primeira resposta para continuar a mesma conversa. + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-2", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-3", + "taskId": "task-id-from-first-response", + "contextId": "context-id-from-first-response", + "role": "ROLE_USER", + "parts": [{"text": "Now add tags for environment and owner."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +O workspace deve permanecer o mesmo para o `contextId` reutilizado. + +## Cancelar uma tarefa em execução + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "cancel-1", + "method": "CancelTask", + "params": { + "id": "task-id" + } + }' +``` + +O cancelamento é cooperativo: o iac-code cancela o turno ativo do agente, emite um estado cancelado e libera o lock do contexto. Cancelar uma tarefa existente que não está mais em execução retorna o `TaskNotCancelableError` A2A padrão. + +## Equivalentes na CLI + +A maioria dos workflows HTTP tem um comando CLI correspondente: + +```yaml +url: http://127.0.0.1:41242/ +``` + +```bash +# Discover the Agent Card +iac-code a2a-client --config a2a-client.yml discover + +# Send a non-streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a Terraform VPC module for Alibaba Cloud." \ + --cwd "$PWD" + +# Send a streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a ROS template for one VPC and two vSwitches." \ + --cwd "$PWD" \ + --stream + +# Inspect task state +iac-code a2a-client --config a2a-client.yml task-get --task-id task-id +iac-code a2a-client --config a2a-client.yml task-list --output table + +# Cancel an active task +iac-code a2a-client --config a2a-client.yml task-cancel --task-id task-id +``` + +Para a lista completa de opções, consulte a [Referência de comandos](./command-reference.md). + +## Observações operacionais + +- Faça bind em `127.0.0.1` para uso apenas local. +- Use `token` na configuração A2A ou `IACCODE_A2A_HTTP_TOKEN` antes de fazer bind a uma interface de rede compartilhada. +- O modo A2A rejeita solicitações de permissão de ferramentas automaticamente; proteja endpoints não autenticados como serviços de automação local. +- O estado ativo do runtime fica em memória. A persistência espelha metadados de tarefas e contextos, mas reiniciar o processo não retoma trabalho asyncio em andamento. +- Um contexto pode executar apenas uma tarefa por vez; contextos separados podem executar simultaneamente. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/overview.md new file mode 100644 index 00000000..a0674cd0 --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: Protocolo A2A +description: Visão geral do suporte ao Agent2Agent no iac-code. +--- + +# Protocolo A2A + +## O que é A2A + +[Agent2Agent (A2A)](https://github.com/a2aproject/A2A) é um protocolo para descobrir e chamar agentes remotos. Ele permite que um agente publique um Agent Card, aceite mensagens estruturadas, transmita atualizações de tarefas em streaming e exponha operações de cancelamento e consulta de tarefas por meio de transports padrão. + +## iac-code como servidor A2A + +O iac-code pode executar como um servidor / agente A2A 1.0. Outros clientes compatíveis com A2A podem descobri-lo, enviar solicitações de Infrastructure as Code, receber atualizações de execução em streaming e cancelar tarefas ativas. + +Use A2A quando outro agente, mecanismo de workflow ou serviço precisar chamar o iac-code como um especialista em IaC interoperável. Use ACP quando um cliente no estilo editor precisar de gerenciamento de sessão, prompts de permissão e integração com desenvolvimento local. + +## Casos de uso + +- **Orquestração de agentes** — Um agente planejador pode delegar trabalho de Alibaba Cloud ROS ou Terraform ao iac-code. +- **Automação de workflow** — Ferramentas internas podem enviar tarefas de geração, revisão ou conversão de IaC via HTTP. +- **Descoberta de serviço** — Clientes podem buscar o Agent Card e escolher capacidades como geração de IaC ou revisão de templates. +- **Integrações de streaming** — Um cliente de chatops ou dashboard pode mostrar texto do modelo, atividade de ferramentas, metadados de uso e o estado final da tarefa enquanto o turno executa. + +## Comparação dos modos de interação + +| Modo | Comando | Melhor para | +|------|---------|-------------| +| **REPL interativo** | `iac-code` | Exploração prática e autoria iterativa de templates | +| **CLI não interativa** | `iac-code --prompt "..."` ou `--headless` | Scripts de uma única execução e jobs de CI | +| **Servidor ACP** | `iac-code acp` | Integração com IDE/editor e controle de cliente multi-sessão | +| **Servidor A2A** | `iac-code a2a` | Interoperabilidade agente-a-agente sobre transports A2A | +| **Cliente A2A** | `iac-code a2a-client call` | Chamar agentes A2A remotos a partir do iac-code | + +## Capacidades principais + +- **Descoberta de Agent Card** — Publica `/.well-known/agent-card.json` com binding de protocolo, versão, skills, modos de entrada/saída e metadados opcionais de autenticação. +- **HTTP JSON-RPC e REST** — Serve requisições A2A JSON-RPC em `/` e registra as rotas REST do SDK. +- **Respostas em streaming** — Suporta `SendStreamingMessage` para atualizações incrementais de tarefas. +- **Gerenciamento de tarefas** — Suporta consulta de tarefas, listagem autenticada de tarefas com paginação por cursor, cancelamento de tarefas ativas e assinatura de tarefas ativas. +- **Reuso de contexto** — Reutiliza um runtime do iac-code para mensagens de acompanhamento no mesmo `contextId` A2A. +- **Escopo de workspace** — Lê o diretório do projeto a partir dos metadados da mensagem em `iac_code.cwd`. +- **Metadados de ferramentas** — Emite metadados específicos do iac-code para inícios de ferramentas, deltas de entrada, resultados de ferramentas concluídos, decisões de permissão e uso de tokens. +- **Partes de entrada** — Aceita partes semelhantes a texto, partes de dados JSON, texto UTF-8 bruto, arquivos de texto locais `file://` do workspace e anexos multimodais limitados representados como manifestos de prompt. +- **Chamadas de cliente** — Descobre Agent Cards remotos, verifica assinaturas quando configurado e envia prompts de texto para agentes remotos. +- **Roteamento** — Seleciona agentes remotos configurados por nome explícito, skill ou correspondência de prompt/tag. +- **Metadados de persistência** — Espelha snapshots locais de tarefas/contextos A2A em arquivos JSON para metadados de restauração entre processos. +- **Artefatos** — Armazena payloads de artefatos de texto locais suportados fora do corpo do evento em streaming, emite eventos padrão `TaskArtifactUpdateEvent` e registra `artifacts` da tarefa. +- **Extensões e cache** — Anuncia a extensão opcional de metadados de artefato do iac-code, valida `A2A-Extensions` exigidas e serve Agent Cards com cabeçalhos de cache. +- **Notificações push** — Suporta métodos de configuração de notificação push de tarefas A2A quando `push-notifications: true` está configurado, com filas de entrega baseadas em arquivos locais ou Redis. +- **Assinatura de Agent Card** — Adiciona assinaturas JWS opcionais do SDK A2A para Agent Cards e suporta verificação baseada em `kid` com chaves configuradas, dados JWKS octet locais ou uma URL JWKS remota. +- **Múltiplos transports** — Executa sobre HTTP, stdio, Unix sockets, WebSocket, gRPC oficial, JSON-RPC gRPC customizado e transports Redis Streams. +- **Operações de CLI** — Fornece comandos para descoberta, envio de mensagens, consulta/listagem/cancelamento/assinatura de tarefas, CRUD de configuração push, cards estendidos e pré-visualizações de rotas. + +## Suporte da Fase 1 + +O iac-code suporta modo servidor A2A sobre HTTP JSON-RPC/REST e vários transports opcionais, além do modo cliente Fase 1 para chamar agentes A2A remotos. Ele pode descobrir Agent Cards remotos, selecionar endpoints anunciados, enviar prompts A2A 1.0, consultar/listar/cancelar/assinar tarefas, rotear para agentes configurados, persistir metadados locais de restauração de tarefas/contextos, armazenar payloads de artefatos locais como artefatos de tarefa padrão, validar extensões obrigatórias, gerenciar configurações de notificação push e assinar ou verificar Agent Cards com metadados HMAC ou JWKS. + +## Sem suporte na Fase 1 {#phase-1-unsupported} + +- stdio, Unix sockets, WebSocket, envelope JSON-RPC gRPC e Redis Streams são transports JSON-RPC customizados experimentais. +- gRPC oficial exige dependências opcionais e usa por padrão um binding de servidor local inseguro. +- Não há armazenamento de tarefas distribuído ou compartilhado. A persistência é armazenamento local de arquivos na área de configuração de runtime do iac-code. +- Não há restauração de uma tarefa asyncio em execução após reinício do processo. +- Não há continuação automática em segundo plano de tarefas remotas interrompidas. +- Não há backend de artefatos OSS, S3, banco de dados ou object-store externo. +- Não há ingestão de URL HTTP remota, divisão de binários grandes em chunks ou protocolo de upload retomável. Partes de URL de arquivo local devem permanecer dentro das raízes de workspace permitidas. +- Não há falha rígida padrão para Agent Cards não assinados. +- Não há assinatura assimétrica de Agent Card pelo servidor nem rotação automática de chaves de assinatura. +- Não há DAG de planejador autônomo nem orquestração multiagente complexa. +- A entrega push é at-least-once para filas baseadas em Redis; receptores de callback devem lidar com duplicatas e aplicar sua própria política de autorização no lado do endpoint. + +Solicitações de permissão de ferramentas são rejeitadas automaticamente no modo servidor A2A. Execute o modo A2A não autenticado apenas em ambientes locais confiáveis ou proteja-o com autenticação por Bearer token, Basic auth ou API key. diff --git a/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md new file mode 100644 index 00000000..2dbced58 --- /dev/null +++ b/website/i18n/pt/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -0,0 +1,400 @@ +--- +title: Referência do protocolo +description: Referência completa do protocolo A2A para integração com iac-code. +sidebar_position: 4 +--- + +# Referência do protocolo + +Este documento descreve a superfície A2A 1.0 exposta pelo servidor iac-code e o comportamento do cliente Fase 1 usado por `iac-code a2a-client call`. Para opções exatas da CLI, consulte a [Referência de comandos](./command-reference.md). + +## Visão geral do ciclo de vida + +Uma interação A2A típica segue este fluxo: + +```text +GET Agent Card -> SendMessage or SendStreamingMessage -> GetTask / follow-up / CancelTask +``` + +1. **Descobrir** — Busque `/.well-known/agent-card.json`. +2. **Enviar** — Envie uma mensagem de texto ao endpoint JSON-RPC em `/`. +3. **Transmitir** — Receba payloads `Task`, `Message` e `TaskStatusUpdateEvent`. +4. **Continuar** — Envie uma mensagem de acompanhamento com o mesmo `contextId`. +5. **Cancelar ou consultar** — Use `CancelTask`, `GetTask` ou `ListTasks`. + +## Agent Card + +O Agent Card está disponível em: + +```text +GET /.well-known/agent-card.json +``` + +Campos importantes: + +| Campo | Valor | Significado | +|-------|-------|-------------| +| `name` | `iac-code` | Nome do agente | +| `supportedInterfaces[0].protocolBinding` | `JSONRPC` | Binding de transport | +| `supportedInterfaces[0].protocolVersion` | `1.0` | Versão do protocolo A2A | +| `supportedInterfaces[0].url` | `http://:/` | Endpoint JSON-RPC | +| `capabilities.streaming` | `true` | Suporta atualizações de tarefas em streaming | +| `capabilities.pushNotifications` | `false` ou `true` | `true` quando `push-notifications: true` está configurado | +| `capabilities.extendedAgentCard` | `true` | Chamadores autenticados podem solicitar detalhes estendidos do runtime | +| `capabilities.extensions` | `urn:iac-code:a2a:artifact-metadata:v1` | Namespace opcional de metadados do iac-code para status de ferramentas e metadados de artefatos armazenados | +| `defaultInputModes` | text, JSON, YAML, image, audio, and binary MIME types | Modos MIME de entrada aceitos | +| `defaultOutputModes` | `["text/plain"]` | Apenas saída de texto | + +As respostas do Agent Card incluem `Cache-Control: public, max-age=60`, `ETag` e `Last-Modified`. Clientes podem enviar `If-None-Match` e receber `304 Not Modified` quando o card não mudou. + +Skills anunciadas: + +| Skill ID | Finalidade | +|----------|------------| +| `iac_generation` | Gerar templates Alibaba Cloud ROS e Terraform a partir de linguagem natural | +| `iac_review` | Inspecionar templates IaC e sugerir correções | +| `aliyun_ros_operations` | Auxiliar workflows de stacks Alibaba Cloud ROS | +| `terraform_ros_conversion` | Auxiliar conversão Terraform-para-ROS usando recursos de skill agrupados | + +Quando a autenticação está habilitada, o Agent Card anuncia os esquemas de segurança configurados: + +| Esquema | Quando anunciado | +|---------|------------------| +| `bearerAuth` | `token` ou `IACCODE_A2A_HTTP_TOKEN` está definido | +| `basicAuth` | Nome de usuário e senha Basic estão ambos definidos | +| `apiKeyAuth` | `api-key` ou `IACCODE_A2A_API_KEY` está definido | + +## Rotas + +| Rota | Método | Descrição | +|------|--------|-----------| +| `/health` | `GET` | Retorna `{"status":"healthy"}` | +| `/.well-known/agent-card.json` | `GET` | Retorna o Agent Card | +| `/` | `POST` | Trata requisições A2A JSON-RPC | +| Rotas REST | mistas | As rotas REST do SDK A2A registradas por `create_rest_routes` | + +## Cliente Fase 1 e observações de transport + +O transport interoperável padrão da Fase 1 é JSON-RPC sobre HTTP. O modo HTTP também anuncia `HTTP+JSON` para as rotas REST do SDK. + +O servidor também tem transports opcionais para stdio, Unix sockets, WebSocket, gRPC oficial, envelope JSON-RPC gRPC e Redis Streams. stdio, Unix sockets, WebSocket, JSON-RPC gRPC e Redis Streams são transports JSON-RPC customizados. gRPC oficial é anunciado como `grpc` e exige dependências gRPC opcionais. + +O cliente integrado usa descoberta de Agent Card (`GET /.well-known/agent-card.json`) antes das chamadas de mensagem, seleciona o primeiro `supportedInterfaces[].url` executável anunciado e então envia requisições JSON-RPC com `A2A-Version: 1.0` e nomes de métodos A2A 1.0 como `SendMessage`. + +`push-notifications: true` habilita os métodos de configuração de notificações push A2A e a entrega de estados terminais. + +A assinatura de Agent Card usa o utilitário de assinatura do SDK A2A e emite campos JWS `AgentCardSignature` padrão. O modo de chave simétrica usa `HS256`; a verificação pode selecionar um segredo configurado pelo `kid` do cabeçalho protegido, um JWKS local de chave octet ou uma URL JWKS remota. Assinatura assimétrica no lado do servidor e rotação automática de chaves não estão implementadas na Fase 1. + +Para a lista canônica de comportamentos sem suporte na Fase 1, consulte [Protocolo A2A](./overview.md#phase-1-unsupported). + +## Backends de entrega de notificações push + +`iac-code a2a --config a2a-server.yml` suporta duas filas de entrega push: + +- `push-queue: local-file` armazena jobs abaixo do diretório de persistência A2A e é destinado a uso local de nó único. +- `push-queue: redis-streams` armazena jobs em Redis Streams e coordena workers por meio de um consumer group Redis. + +A entrega push baseada em Redis exige o extra opcional `a2a-redis` e é at-least-once. Receptores de callback devem tratar atualizações de tarefas de forma idempotente, porque um job pode ser entregue novamente após falhas de worker, expiração de lease, reconexões ou disputas de retry. + +Opções comuns de Redis: + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +push-lease-timeout-ms: 300000 +``` + +URLs de callback são validadas antes do armazenamento e novamente antes do envio. O validador padrão rejeita URLs que não sejam HTTP(S), hostnames localhost e endereços IP literais privados/locais. Receptores de callback ainda devem aplicar sua própria política de autenticação e idempotência. + +## Métodos JSON-RPC + +### SendMessage + +Executa um turno de mensagem A2A sem streaming. A resposta contém uma tarefa ou mensagem depois que o turno foi concluído. + +**Requisição** + +```json +{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a VPC with two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +**Campos obrigatórios da mensagem** + +| Campo | Tipo | Obrigatório | Descrição | +|-------|------|-------------|-----------| +| `messageId` | string | Sim | ID único da mensagem do cliente | +| `role` | string | Sim | Use `ROLE_USER` para entrada do usuário | +| `parts` | array | Sim | Partes semelhantes a texto, dados JSON, texto bruto, URL de arquivo local ou partes multimodais limitadas | +| `metadata.iac_code.cwd` | string | Recomendado | Caminho absoluto do workspace; usa como padrão o diretório do processo do servidor se omitido | + +`metadata.iac_code.cwd` deve ser um diretório absoluto existente quando fornecido. Ele deve estar dentro de uma raiz de workspace permitida. Por padrão, as raízes permitidas são o diretório do processo do servidor e o diretório temporário do sistema; `IACCODE_A2A_ALLOWED_CWDS` pode fornecer uma allowlist separada pelo separador de caminhos do sistema operacional. + +Categorias de entrada suportadas: + +| Categoria | Formato aceito | Limites e comportamento | +|-----------|----------------|-------------------------| +| Partes semelhantes a texto | `text` com `text/plain`, JSON, Markdown, YAML ou tipos MIME de texto extras configurados | Anexadas diretamente ao prompt | +| Partes de dados JSON | `data` com `application/json` | Serializadas em JSON compacto; máximo de 1 MiB inline | +| Partes de texto bruto | `raw` com um tipo MIME semelhante a texto | Devem ser UTF-8 válido; máximo de 1 MiB inline | +| URLs de arquivos de texto locais | `url` com `file://...` e tipo MIME semelhante a texto | O arquivo deve existir dentro de `cwd` e das raízes permitidas; máximo de 1 MiB | +| Partes multimodais raw/data/file | image, audio ou tipos MIME multimodais configurados | Convertidas em um manifesto de prompt com nome de arquivo, tipo de mídia, tamanho em bytes, hash e fonte; raw/data máx. 5 MiB, URL de arquivo máx. 25 MiB | + +Ingestão de URLs HTTP(S) remotas não é suportada. Partes de URL de arquivo devem usar URLs locais `file://` e permanecer dentro do workspace permitido. + +### SendStreamingMessage + +Executa um turno de mensagem A2A em streaming. O corpo da requisição tem o mesmo formato de `SendMessage`, mas o servidor transmite respostas JSON-RPC como Server-Sent Events. + +```json +{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +### GetTask + +Retorna a tarefa A2A salva pelo ID. Use `historyLength` para limitar o histórico retornado sem alterar o histórico de tarefa armazenado. Omita-o para receber o histórico padrão atual do servidor. + +```json +{ + "jsonrpc": "2.0", + "id": "3", + "method": "GetTask", + "params": { + "id": "task-id", + "historyLength": 10 + } +} +``` + +### ListTasks + +Retorna tarefas conhecidas visíveis ao chamador autenticado. Os resultados são ordenados pelo timestamp de status em ordem decrescente e, em seguida, pelo ID da tarefa em ordem decrescente para manter ordenação estável. O servidor suporta `contextId`, `status`, `pageSize`, `pageToken`, `historyLength` e `includeArtifacts`. + +```json +{ + "jsonrpc": "2.0", + "id": "4", + "method": "ListTasks", + "params": { + "contextId": "ctx-id", + "status": "TASK_STATE_WORKING", + "pageSize": 20, + "includeArtifacts": false + } +} +``` + +`nextPageToken` é retornado quando outra página está disponível. `includeArtifacts` tem padrão `false`, portanto respostas de listagem omitem artefatos de tarefas a menos que solicitados explicitamente. + +### CancelTask + +Solicita cancelamento para uma tarefa em execução. + +```json +{ + "jsonrpc": "2.0", + "id": "5", + "method": "CancelTask", + "params": { + "id": "task-id" + } +} +``` + +Se a tarefa estiver ativa, o servidor cancela o turno do agente em execução e emite um estado de tarefa cancelado. Se a tarefa existir, mas não estiver em execução, o servidor retorna o `TaskNotCancelableError` A2A padrão. + +### SubscribeToTask + +Assina um stream de atualizações de tarefa ativa quando suportado pelo transport do cliente. + +```json +{ + "jsonrpc": "2.0", + "id": "6", + "method": "SubscribeToTask", + "params": { + "id": "task-id" + } +} +``` + +Para tarefas ativas, o stream começa com a `Task` atual, depois emite eventos de tarefa subsequentes e fecha quando o turno ativo termina. Assinar uma tarefa concluída, falha, cancelada ou que exige entrada retorna um erro no estilo task-not-found em vez de esperar indefinidamente. Para novos turnos, prefira `SendStreamingMessage`; ele inicia a execução e transmite a resposta em uma requisição. + +### Métodos de configuração de notificações push + +Quando o servidor inicia com `push-notifications: true`, ele suporta: + +| Método | Finalidade | +|--------|------------| +| `CreateTaskPushNotificationConfig` | Armazenar uma configuração de callback para uma tarefa | +| `GetTaskPushNotificationConfig` | Buscar uma configuração de callback | +| `ListTaskPushNotificationConfigs` | Listar configurações de callback para uma tarefa | +| `DeleteTaskPushNotificationConfig` | Excluir uma configuração de callback | + +Exemplo de requisição de criação: + +```json +{ + "jsonrpc": "2.0", + "id": "7", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-id", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token" + } + } +} +``` + +O servidor criptografa tokens de notificação armazenados e credenciais de autenticação de callback quando o keyring push local está disponível. + +### GetExtendedAgentCard + +Clientes autenticados podem solicitar o Agent Card estendido: + +```json +{ + "jsonrpc": "2.0", + "id": "8", + "method": "GetExtendedAgentCard", + "params": {} +} +``` + +O card estendido inclui o card público mais detalhes autenticados do runtime. + +## Comportamento de tarefas e contextos + +O iac-code mapeia contextos A2A para runtimes internos do agente: + +| Conceito | Comportamento | +|----------|---------------| +| `contextId` omitido | O SDK/servidor gera um novo ID de contexto | +| Mesmo `contextId` | Reutiliza a mesma sessão interna do iac-code e o estado da conversa | +| Mesmo `contextId`, `cwd` diferente | Rejeitado como um workspace diferente | +| Mesmo `contextId`, mensagem concorrente | Rejeitado com `Task is already working.` | +| Valores diferentes de `contextId` | Podem executar simultaneamente | +| Contexto ocioso | Removido da memória após o timeout de ociosidade configurado | + +IDs de tarefas e contextos devem ser não vazios, ter no máximo 128 caracteres e conter apenas letras, dígitos, `_`, `.`, `:` ou `-`. + +## Estados de tarefa + +| Estado | Significado | +|--------|-------------| +| `TASK_STATE_SUBMITTED` | A tarefa foi aceita | +| `TASK_STATE_WORKING` | O iac-code está executando o turno do agente | +| `TASK_STATE_INPUT_REQUIRED` | O turno foi concluído e o agente está pronto para entrada de acompanhamento | +| `TASK_STATE_CANCELED` | O cancelamento foi solicitado e aplicado | +| `TASK_STATE_FAILED` | A tarefa falhou na validação ou execução | + +O iac-code usa `TASK_STATE_INPUT_REQUIRED` como o estado normal de conclusão porque o contexto permanece disponível para mensagens de acompanhamento. + +## Atualizações em streaming + +Durante a execução, o iac-code emite atualizações `TaskStatusUpdateEvent`. + +Texto do assistente é entregue como uma mensagem de status: + +```json +{ + "statusUpdate": { + "taskId": "task-1", + "contextId": "ctx-1", + "status": { + "state": "TASK_STATE_WORKING", + "message": { + "role": "ROLE_AGENT", + "parts": [{"text": "Here is the ROS template..."}] + } + } + } +} +``` + +Detalhes de ferramentas e uso são entregues por `metadata.iac_code`: + +| Caminho de metadados | Descrição | +|----------------------|-----------| +| `iac_code.tool.status` | `started`, `input_delta`, `input_complete`, `completed` ou `failed` | +| `iac_code.tool.toolUseId` | ID estável de uso de ferramenta para correlacionar eventos de ferramenta | +| `iac_code.tool.name` | Nome da ferramenta quando disponível | +| `iac_code.tool.input` | Entrada completa da ferramenta, truncada para 4000 caracteres por campo | +| `iac_code.tool.result` | Resultado da ferramenta, truncado para 4000 caracteres por campo | +| `iac_code.permission.autoApproved` | `false` quando uma solicitação de permissão de ferramenta foi rejeitada pelo modo servidor A2A | +| `iac_code.usage.inputTokens` | Contagem de tokens de entrada do turno | +| `iac_code.usage.outputTokens` | Contagem de tokens de saída do turno | +| `iac_code.usage.totalTokens` | Contagem total de tokens do turno | + +Quando um resultado de ferramenta inclui um payload de artefato de texto suportado, o servidor armazena o payload localmente, emite um `TaskArtifactUpdateEvent` padrão e registra o artefato no campo `artifacts` da tarefa. A parte do artefato usa uma URL `file://` mais metadados como `mediaType`, `byteSize` e `sha256`; o conteúdo original do artefato não é duplicado dentro dos metadados da ferramenta. + +## Extensões + +O Agent Card anuncia a extensão opcional de metadados de artefato do iac-code: + +```text +urn:iac-code:a2a:artifact-metadata:v1 +``` + +Esta extensão identifica o namespace `metadata.iac_code` usado para progresso de ferramentas, decisões de permissão, uso de tokens e metadados de artefatos locais. Se o servidor estiver configurado com qualquer extensão obrigatória, os clientes devem incluir seu URI no cabeçalho `A2A-Extensions`. Extensões obrigatórias ausentes retornam o `ExtensionSupportRequiredError` A2A padrão. + +## Tratamento de erros + +| Cenário | Resultado | +|---------|-----------| +| Entrada de texto vazia | `TASK_STATE_FAILED` com `A2A server currently accepts text input only.` | +| Tipo de mídia sem suporte | Erro de validação ou erro padrão A2A de content-type, dependendo de onde o SDK rejeita a requisição | +| Parte de URL remota | Erro de validação porque partes de URL devem usar URLs locais `file://` | +| URL de arquivo fora do workspace permitido | Erro de validação | +| Extensão A2A obrigatória ausente | `ExtensionSupportRequiredError` A2A padrão | +| Metadados de workspace inválidos | `TASK_STATE_FAILED` com uma mensagem de workspace inválido | +| Autenticação ausente ou inválida | HTTP `401` com `{"error":"Unauthorized"}` | +| Dependências do servidor A2A ausentes | CLI sai com uma dica de instalação para o extra `a2a` | +| Credenciais do provedor ausentes | Erro de autenticação sanitizado | +| Erro inesperado de runtime | Erro interno sanitizado | + +O servidor evita retornar caminhos locais, segredos e detalhes do provedor em mensagens de erro inesperadas. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json index c6bab3e0..bfc236cb 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current.json @@ -19,6 +19,10 @@ "message": "ACP 协议", "description": "The label for category 'ACP Protocol' in sidebar 'docsSidebar'" }, + "sidebar.docsSidebar.category.A2A Protocol": { + "message": "A2A 协议", + "description": "The label for category 'A2A Protocol' in sidebar 'docsSidebar'" + }, "sidebar.docsSidebar.category.Automation": { "message": "自动化", "description": "The label for category 'Automation' in sidebar 'docsSidebar'" diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md new file mode 100644 index 00000000..6344eaa3 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/command-reference.md @@ -0,0 +1,504 @@ +--- +title: 命令参考 +description: 用于通过 A2A 运行和调用 iac-code 的完整 CLI 命令参考。 +sidebar_position: 3 +--- + +# A2A 命令参考 + +本页面记录每个 A2A 相关的 `iac-code` 命令。当你需要准确的选项名称、常见命令模式以及每个 flag 的运行含义时,请使用本页面。 + +## 命令概览 + +| 命令 | 用途 | +|---------|---------| +| `iac-code a2a` | 将 iac-code 作为 A2A server 运行 | +| `iac-code a2a-client call` | 发现远程 Agent Card 并发送 prompt | +| `iac-code a2a-client discover` | 获取并可选验证 Agent Card | +| `iac-code a2a-client task-get` | 按 ID 获取一个 task | +| `iac-code a2a-client task-list` | 使用过滤器和分页列出 tasks | +| `iac-code a2a-client task-cancel` | 取消活动 task | +| `iac-code a2a-client task-subscribe` | 订阅活动 task event stream | +| `iac-code a2a-client push-config-create` | 创建 task push notification config | +| `iac-code a2a-client push-config-get` | 获取一个 task push notification config | +| `iac-code a2a-client push-config-list` | 列出 task push notification configs | +| `iac-code a2a-client push-config-delete` | 删除 task push notification config | +| `iac-code a2a-client extended-card` | 获取已认证的扩展 Agent Card | +| `iac-code a2a-route-preview` | 为 `a2a-client call` 预览本地路由选择 | + +所有 HTTP client 命令都接受相同的认证选项: + +| 选项 | 描述 | +|--------|-------------| +| `--token` | 作为 `Authorization: Bearer ` 发送的 Bearer token | +| `--basic-username` | Basic auth username | +| `--basic-password` | Basic auth password | +| `--api-key` | API key 值 | +| `--api-key-header` | API key header 名称;默认为 `X-API-Key` | + +## A2A Client 配置 + +所有 `a2a-client` 子命令都在 group 层级接受 YAML 配置文件: + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC" +``` + +CLI 选项会覆盖配置值。使用 config 保存稳定连接、认证、验证、路由以及重复任务或推送设置;一次性的 prompt 文本保留在命令行上。 + +```yaml +url: http://127.0.0.1:41242/ +token: your-bearer-token +basic-username: iac-code +basic-password: your-password +api-key: your-api-key +api-key-header: X-IAC-Code-Key +verify-card-secret: your-card-signing-secret +verify-card-jwks-url: https://a2a.example.com/.well-known/jwks.json +require-card-signature: true +timeout: 30 +cwd: /path/to/workspace +context-id: ctx-123 +task-id: task-123 +config-id: webhook-1 +callback-url: https://hooks.example.com/a2a +notification-token: notification-token +auth-scheme: bearer +auth-credentials: callback-token +routes: + - name: ros + url: http://127.0.0.1:41242/ + skills: + - iac_generation + tags: + - ros + - template +``` + +## `iac-code a2a` + +将 iac-code 作为 A2A server 运行。 + +```bash +iac-code a2a +``` + +默认情况下,服务器绑定到 `127.0.0.1:41242`,并通过 HTTP 提供 JSON-RPC。端口 `41242` 是 iac-code 默认值;它不是已注册的 A2A 端口。 + +### 基本服务器选项 + +| 选项 | 默认值 | 描述 | +|--------|---------|-------------| +| `--config` | 空 | 包含 A2A server 选项的 YAML 配置文件 | +| `--host` | `127.0.0.1` | HTTP server host | +| `--port` | `41242` | HTTP server port | +| `--transport` | `http` | Server transport:`http`、`stdio`、`unix`、`websocket`、`grpc`、`grpc-jsonrpc` 或 `redis-streams` | +| `--debug`, `-d` | `false` | 启用 debug logging | + +示例: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 --debug +``` + +### YAML 配置 + +使用 `--config` 配置认证、存储、签名、传输专用设置、推送投递和其他部署细节。Keys 可以使用连字符或下划线。常用 CLI flags `--host`、`--port` 和 `--transport` 会覆盖 config-file values。 + +```yaml +host: 127.0.0.1 +port: 41242 +transport: http +token: local-dev-token +persistence-dir: .iac-code-a2a/state +artifact-dir: .iac-code-a2a/artifacts +push-notifications: true +``` + +使用以下命令运行: + +```bash +iac-code a2a --config a2a-server.yml --port 41243 +``` + +### HTTP 认证 + +认证是可选的。可以在 YAML 或环境变量中配置服务器认证。如果未配置任何 auth 设置,请求无需认证。当配置了一个或多个方案时,请求可以满足任一已配置方案。 + +| 配置键 | 环境变量 | 描述 | +|--------|----------------------|-------------| +| `token` | `IACCODE_A2A_HTTP_TOKEN` | Bearer token | +| `basic-username` | `IACCODE_A2A_BASIC_USERNAME` | Basic auth username | +| `basic-password` | `IACCODE_A2A_BASIC_PASSWORD` | Basic auth password | +| `api-key` | `IACCODE_A2A_API_KEY` | API key 值 | +| `api-key-header` | `IACCODE_A2A_API_KEY_HEADER` | API key header 名称 | + +Bearer token: + +```yaml +token: local-dev-token +``` + +Basic auth: + +```yaml +basic-username: iac-code +basic-password: local-dev-password +``` + +API key: + +```yaml +api-key: local-dev-key +api-key-header: X-IAC-Code-Key +``` + +### 持久化和 Artifacts + +| 配置键 | 默认值 | 描述 | +|--------|---------|-------------| +| `persistence-dir` | `~/.iac-code/a2a` | 用于 tasks、contexts、routes 和 push configs 的本地 JSON 元数据 | +| `artifact-dir` | `/artifacts` | 本地 artifact payload store | + +持久化会镜像 task 和 context snapshots,用作恢复元数据。它不会在进程崩溃后重启正在运行的 asyncio task。 + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +``` + +### Agent Card 签名 + +| 配置键 | 描述 | +|--------|-------------| +| `signing-secret` | 用于签名 public Agent Card 的 HMAC secret | + +服务器会发出 A2A SDK `AgentCardSignature` JWS 字段。对称模式使用 `HS256`。 + +```yaml +signing-secret: local-card-signing-secret +``` + +### 推送通知投递 + +| 配置键 | 默认值 | 描述 | +|--------|---------|-------------| +| `push-notifications` | `false` | 启用 A2A task push notification config 方法和终态投递 | +| `push-queue` | `local-file` | Push queue backend:`local-file` 或 `redis-streams` | +| `push-redis-url` | 空 | Redis-backed push queue 的 Redis URL | +| `push-stream` | `iac-code:a2a:push` | push jobs 的 Redis stream | +| `push-retry-key` | `iac-code:a2a:push:retry` | delayed retries 的 Redis sorted set | +| `push-dead-stream` | `iac-code:a2a:push:dead` | dead-letter jobs 的 Redis stream | +| `push-consumer-group` | `iac-code-push` | push workers 的 Redis consumer group | +| `push-consumer-name` | 空 | 此 worker 的 Redis consumer name | +| `push-lease-timeout-ms` | `300000` | Redis pending lease timeout | + +本地文件队列: + +```yaml +push-notifications: true +persistence-dir: ~/.iac-code/a2a +push-queue: local-file +``` + +Redis Streams 队列: + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +``` + +Redis-backed push delivery 需要 `a2a-redis` extra。 + +### 传输选项 + +| 传输 | 命令 | 说明 | +|-----------|---------|-------| +| HTTP JSON-RPC 和 REST | `iac-code a2a --transport http` | 默认。公布 `JSONRPC` 和 `HTTP+JSON` 接口。 | +| stdio | `iac-code a2a --transport stdio` | 标准输入/输出上的实验性自定义 JSON-RPC frames。 | +| Unix socket | `iac-code a2a --config a2a-server.yml --transport unix` | 需要 config 中的 `socket-path`。 | +| WebSocket | `iac-code a2a --config a2a-server.yml --transport websocket` | 使用 config 中的 `ws-path`,默认为 `/a2a`。 | +| gRPC | `iac-code a2a --config a2a-server.yml --transport grpc` | 使用 config 中的 `grpc-host` 和 `grpc-port`。 | +| gRPC JSON-RPC | `iac-code a2a --config a2a-server.yml --transport grpc-jsonrpc` | gRPC 上的自定义 JSON-RPC envelope。 | +| Redis Streams | `iac-code a2a --config a2a-server.yml --transport redis-streams` | 需要 config 中的 `redis-url`。 | + +Redis Streams 传输选项: + +| 配置键 | 默认值 | 描述 | +|--------|---------|-------------| +| `redis-url` | 空 | Redis connection URL;`--transport redis-streams` 必需 | +| `request-stream` | `iac-code:a2a:requests` | Request stream 名称 | +| `response-stream` | `iac-code:a2a:responses` | Response stream 名称 | +| `consumer-group` | `iac-code` | Request stream consumer group | + +### 权限行为 + +| 配置键 | 默认值 | 描述 | +|--------|---------|-------------| +| `auto-approve-permissions` | `false` | 自动批准 A2A 轮次期间发起的工具权限请求 | + +如果没有 `auto-approve-permissions: true`,A2A 模式会拒绝权限提示并发出权限元数据。仅在受信任的自动化环境中使用它。 + +## `iac-code a2a-client call` + +发现 Agent Card、选择已公布端点并发送 prompt。 + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" +``` + +| 选项 | 默认值 | 描述 | +|--------|---------|-------------| +| `--url` | 空 | A2A agent base URL 或 JSON-RPC endpoint URL;可来自 config | +| `--route` | 可重复 | 省略 `--url` 时使用的 route spec | +| `--route-name` | 空 | 要选择的 named route | +| `--prompt`, `-p` | 必需 | Prompt text | +| `--cwd` | `.` | 作为 `message.metadata.iac_code.cwd` 发送的 workspace path | +| `--context-id` | 空 | 用于后续消息的现有 A2A context ID | +| `--verify-card-secret`, `--signing-secret` | 空 | Agent Card verification 的 HMAC secret | +| `--verify-card-jwks-url` | 空 | 用于 Agent Card verification 的远程 JWKS URL | +| `--require-card-signature`, `--require-signature` | `false` | 拒绝未签名或无效的 Agent Cards | +| `--timeout` | `30.0` | 调用 timeout(秒) | +| `--stream` | `false` | 使用 `SendStreamingMessage` 并打印 stream events | + +在同一 context 中发送后续消息: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --context-id ctx-123 \ + --prompt "Now add outputs for the VPC and vSwitch IDs." \ + --cwd "$PWD" +``` + +流式: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this Terraform module." \ + --cwd "$PWD" \ + --stream +``` + +要求已签名 Agent Card: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a production VPC template." \ + --cwd "$PWD" +``` + +使用远程 JWKS URL 验证: + +```bash +iac-code a2a-client --config jwks-client.yml call \ + --prompt "Review the ROS stack." +``` + +## `iac-code a2a-client discover` + +获取并打印远程 Agent Card。 + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +| 选项 | 描述 | +|--------|-------------| +| `--url` | A2A agent base URL;可来自 config | +| `--verify-card-secret`, `--signing-secret` | 用于验证的 HMAC secret | +| `--verify-card-jwks-url` | 用于验证的远程 JWKS URL | +| `--require-card-signature`, `--require-signature` | 要求有效签名 | + +已认证发现: + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +## Task 命令 + +Task 命令会直接调用 JSON-RPC task methods。它们适用于运维工具、仪表板和调试。 + +### `iac-code a2a-client task-get` + +```bash +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +| 选项 | 描述 | +|--------|-------------| +| `--url` | A2A JSON-RPC endpoint URL;可来自 config | +| `--task-id` | Task ID;可来自 config | +| `--history-length` | 要返回的最大 task history entries 数 | + +### `iac-code a2a-client task-list` + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --context-id ctx-123 \ + --status TASK_STATE_INPUT_REQUIRED \ + --page-size 20 \ + --output table +``` + +| 选项 | 默认值 | 描述 | +|--------|---------|-------------| +| `--url` | 空 | A2A JSON-RPC endpoint URL;可来自 config | +| `--context-id` | 空 | 按 context ID 过滤 | +| `--status` | 空 | 按 task state 过滤 | +| `--page-size` | 空 | 要返回的最大 tasks 数 | +| `--page-token` | 空 | Pagination token | +| `--include-artifacts` | `false` | 在响应中包含 task artifacts | +| `--output` | `table` | `table` 或 `json` | + +JSON 输出: + +```bash +iac-code a2a-client --config a2a-client.yml task-list \ + --include-artifacts \ + --output json +``` + +### `iac-code a2a-client task-cancel` + +```bash +iac-code a2a-client --config a2a-client.yml task-cancel \ + --task-id task-123 +``` + +取消是协作式的。已完成、失败、已取消或需要输入的 task 会返回标准 A2A task-not-cancelable 错误。 + +### `iac-code a2a-client task-subscribe` + +```bash +iac-code a2a-client --config a2a-client.yml task-subscribe \ + --task-id task-123 +``` + +该命令会为活动 tasks 流式传输 events。对于新轮次,优先使用 `a2a-client call --stream`;它会在一个命令中启动 task 并流式传输更新。 + +## 推送通知配置命令 + +这些命令需要服务器以 `push-notifications: true` 启动。它们管理标准 A2A task push notification configs。 + +### `iac-code a2a-client push-config-create` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +| 选项 | 描述 | +|--------|-------------| +| `--url` | A2A JSON-RPC endpoint URL;可来自 config | +| `--task-id` | Task ID;可来自 config | +| `--config-id` | Push config ID;可来自 config | +| `--callback-url` | HTTP(S) callback URL;可来自 config | +| `--notification-token` | 作为 `X-A2A-Notification-Token` 发送的 token | +| `--auth-scheme` | Callback auth scheme,例如 `bearer` 或 `basic` | +| `--auth-credentials` | Callback auth credentials | + +Callback URLs 会在存储和分发前校验。默认 validator 会拒绝非 HTTP(S) URLs、localhost names 和字面量 private/local IP addresses。 + +### `iac-code a2a-client push-config-get` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-get \ + --task-id task-123 \ + --config-id webhook-1 +``` + +### `iac-code a2a-client push-config-list` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-list \ + --task-id task-123 \ + --page-size 10 +``` + +### `iac-code a2a-client push-config-delete` + +```bash +iac-code a2a-client --config a2a-client.yml push-config-delete \ + --task-id task-123 \ + --config-id webhook-1 +``` + +## `iac-code a2a-client extended-card` + +获取已认证的扩展 Agent Card。 + +```bash +iac-code a2a-client --config a2a-client.yml extended-card \ + --token "$A2A_TOKEN" +``` + +Public Agent Card 会公布 `capabilities.extendedAgentCard=true`。扩展 card 会添加已认证运行时细节,包括任务管理和推送配置能力元数据。 + +## `iac-code a2a-route-preview` + +预览省略 `--url` 时 `a2a-client call` 如何解析已配置路由。 + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +| 选项 | 描述 | +|--------|-------------| +| `--route` | `name=url;skills=a,b;tags=x,y` 格式的可重复 route spec | +| `--name` | 要解析的 route name | +| `--skill` | 要解析的 Skill ID | +| `--prompt` | 用于 name/tag matching 的 prompt text | +| `--route-state-dir`, `--persistence-dir` | 用于持久化 route snapshots 的目录 | +| `--save-routes` | 将提供的 routes 保存到 route state directory | + +保存 route snapshots: + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-state-dir ~/.iac-code/a2a \ + --save-routes +``` + +通过 routes 调用: + +```bash +iac-code a2a-client call \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros" \ + --route-name ros \ + --prompt "Create a ROS VPC template." \ + --cwd "$PWD" +``` + +## 环境变量 + +| 变量 | 描述 | +|----------|-------------| +| `IACCODE_A2A_HTTP_TOKEN` | Server/client Bearer token 默认值 | +| `IACCODE_A2A_BASIC_USERNAME` | Server/client Basic auth username 默认值 | +| `IACCODE_A2A_BASIC_PASSWORD` | Server/client Basic auth password 默认值 | +| `IACCODE_A2A_API_KEY` | Server/client API key 默认值 | +| `IACCODE_A2A_API_KEY_HEADER` | API key header 名称默认值 | +| `IACCODE_A2A_ALLOWED_CWDS` | 用于传入 message metadata 和 file URLs 的按 OS path separator 分隔的 allowed workspace roots 列表 | +| `IACCODE_A2A_TEXT_MIME_TYPES` | 额外的以逗号或分号分隔的 text-like MIME types | +| `IACCODE_A2A_MULTIMODAL_MIME_TYPES` | 额外的以逗号或分号分隔的 multimodal MIME types | +| `IAC_CODE_A2A_PUSH_KEYRING` | 由环境管理的 encrypted push secret keyring | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/examples.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/examples.md new file mode 100644 index 00000000..30051216 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/examples.md @@ -0,0 +1,388 @@ +--- +title: 示例 +description: 与 iac-code A2A server 集成的实用示例。 +sidebar_position: 6 +--- + +# 示例 + +本页面提供可直接使用的 A2A 集成示例。 + +## 前提条件 + +这些示例假定: + +| 依赖 | 版本 | 用途 | +|------------|---------|---------| +| Python | `3.12` | 匹配项目运行时 | +| `a2a-sdk` | `>=1.0.2,<2` | A2A client 和 protobuf types | +| `httpx` | `>=0.27.0` | SDK 和直接示例使用的 HTTP client | +| `iac-code` | 当前仓库 | 提供 `iac-code a2a` 子命令 | + +启动服务器: + +```bash +uv sync --extra a2a +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +## Python SDK — 流式会话 + +此示例发现 Agent Card、发送消息、打印 assistant 文本块,并报告工具元数据。 + +```python +"""Streaming iac-code A2A session using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path +from typing import Any + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest +from google.protobuf.json_format import MessageToDict + + +def metadata_to_dict(value: Any) -> dict[str, Any]: + if value is None: + return {} + return MessageToDict(value, preserving_proto_field_name=False) + + +async def main() -> None: + headers = {} + # For authenticated servers: + # headers["Authorization"] = "Bearer YOUR_TOKEN" + + async with httpx.AsyncClient(headers=headers, timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Create a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("task"): + print(f"[task] {event.task.id} context={event.task.context_id}") + + elif event.HasField("status_update"): + update = event.status_update + status = update.status + + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + metadata = metadata_to_dict(update.metadata) + tool = metadata.get("iac_code", {}).get("tool") + if tool: + print(f"\n[tool] {tool.get('status')} {tool.get('name', '')}".rstrip()) + + usage = metadata.get("iac_code", {}).get("usage") + if usage: + print(f"\n[usage] {usage['totalTokens']} total tokens") + + if status.state == "TASK_STATE_INPUT_REQUIRED": + print("\n[done]") + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## CLI — 端到端工作流 + +启动一个带持久化、artifacts、推送通知支持和已签名 Agent Card 的本地服务器: + +```yaml +host: 127.0.0.1 +port: 41242 +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +```bash +iac-code a2a --config a2a-server.yml +``` + +为稳定端点和 card 验证设置创建 client config: + +```yaml +url: http://127.0.0.1:41242/ +verify-card-secret: local-card-signing-secret +require-card-signature: true +``` + +发现并验证 Agent Card: + +```bash +iac-code a2a-client --config a2a-client.yml discover +``` + +发送流式请求: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a ROS VPC template with two vSwitches." \ + --cwd "$PWD" \ + --stream +``` + +列出 tasks 并以 JSON 获取一个 task: + +```bash +iac-code a2a-client --config a2a-client.yml task-list --output table + +iac-code a2a-client --config a2a-client.yml task-get \ + --task-id task-123 \ + --history-length 20 +``` + +为 task 注册 push callback: + +```bash +iac-code a2a-client --config a2a-client.yml push-config-create \ + --task-id task-123 \ + --config-id webhook-1 \ + --callback-url https://hooks.example.com/a2a \ + --notification-token "$NOTIFICATION_TOKEN" \ + --auth-scheme bearer \ + --auth-credentials "$WEBHOOK_BEARER_TOKEN" +``` + +调用 routed agent 前预览路由选择: + +```bash +iac-code a2a-route-preview \ + --route "ros=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --prompt "Create a ROS VPC template" +``` + +## Python SDK — 后续消息 + +后续消息会复用相同的 `context_id`,通常也会复用相同的 task ID。这会让内部 iac-code runtime 和 conversation history 保持活动状态。 + +```python +import uuid +from pathlib import Path + +from a2a.types import Message, Part, Role, SendMessageRequest + + +def build_follow_up(task_id: str, context_id: str, text: str) -> SendMessageRequest: + return SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + task_id=task_id, + context_id=context_id, + role=Role.ROLE_USER, + parts=[Part(text=text)], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + +# Usage inside an async function: +# async for event in client.send_message( +# build_follow_up(task_id, context_id, "Add outputs for VPC and VSwitch IDs.") +# ): +# ... +``` + +如果新消息指向不同工作区,服务器会拒绝复用的 `contextId`。 + +## Python SDK — 取消任务 + +```python +from a2a.types import CancelTaskRequest + + +async def cancel_running_task(client, task_id: str) -> None: + task = await client.cancel_task(CancelTaskRequest(id=task_id)) + print(f"{task.id}: {task.status.state}") +``` + +## 直接 HTTP — 最小 JSON-RPC Client + +当调用方不想引入 SDK 依赖时,请使用此方式。 + +```python +"""Direct A2A JSON-RPC client using httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=120.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template for missing parameters."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## 直接 HTTP — 流式 SSE + +```python +"""Read SendStreamingMessage events directly with httpx.""" + +import asyncio +from pathlib import Path + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=None) as client: + async with client.stream( + "POST", + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Generate a Terraform VPC example."}], + "metadata": {"iac_code": {"cwd": str(Path.cwd())}}, + }, + "configuration": {"acceptedOutputModes": ["text/plain"]}, + }, + }, + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if line.startswith("data:"): + print(line.removeprefix("data:").strip()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## 直接 HTTP — 推送通知配置 + +当服务器以 `push-notifications: true` 运行时,push config 方法可用。 + +```python +"""Create an A2A task push notification config.""" + +import asyncio + +import httpx + +BASE_URL = "http://127.0.0.1:41242" + + +async def main() -> None: + async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as client: + response = await client.post( + "/", + headers={"A2A-Version": "1.0"}, + json={ + "jsonrpc": "2.0", + "id": "push-1", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-123", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token", + }, + }, + }, + ) + response.raise_for_status() + print(response.json()) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## 处理 iac-code 元数据 + +工具和用量事件会到达 `TaskStatusUpdateEvent.metadata.iac_code`。 + +```python +from typing import Any + + +def handle_iac_metadata(metadata: dict[str, Any]) -> None: + iac = metadata.get("iac_code", {}) + + if tool := iac.get("tool"): + status = tool.get("status") + tool_id = tool.get("toolUseId") + name = tool.get("name", "") + print(f"tool={name} id={tool_id} status={status}") + + if permission := iac.get("permission"): + if permission.get("autoApproved") is False: + print(f"permission rejected for {permission.get('toolName')}") + + if usage := iac.get("usage"): + print( + "tokens=" + f"{usage.get('inputTokens', 0)}+{usage.get('outputTokens', 0)}" + f"={usage.get('totalTokens', 0)}" + ) +``` + +## 常见问题 + +| 现象 | 修复方式 | +|---------|-----| +| HTTP `401` | 在 Agent Card 和 JSON-RPC 请求中都包含已配置的 auth scheme,例如 `Authorization: Bearer `、Basic auth 或 `X-API-Key: ` | +| `Invalid A2A workspace metadata.` | 在 `metadata.iac_code.cwd` 中使用已存在的绝对路径 | +| `A2A server currently accepts text input only.` | 至少发送一个非空 text part | +| `Task is already working.` | 等待当前轮次完成后,再在同一 context 中发送另一条消息 | +| 后续消息因不同工作区被拒绝 | 对复用的 `contextId` 保持 `metadata.iac_code.cwd` 不变 | +| 本地 file URL 被拒绝 | 将 `file://` parts 保持在 `metadata.iac_code.cwd` 和 `IACCODE_A2A_ALLOWED_CWDS` 内 | +| Push callback 被拒绝 | 使用不是 localhost 且不是字面量 private/local IP address 的 HTTP(S) callback URL | +| Redis push queue 启动失败 | 安装 `a2a-redis` extra,并在 A2A 配置中提供 `push-redis-url` | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/getting-started.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/getting-started.md new file mode 100644 index 00000000..a774d643 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/getting-started.md @@ -0,0 +1,291 @@ +--- +sidebar_position: 2 +title: 快速开始 +description: 启动 A2A server 并发送第一条消息。 +--- + +# A2A 快速开始 + +## 前提条件 + +1. **已安装 iac-code** — 请参阅 [安装](/docs/getting-started/installation) 指南。 + +2. **已配置 LLM 凭据** — 请参阅 [认证](/docs/configuration/authentication) 指南来配置模型 provider 凭据。 + +3. **A2A server 依赖** — 使用 `a2a` extra 安装 iac-code: + +```bash +uv sync --extra a2a +``` + +## 启动 A2A Server + +在默认本地接口上启动服务器: + +```bash +iac-code a2a --host 127.0.0.1 --port 41242 +``` + +当你需要本地状态、artifact 存储、推送通知投递或已签名 Agent Cards 时,请使用 YAML 配置文件: + +```yaml +persistence-dir: ~/.iac-code/a2a +artifact-dir: ~/.iac-code/a2a/artifacts +signing-secret: local-card-signing-secret +push-notifications: true +``` + +使用以下命令运行: + +```bash +iac-code a2a --config a2a-server.yml +``` + +`push-notifications: true` 会启用 A2A 任务推送通知配置方法和终态投递。当多个 worker 需要协调推送投递时,请将 `push-queue: redis-streams` 与 `push-redis-url` 搭配使用。 + +服务器暴露: + +| 路由 | 用途 | +|-------|---------| +| `GET /health` | 健康检查 | +| `GET /.well-known/agent-card.json` | Agent Card 发现 | +| `POST /` | A2A JSON-RPC 端点 | + +HTTP 服务器还会注册 A2A SDK REST 路由,并在 Agent Card 中公布 `JSONRPC` 和 `HTTP+JSON` 两种接口。 + +## 验证发现 + +获取 Agent Card: + +```text +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +你应该能看到 `name: "iac-code"`、`JSONRPC` 和 `HTTP+JSON` 接口、`ETag` 等缓存标头、可选的 `urn:iac-code:a2a:artifact-metadata:v1` 扩展、受支持的输入模式,以及 `iac_generation`、`iac_review`、`aliyun_ros_operations` 和 `terraform_ros_conversion` 等技能。 + +检查健康检查端点: + +```bash +curl http://127.0.0.1:41242/health +``` + +预期响应: + +```json +{"status":"healthy"} +``` + +## 要求认证 + +认证是可选的。如果未设置任何 A2A 认证选项或环境变量,请求不需要认证。当配置了任意认证方案时,包括 Agent Card 发现以内的每个请求都必须满足一个已配置方案。 + +### Bearer Token + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +等价的 YAML 配置键是 `token`。 + +```text +Authorization: Bearer +``` + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +用户名和密码必须同时存在。等价的 YAML 配置键是 `basic-username` 和 `basic-password`。 + +### API Key + +```bash +export IACCODE_A2A_API_KEY=your-api-key + +iac-code a2a +``` + +默认 API key 标头是: + +```text +X-API-Key: +``` + +可使用 `api-key-header` YAML 配置键或 `IACCODE_A2A_API_KEY_HEADER` 覆盖它: + +```yaml +api-key: your-api-key +api-key-header: X-IAC-Code-Key +``` + +## 调用远程 A2A Agent + +将稳定的客户端连接和认证设置放入 YAML 文件: + +```yaml +url: http://127.0.0.1:41242/ +token: your-secret-token +verify-card-secret: your-card-signing-secret +require-card-signature: true +cwd: /path/to/workspace +``` + +使用 `a2a-client call` 进行直接的 Phase 1 client 调用: + +```bash +iac-code a2a-client --config a2a-client.yml call --prompt "Create a VPC with two vSwitches" --cwd "$PWD" +``` + +当你想要增量事件而不是单个最终响应时,使用 `--stream`: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Review this template" \ + --cwd "$PWD" \ + --stream +``` + +当你需要一次性的目标或 token 时,命令行选项会覆盖配置值: + +```bash +iac-code a2a-client --config a2a-client.yml call \ + --url https://other-agent.example.com/ \ + --prompt "Review this template" +``` + +对于多 agent 路由,请在调用前预览路由选择: + +```bash +iac-code a2a-route-preview \ + --route "template=http://127.0.0.1:41242/;skills=iac_generation;tags=ros,template" \ + --skill iac_generation \ + --route-state-dir ~/.iac-code/a2a +``` + +请参阅 [命令参考](./command-reference.md) 了解所有 A2A 命令,包括任务管理、推送配置 CRUD、扩展 Agent Cards 和传输选项。 + +## 使用 curl 发送第一条消息 + +通过 `message.metadata.iac_code.cwd` 传递工作区目录;该路径必须是绝对路径,必须已经存在,并且必须位于允许的工作区根目录内。默认情况下,允许的根目录是服务器进程目录和系统临时目录。可以用 `IACCODE_A2A_ALLOWED_CWDS` 覆盖它们。 + +服务器接受类文本 parts、JSON 数据 parts、原始 UTF-8 文本、本地工作区 `file://` 文本文件和有界多模态附件。不支持远程 URL 摄取;`url` parts 必须是位于允许工作区内的本地 `file://` URLs。 + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [ + {"text": "Generate a ROS VPC template with two vSwitches."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +对于流式输出,请使用 `SendStreamingMessage`: + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [ + {"text": "Review my Terraform files and suggest ROS equivalents."} + ], + "metadata": { + "iac_code": { + "cwd": "/path/to/project" + } + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## 最小 Python SDK 示例 + +下面的示例使用 `a2a-sdk>=1.0.2,<2`,这是 `a2a` extra 使用的版本范围。 + +```python +"""Minimal iac-code A2A client using a2a-sdk.""" + +import asyncio +import uuid +from pathlib import Path + +import httpx +from a2a.client import ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest + + +async def main() -> None: + async with httpx.AsyncClient(timeout=120.0) as httpx_client: + config = ClientConfig(httpx_client=httpx_client, streaming=True) + client = await ClientFactory(config).create_from_url("http://127.0.0.1:41242") + + request = SendMessageRequest( + message=Message( + message_id=f"msg-{uuid.uuid4().hex}", + role=Role.ROLE_USER, + parts=[Part(text="Generate a ROS VPC template with two vSwitches.")], + metadata={"iac_code": {"cwd": str(Path.cwd())}}, + ) + ) + + async for event in client.send_message(request): + if event.HasField("status_update"): + status = event.status_update.status + if status.message: + for part in status.message.parts: + if part.text: + print(part.text, end="", flush=True) + + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +:::tip +对于启用了认证的服务器,请使用 `headers={"Authorization": "Bearer "}` 构造 `httpx.AsyncClient`,以便 Agent Card 发现和 JSON-RPC 调用都包含 token。 +::: + +## 后续步骤 + +- [命令参考](./command-reference.md) — 完整 CLI 命令和选项参考。 +- [协议参考](./protocol-reference.md) — 方法、路由、状态和元数据细节。 +- [HTTP 传输](./http-transport.md) — JSON-RPC HTTP 行为、bearer auth 和 curl 工作流。 +- [示例](./examples.md) — SDK、直接 HTTP、后续消息、取消和元数据处理示例。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/http-transport.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/http-transport.md new file mode 100644 index 00000000..bbc38310 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/http-transport.md @@ -0,0 +1,269 @@ +--- +title: HTTP 传输 +description: 通过 JSON-RPC HTTP 运行和调用 iac-code A2A server。 +sidebar_position: 5 +--- + +# HTTP 传输 + +iac-code 默认的 A2A server 通过 HTTP 暴露 JSON-RPC,并同时暴露 A2A SDK REST 路由。该服务器基于 Starlette 构建,并运行在 Uvicorn 上。 + +## 启动服务器 + +```bash +# Default host and port +iac-code a2a + +# Explicit host and port +iac-code a2a --host 127.0.0.1 --port 41242 + +# Listen on all interfaces +iac-code a2a --host 0.0.0.0 --port 41242 +``` + +请先安装可选服务器依赖: + +```bash +uv sync --extra a2a +``` + +## 端点概要 + +| 路由 | 方法 | 响应 | +|-------|--------|----------| +| `/health` | `GET` | 普通 JSON 健康检查响应 | +| `/.well-known/agent-card.json` | `GET` | Agent Card JSON | +| `/` | `POST` | JSON-RPC 响应或 SSE stream | +| SDK REST routes | mixed | 由 SDK 注册的 A2A REST 端点 | + +## 标头 + +推荐 headers: + +```text +Content-Type: application/json +A2A-Version: 1.0 +``` + +启用 Bearer auth 时: + +```text +Authorization: Bearer +``` + +## 认证 + +服务器支持可选的 Bearer token、Basic auth 和 API key authentication。如果没有设置认证选项或环境变量,请求不需要认证。如果配置了一个或多个方案,请求可以使用任一已配置方案进行认证。 + +### Bearer Token + +```bash +export IACCODE_A2A_HTTP_TOKEN=your-secret-token +iac-code a2a +``` + +你也可以在 A2A YAML 配置文件中设置 `token`。 + +### Basic Auth + +```bash +export IACCODE_A2A_BASIC_USERNAME=iac-code +export IACCODE_A2A_BASIC_PASSWORD=your-password + +iac-code a2a +``` + +必须同时设置用户名和密码,Basic auth 才会启用。 + +### API Key + +```bash +export IACCODE_A2A_API_KEY=your-api-key +iac-code a2a +``` + +默认 API key header 是 `X-API-Key`。你可以在 YAML 中更改它: + +```yaml +api-key: ${IACCODE_A2A_API_KEY} +api-key-header: X-IAC-Code-Key +``` + +也可以使用 `IACCODE_A2A_API_KEY_HEADER`。 + +| 场景 | 行为 | +|----------|----------| +| 未配置 auth scheme | 不需要认证 | +| 配置了一个或多个 schemes,且任意一个匹配 | 请求继续 | +| 配置了一个或多个 schemes,但没有 scheme 匹配 | HTTP `401`,响应 `{"error":"Unauthorized"}` | + +## Agent Card 发现 + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json +``` + +已认证: + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "Authorization: Bearer $IACCODE_A2A_HTTP_TOKEN" +``` + +使用 API key authentication: + +```bash +curl http://127.0.0.1:41242/.well-known/agent-card.json \ + -H "X-API-Key: $IACCODE_A2A_API_KEY" +``` + +JSON-RPC 端点 URL 会在 `supportedInterfaces[0].url` 中公布。HTTP 模式还会为支持 REST 的客户端公布 `HTTP+JSON` 接口。 + +## 非流式消息 + +`SendMessage` 会在 agent 轮次完成后返回单个 JSON-RPC 响应。 + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a Terraform VPC module for Alibaba Cloud."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +## 流式消息 + +`SendStreamingMessage` 返回 Server-Sent Events。使用 `curl -N` 可以在事件到达时打印它们。 + +```bash +curl -N -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "stream-1", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Generate a ROS template for one VPC and two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +每一行 SSE `data:` 都包含一个 JSON-RPC 响应,其 `result` 是 A2A `StreamResponse`。 + +## 后续消息 + +使用第一个响应返回的 `taskId` 和 `contextId` 继续同一段对话。 + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "send-2", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-3", + "taskId": "task-id-from-first-response", + "contextId": "context-id-from-first-response", + "role": "ROLE_USER", + "parts": [{"text": "Now add tags for environment and owner."}], + "metadata": { + "iac_code": {"cwd": "/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } + }' +``` + +对于复用的 `contextId`,工作区必须保持相同。 + +## 取消运行中的任务 + +```bash +curl -s -X POST http://127.0.0.1:41242/ \ + -H "Content-Type: application/json" \ + -H "A2A-Version: 1.0" \ + -d '{ + "jsonrpc": "2.0", + "id": "cancel-1", + "method": "CancelTask", + "params": { + "id": "task-id" + } + }' +``` + +取消是协作式的:iac-code 会取消活动 agent 轮次,发出 canceled 状态,并释放上下文锁。取消一个已存在但不再运行的任务会返回标准 A2A `TaskNotCancelableError`。 + +## CLI 等价命令 + +大多数 HTTP 工作流都有对应的 CLI 命令: + +```yaml +url: http://127.0.0.1:41242/ +``` + +```bash +# Discover the Agent Card +iac-code a2a-client --config a2a-client.yml discover + +# Send a non-streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Create a Terraform VPC module for Alibaba Cloud." \ + --cwd "$PWD" + +# Send a streaming prompt +iac-code a2a-client --config a2a-client.yml call \ + --prompt "Generate a ROS template for one VPC and two vSwitches." \ + --cwd "$PWD" \ + --stream + +# Inspect task state +iac-code a2a-client --config a2a-client.yml task-get --task-id task-id +iac-code a2a-client --config a2a-client.yml task-list --output table + +# Cancel an active task +iac-code a2a-client --config a2a-client.yml task-cancel --task-id task-id +``` + +完整选项列表请参阅 [命令参考](./command-reference.md)。 + +## 运行说明 + +- 对仅本地使用,请绑定到 `127.0.0.1`。 +- 绑定到共享网络接口前,请在 A2A 配置中使用 `token` 或设置 `IACCODE_A2A_HTTP_TOKEN`。 +- A2A 模式会自动拒绝工具权限请求;请像保护本地自动化服务一样保护未认证端点。 +- 活动运行时状态位于内存中。持久化会镜像任务和上下文元数据,但重启进程不会恢复正在运行的 asyncio 工作。 +- 一个上下文同一时间只能运行一个任务;不同上下文可以并发运行。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/overview.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/overview.md new file mode 100644 index 00000000..892fd96f --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/overview.md @@ -0,0 +1,74 @@ +--- +sidebar_position: 1 +title: A2A 协议 +description: iac-code 中 Agent2Agent 支持的概览。 +--- + +# A2A 协议 + +## 什么是 A2A + +[Agent2Agent (A2A)](https://github.com/a2aproject/A2A) 是一种用于发现和调用远程 agent 的协议。它允许 agent 发布 Agent Card、接收结构化消息、流式传输任务更新,并通过标准传输暴露取消和任务查询操作。 + +## iac-code 作为 A2A 服务器 + +iac-code 可以作为 A2A 1.0 Server / Agent 运行。其他兼容 A2A 的客户端可以发现它、发送 Infrastructure as Code 请求、流式接收执行更新,并取消活动任务。 + +当另一个 agent、工作流引擎或服务需要把 iac-code 作为可互操作的 IaC 专家来调用时,请使用 A2A。当编辑器风格的客户端需要会话管理、权限提示和本地开发集成时,请使用 ACP。 + +## 使用场景 + +- **Agent 编排** — 规划 agent 可以把 Alibaba Cloud ROS 或 Terraform 工作委派给 iac-code。 +- **工作流自动化** — 内部工具可以通过 HTTP 提交 IaC 生成、审查或转换任务。 +- **服务发现** — 客户端可以获取 Agent Card,并选择 IaC 生成或模板审查等能力。 +- **流式集成** — chatops 或仪表板客户端可以在轮次运行时显示模型文本、工具活动、用量元数据和最终任务状态。 + +## 交互模式对比 + +| 模式 | 命令 | 最适合 | +|------|---------|----------| +| **交互式 REPL** | `iac-code` | 上手探索和迭代式模板编写 | +| **非交互式 CLI** | `iac-code --prompt "..."` 或 `--headless` | 一次性脚本和 CI 作业 | +| **ACP Server** | `iac-code acp` | IDE/编辑器集成和多会话客户端控制 | +| **A2A Server** | `iac-code a2a` | 通过 A2A 传输实现 agent 到 agent 的互操作 | +| **A2A Client** | `iac-code a2a-client call` | 从 iac-code 调用远程 A2A agent | + +## 核心能力 + +- **Agent Card 发现** — 发布 `/.well-known/agent-card.json`,包含协议绑定、版本、技能、输入/输出模式和可选认证元数据。 +- **HTTP JSON-RPC 和 REST** — 在 `/` 提供 A2A JSON-RPC 请求服务,并注册 SDK REST 路由。 +- **流式响应** — 支持 `SendStreamingMessage`,用于增量任务更新。 +- **任务管理** — 支持任务查询、带游标分页的认证任务列表、活动任务取消和活动任务订阅。 +- **上下文复用** — 对同一 A2A `contextId` 中的后续消息复用 iac-code 运行时。 +- **工作区范围限定** — 从 `iac_code.cwd` 处的消息元数据读取项目目录。 +- **工具元数据** — 发出 iac-code 专用元数据,用于工具启动、输入增量、已完成工具结果、权限决策和 token 用量。 +- **输入 parts** — 接收类文本 parts、JSON 数据 parts、原始 UTF-8 文本、本地工作区 `file://` 文本文件,以及表示为 prompt manifests 的有界多模态附件。 +- **客户端调用** — 发现远程 Agent Cards,在配置后验证签名,并向远程 agent 发送文本提示。 +- **路由** — 按显式名称、技能或 prompt/tag 匹配选择已配置的远程 agent。 +- **持久化元数据** — 将本地 A2A 任务/上下文快照镜像到 JSON 文件,用作跨进程恢复元数据。 +- **Artifacts** — 在流式事件正文之外存储受支持的本地文本 artifact payload,发出标准 `TaskArtifactUpdateEvent` 事件,并记录任务 `artifacts`。 +- **扩展和缓存** — 公布可选的 iac-code artifact 元数据扩展,校验必需的 `A2A-Extensions`,并使用缓存标头提供 Agent Cards。 +- **推送通知** — 当配置 `push-notifications: true` 时,支持 A2A 任务推送通知配置方法,并通过本地文件或 Redis 后端的投递队列进行投递。 +- **Agent Card 签名** — 为 Agent Cards 添加可选的 A2A SDK JWS 签名,并支持使用已配置密钥、本地 octet JWKS 数据或远程 JWKS URL 进行基于 `kid` 的验证。 +- **多种传输** — 通过 HTTP、stdio、Unix sockets、WebSocket、官方 gRPC、自定义 gRPC JSON-RPC 和 Redis Streams 传输运行。 +- **CLI 操作** — 提供发现、消息发送、任务查询/列表/取消/订阅、推送配置 CRUD、扩展卡和路由预览命令。 + +## Phase 1 支持 + +iac-code 支持通过 HTTP JSON-RPC/REST 以及若干可选传输运行 A2A server 模式,并支持用于调用远程 A2A agent 的 Phase 1 client 模式。它可以发现远程 Agent Cards、选择已公布的端点、发送 A2A 1.0 prompts、查询/列出/取消/订阅任务、路由到已配置的 agent、持久化本地任务/上下文恢复元数据、将本地 artifact payload 作为标准任务 artifacts 存储、校验必需扩展、管理推送通知配置,并使用 HMAC 或 JWKS 元数据签名或验证 Agent Cards。 + +## Phase 1 不支持 {#phase-1-unsupported} + +- stdio、Unix sockets、WebSocket、gRPC JSON-RPC envelope 和 Redis Streams 是实验性的自定义 JSON-RPC 传输。 +- 官方 gRPC 需要可选依赖,并且默认使用不安全的本地服务器绑定。 +- 没有分布式或共享任务存储。持久化是 iac-code 运行时配置区域下的本地文件存储。 +- 进程重启后不会恢复正在运行的 asyncio 任务。 +- 不会自动在后台继续已中断的远程任务。 +- 没有 OSS、S3、数据库或外部对象存储 artifact 后端。 +- 没有远程 HTTP URL 摄取、大型二进制分块或可恢复上传协议。本地 file URL parts 必须留在允许的工作区根目录内。 +- 默认不会因为未签名的 Agent Cards 而硬失败。 +- 服务器端不支持非对称 Agent Card 签名,也不会自动轮换签名密钥。 +- 没有自主 planner DAG 或复杂多 agent 编排。 +- Redis 后端队列的推送投递是至少一次;callback 接收方必须处理重复投递,并执行自己的端点侧授权策略。 + +在 A2A server 模式下,工具权限请求会被自动拒绝。只应在受信任的本地环境中运行未认证的 A2A 模式,或者使用 Bearer token、Basic auth 或 API key authentication 进行保护。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md new file mode 100644 index 00000000..c33533b9 --- /dev/null +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/a2a/protocol-reference.md @@ -0,0 +1,400 @@ +--- +title: 协议参考 +description: 用于 iac-code 集成的完整 A2A 协议参考。 +sidebar_position: 4 +--- + +# 协议参考 + +本文档描述 iac-code server 暴露的 A2A 1.0 接口面,以及 `iac-code a2a-client call` 使用的 Phase 1 client 行为。准确的 CLI 选项请参阅 [命令参考](./command-reference.md)。 + +## 生命周期概览 + +典型 A2A 交互遵循以下流程: + +```text +GET Agent Card -> SendMessage or SendStreamingMessage -> GetTask / follow-up / CancelTask +``` + +1. **发现** — 获取 `/.well-known/agent-card.json`。 +2. **发送** — 向 `/` 上的 JSON-RPC 端点提交文本消息。 +3. **流式接收** — 接收 `Task`、`Message` 和 `TaskStatusUpdateEvent` payloads。 +4. **继续** — 使用相同 `contextId` 发送后续消息。 +5. **取消或查询** — 使用 `CancelTask`、`GetTask` 或 `ListTasks`。 + +## Agent Card + +Agent Card 可通过以下位置获取: + +```text +GET /.well-known/agent-card.json +``` + +重要字段: + +| 字段 | 值 | 含义 | +|-------|-------|---------| +| `name` | `iac-code` | Agent 名称 | +| `supportedInterfaces[0].protocolBinding` | `JSONRPC` | 传输绑定 | +| `supportedInterfaces[0].protocolVersion` | `1.0` | A2A 协议版本 | +| `supportedInterfaces[0].url` | `http://:/` | JSON-RPC 端点 | +| `capabilities.streaming` | `true` | 支持流式任务更新 | +| `capabilities.pushNotifications` | `false` 或 `true` | 配置了 `push-notifications: true` 时为 `true` | +| `capabilities.extendedAgentCard` | `true` | 已认证调用方可以请求扩展运行时细节 | +| `capabilities.extensions` | `urn:iac-code:a2a:artifact-metadata:v1` | 用于工具状态和已存储 artifact 元数据的可选 iac-code 元数据命名空间 | +| `defaultInputModes` | text、JSON、YAML、image、audio 和 binary MIME types | 接受的输入 MIME 模式 | +| `defaultOutputModes` | `["text/plain"]` | 仅文本输出 | + +Agent Card 响应包含 `Cache-Control: public, max-age=60`、`ETag` 和 `Last-Modified`。客户端可以发送 `If-None-Match`,当 card 未更改时会收到 `304 Not Modified`。 + +公布的技能: + +| 技能 ID | 用途 | +|----------|---------| +| `iac_generation` | 根据自然语言生成 Alibaba Cloud ROS 和 Terraform 模板 | +| `iac_review` | 检查 IaC 模板并建议修复 | +| `aliyun_ros_operations` | 协助 Alibaba Cloud ROS stack 工作流 | +| `terraform_ros_conversion` | 使用 bundled skill resources 协助 Terraform 到 ROS 转换 | + +启用认证后,Agent Card 会公布已配置的安全方案: + +| 方案 | 公布时机 | +|--------|-----------------| +| `bearerAuth` | 设置了 `token` 或 `IACCODE_A2A_HTTP_TOKEN` | +| `basicAuth` | 同时设置了 Basic username 和 password | +| `apiKeyAuth` | 设置了 `api-key` 或 `IACCODE_A2A_API_KEY` | + +## 路由 + +| 路由 | 方法 | 描述 | +|-------|--------|-------------| +| `/health` | `GET` | 返回 `{"status":"healthy"}` | +| `/.well-known/agent-card.json` | `GET` | 返回 Agent Card | +| `/` | `POST` | 处理 A2A JSON-RPC 请求 | +| REST 路由 | mixed | 由 `create_rest_routes` 注册的 A2A SDK REST 路由 | + +## Phase 1 Client 和传输说明 + +默认的可互操作 Phase 1 传输是基于 HTTP 的 JSON-RPC。HTTP 模式还会为 SDK REST 路由公布 `HTTP+JSON`。 + +服务器还提供 stdio、Unix sockets、WebSocket、官方 gRPC、gRPC JSON-RPC envelope 和 Redis Streams 的可选传输。stdio、Unix sockets、WebSocket、gRPC JSON-RPC 和 Redis Streams 是自定义 JSON-RPC 传输。官方 gRPC 公布为 `grpc`,并需要可选 gRPC 依赖。 + +内置客户端在消息调用前使用 Agent Card 发现(`GET /.well-known/agent-card.json`),选择第一个已公布且可运行的 `supportedInterfaces[].url`,然后使用 `A2A-Version: 1.0` 和 `SendMessage` 等 A2A 1.0 方法名发送 JSON-RPC 请求。 + +`push-notifications: true` 启用 A2A 推送通知配置方法和终态投递。 + +Agent Card 签名使用 A2A SDK 签名工具,并发出标准 `AgentCardSignature` JWS 字段。对称密钥模式使用 `HS256`;验证可以通过 protected-header `kid` 选择已配置 secret、本地 octet-key JWKS 或远程 JWKS URL。Phase 1 未实现服务器端非对称签名和自动密钥轮换。 + +Phase 1 不支持行为的规范列表请参阅 [A2A 协议](./overview.md#phase-1-unsupported)。 + +## 推送通知投递后端 + +`iac-code a2a --config a2a-server.yml` 支持两种推送投递队列: + +- `push-queue: local-file` 将 jobs 存储在 A2A 持久化目录下方,适用于本地单节点使用。 +- `push-queue: redis-streams` 将 jobs 存储在 Redis Streams 中,并通过 Redis consumer group 协调 workers。 + +Redis 后端推送投递需要可选的 `a2a-redis` extra,并且是至少一次投递。Callback 接收方应以幂等方式处理任务更新,因为 worker 崩溃、lease 过期、重连或重试竞争后,一个 job 可能会再次投递。 + +常用 Redis 选项: + +```yaml +push-notifications: true +push-queue: redis-streams +push-redis-url: redis://localhost:6379/0 +push-stream: iac-code:a2a:push +push-retry-key: iac-code:a2a:push:retry +push-dead-stream: iac-code:a2a:push:dead +push-consumer-group: iac-code-push +push-consumer-name: worker-1 +push-lease-timeout-ms: 300000 +``` + +Callback URLs 会在存储前以及分发前再次校验。默认 validator 会拒绝非 HTTP(S) URLs、localhost hostnames 和字面量 private/local IP addresses。Callback 接收方仍应执行自己的认证和幂等策略。 + +## JSON-RPC 方法 + +### SendMessage + +运行非流式 A2A 消息轮次。响应会在轮次完成后包含一个 task 或 message。 + +**请求** + +```json +{ + "jsonrpc": "2.0", + "id": "1", + "method": "SendMessage", + "params": { + "message": { + "messageId": "msg-1", + "role": "ROLE_USER", + "parts": [{"text": "Create a VPC with two vSwitches."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +**必需消息字段** + +| 字段 | 类型 | 必需 | 描述 | +|-------|------|----------|-------------| +| `messageId` | string | 是 | 唯一客户端消息 ID | +| `role` | string | 是 | 对用户输入使用 `ROLE_USER` | +| `parts` | array | 是 | 类文本、JSON 数据、原始文本、本地 file URL 或有界多模态 parts | +| `metadata.iac_code.cwd` | string | 建议 | 绝对工作区路径;省略时默认为服务器进程目录 | + +提供 `metadata.iac_code.cwd` 时,它必须是一个已存在的绝对目录。它必须位于允许的工作区根目录内。默认情况下,允许的根目录是服务器进程目录和系统临时目录;`IACCODE_A2A_ALLOWED_CWDS` 可以提供按 OS path separator 分隔的 allowlist。 + +支持的输入类别: + +| 类别 | 接受的形状 | 限制和行为 | +|----------|----------------|---------------------| +| 类文本 parts | 带 `text/plain`、JSON、Markdown、YAML 或已配置额外 text MIME types 的 `text` | 直接追加到 prompt | +| JSON 数据 parts | 带 `application/json` 的 `data` | 序列化为紧凑 JSON;inline 最大 1 MiB | +| 原始文本 parts | 带 text-like MIME type 的 `raw` | 必须是有效 UTF-8;inline 最大 1 MiB | +| 本地文本 file URLs | 带 `file://...` 和 text-like MIME type 的 `url` | 文件必须存在于 `cwd` 和允许的根目录内;最大 1 MiB | +| 多模态 raw/data/file parts | image、audio 或已配置 multimodal MIME types | 转换为包含 filename、media type、byte size、hash 和 source 的 prompt manifest;raw/data 最大 5 MiB,file URL 最大 25 MiB | + +不支持远程 HTTP(S) URL 摄取。File URL parts 必须使用本地 `file://` URLs,并留在允许的工作区内。 + +### SendStreamingMessage + +运行流式 A2A 消息轮次。请求体形状与 `SendMessage` 相同,但服务器会以 Server-Sent Events 形式流式传输 JSON-RPC 响应。 + +```json +{ + "jsonrpc": "2.0", + "id": "2", + "method": "SendStreamingMessage", + "params": { + "message": { + "messageId": "msg-2", + "role": "ROLE_USER", + "parts": [{"text": "Review this ROS template."}], + "metadata": { + "iac_code": {"cwd": "/absolute/path/to/project"} + } + }, + "configuration": { + "acceptedOutputModes": ["text/plain"] + } + } +} +``` + +### GetTask + +按 ID 返回已保存的 A2A task。使用 `historyLength` 可以限制返回的历史记录,且不会改变已存储的任务历史。省略它则接收服务器当前默认历史。 + +```json +{ + "jsonrpc": "2.0", + "id": "3", + "method": "GetTask", + "params": { + "id": "task-id", + "historyLength": 10 + } +} +``` + +### ListTasks + +返回认证调用方可见的已知 tasks。结果按 status timestamp 降序排序,然后按 task ID 降序排序以实现稳定顺序。服务器支持 `contextId`、`status`、`pageSize`、`pageToken`、`historyLength` 和 `includeArtifacts`。 + +```json +{ + "jsonrpc": "2.0", + "id": "4", + "method": "ListTasks", + "params": { + "contextId": "ctx-id", + "status": "TASK_STATE_WORKING", + "pageSize": 20, + "includeArtifacts": false + } +} +``` + +当还有下一页可用时会返回 `nextPageToken`。`includeArtifacts` 默认为 `false`,因此 list 响应会省略 task artifacts,除非显式请求。 + +### CancelTask + +请求取消一个运行中的 task。 + +```json +{ + "jsonrpc": "2.0", + "id": "5", + "method": "CancelTask", + "params": { + "id": "task-id" + } +} +``` + +如果 task 是活动的,服务器会取消正在运行的 agent 轮次并发出 canceled task state。如果 task 存在但未运行,服务器会返回标准 A2A `TaskNotCancelableError`。 + +### SubscribeToTask + +当客户端传输支持时,订阅活动 task 更新流。 + +```json +{ + "jsonrpc": "2.0", + "id": "6", + "method": "SubscribeToTask", + "params": { + "id": "task-id" + } +} +``` + +对于活动 tasks,stream 会从当前 `Task` 开始,然后发出后续 task events,并在活动轮次完成时关闭。订阅 completed、failed、canceled 或 input-required task 会返回 task-not-found 风格错误,而不是无限等待。对于新轮次,优先使用 `SendStreamingMessage`;它会在一个请求中启动执行并流式传输响应。 + +### 推送通知配置方法 + +当服务器以 `push-notifications: true` 启动时,它支持: + +| 方法 | 用途 | +|--------|---------| +| `CreateTaskPushNotificationConfig` | 为 task 存储 callback config | +| `GetTaskPushNotificationConfig` | 获取一个 callback config | +| `ListTaskPushNotificationConfigs` | 列出 task 的 callback configs | +| `DeleteTaskPushNotificationConfig` | 删除一个 callback config | + +创建请求示例: + +```json +{ + "jsonrpc": "2.0", + "id": "7", + "method": "CreateTaskPushNotificationConfig", + "params": { + "taskId": "task-id", + "id": "webhook-1", + "url": "https://hooks.example.com/a2a", + "token": "notification-token", + "authentication": { + "scheme": "bearer", + "credentials": "callback-token" + } + } +} +``` + +当本地 push keyring 可用时,服务器会加密已存储的 notification tokens 和 callback authentication credentials。 + +### GetExtendedAgentCard + +已认证客户端可以请求扩展 Agent Card: + +```json +{ + "jsonrpc": "2.0", + "id": "8", + "method": "GetExtendedAgentCard", + "params": {} +} +``` + +扩展 card 包含 public card 以及已认证运行时细节。 + +## Task 和 Context 行为 + +iac-code 将 A2A contexts 映射到内部 agent runtimes: + +| 概念 | 行为 | +|---------|----------| +| 省略 `contextId` | SDK/server 生成新的 context ID | +| 相同 `contextId` | 复用同一内部 iac-code session 和 conversation state | +| 相同 `contextId`,不同 `cwd` | 作为不同工作区被拒绝 | +| 相同 `contextId`,并发消息 | 以 `Task is already working.` 拒绝 | +| 不同 `contextId` 值 | 可以并发执行 | +| 空闲 context | 在配置的 idle timeout 后从内存中逐出 | + +Task 和 context IDs 必须非空,最多 128 个字符,并且只能包含字母、数字、`_`、`.`、`:` 或 `-`。 + +## Task 状态 + +| 状态 | 含义 | +|-------|---------| +| `TASK_STATE_SUBMITTED` | task 已被接受 | +| `TASK_STATE_WORKING` | iac-code 正在运行 agent 轮次 | +| `TASK_STATE_INPUT_REQUIRED` | 轮次已完成,agent 已准备好接收后续输入 | +| `TASK_STATE_CANCELED` | 已请求并应用取消 | +| `TASK_STATE_FAILED` | task 验证或执行失败 | + +iac-code 使用 `TASK_STATE_INPUT_REQUIRED` 作为正常完成状态,因为 context 仍可用于后续消息。 + +## 流式更新 + +执行期间,iac-code 会发出 `TaskStatusUpdateEvent` 更新。 + +Assistant text 作为 status message 投递: + +```json +{ + "statusUpdate": { + "taskId": "task-1", + "contextId": "ctx-1", + "status": { + "state": "TASK_STATE_WORKING", + "message": { + "role": "ROLE_AGENT", + "parts": [{"text": "Here is the ROS template..."}] + } + } + } +} +``` + +工具和用量细节通过 `metadata.iac_code` 投递: + +| 元数据路径 | 描述 | +|---------------|-------------| +| `iac_code.tool.status` | `started`、`input_delta`、`input_complete`、`completed` 或 `failed` | +| `iac_code.tool.toolUseId` | 用于关联工具事件的稳定 tool-use ID | +| `iac_code.tool.name` | 可用时的工具名称 | +| `iac_code.tool.input` | 已完成工具输入,每个字段截断为 4000 个字符 | +| `iac_code.tool.result` | 工具结果,每个字段截断为 4000 个字符 | +| `iac_code.permission.autoApproved` | A2A server 模式拒绝工具权限请求时为 `false` | +| `iac_code.usage.inputTokens` | 该轮次的 input token 数 | +| `iac_code.usage.outputTokens` | 该轮次的 output token 数 | +| `iac_code.usage.totalTokens` | 该轮次的 total token 数 | + +当工具结果包含受支持的文本 artifact payload 时,服务器会在本地存储该 payload,发出标准 `TaskArtifactUpdateEvent`,并在 task `artifacts` 字段中记录该 artifact。Artifact part 使用 `file://` URL 以及 `mediaType`、`byteSize` 和 `sha256` 等元数据;原始 artifact 内容不会在工具元数据中重复。 + +## 扩展 + +Agent Card 会公布可选的 iac-code artifact 元数据扩展: + +```text +urn:iac-code:a2a:artifact-metadata:v1 +``` + +该扩展标识 `metadata.iac_code` 命名空间,该命名空间用于工具进度、权限决策、token 用量和本地 artifact 元数据。如果服务器配置了任何必需扩展,客户端必须在 `A2A-Extensions` header 中包含其 URI。缺少必需扩展会返回标准 A2A `ExtensionSupportRequiredError`。 + +## 错误处理 + +| 场景 | 结果 | +|----------|--------| +| 空文本输入 | `TASK_STATE_FAILED`,消息为 `A2A server currently accepts text input only.` | +| 不支持的 media type | 验证错误或标准 A2A content-type 错误,取决于 SDK 在何处拒绝请求 | +| 远程 URL part | 验证错误,因为 URL parts 必须使用本地 `file://` URLs | +| 允许工作区之外的 File URL | 验证错误 | +| 缺少必需的 A2A 扩展 | 标准 A2A `ExtensionSupportRequiredError` | +| 无效的工作区元数据 | `TASK_STATE_FAILED`,带 invalid workspace 消息 | +| 缺少认证或认证无效 | HTTP `401`,响应 `{"error":"Unauthorized"}` | +| 缺少 A2A server 依赖 | CLI 退出,并提示安装 `a2a` extra | +| 缺少 provider 凭据 | 已清理的认证错误 | +| 意外运行时错误 | 已清理的内部错误 | + +服务器会避免在意外错误消息中返回本地路径、secrets 和 provider 细节。 diff --git a/website/sidebars.ts b/website/sidebars.ts index ca5ceaa6..186f1781 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -46,6 +46,18 @@ const sidebars: SidebarsConfig = { 'acp/examples', ], }, + { + type: 'category', + label: 'A2A Protocol', + items: [ + 'a2a/overview', + 'a2a/getting-started', + 'a2a/command-reference', + 'a2a/protocol-reference', + 'a2a/http-transport', + 'a2a/examples', + ], + }, { type: 'category', label: 'Automation',