From 5154e2e53a52880a40c19187819256bf393aad26 Mon Sep 17 00:00:00 2001 From: a2a-platform Date: Sun, 14 Jun 2026 17:16:37 +0000 Subject: [PATCH] deploy --- .gitignore | 1 - README.md | 3 +- a2a.yaml | 2 +- agent.py | 158 +++++++++++++++++++++++++++++++++++++++-------------- 4 files changed, 118 insertions(+), 46 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 930cce6..0000000 --- a/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.codegraph/ diff --git a/README.md b/README.md index 837ebf4..bf661a9 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,9 @@ Generated A2APack agent for a2a cloud blog API. - Source OpenAPI: https://blog.a2acloud.io/openapi.json -- Refresh metadata is exposed through the agent capabilities card. - Generated operations: 6 - Main skill: `auto` -- Required setup: `BLOG_API_KEY` +- Routing mode: direct operation skills The generated code is intentionally editable. Tune prompts, operation grouping, auth names, and safety policy before publishing serious agents. diff --git a/a2a.yaml b/a2a.yaml index 2b3befb..a94aa1d 100644 --- a/a2a.yaml +++ b/a2a.yaml @@ -1,5 +1,5 @@ name: blog-openapi-agent -version: 0.1.1 +version: 0.1.0 entrypoint: agent:BlogOpenapiAgent description: Publishing and read API for the a2a cloud blog. Published content is public; draft, preview, create, update, and delete operations require the blog API diff --git a/agent.py b/agent.py index e99fd44..6ab05cd 100644 --- a/agent.py +++ b/agent.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 import json import re +from pathlib import Path from typing import Any import httpx @@ -26,45 +27,15 @@ from a2a_pack import ( DEFAULT_BASE_URL = "https://blog.a2acloud.io" -OPERATIONS = json.loads("{\n \"create_blog_post\": {\n \"description\": \"Create a blog post\",\n \"destructive\": true,\n \"method\": \"POST\",\n \"operation_id\": \"create_blog_post\",\n \"parameters\": [],\n \"path\": \"/api/posts\",\n \"request_body\": {\n \"$ref\": \"#/components/schemas/BlogPostInput\"\n },\n \"security\": [\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"create_blog_post\",\n \"summary\": \"Create a blog post\",\n \"tags\": [\n \"Posts\"\n ]\n },\n \"delete_blog_post\": {\n \"description\": \"Delete a post by slug\",\n \"destructive\": true,\n \"method\": \"DELETE\",\n \"operation_id\": \"delete_blog_post\",\n \"parameters\": [],\n \"path\": \"/api/posts/{slug}\",\n \"request_body\": null,\n \"security\": [\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"delete_blog_post\",\n \"summary\": \"Delete a post by slug\",\n \"tags\": [\n \"Posts\"\n ]\n },\n \"get_blog_health\": {\n \"description\": \"Check blog service health\",\n \"destructive\": false,\n \"method\": \"GET\",\n \"operation_id\": \"get_blog_health\",\n \"parameters\": [],\n \"path\": \"/api/healthz\",\n \"request_body\": null,\n \"security\": null,\n \"skill_name\": \"get_blog_health\",\n \"summary\": \"Check blog service health\",\n \"tags\": [\n \"Health\"\n ]\n },\n \"get_blog_post\": {\n \"description\": \"Returns a published post. Add `preview=1` to fetch drafts or future-dated posts; preview requests require either bearer auth or the `x-api-key` header.\",\n \"destructive\": false,\n \"method\": \"GET\",\n \"operation_id\": \"get_blog_post\",\n \"parameters\": [\n {\n \"description\": \"Set to `1` to bypass the public published-post filter. Requires authentication.\",\n \"in\": \"query\",\n \"name\": \"preview\",\n \"required\": false,\n \"schema\": {\n \"enum\": [\n \"1\"\n ],\n \"type\": \"string\"\n }\n }\n ],\n \"path\": \"/api/posts/{slug}\",\n \"request_body\": null,\n \"security\": [\n {},\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"get_blog_post\",\n \"summary\": \"Get a post by slug\",\n \"tags\": [\n \"Posts\"\n ]\n },\n \"list_blog_posts\": {\n \"description\": \"Returns published posts by default. `status=all` and `status=draft` include draft content and require either bearer auth or the `x-api-key` header.\",\n \"destructive\": false,\n \"method\": \"GET\",\n \"operation_id\": \"list_blog_posts\",\n \"parameters\": [\n {\n \"description\": \"Use `published` or omit the parameter for public published posts. `all` and `draft` require authentication.\",\n \"in\": \"query\",\n \"name\": \"status\",\n \"required\": false,\n \"schema\": {\n \"default\": \"published\",\n \"enum\": [\n \"published\",\n \"draft\",\n \"all\"\n ],\n \"type\": \"string\"\n }\n }\n ],\n \"path\": \"/api/posts\",\n \"request_body\": null,\n \"security\": [\n {},\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"list_blog_posts\",\n \"summary\": \"List posts\",\n \"tags\": [\n \"Posts\"\n ]\n },\n \"upsert_blog_post\": {\n \"description\": \"Upsert a post by slug\",\n \"destructive\": true,\n \"method\": \"PUT\",\n \"operation_id\": \"upsert_blog_post\",\n \"parameters\": [],\n \"path\": \"/api/posts/{slug}\",\n \"request_body\": {\n \"$ref\": \"#/components/schemas/BlogPostInput\"\n },\n \"security\": [\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"upsert_blog_post\",\n \"summary\": \"Upsert a post by slug\",\n \"tags\": [\n \"Posts\"\n ]\n }\n}") +OPERATIONS = json.loads("{\n \"create_blog_post\": {\n \"base_url\": \"https://blog.a2acloud.io\",\n \"base_url_field\": \"OPENAPI_BASE_URL\",\n \"description\": \"Create a blog post\",\n \"destructive\": true,\n \"method\": \"POST\",\n \"operation_id\": \"create_blog_post\",\n \"parameters\": [],\n \"path\": \"/api/posts\",\n \"request_body\": {\n \"$ref\": \"#/components/schemas/BlogPostInput\"\n },\n \"security\": [\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"create_blog_post\",\n \"source_index\": 1,\n \"source_openapi_url\": \"https://blog.a2acloud.io/openapi.json\",\n \"source_title\": \"a2a cloud blog API\",\n \"summary\": \"Create a blog post\",\n \"tags\": [\n \"Posts\"\n ]\n },\n \"delete_blog_post\": {\n \"base_url\": \"https://blog.a2acloud.io\",\n \"base_url_field\": \"OPENAPI_BASE_URL\",\n \"description\": \"Delete a post by slug\",\n \"destructive\": true,\n \"method\": \"DELETE\",\n \"operation_id\": \"delete_blog_post\",\n \"parameters\": [\n {\n \"description\": \"Post slug. The API normalizes path slugs to lowercase kebab-case before lookup.\",\n \"in\": \"path\",\n \"name\": \"slug\",\n \"required\": true,\n \"schema\": {\n \"example\": \"shipping-agents-with-receipts\",\n \"maxLength\": 120,\n \"minLength\": 1,\n \"type\": \"string\"\n }\n }\n ],\n \"path\": \"/api/posts/{slug}\",\n \"request_body\": null,\n \"security\": [\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"delete_blog_post\",\n \"source_index\": 1,\n \"source_openapi_url\": \"https://blog.a2acloud.io/openapi.json\",\n \"source_title\": \"a2a cloud blog API\",\n \"summary\": \"Delete a post by slug\",\n \"tags\": [\n \"Posts\"\n ]\n },\n \"get_blog_health\": {\n \"base_url\": \"https://blog.a2acloud.io\",\n \"base_url_field\": \"OPENAPI_BASE_URL\",\n \"description\": \"Check blog service health\",\n \"destructive\": false,\n \"method\": \"GET\",\n \"operation_id\": \"get_blog_health\",\n \"parameters\": [],\n \"path\": \"/api/healthz\",\n \"request_body\": null,\n \"security\": null,\n \"skill_name\": \"get_blog_health\",\n \"source_index\": 1,\n \"source_openapi_url\": \"https://blog.a2acloud.io/openapi.json\",\n \"source_title\": \"a2a cloud blog API\",\n \"summary\": \"Check blog service health\",\n \"tags\": [\n \"Health\"\n ]\n },\n \"get_blog_post\": {\n \"base_url\": \"https://blog.a2acloud.io\",\n \"base_url_field\": \"OPENAPI_BASE_URL\",\n \"description\": \"Returns a published post. Add `preview=1` to fetch drafts or future-dated posts; preview requests require either bearer auth or the `x-api-key` header.\",\n \"destructive\": false,\n \"method\": \"GET\",\n \"operation_id\": \"get_blog_post\",\n \"parameters\": [\n {\n \"description\": \"Post slug. The API normalizes path slugs to lowercase kebab-case before lookup.\",\n \"in\": \"path\",\n \"name\": \"slug\",\n \"required\": true,\n \"schema\": {\n \"example\": \"shipping-agents-with-receipts\",\n \"maxLength\": 120,\n \"minLength\": 1,\n \"type\": \"string\"\n }\n },\n {\n \"description\": \"Set to `1` to bypass the public published-post filter. Requires authentication.\",\n \"in\": \"query\",\n \"name\": \"preview\",\n \"required\": false,\n \"schema\": {\n \"enum\": [\n \"1\"\n ],\n \"type\": \"string\"\n }\n }\n ],\n \"path\": \"/api/posts/{slug}\",\n \"request_body\": null,\n \"security\": [\n {},\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"get_blog_post\",\n \"source_index\": 1,\n \"source_openapi_url\": \"https://blog.a2acloud.io/openapi.json\",\n \"source_title\": \"a2a cloud blog API\",\n \"summary\": \"Get a post by slug\",\n \"tags\": [\n \"Posts\"\n ]\n },\n \"list_blog_posts\": {\n \"base_url\": \"https://blog.a2acloud.io\",\n \"base_url_field\": \"OPENAPI_BASE_URL\",\n \"description\": \"Returns published posts by default. `status=all` and `status=draft` include draft content and require either bearer auth or the `x-api-key` header.\",\n \"destructive\": false,\n \"method\": \"GET\",\n \"operation_id\": \"list_blog_posts\",\n \"parameters\": [\n {\n \"description\": \"Use `published` or omit the parameter for public published posts. `all` and `draft` require authentication.\",\n \"in\": \"query\",\n \"name\": \"status\",\n \"required\": false,\n \"schema\": {\n \"default\": \"published\",\n \"enum\": [\n \"published\",\n \"draft\",\n \"all\"\n ],\n \"type\": \"string\"\n }\n }\n ],\n \"path\": \"/api/posts\",\n \"request_body\": null,\n \"security\": [\n {},\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"list_blog_posts\",\n \"source_index\": 1,\n \"source_openapi_url\": \"https://blog.a2acloud.io/openapi.json\",\n \"source_title\": \"a2a cloud blog API\",\n \"summary\": \"List posts\",\n \"tags\": [\n \"Posts\"\n ]\n },\n \"upsert_blog_post\": {\n \"base_url\": \"https://blog.a2acloud.io\",\n \"base_url_field\": \"OPENAPI_BASE_URL\",\n \"description\": \"Upsert a post by slug\",\n \"destructive\": true,\n \"method\": \"PUT\",\n \"operation_id\": \"upsert_blog_post\",\n \"parameters\": [\n {\n \"description\": \"Post slug. The API normalizes path slugs to lowercase kebab-case before lookup.\",\n \"in\": \"path\",\n \"name\": \"slug\",\n \"required\": true,\n \"schema\": {\n \"example\": \"shipping-agents-with-receipts\",\n \"maxLength\": 120,\n \"minLength\": 1,\n \"type\": \"string\"\n }\n }\n ],\n \"path\": \"/api/posts/{slug}\",\n \"request_body\": {\n \"$ref\": \"#/components/schemas/BlogPostInput\"\n },\n \"security\": [\n {\n \"bearerAuth\": []\n },\n {\n \"apiKeyAuth\": []\n }\n ],\n \"skill_name\": \"upsert_blog_post\",\n \"source_index\": 1,\n \"source_openapi_url\": \"https://blog.a2acloud.io/openapi.json\",\n \"source_title\": \"a2a cloud blog API\",\n \"summary\": \"Upsert a post by slug\",\n \"tags\": [\n \"Posts\"\n ]\n }\n}") +OPERATION_GROUPS = json.loads("[]") ROOT_SECURITY = json.loads("[]") SECURITY_SCHEMES = json.loads("{\n \"apiKeyAuth\": {\n \"description\": \"Use the configured `BLOG_API_KEY` value.\",\n \"in\": \"header\",\n \"name\": \"x-api-key\",\n \"type\": \"apiKey\"\n },\n \"bearerAuth\": {\n \"description\": \"Use `Authorization: Bearer $BLOG_API_KEY`.\",\n \"scheme\": \"bearer\",\n \"type\": \"http\"\n }\n}") -SECURITY_FIELDS = json.loads("{\n \"apiKeyAuth\": {\n \"field\": \"BLOG_API_KEY\",\n \"kind\": \"apiKey\",\n \"location\": \"header\",\n \"name\": \"x-api-key\"\n },\n \"bearerAuth\": {\n \"field\": \"BLOG_API_KEY\",\n \"kind\": \"http\",\n \"scheme\": \"bearer\"\n }\n}") +SECURITY_FIELDS = json.loads("{\n \"apiKeyAuth\": {\n \"field\": \"X_API_KEY\",\n \"kind\": \"apiKey\",\n \"location\": \"header\",\n \"name\": \"x-api-key\"\n },\n \"bearerAuth\": {\n \"field\": \"BEARERAUTH_TOKEN\",\n \"kind\": \"http\",\n \"scheme\": \"bearer\"\n }\n}") PATH_PARAMETER_RE = re.compile(r"{([^}/]+)}") - - -def _ensure_path_parameters() -> None: - for operation in OPERATIONS.values(): - path = str(operation.get("path") or "") - parameters = operation.get("parameters") - if not isinstance(parameters, list): - parameters = [] - operation["parameters"] = parameters - seen = { - (param.get("name"), param.get("in")) - for param in parameters - if isinstance(param, dict) - } - insert_at = 0 - for name in PATH_PARAMETER_RE.findall(path): - key = (name, "path") - if key in seen: - continue - parameters.insert( - insert_at, - { - "name": name, - "in": "path", - "required": True, - "description": f"Path parameter `{name}`.", - "schema": {"type": "string"}, - }, - ) - insert_at += 1 - seen.add(key) - - -_ensure_path_parameters() +SOURCE_ROOT = Path(globals().get("__file__", "agent.py")).resolve().parent +SOURCE_SKILLS_DIR = SOURCE_ROOT / "skills" +RUNTIME_SKILLS_DIR = ".deepagents/openapi-skills/" class OperationInput(BaseModel): @@ -81,7 +52,8 @@ class BlogOpenapiAgent(A2AAgent): version = "0.1.0" consumer_setup = ConsumerSetup.from_fields( ConsumerSetupField.config("OPENAPI_BASE_URL", label="API base URL", description="Override the default API server (https://blog.a2acloud.io).", required=False, input_type="url"), - ConsumerSetupField.secret("BLOG_API_KEY", label="Blog API key", description="Used as either `Authorization: Bearer $BLOG_API_KEY` or the `x-api-key` header.", required=True), + ConsumerSetupField.secret("BEARERAUTH_TOKEN", label="bearerAuth bearer credential", description="Use `Authorization: Bearer $BLOG_API_KEY`.", required=True), + ConsumerSetupField.secret("X_API_KEY", label="apiKeyAuth API key", description="Use the configured `BLOG_API_KEY` value.", required=True), ) llm_provisioning = LLMProvisioning.PLATFORM pricing = Pricing( @@ -99,7 +71,9 @@ class BlogOpenapiAgent(A2AAgent): "openapi_auto_agent": { "operation_count": len(OPERATIONS), "default_base_url": DEFAULT_BASE_URL, - "source_openapi_url": "https://blog.a2acloud.io/openapi.json", + "source_openapi_url": 'https://blog.a2acloud.io/openapi.json', + "source_openapi_urls": ['https://blog.a2acloud.io/openapi.json'], + "server_urls": ['https://blog.a2acloud.io'], "regenerable": True, "security_schemes": list(SECURITY_SCHEMES), } @@ -250,9 +224,34 @@ class BlogOpenapiAgent(A2AAgent): body=body, ) - def _operation_tools(self, ctx: RunContext) -> list[StructuredTool]: + def _operation_subagents(self, ctx: RunContext, skills_root: str) -> list[dict[str, Any]]: + subagents: list[dict[str, Any]] = [] + for group in OPERATION_GROUPS: + skill_path = ( + f"{skills_root}{group['skill_dir']}/" + if skills_root + else "" + ) + subagent = { + "name": group["subagent_name"], + "description": group["description"], + "system_prompt": self._subagent_prompt(group), + "tools": self._operation_tools(ctx, set(group["operation_ids"])), + } + if skill_path: + subagent["skills"] = [skill_path] + subagents.append(subagent) + return subagents + + def _operation_tools( + self, + ctx: RunContext, + operation_ids: set[str] | None = None, + ) -> list[StructuredTool]: tools: list[StructuredTool] = [] for operation_id, operation in OPERATIONS.items(): + if operation_ids is not None and operation_id not in operation_ids: + continue async def call( parameters: dict[str, Any] | None = None, body: Any | None = None, @@ -277,6 +276,21 @@ class BlogOpenapiAgent(A2AAgent): return tools def _system_prompt(self) -> str: + if OPERATION_GROUPS: + lines = [ + "You coordinate route-specific OpenAPI subagents.", + "Delegate API work to the matching subagent with the task tool.", + "Do not invent API responses; ask subagents to call their tools for real results.", + "If a subagent reports missing consumer setup, tell the user which setup field is required.", + "If a subagent reports that the generated OpenAPI agent may be stale, ask whether the user wants to refresh it from the latest OpenAPI spec.", + "", + "Available route groups:", + ] + for group in OPERATION_GROUPS: + lines.append( + f"- {group['subagent_name']}: {group['description']}" + ) + return "\n".join(lines) lines = [ "You operate an API through generated OpenAPI tools.", "Call tools to get real results. Do not invent API responses.", @@ -293,6 +307,27 @@ class BlogOpenapiAgent(A2AAgent): ) return "\n".join(lines) + def _subagent_prompt(self, group: dict[str, Any]) -> str: + lines = [ + "You operate one route group from a generated OpenAPI API.", + "Use the available operation tools to make real API calls. Do not invent API responses.", + "Read the route group's SKILL.md when it applies; it lists every route you cover.", + "For write, update, or delete operations, explain the intended action before calling the tool.", + "If an operation reports missing consumer setup, return the exact setup field name.", + "If an operation returns 404, 405, 410, or a schema/validation error that suggests the live API no longer matches these tools, say the generated agent may need to be refreshed from the latest OpenAPI spec.", + "", + f"Route group: {group['label']}", + f"Common prefix: {group.get('common_prefix') or '/'}", + "Available operation tools:", + ] + for operation_id in group["operation_ids"]: + operation = OPERATIONS[operation_id] + marker = "WRITE" if operation.get("destructive") else "READ" + lines.append( + f"- {operation['skill_name']}: {marker} {operation['method']} {operation['path']} - {operation['summary']}" + ) + return "\n".join(lines) + def _tool_description(self, operation: dict[str, Any]) -> str: parts = [ f"{operation['method']} {operation['path']}", @@ -319,13 +354,24 @@ class BlogOpenapiAgent(A2AAgent): body: Any | None, ) -> dict[str, Any]: operation = OPERATIONS[operation_id] - base_url = str(ctx.consumer_config("OPENAPI_BASE_URL", DEFAULT_BASE_URL) or DEFAULT_BASE_URL).rstrip("/") + default_base_url = str(operation.get("base_url") or DEFAULT_BASE_URL).rstrip("/") + base_url_field = str(operation.get("base_url_field") or "OPENAPI_BASE_URL") + base_url = str(ctx.consumer_config(base_url_field, default_base_url) or default_base_url).rstrip("/") url, query, headers = self._request_parts(ctx, operation, parameters) request_kwargs: dict[str, Any] = { "params": query, "headers": headers, } if body is not None: + if isinstance(body, (bytes, bytearray)): + body = body.decode("utf-8", errors="replace") + if isinstance(body, str): + stripped = body.strip() + if stripped and stripped[0] in "{[": + try: + body = json.loads(stripped) + except ValueError: + pass request_kwargs["json"] = body async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: response = await client.request( @@ -419,10 +465,12 @@ class BlogOpenapiAgent(A2AAgent): continue kind = mapping.get("kind") if kind == "apiKey": + prefix = mapping.get("prefix") or "" + sent_value = f"{prefix}{value}" if mapping.get("location") == "query": - query[mapping.get("name") or scheme_name] = value + query[mapping.get("name") or scheme_name] = sent_value else: - headers[mapping.get("name") or scheme_name] = value + headers[mapping.get("name") or scheme_name] = sent_value elif kind in {"http", "oauth2"}: scheme = mapping.get("scheme") or "Bearer" if scheme.lower() == "basic": @@ -451,6 +499,32 @@ def _consumer_secret_optional(ctx: RunContext, name: str | None) -> str: return "" +def _runtime_skills_root(ctx: RunContext) -> str: + workspace = getattr(ctx, "_workspace", None) + prefixes = tuple(getattr(workspace, "write_prefixes", ()) or ()) + if not prefixes: + outputs_prefix = getattr(workspace, "outputs_prefix", None) + prefixes = (outputs_prefix or "outputs/",) + prefix = str(prefixes[0]).strip("/") + return f"/{prefix}/{RUNTIME_SKILLS_DIR}" if prefix else f"/{RUNTIME_SKILLS_DIR}" + + +def _seed_runtime_skills(backend: Any, ctx: RunContext) -> str: + if not SOURCE_SKILLS_DIR.exists(): + return "" + runtime_root = _runtime_skills_root(ctx) + uploads: list[tuple[str, bytes]] = [] + for path in SOURCE_SKILLS_DIR.rglob("*"): + if not path.is_file(): + continue + rel = path.relative_to(SOURCE_SKILLS_DIR).as_posix() + uploads.append((runtime_root + rel, path.read_bytes())) + if not uploads: + return "" + backend.upload_files(uploads) + return runtime_root + + def _message_text(message: Any) -> Any: content = getattr(message, "content", message) return content