This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
.git
|
||||
.pytest_cache
|
||||
node_modules
|
||||
.a2a
|
||||
.env
|
||||
.env.local
|
||||
38
.gitea/workflows/build.yml
Normal file
38
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'deploy/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: build image
|
||||
run: |
|
||||
IMG=registry.a2acloud.io/agents/blog-openapi-agent
|
||||
docker build --pull -t "$IMG:$GITHUB_SHA" -t "$IMG:latest" .
|
||||
docker push "$IMG:$GITHUB_SHA"
|
||||
docker push "$IMG:latest"
|
||||
|
||||
- name: bump deploy manifest
|
||||
run: |
|
||||
IMG=registry.a2acloud.io/agents/blog-openapi-agent
|
||||
sed -i "s|image: $IMG:.*|image: $IMG:$GITHUB_SHA|" deploy/20-deployment.yaml
|
||||
sed -i "s|value: $IMG:.*|value: $IMG:$GITHUB_SHA|" deploy/20-deployment.yaml
|
||||
git config user.email "ci@a2a.local"
|
||||
git config user.name "ci"
|
||||
git add deploy/20-deployment.yaml
|
||||
if git diff --staged --quiet; then
|
||||
echo "no manifest changes"
|
||||
else
|
||||
git commit -m "ci: bump image to $GITHUB_SHA"
|
||||
git push origin HEAD:main
|
||||
fi
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM registry.a2acloud.io/a2a/a2a-pack-base:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
ENV A2A_ENTRYPOINT=agent:BlogOpenapiAgent
|
||||
ENV PORT=8000
|
||||
EXPOSE 8000
|
||||
|
||||
CMD a2a run --entrypoint "$A2A_ENTRYPOINT" --host 0.0.0.0 --port 8000
|
||||
10
README.md
Normal file
10
README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# blog-openapi-agent
|
||||
|
||||
Generated A2APack agent for a2a cloud blog API.
|
||||
|
||||
- Source OpenAPI: https://blog.a2acloud.io/openapi.json
|
||||
- Generated operations: 6
|
||||
- Main skill: `auto`
|
||||
|
||||
The generated code is intentionally editable. Tune prompts, operation
|
||||
grouping, auth names, and safety policy before publishing serious agents.
|
||||
14
a2a.yaml
Normal file
14
a2a.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
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.
|
||||
runtime:
|
||||
resources:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
egress:
|
||||
allow_hosts:
|
||||
- blog.a2acloud.io
|
||||
deny_internet_by_default: true
|
||||
431
agent.py
Normal file
431
agent.py
Normal file
@@ -0,0 +1,431 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from deepagents import create_deep_agent
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.tools import StructuredTool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from a2a_pack import (
|
||||
A2AAgent,
|
||||
ConsumerSetup,
|
||||
ConsumerSetupField,
|
||||
ConsumerSetupMissing,
|
||||
EgressPolicy,
|
||||
LLMProvisioning,
|
||||
Pricing,
|
||||
Resources,
|
||||
RunContext,
|
||||
skill,
|
||||
)
|
||||
|
||||
|
||||
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}")
|
||||
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}")
|
||||
|
||||
|
||||
class OperationInput(BaseModel):
|
||||
parameters: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Path, query, header, and cookie parameters keyed by OpenAPI parameter name.",
|
||||
)
|
||||
body: Any | None = Field(default=None, description="JSON request body, when the operation accepts one.")
|
||||
|
||||
|
||||
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."
|
||||
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=False),
|
||||
ConsumerSetupField.secret("X_API_KEY", label="apiKeyAuth API key", description="Use the configured `BLOG_API_KEY` value.", required=False),
|
||||
)
|
||||
llm_provisioning = LLMProvisioning.PLATFORM
|
||||
pricing = Pricing(
|
||||
price_per_call_usd=0.0,
|
||||
caller_pays_llm=False,
|
||||
notes="Uses a scoped platform LLM grant through ctx.llm.",
|
||||
)
|
||||
resources = Resources(cpu="200m", memory="512Mi")
|
||||
egress = EgressPolicy(
|
||||
allow_hosts=('blog.a2acloud.io',),
|
||||
deny_internet_by_default=True,
|
||||
)
|
||||
tools_used = ("openapi", "deepagents")
|
||||
capabilities = {
|
||||
"openapi_auto_agent": {
|
||||
"operation_count": len(OPERATIONS),
|
||||
"default_base_url": DEFAULT_BASE_URL,
|
||||
"security_schemes": list(SECURITY_SCHEMES),
|
||||
}
|
||||
}
|
||||
|
||||
@skill(
|
||||
name="auto",
|
||||
description="Use the OpenAPI service to complete a natural-language goal.",
|
||||
tags=("openapi", "auto"),
|
||||
timeout_seconds=180,
|
||||
)
|
||||
async def auto(self, ctx: RunContext, goal: str) -> dict[str, Any]:
|
||||
creds = ctx.llm
|
||||
if not creds.api_key:
|
||||
return {
|
||||
"error": "llm_credentials_missing",
|
||||
"final": (
|
||||
"LLM credentials were not available for this run. Hosted "
|
||||
"generated OpenAPI agents should receive a platform LLM grant."
|
||||
),
|
||||
"messages": [],
|
||||
}
|
||||
model_kwargs: dict[str, Any] = {
|
||||
"model": creds.model,
|
||||
"base_url": creds.base_url,
|
||||
"api_key": creds.api_key,
|
||||
}
|
||||
if creds.temperature_mode != "omit" and creds.temperature is not None:
|
||||
model_kwargs["temperature"] = creds.temperature
|
||||
if creds.extra_body:
|
||||
model_kwargs["extra_body"] = dict(creds.extra_body)
|
||||
model = ChatOpenAI(**model_kwargs)
|
||||
graph = create_deep_agent(
|
||||
model=model,
|
||||
tools=self._operation_tools(ctx),
|
||||
system_prompt=self._system_prompt(),
|
||||
)
|
||||
result = await graph.ainvoke({"messages": [{"role": "user", "content": goal}]})
|
||||
messages = result.get("messages", []) if isinstance(result, dict) else []
|
||||
final = _message_text(messages[-1]) if messages else result
|
||||
return {
|
||||
"final": final,
|
||||
"messages": [_message_to_dict(message) for message in messages[-8:]],
|
||||
}
|
||||
|
||||
@skill(
|
||||
name="get_blog_health",
|
||||
description="GET /api/healthz - Check blog service health",
|
||||
tags=('Health',),
|
||||
timeout_seconds=60,
|
||||
)
|
||||
async def get_blog_health(
|
||||
self,
|
||||
ctx: RunContext,
|
||||
parameters: dict[str, Any] | None = None,
|
||||
body: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._request(
|
||||
ctx,
|
||||
"get_blog_health",
|
||||
parameters=parameters or {},
|
||||
body=body,
|
||||
)
|
||||
|
||||
@skill(
|
||||
name="list_blog_posts",
|
||||
description="GET /api/posts - List posts",
|
||||
tags=('Posts',),
|
||||
timeout_seconds=60,
|
||||
)
|
||||
async def list_blog_posts(
|
||||
self,
|
||||
ctx: RunContext,
|
||||
parameters: dict[str, Any] | None = None,
|
||||
body: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._request(
|
||||
ctx,
|
||||
"list_blog_posts",
|
||||
parameters=parameters or {},
|
||||
body=body,
|
||||
)
|
||||
|
||||
@skill(
|
||||
name="create_blog_post",
|
||||
description="POST /api/posts - Create a blog post",
|
||||
tags=('Posts',),
|
||||
timeout_seconds=120,
|
||||
)
|
||||
async def create_blog_post(
|
||||
self,
|
||||
ctx: RunContext,
|
||||
parameters: dict[str, Any] | None = None,
|
||||
body: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._request(
|
||||
ctx,
|
||||
"create_blog_post",
|
||||
parameters=parameters or {},
|
||||
body=body,
|
||||
)
|
||||
|
||||
@skill(
|
||||
name="get_blog_post",
|
||||
description="GET /api/posts/{slug} - Get a post by slug",
|
||||
tags=('Posts',),
|
||||
timeout_seconds=60,
|
||||
)
|
||||
async def get_blog_post(
|
||||
self,
|
||||
ctx: RunContext,
|
||||
parameters: dict[str, Any] | None = None,
|
||||
body: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._request(
|
||||
ctx,
|
||||
"get_blog_post",
|
||||
parameters=parameters or {},
|
||||
body=body,
|
||||
)
|
||||
|
||||
@skill(
|
||||
name="upsert_blog_post",
|
||||
description="PUT /api/posts/{slug} - Upsert a post by slug",
|
||||
tags=('Posts',),
|
||||
timeout_seconds=120,
|
||||
)
|
||||
async def upsert_blog_post(
|
||||
self,
|
||||
ctx: RunContext,
|
||||
parameters: dict[str, Any] | None = None,
|
||||
body: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._request(
|
||||
ctx,
|
||||
"upsert_blog_post",
|
||||
parameters=parameters or {},
|
||||
body=body,
|
||||
)
|
||||
|
||||
@skill(
|
||||
name="delete_blog_post",
|
||||
description="DELETE /api/posts/{slug} - Delete a post by slug",
|
||||
tags=('Posts',),
|
||||
timeout_seconds=120,
|
||||
)
|
||||
async def delete_blog_post(
|
||||
self,
|
||||
ctx: RunContext,
|
||||
parameters: dict[str, Any] | None = None,
|
||||
body: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._request(
|
||||
ctx,
|
||||
"delete_blog_post",
|
||||
parameters=parameters or {},
|
||||
body=body,
|
||||
)
|
||||
|
||||
def _operation_tools(self, ctx: RunContext) -> list[StructuredTool]:
|
||||
tools: list[StructuredTool] = []
|
||||
for operation_id, operation in OPERATIONS.items():
|
||||
async def call(
|
||||
parameters: dict[str, Any] | None = None,
|
||||
body: Any | None = None,
|
||||
*,
|
||||
_operation_id: str = operation_id,
|
||||
) -> dict[str, Any]:
|
||||
return await self._request(
|
||||
ctx,
|
||||
_operation_id,
|
||||
parameters=parameters or {},
|
||||
body=body,
|
||||
)
|
||||
|
||||
tools.append(
|
||||
StructuredTool.from_function(
|
||||
coroutine=call,
|
||||
name=operation["skill_name"],
|
||||
description=self._tool_description(operation),
|
||||
args_schema=OperationInput,
|
||||
)
|
||||
)
|
||||
return tools
|
||||
|
||||
def _system_prompt(self) -> str:
|
||||
lines = [
|
||||
"You operate an API through generated OpenAPI tools.",
|
||||
"Call tools to get real results. Do not invent API responses.",
|
||||
"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.",
|
||||
"",
|
||||
"Available operations:",
|
||||
]
|
||||
for operation in OPERATIONS.values():
|
||||
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']}",
|
||||
str(operation.get("description") or operation.get("summary") or ""),
|
||||
]
|
||||
if operation.get("parameters"):
|
||||
parts.append(
|
||||
"Parameters: "
|
||||
+ ", ".join(
|
||||
f"{p['name']} in {p['in']}{' required' if p.get('required') else ''}"
|
||||
for p in operation["parameters"]
|
||||
)
|
||||
)
|
||||
if operation.get("request_body") is not None:
|
||||
parts.append("Accepts a JSON request body.")
|
||||
return "\n".join(part for part in parts if part)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
ctx: RunContext,
|
||||
operation_id: str,
|
||||
*,
|
||||
parameters: dict[str, Any],
|
||||
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("/")
|
||||
url, query, headers = self._request_parts(ctx, operation, parameters)
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"params": query,
|
||||
"headers": headers,
|
||||
}
|
||||
if body is not None:
|
||||
request_kwargs["json"] = body
|
||||
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
|
||||
response = await client.request(
|
||||
operation["method"],
|
||||
f"{base_url}{url}",
|
||||
**request_kwargs,
|
||||
)
|
||||
content_type = response.headers.get("content-type", "")
|
||||
try:
|
||||
payload: Any = response.json() if "json" in content_type else response.text
|
||||
except ValueError:
|
||||
payload = response.text
|
||||
if response.status_code >= 400:
|
||||
return {
|
||||
"ok": False,
|
||||
"status_code": response.status_code,
|
||||
"operation_id": operation_id,
|
||||
"error": payload,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"status_code": response.status_code,
|
||||
"operation_id": operation_id,
|
||||
"result": payload,
|
||||
}
|
||||
|
||||
def _request_parts(
|
||||
self,
|
||||
ctx: RunContext,
|
||||
operation: dict[str, Any],
|
||||
parameters: dict[str, Any],
|
||||
) -> tuple[str, dict[str, Any], dict[str, str]]:
|
||||
path = operation["path"]
|
||||
query: dict[str, Any] = {}
|
||||
headers: dict[str, str] = {}
|
||||
cookies: dict[str, Any] = {}
|
||||
for param in operation.get("parameters") or []:
|
||||
name = param["name"]
|
||||
if name not in parameters:
|
||||
if param.get("required"):
|
||||
raise ValueError(f"missing required parameter {name!r}")
|
||||
continue
|
||||
value = parameters[name]
|
||||
location = param["in"]
|
||||
if location == "path":
|
||||
path = path.replace(" + name + ", str(value))
|
||||
elif location == "query":
|
||||
query[name] = value
|
||||
elif location == "header":
|
||||
headers[name] = str(value)
|
||||
elif location == "cookie":
|
||||
cookies[name] = value
|
||||
if cookies:
|
||||
headers["cookie"] = "; ".join(f"{key}={value}" for key, value in cookies.items())
|
||||
auth_headers, auth_query = self._auth_for_operation(ctx, operation)
|
||||
headers.update(auth_headers)
|
||||
query.update(auth_query)
|
||||
return path, query, headers
|
||||
|
||||
def _auth_for_operation(self, ctx: RunContext, operation: dict[str, Any]) -> tuple[dict[str, str], dict[str, Any]]:
|
||||
requirements = operation.get("security")
|
||||
if requirements is None:
|
||||
requirements = ROOT_SECURITY
|
||||
if requirements == []:
|
||||
return {}, {}
|
||||
missing: list[str] = []
|
||||
for requirement in requirements or []:
|
||||
if not isinstance(requirement, dict) or not requirement:
|
||||
return {}, {}
|
||||
headers: dict[str, str] = {}
|
||||
query: dict[str, Any] = {}
|
||||
ok = True
|
||||
for scheme_name in requirement:
|
||||
mapping = SECURITY_FIELDS.get(scheme_name) or {}
|
||||
secret_name = mapping.get("field")
|
||||
value = _consumer_secret_optional(ctx, secret_name) if secret_name else ""
|
||||
if not value:
|
||||
ok = False
|
||||
if secret_name:
|
||||
missing.append(secret_name)
|
||||
continue
|
||||
kind = mapping.get("kind")
|
||||
if kind == "apiKey":
|
||||
if mapping.get("location") == "query":
|
||||
query[mapping.get("name") or scheme_name] = value
|
||||
else:
|
||||
headers[mapping.get("name") or scheme_name] = value
|
||||
elif kind in {"http", "oauth2"}:
|
||||
scheme = mapping.get("scheme") or "Bearer"
|
||||
if scheme.lower() == "basic":
|
||||
token = base64.b64encode(value.encode("utf-8")).decode("ascii")
|
||||
headers["authorization"] = f"Basic {token}"
|
||||
else:
|
||||
headers["authorization"] = f"{scheme} {value}"
|
||||
else:
|
||||
ok = False
|
||||
if ok:
|
||||
return headers, query
|
||||
if missing:
|
||||
raise ConsumerSetupMissing(
|
||||
"operation requires consumer setup secret(s): "
|
||||
+ ", ".join(sorted(set(missing)))
|
||||
)
|
||||
return {}, {}
|
||||
|
||||
|
||||
def _consumer_secret_optional(ctx: RunContext, name: str | None) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
try:
|
||||
return ctx.consumer_secret(name)
|
||||
except ConsumerSetupMissing:
|
||||
return ""
|
||||
|
||||
|
||||
def _message_text(message: Any) -> Any:
|
||||
content = getattr(message, "content", message)
|
||||
return content
|
||||
|
||||
|
||||
def _message_to_dict(message: Any) -> dict[str, Any]:
|
||||
if isinstance(message, BaseMessage):
|
||||
return message.model_dump(mode="json")
|
||||
if hasattr(message, "model_dump"):
|
||||
return message.model_dump(mode="json")
|
||||
if isinstance(message, dict):
|
||||
return message
|
||||
return {"content": str(message)}
|
||||
|
||||
|
||||
agent = BlogOpenapiAgent()
|
||||
102
deploy/20-deployment.yaml
Normal file
102
deploy/20-deployment.yaml
Normal file
@@ -0,0 +1,102 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: blog-openapi-agent
|
||||
namespace: agents
|
||||
labels:
|
||||
app: blog-openapi-agent
|
||||
a2a/managed-by: control-plane
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: blog-openapi-agent
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: blog-openapi-agent
|
||||
spec:
|
||||
containers:
|
||||
- name: agent
|
||||
image: registry.a2acloud.io/agents/blog-openapi-agent:latest
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: A2A_AGENT_IMAGE
|
||||
value: registry.a2acloud.io/agents/blog-openapi-agent:latest
|
||||
- name: A2A_RENDER_IMAGE
|
||||
value: registry.a2acloud.io/agents/blog-openapi-agent:latest
|
||||
- name: A2A_CP_URL
|
||||
value: "http://control-plane.control-plane.svc.cluster.local"
|
||||
- name: A2A_LOGIN_URL
|
||||
value: "https://app.a2acloud.io"
|
||||
- name: A2A_SESSION_COOKIE_NAME
|
||||
value: "a2a_session"
|
||||
- name: A2A_GRANT_VERIFYING_KEY
|
||||
valueFrom:
|
||||
secretKeyRef: {name: platform-secrets, key: grant_verifying_key}
|
||||
- name: A2A_SANDBOX_URL
|
||||
value: http://sandbox.sandbox.svc.cluster.local:8000
|
||||
- name: A2A_SANDBOX_TIMEOUT_S
|
||||
value: "180"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: blog-openapi-agent-agent-secrets
|
||||
optional: true
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
name: http
|
||||
readinessProbe:
|
||||
httpGet: {path: /healthz, port: 8000}
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
httpGet: {path: /healthz, port: 8000}
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 15
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 6
|
||||
resources:
|
||||
requests: {cpu: "200m", memory: "512Mi"}
|
||||
limits: {cpu: "200m", memory: "512Mi"}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: blog-openapi-agent
|
||||
namespace: agents
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: blog-openapi-agent
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8000
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: blog-openapi-agent
|
||||
namespace: agents
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
||||
spec:
|
||||
tls:
|
||||
- hosts: [blog-openapi-agent.a2acloud.io]
|
||||
secretName: blog-openapi-agent-a2acloud-io-tls
|
||||
rules:
|
||||
- host: blog-openapi-agent.a2acloud.io
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: blog-openapi-agent
|
||||
port:
|
||||
number: 80
|
||||
626
openapi.json
Normal file
626
openapi.json
Normal file
@@ -0,0 +1,626 @@
|
||||
{
|
||||
"components": {
|
||||
"parameters": {
|
||||
"Slug": {
|
||||
"description": "Post slug. The API normalizes path slugs to lowercase kebab-case before lookup.",
|
||||
"in": "path",
|
||||
"name": "slug",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"example": "shipping-agents-with-receipts",
|
||||
"maxLength": 120,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"AuthNotConfigured": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "`BLOG_API_KEY` is not configured on the server."
|
||||
},
|
||||
"BadRequest": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The request body, slug, or query parameters are invalid."
|
||||
},
|
||||
"NotFound": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The requested post was not found."
|
||||
},
|
||||
"Unauthorized": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The blog API key is missing or invalid."
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"BlogPost": {
|
||||
"properties": {
|
||||
"author": {
|
||||
"example": "a2a cloud",
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"example": "# Shipping agents with receipts\n\nAgents need durable runtime boundaries.",
|
||||
"type": "string"
|
||||
},
|
||||
"cover_image_url": {
|
||||
"example": "https://blog.a2acloud.io/brand/og-image.png",
|
||||
"format": "uri",
|
||||
"maxLength": 500,
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"created_at": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"excerpt": {
|
||||
"example": "Why agent infrastructure needs identity, authority, and proof.",
|
||||
"maxLength": 420,
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"example": 42,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"published_at": {
|
||||
"format": "date-time",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"slug": {
|
||||
"example": "shipping-agents-with-receipts",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/BlogStatus"
|
||||
},
|
||||
"tags": {
|
||||
"example": [
|
||||
"platform",
|
||||
"agents"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 12,
|
||||
"type": "array"
|
||||
},
|
||||
"title": {
|
||||
"example": "Shipping agents with receipts",
|
||||
"maxLength": 180,
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"slug",
|
||||
"title",
|
||||
"excerpt",
|
||||
"content",
|
||||
"author",
|
||||
"status",
|
||||
"tags",
|
||||
"cover_image_url",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"published_at"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"BlogPostInput": {
|
||||
"properties": {
|
||||
"author": {
|
||||
"default": "a2a cloud",
|
||||
"maxLength": 120,
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"example": "# Shipping agents with receipts\n\nAgents need durable runtime boundaries.",
|
||||
"maxLength": 120000,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"cover_image_url": {
|
||||
"format": "uri",
|
||||
"maxLength": 500,
|
||||
"type": "string"
|
||||
},
|
||||
"excerpt": {
|
||||
"default": "",
|
||||
"example": "Why agent infrastructure needs identity, authority, and proof.",
|
||||
"maxLength": 420,
|
||||
"type": "string"
|
||||
},
|
||||
"published_at": {
|
||||
"description": "Defaults to the current time for published posts and null for drafts.",
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"slug": {
|
||||
"description": "Optional on create. The path slug is used for upserts. Slugs are normalized to lowercase kebab-case.",
|
||||
"example": "shipping-agents-with-receipts",
|
||||
"maxLength": 120,
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/BlogStatus",
|
||||
"default": "published"
|
||||
},
|
||||
"tags": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 12,
|
||||
"type": "array"
|
||||
},
|
||||
"title": {
|
||||
"example": "Shipping agents with receipts",
|
||||
"maxLength": 180,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"content"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"BlogStatus": {
|
||||
"enum": [
|
||||
"draft",
|
||||
"published"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"DeleteResponse": {
|
||||
"properties": {
|
||||
"ok": {
|
||||
"const": true,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ok"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HealthError": {
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"ok": {
|
||||
"const": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ok",
|
||||
"error"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HealthOk": {
|
||||
"properties": {
|
||||
"ok": {
|
||||
"const": true,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ok"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PostListResponse": {
|
||||
"properties": {
|
||||
"posts": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BlogPost"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"posts"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PostResponse": {
|
||||
"properties": {
|
||||
"post": {
|
||||
"$ref": "#/components/schemas/BlogPost"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"post"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"apiKeyAuth": {
|
||||
"description": "Use the configured `BLOG_API_KEY` value.",
|
||||
"in": "header",
|
||||
"name": "x-api-key",
|
||||
"type": "apiKey"
|
||||
},
|
||||
"bearerAuth": {
|
||||
"description": "Use `Authorization: Bearer $BLOG_API_KEY`.",
|
||||
"scheme": "bearer",
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"title": "a2a cloud blog API",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
"/api/healthz": {
|
||||
"get": {
|
||||
"operationId": "getBlogHealth",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HealthOk"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The service can initialize its schema and query Postgres."
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HealthError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The service or database is unavailable."
|
||||
}
|
||||
},
|
||||
"summary": "Check blog service health",
|
||||
"tags": [
|
||||
"Health"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/posts": {
|
||||
"get": {
|
||||
"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.",
|
||||
"operationId": "listBlogPosts",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Use `published` or omit the parameter for public published posts. `all` and `draft` require authentication.",
|
||||
"in": "query",
|
||||
"name": "status",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"default": "published",
|
||||
"enum": [
|
||||
"published",
|
||||
"draft",
|
||||
"all"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PostListResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Posts returned in newest-first order."
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"503": {
|
||||
"$ref": "#/components/responses/AuthNotConfigured"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"apiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "List posts",
|
||||
"tags": [
|
||||
"Posts"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createBlogPost",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BlogPostInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PostResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The post was created."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "A post with this slug already exists."
|
||||
},
|
||||
"503": {
|
||||
"$ref": "#/components/responses/AuthNotConfigured"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"apiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Create a blog post",
|
||||
"tags": [
|
||||
"Posts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/posts/{slug}": {
|
||||
"delete": {
|
||||
"operationId": "deleteBlogPost",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/Slug"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeleteResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The post was deleted."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"503": {
|
||||
"$ref": "#/components/responses/AuthNotConfigured"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"apiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Delete a post by slug",
|
||||
"tags": [
|
||||
"Posts"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"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.",
|
||||
"operationId": "getBlogPost",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/Slug"
|
||||
},
|
||||
{
|
||||
"description": "Set to `1` to bypass the public published-post filter. Requires authentication.",
|
||||
"in": "query",
|
||||
"name": "preview",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"enum": [
|
||||
"1"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PostResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The matching post."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/NotFound"
|
||||
},
|
||||
"503": {
|
||||
"$ref": "#/components/responses/AuthNotConfigured"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{},
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"apiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Get a post by slug",
|
||||
"tags": [
|
||||
"Posts"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "upsertBlogPost",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/Slug"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BlogPostInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PostResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The post was created or updated."
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"503": {
|
||||
"$ref": "#/components/responses/AuthNotConfigured"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"apiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Upsert a post by slug",
|
||||
"tags": [
|
||||
"Posts"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"description": "Production",
|
||||
"url": "https://blog.a2acloud.io"
|
||||
},
|
||||
{
|
||||
"description": "Local development",
|
||||
"url": "http://localhost:3000"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"description": "Blog post listing, publishing, and lookup.",
|
||||
"name": "Posts"
|
||||
},
|
||||
{
|
||||
"description": "Service health checks.",
|
||||
"name": "Health"
|
||||
}
|
||||
]
|
||||
}
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
# a2a-pack is installed by the platform base image.
|
||||
deepagents>=0.5.0
|
||||
langchain>=0.3
|
||||
langchain-openai>=0.2
|
||||
langchain-core>=0.3
|
||||
langgraph>=0.6
|
||||
httpx>=0.27
|
||||
Reference in New Issue
Block a user