From 4e43fbe8e2fcbc1f2546e023a572209299536168 Mon Sep 17 00:00:00 2001 From: a2a-platform Date: Wed, 8 Jul 2026 12:05:26 +0000 Subject: [PATCH] deploy --- README.md | 2 +- a2a.yaml | 6 +- agent.py | 174 +++++++++++++++++++++++- openapi.json | 373 ++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 543 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index bf661a9..5196b92 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Generated A2APack agent for a2a cloud blog API. - Source OpenAPI: https://blog.a2acloud.io/openapi.json -- Generated operations: 6 +- Generated operations: 10 - Main skill: `auto` - Routing mode: direct operation skills diff --git a/a2a.yaml b/a2a.yaml index a94aa1d..27c1a37 100644 --- a/a2a.yaml +++ b/a2a.yaml @@ -1,9 +1,9 @@ name: blog-openapi-agent 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 - key. +description: Publishing, read, comment, and reaction API for the a2a cloud blog. Published + content and comments are public; publishing requires the blog API key; comment and + reaction writes require an a2a Cloud browser session. runtime: resources: cpu: 200m diff --git a/agent.py b/agent.py index 6ab05cd..c06cc88 100644 --- a/agent.py +++ b/agent.py @@ -27,11 +27,11 @@ from a2a_pack import ( DEFAULT_BASE_URL = "https://blog.a2acloud.io" -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}") +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 \"create_blog_post_comment\": {\n \"base_url\": \"https://blog.a2acloud.io\",\n \"base_url_field\": \"OPENAPI_BASE_URL\",\n \"description\": \"Requires the shared a2a Cloud browser session cookie from the web login flow.\",\n \"destructive\": true,\n \"method\": \"POST\",\n \"operation_id\": \"create_blog_post_comment\",\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}/comments\",\n \"request_body\": {\n \"$ref\": \"#/components/schemas/BlogCommentInput\"\n },\n \"security\": [\n {\n \"a2aSession\": []\n }\n ],\n \"skill_name\": \"create_blog_post_comment\",\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 comment\",\n \"tags\": [\n \"Comments\"\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 \"get_blog_post_reactions\": {\n \"base_url\": \"https://blog.a2acloud.io\",\n \"base_url_field\": \"OPENAPI_BASE_URL\",\n \"description\": \"Returns public like/dislike counts. If an a2a Cloud session cookie is present, `viewer_reaction` is populated.\",\n \"destructive\": false,\n \"method\": \"GET\",\n \"operation_id\": \"get_blog_post_reactions\",\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}/reaction\",\n \"request_body\": null,\n \"security\": [\n {},\n {\n \"a2aSession\": []\n }\n ],\n \"skill_name\": \"get_blog_post_reactions\",\n \"source_index\": 1,\n \"source_openapi_url\": \"https://blog.a2acloud.io/openapi.json\",\n \"source_title\": \"a2a cloud blog API\",\n \"summary\": \"Get post reaction counts\",\n \"tags\": [\n \"Reactions\"\n ]\n },\n \"list_blog_post_comments\": {\n \"base_url\": \"https://blog.a2acloud.io\",\n \"base_url_field\": \"OPENAPI_BASE_URL\",\n \"description\": \"List comments for a post\",\n \"destructive\": false,\n \"method\": \"GET\",\n \"operation_id\": \"list_blog_post_comments\",\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}/comments\",\n \"request_body\": null,\n \"security\": null,\n \"skill_name\": \"list_blog_post_comments\",\n \"source_index\": 1,\n \"source_openapi_url\": \"https://blog.a2acloud.io/openapi.json\",\n \"source_title\": \"a2a cloud blog API\",\n \"summary\": \"List comments for a post\",\n \"tags\": [\n \"Comments\"\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 \"set_blog_post_reaction\": {\n \"base_url\": \"https://blog.a2acloud.io\",\n \"base_url_field\": \"OPENAPI_BASE_URL\",\n \"description\": \"Creates, updates, or clears the authenticated viewer's like/dislike reaction.\",\n \"destructive\": true,\n \"method\": \"PUT\",\n \"operation_id\": \"set_blog_post_reaction\",\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}/reaction\",\n \"request_body\": {\n \"$ref\": \"#/components/schemas/BlogReactionInput\"\n },\n \"security\": [\n {\n \"a2aSession\": []\n }\n ],\n \"skill_name\": \"set_blog_post_reaction\",\n \"source_index\": 1,\n \"source_openapi_url\": \"https://blog.a2acloud.io/openapi.json\",\n \"source_title\": \"a2a cloud blog API\",\n \"summary\": \"Set the viewer reaction\",\n \"tags\": [\n \"Reactions\"\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\": \"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}") +SECURITY_SCHEMES = json.loads("{\n \"a2aSession\": {\n \"description\": \"Shared a2a Cloud browser session cookie.\",\n \"in\": \"cookie\",\n \"name\": \"a2a_session\",\n \"type\": \"apiKey\"\n },\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 \"a2aSession\": {\n \"field\": \"A2A_SESSION\",\n \"kind\": \"apiKey\",\n \"location\": \"cookie\",\n \"name\": \"a2a_session\"\n },\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}") PATH_PARAMETER_RE = re.compile(r"{([^}/]+)}") SOURCE_ROOT = Path(globals().get("__file__", "agent.py")).resolve().parent SOURCE_SKILLS_DIR = SOURCE_ROOT / "skills" @@ -48,12 +48,12 @@ class OperationInput(BaseModel): class BlogOpenapiAgent(A2AAgent): name = "blog-openapi-agent" - 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 key." + description = "Publishing, read, comment, and reaction API for the a2a cloud blog. Published content and comments are public; publishing requires the blog API key; comment and reaction writes require an a2a Cloud browser session." 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("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), + ConsumerSetupField.secret("BLOG_API_KEY", label="bearerAuth bearer credential", description="Use `Authorization: Bearer $BLOG_API_KEY`. Use the configured `BLOG_API_KEY` value.", required=True), + ConsumerSetupField.secret("A2A_SESSION", label="a2aSession API key", description="Shared a2a Cloud browser session cookie.", required=True), ) llm_provisioning = LLMProvisioning.PLATFORM pricing = Pricing( @@ -83,9 +83,12 @@ class BlogOpenapiAgent(A2AAgent): name="auto", description="Use the OpenAPI service to complete a natural-language goal.", tags=("openapi", "auto"), - timeout_seconds=180, + timeout_seconds=900, ) async def auto(self, ctx: RunContext, goal: str) -> dict[str, Any]: + smoke_result = await self._maybe_smoke_test(ctx, goal) + if smoke_result is not None: + return smoke_result creds = ctx.llm if not creds.api_key: return { @@ -115,6 +118,7 @@ class BlogOpenapiAgent(A2AAgent): description="GET /api/healthz - Check blog service health", tags=('Health',), timeout_seconds=60, + input_schema={'type': 'object', 'properties': {}, 'additionalProperties': False}, ) async def get_blog_health( self, @@ -134,6 +138,7 @@ class BlogOpenapiAgent(A2AAgent): description="GET /api/posts - List posts", tags=('Posts',), timeout_seconds=60, + input_schema={'type': 'object', 'properties': {'parameters': {'type': 'object', 'properties': {'status': {'type': 'string', 'enum': ['published', 'draft', 'all'], 'default': 'published', 'description': 'Use `published` or omit the parameter for public published posts. `all` and `draft` require authentication.'}}, 'required': [], 'additionalProperties': False}}, 'required': [], 'additionalProperties': False}, ) async def list_blog_posts( self, @@ -153,6 +158,7 @@ class BlogOpenapiAgent(A2AAgent): description="POST /api/posts - Create a blog post", tags=('Posts',), timeout_seconds=120, + input_schema={'type': 'object', 'properties': {'body': {'$ref': '#/components/schemas/BlogPostInput', 'description': 'JSON request body.'}}, 'required': ['body'], 'additionalProperties': False}, ) async def create_blog_post( self, @@ -172,6 +178,7 @@ class BlogOpenapiAgent(A2AAgent): description="GET /api/posts/{slug} - Get a post by slug", tags=('Posts',), timeout_seconds=60, + input_schema={'type': 'object', 'properties': {'parameters': {'type': 'object', 'properties': {'slug': {'type': 'string', 'minLength': 1, 'maxLength': 120, 'example': 'shipping-agents-with-receipts', 'description': 'Post slug. The API normalizes path slugs to lowercase kebab-case before lookup.'}, 'preview': {'type': 'string', 'enum': ['1'], 'description': 'Set to `1` to bypass the public published-post filter. Requires authentication.'}}, 'required': ['slug'], 'additionalProperties': False}}, 'required': ['parameters'], 'additionalProperties': False}, ) async def get_blog_post( self, @@ -191,6 +198,7 @@ class BlogOpenapiAgent(A2AAgent): description="PUT /api/posts/{slug} - Upsert a post by slug", tags=('Posts',), timeout_seconds=120, + input_schema={'type': 'object', 'properties': {'parameters': {'type': 'object', 'properties': {'slug': {'type': 'string', 'minLength': 1, 'maxLength': 120, 'example': 'shipping-agents-with-receipts', 'description': 'Post slug. The API normalizes path slugs to lowercase kebab-case before lookup.'}}, 'required': ['slug'], 'additionalProperties': False}, 'body': {'$ref': '#/components/schemas/BlogPostInput', 'description': 'JSON request body.'}}, 'required': ['parameters', 'body'], 'additionalProperties': False}, ) async def upsert_blog_post( self, @@ -210,6 +218,7 @@ class BlogOpenapiAgent(A2AAgent): description="DELETE /api/posts/{slug} - Delete a post by slug", tags=('Posts',), timeout_seconds=120, + input_schema={'type': 'object', 'properties': {'parameters': {'type': 'object', 'properties': {'slug': {'type': 'string', 'minLength': 1, 'maxLength': 120, 'example': 'shipping-agents-with-receipts', 'description': 'Post slug. The API normalizes path slugs to lowercase kebab-case before lookup.'}}, 'required': ['slug'], 'additionalProperties': False}}, 'required': ['parameters'], 'additionalProperties': False}, ) async def delete_blog_post( self, @@ -224,6 +233,103 @@ class BlogOpenapiAgent(A2AAgent): body=body, ) + @skill( + name="list_blog_post_comments", + description="GET /api/posts/{slug}/comments - List comments for a post", + tags=('Comments',), + timeout_seconds=60, + input_schema={'type': 'object', 'properties': {'parameters': {'type': 'object', 'properties': {'slug': {'type': 'string', 'minLength': 1, 'maxLength': 120, 'example': 'shipping-agents-with-receipts', 'description': 'Post slug. The API normalizes path slugs to lowercase kebab-case before lookup.'}}, 'required': ['slug'], 'additionalProperties': False}}, 'required': ['parameters'], 'additionalProperties': False}, + ) + async def list_blog_post_comments( + self, + ctx: RunContext, + parameters: dict[str, Any] | None = None, + body: Any | None = None, + ) -> dict[str, Any]: + return await self._request( + ctx, + "list_blog_post_comments", + parameters=parameters or {}, + body=body, + ) + + @skill( + name="create_blog_post_comment", + description="POST /api/posts/{slug}/comments - Create a comment", + tags=('Comments',), + timeout_seconds=120, + input_schema={'type': 'object', 'properties': {'parameters': {'type': 'object', 'properties': {'slug': {'type': 'string', 'minLength': 1, 'maxLength': 120, 'example': 'shipping-agents-with-receipts', 'description': 'Post slug. The API normalizes path slugs to lowercase kebab-case before lookup.'}}, 'required': ['slug'], 'additionalProperties': False}, 'body': {'$ref': '#/components/schemas/BlogCommentInput', 'description': 'JSON request body.'}}, 'required': ['parameters', 'body'], 'additionalProperties': False}, + ) + async def create_blog_post_comment( + self, + ctx: RunContext, + parameters: dict[str, Any] | None = None, + body: Any | None = None, + ) -> dict[str, Any]: + return await self._request( + ctx, + "create_blog_post_comment", + parameters=parameters or {}, + body=body, + ) + + @skill( + name="get_blog_post_reactions", + description="GET /api/posts/{slug}/reaction - Get post reaction counts", + tags=('Reactions',), + timeout_seconds=60, + input_schema={'type': 'object', 'properties': {'parameters': {'type': 'object', 'properties': {'slug': {'type': 'string', 'minLength': 1, 'maxLength': 120, 'example': 'shipping-agents-with-receipts', 'description': 'Post slug. The API normalizes path slugs to lowercase kebab-case before lookup.'}}, 'required': ['slug'], 'additionalProperties': False}}, 'required': ['parameters'], 'additionalProperties': False}, + ) + async def get_blog_post_reactions( + self, + ctx: RunContext, + parameters: dict[str, Any] | None = None, + body: Any | None = None, + ) -> dict[str, Any]: + return await self._request( + ctx, + "get_blog_post_reactions", + parameters=parameters or {}, + body=body, + ) + + @skill( + name="set_blog_post_reaction", + description="PUT /api/posts/{slug}/reaction - Set the viewer reaction", + tags=('Reactions',), + timeout_seconds=120, + input_schema={'type': 'object', 'properties': {'parameters': {'type': 'object', 'properties': {'slug': {'type': 'string', 'minLength': 1, 'maxLength': 120, 'example': 'shipping-agents-with-receipts', 'description': 'Post slug. The API normalizes path slugs to lowercase kebab-case before lookup.'}}, 'required': ['slug'], 'additionalProperties': False}, 'body': {'$ref': '#/components/schemas/BlogReactionInput', 'description': 'JSON request body.'}}, 'required': ['parameters', 'body'], 'additionalProperties': False}, + ) + async def set_blog_post_reaction( + self, + ctx: RunContext, + parameters: dict[str, Any] | None = None, + body: Any | None = None, + ) -> dict[str, Any]: + return await self._request( + ctx, + "set_blog_post_reaction", + parameters=parameters or {}, + body=body, + ) + + async def _maybe_smoke_test(self, ctx: RunContext, goal: str) -> dict[str, Any] | None: + operation_id = _select_smoke_operation(goal) + if operation_id is None: + return None + operation = OPERATIONS[operation_id] + result = await self._request(ctx, operation_id, parameters={}, body=None) + body = result.get("result") if result.get("ok") else result.get("error") + final = { + "endpoint_called": str(operation.get("method")) + " " + str(operation.get("path")), + "http_status": result.get("status_code"), + "body_preview": _body_preview(body, 200), + } + return { + "final": json.dumps(final, indent=2, ensure_ascii=False), + "messages": [], + } + def _operation_subagents(self, ctx: RunContext, skills_root: str) -> list[dict[str, Any]]: subagents: list[dict[str, Any]] = [] for group in OPERATION_GROUPS: @@ -281,6 +387,7 @@ class BlogOpenapiAgent(A2AAgent): "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.", + "For smoke tests, health checks, status checks, or ping-style requests, prefer explicit health/status/ping route groups over a generic root '/' route group.", "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.", "", @@ -294,6 +401,8 @@ class BlogOpenapiAgent(A2AAgent): lines = [ "You operate an API through generated OpenAPI tools.", "Call tools to get real results. Do not invent API responses.", + "For smoke tests, health checks, status checks, or ping-style requests, prefer explicit health/status/ping GET operations over generic root '/' operations.", + "Use a root '/' operation only when the user asks for the service root/homepage or no more specific read-only operation fits.", "For write, update, or delete operations, explain the intended action before calling the tool.", "If an operation reports missing consumer setup, tell the user which setup field is required.", "If an operation returns 404, 405, 410, or a schema/validation error that suggests the live API no longer matches these tools, tell the user this generated agent may need to be refreshed from the latest OpenAPI spec and ask whether they want to refresh it.", @@ -312,6 +421,8 @@ class BlogOpenapiAgent(A2AAgent): "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 smoke tests, health checks, status checks, or ping-style requests, prefer explicit health/status/ping GET operations over generic root '/' operations.", + "Use a root '/' operation only when the user asks for the service root/homepage or no more specific read-only operation fits.", "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.", @@ -343,6 +454,7 @@ class BlogOpenapiAgent(A2AAgent): ) if operation.get("request_body") is not None: parts.append("Accepts a JSON request body.") + parts.append(f"Operation ID: {operation['operation_id']}") return "\n".join(part for part in parts if part) async def _request( @@ -389,6 +501,7 @@ class BlogOpenapiAgent(A2AAgent): "ok": False, "status_code": response.status_code, "operation_id": operation_id, + "tool_name": operation.get("skill_name"), "error": payload, } if response.status_code in {404, 405, 410, 422}: @@ -401,6 +514,7 @@ class BlogOpenapiAgent(A2AAgent): "ok": True, "status_code": response.status_code, "operation_id": operation_id, + "tool_name": operation.get("skill_name"), "result": payload, } @@ -499,6 +613,52 @@ def _consumer_secret_optional(ctx: RunContext, name: str | None) -> str: return "" +def _select_smoke_operation(goal: str) -> str | None: + text = str(goal or "").lower() + smoke_terms = ("smoke", "health", "status", "ping", "readiness", "liveness") + if not any(term in text for term in smoke_terms): + return None + preferred_terms = ("health", "status", "ping", "ready", "readiness", "live", "liveness") + candidates: list[tuple[int, int, str]] = [] + for operation_id, operation in OPERATIONS.items(): + if str(operation.get("method") or "").upper() != "GET": + continue + if operation.get("destructive"): + continue + if any(param.get("required") for param in operation.get("parameters") or []): + continue + path = str(operation.get("path") or "") + searchable = " ".join( + str(operation.get(key) or "") + for key in ("operation_id", "skill_name", "path", "summary", "description") + ).lower() + is_root = path.strip() == "/" + matched_preferred = next( + (index for index, term in enumerate(preferred_terms) if term in searchable), + None, + ) + if matched_preferred is not None: + score = matched_preferred + elif "smoke" in text and not is_root: + score = 100 + elif "smoke" in text: + score = 1000 + else: + continue + candidates.append((score, len(path), operation_id)) + if not candidates: + return None + return min(candidates)[2] + + +def _body_preview(body: Any, limit: int) -> str: + if isinstance(body, str): + text = body + else: + text = json.dumps(body, ensure_ascii=False) + return text[:limit] + + def _runtime_skills_root(ctx: RunContext) -> str: workspace = getattr(ctx, "_workspace", None) prefixes = tuple(getattr(workspace, "write_prefixes", ()) or ()) diff --git a/openapi.json b/openapi.json index 058976e..dd073c3 100644 --- a/openapi.json +++ b/openapi.json @@ -15,6 +15,26 @@ } }, "responses": { + "A2AAuthUnavailable": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginErrorResponse" + } + } + }, + "description": "a2a Cloud authentication is unavailable." + }, + "A2AUnauthorized": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginErrorResponse" + } + } + }, + "description": "An a2a Cloud browser session is required." + }, "AuthNotConfigured": { "content": { "application/json": { @@ -57,6 +77,61 @@ } }, "schemas": { + "BlogComment": { + "properties": { + "author_name": { + "example": "Dev User", + "type": "string" + }, + "body": { + "example": "This cleared up the deployment flow.", + "maxLength": 4000, + "minLength": 2, + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "id": { + "example": 12, + "format": "int64", + "type": "integer" + }, + "post_id": { + "example": 42, + "format": "int64", + "type": "integer" + }, + "updated_at": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "id", + "post_id", + "author_name", + "body", + "created_at", + "updated_at" + ], + "type": "object" + }, + "BlogCommentInput": { + "properties": { + "body": { + "example": "This cleared up the deployment flow.", + "maxLength": 4000, + "minLength": 2, + "type": "string" + } + }, + "required": [ + "body" + ], + "type": "object" + }, "BlogPost": { "properties": { "author": { @@ -201,6 +276,62 @@ ], "type": "object" }, + "BlogReaction": { + "enum": [ + "like", + "dislike" + ], + "type": "string" + }, + "BlogReactionInput": { + "properties": { + "reaction": { + "description": "Set to null to clear the viewer's reaction.", + "oneOf": [ + { + "$ref": "#/components/schemas/BlogReaction" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "reaction" + ], + "type": "object" + }, + "BlogReactionSummary": { + "properties": { + "dislikes": { + "example": 1, + "minimum": 0, + "type": "integer" + }, + "likes": { + "example": 10, + "minimum": 0, + "type": "integer" + }, + "viewer_reaction": { + "oneOf": [ + { + "$ref": "#/components/schemas/BlogReaction" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "likes", + "dislikes", + "viewer_reaction" + ], + "type": "object" + }, "BlogStatus": { "enum": [ "draft", @@ -208,6 +339,31 @@ ], "type": "string" }, + "CommentListResponse": { + "properties": { + "comments": { + "items": { + "$ref": "#/components/schemas/BlogComment" + }, + "type": "array" + } + }, + "required": [ + "comments" + ], + "type": "object" + }, + "CommentResponse": { + "properties": { + "comment": { + "$ref": "#/components/schemas/BlogComment" + } + }, + "required": [ + "comment" + ], + "type": "object" + }, "DeleteResponse": { "properties": { "ok": { @@ -259,6 +415,21 @@ ], "type": "object" }, + "LoginErrorResponse": { + "properties": { + "error": { + "type": "string" + }, + "login_url": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + }, "PostListResponse": { "properties": { "posts": { @@ -283,9 +454,26 @@ "post" ], "type": "object" + }, + "ReactionResponse": { + "properties": { + "reactions": { + "$ref": "#/components/schemas/BlogReactionSummary" + } + }, + "required": [ + "reactions" + ], + "type": "object" } }, "securitySchemes": { + "a2aSession": { + "description": "Shared a2a Cloud browser session cookie.", + "in": "cookie", + "name": "a2a_session", + "type": "apiKey" + }, "apiKeyAuth": { "description": "Use the configured `BLOG_API_KEY` value.", "in": "header", @@ -300,7 +488,7 @@ } }, "info": { - "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 key.", + "description": "Publishing, read, comment, and reaction API for the a2a cloud blog. Published content and comments are public; publishing requires the blog API key; comment and reaction writes require an a2a Cloud browser session.", "title": "a2a cloud blog API", "version": "0.1.0" }, @@ -601,6 +789,181 @@ "Posts" ] } + }, + "/api/posts/{slug}/comments": { + "get": { + "operationId": "listBlogPostComments", + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentListResponse" + } + } + }, + "description": "Comments returned oldest-first." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }, + "summary": "List comments for a post", + "tags": [ + "Comments" + ] + }, + "post": { + "description": "Requires the shared a2a Cloud browser session cookie from the web login flow.", + "operationId": "createBlogPostComment", + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlogCommentInput" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentResponse" + } + } + }, + "description": "The comment was created." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/A2AUnauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "503": { + "$ref": "#/components/responses/A2AAuthUnavailable" + } + }, + "security": [ + { + "a2aSession": [] + } + ], + "summary": "Create a comment", + "tags": [ + "Comments" + ] + } + }, + "/api/posts/{slug}/reaction": { + "get": { + "description": "Returns public like/dislike counts. If an a2a Cloud session cookie is present, `viewer_reaction` is populated.", + "operationId": "getBlogPostReactions", + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReactionResponse" + } + } + }, + "description": "Reaction counts for the post." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + }, + "security": [ + {}, + { + "a2aSession": [] + } + ], + "summary": "Get post reaction counts", + "tags": [ + "Reactions" + ] + }, + "put": { + "description": "Creates, updates, or clears the authenticated viewer's like/dislike reaction.", + "operationId": "setBlogPostReaction", + "parameters": [ + { + "$ref": "#/components/parameters/Slug" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BlogReactionInput" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReactionResponse" + } + } + }, + "description": "Updated reaction counts for the post." + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/A2AUnauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "503": { + "$ref": "#/components/responses/A2AAuthUnavailable" + } + }, + "security": [ + { + "a2aSession": [] + } + ], + "summary": "Set the viewer reaction", + "tags": [ + "Reactions" + ] + } } }, "servers": [ @@ -618,6 +981,14 @@ "description": "Blog post listing, publishing, and lookup.", "name": "Posts" }, + { + "description": "Public comments on published posts.", + "name": "Comments" + }, + { + "description": "Like and dislike reactions on published posts.", + "name": "Reactions" + }, { "description": "Service health checks.", "name": "Health"