initial a2a-pack

This commit is contained in:
robert
2026-05-08 21:59:51 -03:00
commit b6f6cd1643
29 changed files with 3218 additions and 0 deletions

80
a2a_pack/__init__.py Normal file
View File

@@ -0,0 +1,80 @@
from .agent import (
A2AAgent,
ParamSpec,
SkillInputError,
SkillInvocationError,
SkillNotFound,
SkillSpec,
skill,
)
from .auth import APIKeyAuth, JWTAuth, NoAuth
from .card import AgentCard, SkillCard
from .context import (
AgentEvent,
ArtifactRef,
CancelledByCaller,
LocalRunContext,
MissingScopes,
RunContext,
)
from .runtime import (
AgentRuntime,
EgressPolicy,
Lifecycle,
Resources,
Sandbox,
SkillPolicy,
State,
)
from .workspace import (
FileMatch,
FileType,
LocalWorkspaceClient,
LocalWorkspaceView,
WorkspaceAccess,
WorkspaceClient,
WorkspaceDenied,
WorkspaceGrant,
WorkspaceMode,
WorkspacePatch,
WorkspaceView,
)
__all__ = [
"A2AAgent",
"APIKeyAuth",
"AgentCard",
"AgentEvent",
"AgentRuntime",
"ArtifactRef",
"CancelledByCaller",
"EgressPolicy",
"FileMatch",
"FileType",
"JWTAuth",
"Lifecycle",
"LocalRunContext",
"LocalWorkspaceClient",
"LocalWorkspaceView",
"MissingScopes",
"NoAuth",
"ParamSpec",
"Resources",
"RunContext",
"Sandbox",
"SkillCard",
"SkillInputError",
"SkillInvocationError",
"SkillNotFound",
"SkillPolicy",
"SkillSpec",
"State",
"WorkspaceAccess",
"WorkspaceClient",
"WorkspaceDenied",
"WorkspaceGrant",
"WorkspaceMode",
"WorkspacePatch",
"WorkspaceView",
"skill",
]

410
a2a_pack/agent.py Normal file
View File

@@ -0,0 +1,410 @@
from __future__ import annotations
import inspect
import typing
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, ClassVar, Generic, Sequence, TypeVar
from pydantic import BaseModel, TypeAdapter
from .auth import NoAuth
from .card import AgentCard
from .context import LocalRunContext, RunContext
from .runtime import (
AgentRuntime,
EgressPolicy,
Lifecycle,
Resources,
Sandbox,
SkillPolicy,
State,
)
from .workspace import WorkspaceAccess
ConfigT = TypeVar("ConfigT", bound=BaseModel)
AuthT = TypeVar("AuthT", bound=BaseModel)
_RESERVED_PARAM_NAMES = frozenset({"self", "ctx", "context"})
class _EmptyConfig(BaseModel):
"""Default config model when an agent declares no config."""
class SkillNotFound(KeyError):
"""Raised when invoke() is called with an unknown skill name."""
class SkillInvocationError(RuntimeError):
"""Raised when a skill handler raises during invoke()."""
class SkillInputError(ValueError):
"""Raised when invoke() inputs fail validation against the skill schema."""
@dataclass(frozen=True)
class ParamSpec:
"""Validation metadata for a single skill parameter."""
name: str
adapter: TypeAdapter[Any]
has_default: bool
default: Any = None
@dataclass(frozen=True)
class SkillSpec:
"""Static metadata about a single skill, captured at decoration time."""
name: str
description: str
tags: tuple[str, ...]
scopes: tuple[str, ...]
stream: bool
policy: SkillPolicy
input_schema: dict[str, Any]
output_schema: dict[str, Any]
handler: Callable[..., Awaitable[Any]]
params: tuple[ParamSpec, ...] = field(default_factory=tuple)
output_adapter: TypeAdapter[Any] | None = None
def skill(
*,
name: str | None = None,
description: str = "",
tags: Sequence[str] = (),
scopes: Sequence[str] = (),
stream: bool = False,
timeout_seconds: float | None = None,
idempotent: bool = False,
max_retries: int = 0,
cost_class: str | None = None,
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
"""Mark an :class:`A2AAgent` method as a discoverable skill.
Conventions:
- The handler MUST be ``async def``.
- Its first parameter (after ``self``) MUST be a :class:`RunContext`;
the context is supplied by the runtime and is omitted from the
published input schema.
- Remaining parameters MUST be type-annotated. ``*args`` and ``**kwargs``
are rejected.
"""
def decorator(fn: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
if not inspect.iscoroutinefunction(fn):
raise TypeError(
f"@skill requires an async function: {fn.__qualname__}"
)
sig = inspect.signature(fn)
hints = typing.get_type_hints(fn)
params = list(sig.parameters.values())[1:] # drop self
if not params:
raise TypeError(
f"@skill {fn.__qualname__}: missing RunContext parameter"
)
ctx_param, *rest = params
ctx_hint = hints.get(ctx_param.name)
if ctx_hint is None or not _is_run_context(ctx_hint):
raise TypeError(
f"@skill {fn.__qualname__}: first arg after self must be "
f"annotated as RunContext (got {ctx_hint!r})"
)
properties: dict[str, Any] = {}
required: list[str] = []
param_specs: list[ParamSpec] = []
for p in rest:
if p.kind is inspect.Parameter.VAR_POSITIONAL:
raise TypeError(
f"@skill {fn.__qualname__}: *{p.name} is not allowed"
)
if p.kind is inspect.Parameter.VAR_KEYWORD:
raise TypeError(
f"@skill {fn.__qualname__}: **{p.name} is not allowed"
)
if p.name in _RESERVED_PARAM_NAMES:
raise TypeError(
f"@skill {fn.__qualname__}: reserved param name {p.name!r}"
)
if p.name not in hints:
raise TypeError(
f"@skill {fn.__qualname__}: parameter {p.name!r} is "
f"missing a type annotation"
)
tp = hints[p.name]
adapter: TypeAdapter[Any] = TypeAdapter(tp)
properties[p.name] = adapter.json_schema()
has_default = p.default is not inspect.Parameter.empty
if not has_default:
required.append(p.name)
param_specs.append(
ParamSpec(
name=p.name,
adapter=adapter,
has_default=has_default,
default=None if not has_default else p.default,
)
)
input_schema: dict[str, Any] = {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
}
return_tp = hints.get("return", Any)
output_adapter: TypeAdapter[Any] = TypeAdapter(return_tp)
spec = SkillSpec(
name=name or fn.__name__,
description=description,
tags=tuple(tags),
scopes=tuple(scopes),
stream=stream,
policy=SkillPolicy(
timeout_seconds=timeout_seconds,
idempotent=idempotent,
max_retries=max_retries,
cost_class=cost_class,
),
input_schema=input_schema,
output_schema=output_adapter.json_schema(),
handler=fn,
params=tuple(param_specs),
output_adapter=output_adapter,
)
fn.__a2a_skill__ = spec # type: ignore[attr-defined]
return fn
return decorator
def _is_run_context(tp: Any) -> bool:
"""True if ``tp`` is :class:`RunContext` or a parametrization of it."""
origin = typing.get_origin(tp) or tp
try:
return isinstance(origin, type) and issubclass(origin, RunContext)
except TypeError:
return False
class _AgentMeta(type):
def __new__(mcs, cls_name, bases, namespace):
cls = super().__new__(mcs, cls_name, bases, namespace)
skills: dict[str, SkillSpec] = {}
for base in bases:
skills.update(getattr(base, "_skills", {}))
for attr in namespace.values():
spec = getattr(attr, "__a2a_skill__", None)
if spec is None:
continue
if spec.name in skills and skills[spec.name].handler is not spec.handler:
# Allow overrides from the same chain (parent → child) but
# forbid two distinct handlers in the same class.
if any(
spec.name in getattr(b, "_skills", {})
and getattr(b, "_skills")[spec.name].handler is spec.handler
for b in bases
):
pass # legitimate override
else:
raise TypeError(
f"duplicate skill name {spec.name!r} in {cls_name}"
)
skills[spec.name] = spec
cls._skills = skills # type: ignore[attr-defined]
return cls
class A2AAgent(Generic[ConfigT, AuthT], metaclass=_AgentMeta):
"""Base class for A2A agents.
Subclasses declare:
- ``name``, ``description`` (and optional ``version``),
- optional ``config_model`` / ``auth_model`` (default to empty / NoAuth),
- deployment metadata: ``required_secrets``, ``required_env``,
``capabilities``, ``input_modes``, ``output_modes``,
- one or more methods decorated with :func:`skill`.
"""
name: ClassVar[str] = ""
description: ClassVar[str] = ""
version: ClassVar[str] = "0.1.0"
config_model: ClassVar[type[BaseModel]] = _EmptyConfig
auth_model: ClassVar[type[BaseModel]] = NoAuth
required_secrets: ClassVar[tuple[str, ...]] = ()
required_env: ClassVar[tuple[str, ...]] = ()
capabilities: ClassVar[dict[str, Any]] = {}
input_modes: ClassVar[tuple[str, ...]] = ("application/json",)
output_modes: ClassVar[tuple[str, ...]] = ("application/json",)
# --- runtime / deployment declaration (read by the platform deployer) ---
# Sandbox is always microsandbox; not exposed as a knob.
lifecycle: ClassVar[Lifecycle] = Lifecycle.EPHEMERAL
state: ClassVar[State] = State.NONE
state_model: ClassVar[type[BaseModel] | None] = None
resources: ClassVar[Resources] = Resources()
concurrency: ClassVar[int] = 1
egress: ClassVar[EgressPolicy] = EgressPolicy()
tools_used: ClassVar[tuple[str, ...]] = ()
workspace_access: ClassVar[WorkspaceAccess] = WorkspaceAccess.none()
_skills: ClassVar[dict[str, SkillSpec]] = {}
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
if not cls.name:
raise TypeError(
f"{cls.__name__}.name must be set as a class attribute"
)
if cls.state is not State.NONE and cls.state_model is None:
raise TypeError(
f"{cls.__name__} declares state={cls.state.value!r} but "
f"state_model is not set"
)
if cls.lifecycle is Lifecycle.EPHEMERAL and cls.state is State.SESSION:
raise TypeError(
f"{cls.__name__}: lifecycle=ephemeral is incompatible with "
f"state=session"
)
@classmethod
def runtime(cls) -> AgentRuntime:
"""Aggregate the class-level runtime declaration.
``sandbox`` is always :attr:`Sandbox.MICROSANDBOX`; it is set here
rather than on the class so developers cannot weaken isolation.
"""
return AgentRuntime(
lifecycle=cls.lifecycle,
state=cls.state,
sandbox=Sandbox.MICROSANDBOX,
resources=cls.resources,
concurrency=cls.concurrency,
egress=cls.egress,
tools_used=cls.tools_used,
)
def __init__(self, config: ConfigT | dict[str, Any] | None = None) -> None:
validated = type(self).config_model.model_validate(config or {})
self.config: ConfigT = typing.cast(ConfigT, validated)
@property
def skills(self) -> dict[str, SkillSpec]:
return type(self)._skills
async def startup(self, ctx: RunContext[AuthT]) -> None:
"""Called once before the first invocation. Override to set up state."""
async def shutdown(self, ctx: RunContext[AuthT]) -> None:
"""Called once before the agent process exits. Override to tear down."""
async def health(self) -> bool:
"""Lightweight liveness check. Override to add real probes."""
return True
async def invoke(
self,
skill_name: str,
ctx: RunContext[AuthT],
/,
**kwargs: Any,
) -> Any:
"""Invoke a skill with caller-supplied kwargs.
Inputs are validated and coerced via the skill's pydantic schema.
Required scopes are enforced against ``ctx.auth`` before the handler
runs. The raw handler return value is returned (Python-typed).
"""
spec = self.skills.get(skill_name)
if spec is None:
raise SkillNotFound(skill_name)
ctx.require_scopes(spec.scopes)
try:
validated = self._validate_inputs(spec, kwargs)
except Exception as exc:
raise SkillInputError(
f"invalid input for skill {spec.name!r}: {exc}"
) from exc
try:
return await spec.handler(self, ctx, **validated)
except SkillInputError:
raise
except Exception as exc:
raise SkillInvocationError(
f"skill {spec.name!r} raised {type(exc).__name__}: {exc}"
) from exc
async def invoke_json(
self,
skill_name: str,
ctx: RunContext[AuthT],
payload: dict[str, Any],
) -> Any:
"""Runtime-facing invoke: takes JSON-shaped payload, returns JSON-shaped result."""
spec = self.skills.get(skill_name)
if spec is None:
raise SkillNotFound(skill_name)
result = await self.invoke(skill_name, ctx, **payload)
if spec.output_adapter is None:
return result
return spec.output_adapter.dump_python(result, mode="json")
async def local_invoke(
self,
skill_name: str,
/,
*,
auth: AuthT | None = None,
secrets: dict[str, str] | None = None,
task_id: str = "local-task",
workspace: Any = None, # WorkspaceClient or None
**kwargs: Any,
) -> Any:
"""Convenience harness: build a :class:`LocalRunContext` and invoke.
Useful in tests and notebooks. ``auth`` defaults to a default-constructed
instance of the agent's ``auth_model`` (works for :class:`NoAuth`; pass
an explicit instance for auth models with required fields). Pass
``workspace=`` to bind a :class:`WorkspaceClient`.
"""
if auth is None:
auth = typing.cast(AuthT, type(self).auth_model())
ctx: LocalRunContext[AuthT] = LocalRunContext(
auth=auth, secrets=secrets, task_id=task_id, workspace=workspace
)
return await self.invoke(skill_name, ctx, **kwargs)
def card(self) -> AgentCard:
return AgentCard.from_agent(self)
@staticmethod
def _validate_inputs(
spec: SkillSpec, kwargs: dict[str, Any]
) -> dict[str, Any]:
known = {p.name for p in spec.params}
unknown = set(kwargs) - known
if unknown:
raise ValueError(f"unknown parameters: {sorted(unknown)}")
validated: dict[str, Any] = {}
for p in spec.params:
if p.name in kwargs:
validated[p.name] = p.adapter.validate_python(kwargs[p.name])
elif not p.has_default:
raise ValueError(f"missing required parameter: {p.name!r}")
# else: omit so the handler's own default applies
return validated

35
a2a_pack/auth.py Normal file
View File

@@ -0,0 +1,35 @@
"""Pluggable auth principal models.
These describe *who* is invoking a skill. The runtime auth provider produces
an instance of the agent's declared ``auth_model`` and hands it to the
:class:`RunContext`.
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
class NoAuth(BaseModel):
"""Public agent: no caller identity required."""
model_config = ConfigDict(extra="forbid", frozen=True)
class APIKeyAuth(BaseModel):
"""Caller authenticated by a long-lived API key."""
model_config = ConfigDict(extra="forbid", frozen=True)
api_key_id: str
scopes: list[str] = Field(default_factory=list)
class JWTAuth(BaseModel):
"""Caller authenticated by a JWT (typically from a user-facing login)."""
model_config = ConfigDict(extra="forbid", frozen=True)
sub: str
org_id: str | None = None
email: str | None = None
scopes: list[str] = Field(default_factory=list)

87
a2a_pack/card.py Normal file
View File

@@ -0,0 +1,87 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict, Field
from .runtime import AgentRuntime, SkillPolicy
from .workspace import WorkspaceAccess
if TYPE_CHECKING:
from .agent import A2AAgent
class SkillCard(BaseModel):
"""Public description of a single skill, shaped for the A2A spec."""
model_config = ConfigDict(extra="forbid")
id: str
name: str
description: str
tags: list[str] = Field(default_factory=list)
scopes: list[str] = Field(default_factory=list)
stream: bool = False
policy: SkillPolicy = Field(default_factory=SkillPolicy)
input_schema: dict[str, Any]
output_schema: dict[str, Any]
class AgentCard(BaseModel):
"""Public description of an agent.
Mirrors the A2A Agent Card spec: identity, capabilities, IO modes, and
the catalog of skills the agent advertises.
"""
model_config = ConfigDict(extra="forbid")
name: str
description: str
version: str
skills: list[SkillCard]
capabilities: dict[str, Any] = Field(default_factory=dict)
input_modes: list[str] = Field(default_factory=lambda: ["application/json"])
output_modes: list[str] = Field(default_factory=lambda: ["application/json"])
required_secrets: list[str] = Field(default_factory=list)
required_env: list[str] = Field(default_factory=list)
runtime: AgentRuntime = Field(default_factory=AgentRuntime)
state_schema: dict[str, Any] | None = None
workspace_access: WorkspaceAccess = Field(default_factory=WorkspaceAccess.none)
@classmethod
def from_agent(cls, agent: "A2AAgent") -> "AgentCard":
agent_cls = type(agent)
skills = [
SkillCard(
id=spec.name,
name=spec.name,
description=spec.description,
tags=list(spec.tags),
scopes=list(spec.scopes),
stream=spec.stream,
policy=spec.policy,
input_schema=spec.input_schema,
output_schema=spec.output_schema,
)
for spec in agent.skills.values()
]
state_schema = (
agent_cls.state_model.model_json_schema()
if agent_cls.state_model is not None
else None
)
return cls(
name=agent_cls.name,
description=agent_cls.description,
version=agent_cls.version,
skills=skills,
capabilities=dict(agent_cls.capabilities),
input_modes=list(agent_cls.input_modes),
output_modes=list(agent_cls.output_modes),
required_secrets=list(agent_cls.required_secrets),
required_env=list(agent_cls.required_env),
runtime=agent_cls.runtime(),
state_schema=state_schema,
workspace_access=agent_cls.workspace_access,
)

1
a2a_pack/cli/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""a2a CLI package."""

View File

@@ -0,0 +1,98 @@
"""Thin HTTP client for the control plane API."""
from __future__ import annotations
from typing import Any
import httpx
class ApiError(RuntimeError):
def __init__(self, status: int, message: str) -> None:
self.status = status
super().__init__(f"API {status}: {message}")
class ControlPlaneClient:
def __init__(self, api_url: str, token: str | None = None) -> None:
self.api_url = api_url.rstrip("/")
self.token = token
def _headers(self) -> dict[str, str]:
h = {"content-type": "application/json"}
if self.token:
h["authorization"] = f"bearer {self.token}"
return h
def _request(self, method: str, path: str, **kw: Any) -> Any:
url = f"{self.api_url}{path}"
with httpx.Client(timeout=30.0) as c:
resp = c.request(method, url, headers=self._headers(), **kw)
if resp.status_code >= 400:
try:
detail = resp.json().get("detail", resp.text)
except Exception: # noqa: BLE001
detail = resp.text
raise ApiError(resp.status_code, str(detail))
if resp.status_code == 204 or not resp.content:
return None
return resp.json()
def signup(self, email: str, password: str) -> dict[str, Any]:
return self._request("POST", "/v1/auth/signup", json={"email": email, "password": password})
def login(self, email: str, password: str) -> dict[str, Any]:
return self._request("POST", "/v1/auth/login", json={"email": email, "password": password})
def me(self) -> dict[str, Any]:
return self._request("GET", "/v1/me")
def register_agent(
self,
*,
name: str,
description: str,
version: str,
image: str,
public: bool,
card: dict[str, Any],
) -> dict[str, Any]:
return self._request(
"POST",
"/v1/agents",
json={
"name": name,
"description": description,
"version": version,
"image": image,
"public": public,
"card": card,
},
)
def from_source(
self,
*,
name: str,
description: str,
version: str,
public: bool,
) -> dict[str, Any]:
return self._request(
"POST",
"/v1/agents/from-source",
json={
"name": name,
"description": description,
"version": version,
"public": public,
},
)
def list_agents(self) -> list[dict[str, Any]]:
return self._request("GET", "/v1/agents")
def get_agent(self, name: str) -> dict[str, Any]:
return self._request("GET", f"/v1/agents/{name}")
def delete_agent(self, name: str) -> None:
self._request("DELETE", f"/v1/agents/{name}")

View File

@@ -0,0 +1,68 @@
"""Credentials store at ``~/.a2a/credentials.json``."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
DEFAULT_API_URL = "http://api.127-0-0-1.nip.io"
def _config_dir() -> Path:
return Path.home() / ".a2a"
def _creds_path() -> Path:
return _config_dir() / "credentials.json"
@dataclass
class Credentials:
api_url: str
token: str
email: str
def save(api_url: str, token: str, email: str) -> Path:
d = _config_dir()
d.mkdir(parents=True, exist_ok=True)
path = _creds_path()
path.write_text(json.dumps({"api_url": api_url, "token": token, "email": email}))
os.chmod(path, 0o600)
return path
def load() -> Credentials | None:
path = _creds_path()
if not path.exists():
return None
try:
data = json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return None
return Credentials(
api_url=data.get("api_url", DEFAULT_API_URL),
token=data["token"],
email=data.get("email", ""),
)
def clear() -> bool:
path = _creds_path()
if path.exists():
path.unlink()
return True
return False
def resolve_api_url(override: str | None = None) -> str:
if override:
return override
env = os.environ.get("A2A_API_URL")
if env:
return env
creds = load()
if creds is not None:
return creds.api_url
return DEFAULT_API_URL

34
a2a_pack/cli/loader.py Normal file
View File

@@ -0,0 +1,34 @@
"""Load an :class:`A2AAgent` subclass from a string entrypoint."""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
from ..agent import A2AAgent
def load_agent_class(entrypoint: str, *, project_dir: Path | None = None) -> type[A2AAgent]:
"""Resolve ``module:ClassName`` to an :class:`A2AAgent` subclass.
If ``project_dir`` is given, it is prepended to ``sys.path`` so a local
``agent.py`` can be imported without packaging.
"""
if ":" not in entrypoint:
raise ValueError(
f"entrypoint must be 'module:ClassName' (got {entrypoint!r})"
)
module_name, class_name = entrypoint.split(":", 1)
if project_dir is not None:
path = str(project_dir.resolve())
if path not in sys.path:
sys.path.insert(0, path)
module = importlib.import_module(module_name)
obj = getattr(module, class_name, None)
if obj is None:
raise AttributeError(f"{module_name} has no attribute {class_name!r}")
if not isinstance(obj, type) or not issubclass(obj, A2AAgent):
raise TypeError(f"{entrypoint} is not an A2AAgent subclass")
return obj

430
a2a_pack/cli/main.py Normal file
View File

@@ -0,0 +1,430 @@
"""``a2a`` CLI: scaffold, validate, build, deploy."""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
import tempfile
from importlib import resources
from pathlib import Path
from typing import Any
import typer
import yaml
from rich.console import Console
from rich.panel import Panel
from . import credentials
from .api_client import ApiError, ControlPlaneClient
from .loader import load_agent_class
from .manifests import INGRESS_HOST_TEMPLATE, NAMESPACE, render_manifests
app = typer.Typer(
add_completion=False,
no_args_is_help=True,
help="Build, package, and deploy A2A agents.",
)
console = Console()
DEFAULT_REGISTRY_HOST = "localhost:30500"
SDK_PYPI_PACKAGE = "a2a_pack"
# --------------------------------------------------------------------------- #
# helpers #
# --------------------------------------------------------------------------- #
def _fail(msg: str, code: int = 1) -> None:
console.print(f"[bold red]error:[/] {msg}")
raise typer.Exit(code)
def _read_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
_fail(f"missing {path.name} (run `a2a init` first)")
return yaml.safe_load(path.read_text()) or {}
def _render_template(template: str, /, **vars: Any) -> str:
text = (
resources.files("a2a_pack.cli.templates")
.joinpath(template)
.read_text(encoding="utf-8")
)
# tiny mustache-style substitution; avoids pulling Jinja just for {{ x }}
for k, v in vars.items():
text = text.replace("{{ " + k + " }}", str(v))
return text
def _slug_to_class(slug: str) -> str:
parts = re.split(r"[-_\s]+", slug.strip())
return "".join(p[:1].upper() + p[1:].lower() for p in parts if p) or "MyAgent"
def _git_short_sha(project: Path) -> str | None:
try:
out = subprocess.run(
["git", "-C", str(project), "rev-parse", "--short=12", "HEAD"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
return out or None
except (subprocess.CalledProcessError, FileNotFoundError):
return None
def _sdk_source_dir() -> Path:
"""Locate the on-disk a2a_pack source for inclusion in the build context."""
pkg_dir = Path(__file__).resolve().parents[1] # .../a2a_pack
project_root = pkg_dir.parent # .../apps/a2a
if not (project_root / "pyproject.toml").exists():
_fail(f"could not find a2a-pack source root from {pkg_dir}")
return project_root
def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
console.print(f"[dim]$ {' '.join(cmd)}[/]")
return subprocess.run(cmd, check=True, text=True, **kwargs)
# --------------------------------------------------------------------------- #
# auth #
# --------------------------------------------------------------------------- #
def _client(api: str | None = None) -> ControlPlaneClient:
api_url = credentials.resolve_api_url(api)
creds = credentials.load()
token = creds.token if creds is not None else None
return ControlPlaneClient(api_url, token=token)
@app.command()
def signup(
email: str = typer.Option(..., "--email", "-e", prompt=True),
password: str = typer.Option(
..., "--password", "-p", prompt=True, hide_input=True, confirmation_prompt=True
),
api: str = typer.Option(credentials.DEFAULT_API_URL, "--api"),
) -> None:
"""Create an account on the control plane and store the JWT locally."""
try:
out = ControlPlaneClient(api).signup(email, password)
except ApiError as exc:
_fail(str(exc))
credentials.save(api, out["access_token"], out["user"]["email"])
console.print(f"[green]signed up[/] as [cyan]{out['user']['email']}[/] @ {api}")
@app.command()
def login(
email: str = typer.Option(..., "--email", "-e", prompt=True),
password: str = typer.Option(..., "--password", "-p", prompt=True, hide_input=True),
api: str = typer.Option(credentials.DEFAULT_API_URL, "--api"),
) -> None:
"""Authenticate with the control plane and cache the JWT."""
try:
out = ControlPlaneClient(api).login(email, password)
except ApiError as exc:
_fail(str(exc))
credentials.save(api, out["access_token"], out["user"]["email"])
console.print(f"[green]logged in[/] as [cyan]{out['user']['email']}[/]")
@app.command()
def logout() -> None:
"""Forget the cached JWT."""
cleared = credentials.clear()
console.print("[green]logged out[/]" if cleared else "(not logged in)")
@app.command()
def whoami() -> None:
"""Show the currently logged-in user."""
creds = credentials.load()
if creds is None:
_fail("not logged in (run `a2a login` or `a2a signup`)")
try:
me = _client().me()
except ApiError as exc:
_fail(str(exc))
console.print(f"[cyan]{me['email']}[/] ({creds.api_url})")
@app.command(name="agents")
def list_agents(
api: str | None = typer.Option(None, "--api"),
) -> None:
"""List agents visible to the current user."""
try:
rows = _client(api).list_agents()
except ApiError as exc:
_fail(str(exc))
if not rows:
console.print("(no agents)")
return
for r in rows:
url = r.get("url") or "-"
console.print(
f" [cyan]{r['name']}[/] v{r['version']} [{r['status']}] {url}"
)
# --------------------------------------------------------------------------- #
# init #
# --------------------------------------------------------------------------- #
@app.command()
def init(
name: str = typer.Argument(..., help="Agent / project slug, e.g. research-agent"),
description: str = typer.Option("A new A2A agent", "--description", "-d"),
target: Path = typer.Option(Path("."), "--target", "-t", help="Parent dir for the new project"),
) -> None:
"""Scaffold a new agent project."""
project = target / name
if project.exists():
_fail(f"{project} already exists")
project.mkdir(parents=True)
class_name = _slug_to_class(name)
files = {
"agent.py": _render_template(
"agent.py.tmpl",
name=name,
class_name=class_name,
description=description,
),
"a2a.yaml": _render_template(
"a2a.yaml.tmpl", name=name, class_name=class_name
),
"requirements.txt": _render_template("requirements.txt.tmpl"),
"Dockerfile": _render_template(
"Dockerfile.tmpl", entrypoint=f"agent:{class_name}"
),
".dockerignore": _render_template("dockerignore.tmpl"),
".gitea/workflows/build.yml": _render_template(
"workflow.yml.tmpl", name=name
),
"deploy/20-deployment.yaml": _render_template(
"deployment.yaml.tmpl", name=name
),
}
for relpath, content in files.items():
target_path = project / relpath
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content)
console.print(f" [green]+[/] {project}/{relpath}")
console.print(
Panel.fit(
f"[bold]{name}[/] scaffolded at [cyan]{project}[/]\n\n"
"next:\n"
f" cd {project}\n"
" a2a card # see what your agent declares\n"
" a2a deploy # ship it",
title="ok",
)
)
# --------------------------------------------------------------------------- #
# validate / card / run #
# --------------------------------------------------------------------------- #
@app.command()
def validate(
project: Path = typer.Option(Path("."), "--project", "-p"),
) -> None:
"""Load the agent and print its Card schema. Exits non-zero on errors."""
cfg = _read_yaml(project / "a2a.yaml")
cls = load_agent_class(cfg["entrypoint"], project_dir=project)
console.print(f"[green]ok[/] {cls.name} v{cls.version} ({len(cls._skills)} skills)")
@app.command()
def card(
project: Path = typer.Option(Path("."), "--project", "-p"),
) -> None:
"""Print the Agent Card JSON for the project's agent."""
cfg = _read_yaml(project / "a2a.yaml")
cls = load_agent_class(cfg["entrypoint"], project_dir=project)
console.print_json(cls().card().model_dump_json())
@app.command()
def run(
entrypoint: str = typer.Option(..., "--entrypoint", "-e", help="module:Class"),
host: str = typer.Option("0.0.0.0", "--host"),
port: int = typer.Option(8000, "--port"),
project: Path = typer.Option(Path("."), "--project", "-p"),
) -> None:
"""Run the agent's HTTP server locally (used inside the container too)."""
from ..serve import serve
cls = load_agent_class(entrypoint, project_dir=project)
serve(cls(), host=host, port=port)
# --------------------------------------------------------------------------- #
# build / deploy #
# --------------------------------------------------------------------------- #
def _resolve_image_ref(name: str, version: str, project: Path, registry: str) -> str:
sha = _git_short_sha(project)
tag = f"{version}-{sha}" if sha else version
return f"{registry}/agents/{name}:{tag}"
def _stage_build_context(project: Path, dst: Path) -> None:
"""Copy project files + bundled SDK source into a temp build dir."""
for item in project.iterdir():
if item.name in {".git", ".venv", "__pycache__", "_a2a_sdk"}:
continue
target = dst / item.name
if item.is_dir():
shutil.copytree(item, target, ignore=shutil.ignore_patterns("__pycache__"))
else:
shutil.copy2(item, target)
sdk_src = _sdk_source_dir()
sdk_dst = dst / "_a2a_sdk"
shutil.copytree(
sdk_src,
sdk_dst,
ignore=shutil.ignore_patterns(".venv", "dist", "build", "__pycache__", "*.egg-info", ".pytest_cache"),
)
@app.command()
def build(
project: Path = typer.Option(Path("."), "--project", "-p"),
registry: str = typer.Option(DEFAULT_REGISTRY_HOST, "--registry"),
push: bool = typer.Option(False, "--push", help="Also push the built image"),
) -> None:
"""Build (and optionally push) the container image for the agent."""
cfg = _read_yaml(project / "a2a.yaml")
image = _resolve_image_ref(cfg["name"], cfg["version"], project, registry)
with tempfile.TemporaryDirectory(prefix="a2a-build-") as tmp:
ctx = Path(tmp)
_stage_build_context(project, ctx)
_run(["docker", "build", "-t", image, str(ctx)])
console.print(f"[green]built[/] [cyan]{image}[/]")
if push:
_run(["docker", "push", image])
console.print(f"[green]pushed[/] [cyan]{image}[/]")
def _git_push_source(project: Path, push_url: str) -> None:
"""Initialize the project as a git repo (if needed) and push to ``push_url``.
Idempotent: re-running on an existing repo just commits any changes
and pushes.
"""
git_dir = project / ".git"
if not git_dir.exists():
_run(["git", "-C", str(project), "init", "-q", "-b", "main"])
_run(["git", "-C", str(project), "config", "user.email", "agent@a2a.local"])
_run(["git", "-C", str(project), "config", "user.name", "agent"])
# Ensure remote
have_remote = subprocess.run(
["git", "-C", str(project), "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=False,
).returncode == 0
if have_remote:
_run(["git", "-C", str(project), "remote", "set-url", "origin", push_url])
else:
_run(["git", "-C", str(project), "remote", "add", "origin", push_url])
_run(["git", "-C", str(project), "add", "-A"])
status = subprocess.run(
["git", "-C", str(project), "status", "--porcelain"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
if status:
_run(["git", "-C", str(project), "commit", "-q", "-m", "deploy"])
# Pull --rebase first to integrate any auto-bump commits the runner pushed back.
subprocess.run(
["git", "-C", str(project), "pull", "--rebase", "origin", "main"],
capture_output=True,
text=True,
check=False,
)
_run(["git", "-C", str(project), "push", "-u", "origin", "main"])
@app.command()
def deploy(
project: Path = typer.Option(Path("."), "--project", "-p"),
public: bool | None = typer.Option(None, "--public/--private"),
api: str | None = typer.Option(None, "--api", help="Override control plane URL"),
) -> None:
"""Push source to gitea; the platform builds + deploys.
No local docker. The CLI:
1. asks the control plane to provision a gitea repo + ArgoCD app
2. ``git push``es the project source to that repo
The Gitea Actions runner builds the image and ArgoCD reconciles
``deploy/`` onto the cluster. Returns the public URL once registered.
"""
creds = credentials.load()
if creds is None:
_fail("not logged in (run `a2a signup` or `a2a login`)")
cfg = _read_yaml(project / "a2a.yaml")
cls = load_agent_class(cfg["entrypoint"], project_dir=project)
is_public = (
public if public is not None else bool(cfg.get("expose", {}).get("public", True))
)
description = cfg.get("description", cls.description or "")
if not (project / "deploy").exists() or not (project / ".gitea/workflows/build.yml").exists():
_fail(
"project missing deploy/ or .gitea/workflows/. "
"Re-run `a2a init` or upgrade the scaffold."
)
client = _client(api)
console.print(f"[dim]asking control plane to provision repo + argo app...[/]")
try:
prov = client.from_source(
name=cls.name,
description=description,
version=cfg["version"],
public=is_public,
)
except ApiError as exc:
_fail(str(exc))
console.print(f"[dim]pushing source → {prov['repo_url']}[/]")
_git_push_source(project, prov["push_url"])
summary = {
"agent": prov["name"],
"repo": prov["repo_url"],
"url": prov.get("expected_url"),
"card": f"{prov['expected_url']}/.well-known/agent-card" if prov.get("expected_url") else None,
"next": "the runner is building. `a2a agents` to check status, or curl the url in ~30s",
}
console.print(
Panel.fit(
json.dumps(summary, indent=2),
title="[bold green]shipped[/]",
)
)
# Used as `python -m a2a_pack.cli.main`
if __name__ == "__main__": # pragma: no cover
app()

133
a2a_pack/cli/manifests.py Normal file
View File

@@ -0,0 +1,133 @@
"""Generate Kubernetes manifests for a deployed agent.
Targets the existing local cluster: namespace ``agents``, registry at
``localhost:30500``, traefik ingress at ``<name>.127-0-0-1.nip.io``.
"""
from __future__ import annotations
from typing import Any
import yaml
from ..agent import A2AAgent
NAMESPACE = "agents"
INGRESS_HOST_TEMPLATE = "{name}.127-0-0-1.nip.io"
def render_manifests(
agent_cls: type[A2AAgent],
*,
image: str,
public: bool = True,
) -> str:
"""Return a multi-doc YAML string ready for ``kubectl apply -f -``."""
rt = agent_cls.runtime()
name = agent_cls.name
docs: list[dict[str, Any]] = []
docs.append(
{
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {"name": NAMESPACE},
}
)
docs.append(
{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": name,
"namespace": NAMESPACE,
"labels": {
"app": name,
"a2a/version": agent_cls.version,
"a2a/lifecycle": rt.lifecycle.value,
},
},
"spec": {
"replicas": 1,
"selector": {"matchLabels": {"app": name}},
"template": {
"metadata": {"labels": {"app": name}},
"spec": {
"containers": [
{
"name": "agent",
"image": image,
"imagePullPolicy": "Always",
"ports": [{"containerPort": 8000, "name": "http"}],
"readinessProbe": {
"httpGet": {"path": "/healthz", "port": 8000},
"initialDelaySeconds": 2,
"periodSeconds": 5,
},
"livenessProbe": {
"httpGet": {"path": "/healthz", "port": 8000},
"initialDelaySeconds": 10,
"periodSeconds": 15,
},
"resources": {
"requests": {
"cpu": rt.resources.cpu,
"memory": rt.resources.memory,
},
"limits": {
"cpu": rt.resources.cpu,
"memory": rt.resources.memory,
},
},
}
]
},
},
},
}
)
docs.append(
{
"apiVersion": "v1",
"kind": "Service",
"metadata": {"name": name, "namespace": NAMESPACE},
"spec": {
"type": "ClusterIP",
"selector": {"app": name},
"ports": [{"name": "http", "port": 80, "targetPort": 8000}],
},
}
)
if public:
docs.append(
{
"apiVersion": "networking.k8s.io/v1",
"kind": "Ingress",
"metadata": {"name": name, "namespace": NAMESPACE},
"spec": {
"rules": [
{
"host": INGRESS_HOST_TEMPLATE.format(name=name),
"http": {
"paths": [
{
"path": "/",
"pathType": "Prefix",
"backend": {
"service": {
"name": name,
"port": {"number": 80},
}
},
}
]
},
}
]
},
}
)
return "---\n".join(yaml.safe_dump(d, sort_keys=False) for d in docs)

View File

@@ -0,0 +1,14 @@
FROM registry.127-0-0-1.nip.io/a2a/a2a-pack-base:latest
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV A2A_ENTRYPOINT={{ entrypoint }}
ENV PORT=8000
EXPOSE 8000
CMD a2a run --entrypoint "$A2A_ENTRYPOINT" --host 0.0.0.0 --port 8000

View File

@@ -0,0 +1,8 @@
# Project identity for `a2a deploy`. Most metadata (resources, scopes,
# secrets, workspace, etc.) lives on the Python class — this file only
# tells the CLI how to find it.
name: {{ name }}
version: 0.1.0
entrypoint: agent:{{ class_name }}
expose:
public: true

View File

@@ -0,0 +1,24 @@
"""{{ name }} agent."""
from __future__ import annotations
from pydantic import BaseModel
from a2a_pack import A2AAgent, NoAuth, RunContext, skill
class {{ class_name }}Config(BaseModel):
pass
class {{ class_name }}(A2AAgent[{{ class_name }}Config, NoAuth]):
name = "{{ name }}"
description = "{{ description }}"
version = "0.1.0"
config_model = {{ class_name }}Config
auth_model = NoAuth
@skill(description="Say hello")
async def hello(self, ctx: RunContext[NoAuth], who: str = "world") -> str:
await ctx.emit_progress(f"greeting {who}")
return f"hello {who}"

View File

@@ -0,0 +1,71 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ name }}
namespace: agents
labels:
app: {{ name }}
a2a/managed-by: control-plane
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: {{ name }}
template:
metadata:
labels:
app: {{ name }}
spec:
containers:
- name: agent
# tag is rewritten by the build workflow on every push
image: registry.127-0-0-1.nip.io/agents/{{ name }}:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
name: http
readinessProbe:
httpGet: {path: /healthz, port: 8000}
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet: {path: /healthz, port: 8000}
initialDelaySeconds: 10
periodSeconds: 15
resources:
requests: {cpu: 100m, memory: 256Mi}
limits: {cpu: 200m, memory: 256Mi}
---
apiVersion: v1
kind: Service
metadata:
name: {{ name }}
namespace: agents
spec:
type: ClusterIP
selector:
app: {{ name }}
ports:
- name: http
port: 80
targetPort: 8000
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ name }}
namespace: agents
spec:
rules:
- host: {{ name }}.127-0-0-1.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ name }}
port:
number: 80

View File

@@ -0,0 +1,7 @@
__pycache__
*.pyc
.venv
.git
.pytest_cache
.mypy_cache
node_modules

View File

@@ -0,0 +1 @@
# add agent-specific deps here; a2a-pack is auto-installed by the deploy build

View File

@@ -0,0 +1,35 @@
name: build
on:
push:
branches: [main]
paths-ignore:
- 'deploy/**'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: build image
run: |
IMG=registry.127-0-0-1.nip.io/agents/{{ name }}
docker build -t "$IMG:$GITHUB_SHA" -t "$IMG:latest" .
docker push "$IMG:$GITHUB_SHA"
docker push "$IMG:latest"
- name: bump deploy manifest
run: |
IMG=registry.127-0-0-1.nip.io/agents/{{ name }}
sed -i "s|image: $IMG:.*|image: $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 "http://gitea_admin:gitea_admin@gitea-http.gitea.svc.cluster.local:3000/gitea_admin/{{ name }}.git" HEAD:main
fi

192
a2a_pack/context.py Normal file
View File

@@ -0,0 +1,192 @@
"""Runtime context handed to skill handlers.
The same agent code runs unchanged on local dev, Docker, Kubernetes, and
hosted runtimes — the runtime provides a concrete :class:`RunContext` that
implements artifact storage, secret access, streaming, and cancellation.
"""
from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Generic, Sequence, TypeVar
from pydantic import BaseModel
from .workspace import WorkspaceClient
AuthT = TypeVar("AuthT", bound=BaseModel)
class CancelledByCaller(RuntimeError):
"""Raised by :meth:`RunContext.check_cancelled` when the caller cancelled."""
class MissingScopes(PermissionError):
"""Raised by :meth:`RunContext.require_scopes` when caller lacks scopes."""
def __init__(self, missing: Sequence[str]) -> None:
self.missing = tuple(missing)
super().__init__(f"missing scopes: {sorted(self.missing)}")
@dataclass(frozen=True)
class ArtifactRef:
"""Opaque handle to a stored artifact (blob, file, etc.)."""
name: str
uri: str
mime_type: str
size_bytes: int
@dataclass(frozen=True)
class AgentEvent:
"""A structured event emitted during a skill run."""
kind: str
payload: dict[str, Any] = field(default_factory=dict)
class RunContext(ABC, Generic[AuthT]):
"""Per-invocation context.
A new context is constructed by the runtime for every skill call. It
carries caller identity (``auth``), the task identity, and runtime
capabilities (artifacts, secrets, streaming, cancellation).
Agents must depend only on this abstract interface, never on a concrete
runtime implementation.
"""
task_id: str
auth: AuthT
@abstractmethod
async def emit_event(self, event: AgentEvent) -> None:
"""Publish a structured event to subscribers (UI, logs, traces)."""
@abstractmethod
async def write_artifact(
self, name: str, data: bytes, mime_type: str
) -> ArtifactRef:
"""Persist ``data`` as a named artifact and return a reference."""
@abstractmethod
async def check_cancelled(self) -> None:
"""Raise :class:`CancelledByCaller` if the caller cancelled."""
@abstractmethod
def secret(self, name: str) -> str:
"""Look up a runtime-injected secret by logical name."""
@property
@abstractmethod
def workspace(self) -> WorkspaceClient:
"""Negotiation surface for workspace access.
Raises if the agent's :attr:`A2AAgent.workspace_access` is disabled.
"""
# --- concrete helpers built on emit_event ---
async def emit_progress(self, message: str) -> None:
"""Emit a human-readable progress event."""
await self.emit_event(AgentEvent(kind="progress", payload={"message": message}))
async def emit_text_delta(self, text: str) -> None:
"""Emit a streamed token chunk (for LLM-style streaming output)."""
await self.emit_event(AgentEvent(kind="text_delta", payload={"text": text}))
async def emit_artifact(self, ref: ArtifactRef) -> None:
"""Notify subscribers that a new artifact is available."""
await self.emit_event(
AgentEvent(
kind="artifact",
payload={
"name": ref.name,
"uri": ref.uri,
"mime_type": ref.mime_type,
"size_bytes": ref.size_bytes,
},
)
)
async def emit_error(self, message: str, *, code: str | None = None) -> None:
"""Emit a structured error event (does not raise)."""
await self.emit_event(
AgentEvent(kind="error", payload={"message": message, "code": code})
)
def require_scopes(self, required: Sequence[str]) -> None:
"""Raise :class:`MissingScopes` if ``self.auth`` lacks any required scope.
Auth models without a ``scopes`` attribute (e.g. :class:`NoAuth`) are
treated as having an empty scope set.
"""
if not required:
return
auth_scopes = set(getattr(self.auth, "scopes", ()) or ())
missing = [s for s in required if s not in auth_scopes]
if missing:
raise MissingScopes(missing)
class LocalRunContext(RunContext[AuthT]):
"""In-memory context for local dev and tests.
Stores events and artifacts in lists/dicts. Secrets come from a plain
mapping. Cancellation is driven by an :class:`asyncio.Event`.
"""
def __init__(
self,
*,
auth: AuthT,
task_id: str = "local-task",
secrets: dict[str, str] | None = None,
workspace: WorkspaceClient | None = None,
) -> None:
self.task_id = task_id
self.auth = auth
self._secrets: dict[str, str] = dict(secrets or {})
self._workspace = workspace
self._cancel = asyncio.Event()
self.events: list[AgentEvent] = []
self.artifacts: dict[str, bytes] = {}
@property
def workspace(self) -> WorkspaceClient:
if self._workspace is None:
raise PermissionError(
"no workspace bound to this context; agent did not declare "
"workspace_access or runtime did not provision one"
)
return self._workspace
async def emit_event(self, event: AgentEvent) -> None:
self.events.append(event)
async def write_artifact(
self, name: str, data: bytes, mime_type: str
) -> ArtifactRef:
self.artifacts[name] = data
return ArtifactRef(
name=name,
uri=f"memory://{self.task_id}/{name}",
mime_type=mime_type,
size_bytes=len(data),
)
async def check_cancelled(self) -> None:
if self._cancel.is_set():
raise CancelledByCaller(self.task_id)
def cancel(self) -> None:
self._cancel.set()
def secret(self, name: str) -> str:
try:
return self._secrets[name]
except KeyError as exc:
raise KeyError(f"unknown secret: {name!r}") from exc

85
a2a_pack/runtime.py Normal file
View File

@@ -0,0 +1,85 @@
"""Declarative runtime/deployment metadata.
These types describe *how* the platform should run an agent: lifecycle,
state needs, isolation level, resource budget, egress policy. They are
read by the deployer and by the registry; agent code itself should not
depend on which runtime is selected.
"""
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, PositiveInt
class Lifecycle(str, Enum):
"""How long an instance of the agent process lives."""
EPHEMERAL = "ephemeral" # spawned per-invocation, torn down after
SESSION = "session" # spawned per-session, kept until the session ends
WARM = "warm" # long-running service, multiplexed across callers
class State(str, Enum):
"""What kind of state the agent retains between invocations."""
NONE = "none" # purely functional
SESSION = "session" # in-memory state for the lifetime of a session
DURABLE = "durable" # persisted across restarts (storage required)
class Sandbox(str, Enum):
"""Isolation level. The platform always runs agents under microsandbox.
Modeled as an enum (rather than a constant) so the wire format stays
stable if more isolation tiers are added later, but only one value is
currently valid: every agent runs in a microvm-class sandbox.
"""
MICROSANDBOX = "microsandbox"
class Resources(BaseModel):
"""Resource budget hint for the deployer."""
model_config = ConfigDict(extra="forbid", frozen=True)
cpu: str = "100m" # k8s-style CPU spec, e.g. "500m", "2"
memory: str = "256Mi" # k8s-style memory, e.g. "512Mi", "4Gi"
gpu: NonNegativeInt = 0
max_runtime_seconds: PositiveInt = 600
class SkillPolicy(BaseModel):
"""Per-skill operational policy advertised on the Agent Card."""
model_config = ConfigDict(extra="forbid", frozen=True)
timeout_seconds: float | None = None
idempotent: bool = False
max_retries: NonNegativeInt = 0
cost_class: str | None = None # informational, e.g. "cheap" / "expensive"
class EgressPolicy(BaseModel):
"""What external hosts the agent is allowed to talk to."""
model_config = ConfigDict(extra="forbid", frozen=True)
allow_hosts: tuple[str, ...] = ()
allow_internal_services: tuple[str, ...] = () # e.g. cluster service DNS
deny_internet_by_default: bool = True
class AgentRuntime(BaseModel):
"""Aggregate runtime declaration; published on the Agent Card."""
model_config = ConfigDict(extra="forbid", frozen=True)
lifecycle: Lifecycle = Lifecycle.EPHEMERAL
state: State = State.NONE
sandbox: Sandbox = Sandbox.MICROSANDBOX
resources: Resources = Field(default_factory=Resources)
concurrency: PositiveInt = 1
egress: EgressPolicy = Field(default_factory=EgressPolicy)
tools_used: tuple[str, ...] = ()

View File

@@ -0,0 +1,3 @@
from .asgi import build_app, serve
__all__ = ["build_app", "serve"]

104
a2a_pack/serve/asgi.py Normal file
View File

@@ -0,0 +1,104 @@
"""HTTP adapter that turns any :class:`A2AAgent` into a service.
This is intentionally minimal: it covers the surface needed to plug into
the wider A2A ecosystem and the platform's control plane.
Endpoints:
GET /healthz -> liveness
GET /.well-known/agent-card -> Agent Card JSON
POST /invoke/{skill} -> invoke skill (JSON in, JSON out)
Auth: a single bearer token is read from the ``A2A_API_KEY`` env var. If set,
all routes except ``/healthz`` and the card require ``Authorization: Bearer
<key>``. The bearer token is materialized into the agent's declared
``auth_model`` (best-effort: APIKeyAuth, otherwise NoAuth).
"""
from __future__ import annotations
import os
from typing import Any
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
from ..agent import A2AAgent, SkillInputError, SkillNotFound
from ..auth import APIKeyAuth, NoAuth
from ..context import LocalRunContext, MissingScopes
class _InvokeIn(BaseModel):
arguments: dict[str, Any] = {}
def build_app(agent: A2AAgent) -> FastAPI:
"""Build a FastAPI app for the given agent instance."""
app = FastAPI(title=type(agent).name, version=type(agent).version)
api_key = os.environ.get("A2A_API_KEY")
def _build_auth(provided_key: str | None) -> Any:
auth_cls = type(agent).auth_model
if auth_cls is APIKeyAuth:
return APIKeyAuth(api_key_id=provided_key or "anonymous")
if auth_cls is NoAuth:
return NoAuth()
# Unknown auth model: try default-construct, else fail loudly.
try:
return auth_cls()
except Exception as exc: # pragma: no cover - depends on user model
raise HTTPException(
status_code=500,
detail=f"cannot materialize auth_model {auth_cls.__name__}: {exc}",
) from exc
def _check_key(authorization: str | None) -> str | None:
if api_key is None:
return None
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(401, "missing bearer token")
token = authorization.split(None, 1)[1].strip()
if token != api_key:
raise HTTPException(401, "invalid bearer token")
return token
@app.get("/healthz")
async def healthz() -> dict[str, Any]:
ok = await agent.health()
return {"ok": ok, "agent": type(agent).name, "version": type(agent).version}
@app.get("/.well-known/agent-card")
async def agent_card() -> dict[str, Any]:
return agent.card().model_dump(mode="json")
@app.post("/invoke/{skill_name}")
async def invoke(
skill_name: str,
body: _InvokeIn,
authorization: str | None = Header(default=None),
) -> dict[str, Any]:
token = _check_key(authorization)
ctx: LocalRunContext[Any] = LocalRunContext(
auth=_build_auth(token), task_id=f"http-{skill_name}"
)
try:
result = await agent.invoke_json(skill_name, ctx, body.arguments)
except SkillNotFound:
raise HTTPException(404, f"unknown skill: {skill_name}")
except SkillInputError as exc:
raise HTTPException(400, str(exc))
except MissingScopes as exc:
raise HTTPException(403, str(exc))
return {
"result": result,
"events": [
{"kind": e.kind, "payload": e.payload} for e in ctx.events
],
}
return app
def serve(agent: A2AAgent, *, host: str = "0.0.0.0", port: int = 8000) -> None:
"""Run the agent's HTTP server with uvicorn (blocking)."""
import uvicorn
uvicorn.run(build_app(agent), host=host, port=port, log_level="info")

446
a2a_pack/workspace.py Normal file
View File

@@ -0,0 +1,446 @@
"""Workspace capability negotiation.
Agents never receive a filesystem path. They negotiate a *view* by intent::
view = await ctx.workspace.open_view(
purpose="Fix failing payment test",
hints=["payment", "checkout"],
file_types=["python"],
max_files=10,
mode=WorkspaceMode.READ_WRITE_OVERLAY,
)
for path in view.files:
content = await view.read(path)
The runtime resolves the request (semantic search + dependency graph + git
metadata + policy + optional human approval) and returns a bounded grant.
Writes are staged as :class:`WorkspacePatch` objects, never applied directly
to the host filesystem from inside the sandbox.
"""
from __future__ import annotations
import re
from abc import ABC, abstractmethod
from datetime import datetime
from enum import Enum
from typing import Literal, Sequence
from pydantic import BaseModel, ConfigDict, NonNegativeInt, PositiveInt
class WorkspaceMode(str, Enum):
READ_ONLY = "read_only"
READ_WRITE_OVERLAY = "read_write_overlay" # writes staged as patches
READ_WRITE_DIRECT = "read_write_direct" # discouraged; needs explicit policy
class FileType(str, Enum):
PYTHON = "python"
TYPESCRIPT = "typescript"
JAVASCRIPT = "javascript"
YAML = "yaml"
JSON = "json"
TOML = "toml"
MARKDOWN = "markdown"
SQL = "sql"
SHELL = "shell"
OTHER = "other"
class FileMatch(BaseModel):
"""Result row from :meth:`WorkspaceClient.search`."""
model_config = ConfigDict(extra="forbid", frozen=True)
path: str
file_type: FileType
score: float = 0.0
summary: str | None = None
size_bytes: NonNegativeInt = 0
class WorkspaceGrant(BaseModel):
"""An approved access grant for a bounded set of files."""
model_config = ConfigDict(extra="forbid", frozen=True)
grant_id: str
purpose: str
files: tuple[FileMatch, ...]
mode: WorkspaceMode
reason: str
expires_at: datetime | None = None
requires_human_approval: bool = False
class WorkspacePatch(BaseModel):
"""A staged write. Not applied until the runtime/approver commits it."""
model_config = ConfigDict(extra="forbid", frozen=True)
grant_id: str
path: str
operation: Literal["create", "update", "delete"]
content: bytes | None = None # bytes for create/update, None for delete
class WorkspaceAccess(BaseModel):
"""Class-level workspace policy.
Use :meth:`none` for agents that do not touch any workspace, or
:meth:`dynamic` to allow capability negotiation under bounds.
"""
model_config = ConfigDict(extra="forbid", frozen=True)
enabled: bool = False
max_files: NonNegativeInt = 0
allowed_modes: tuple[WorkspaceMode, ...] = ()
require_reason: bool = True
deny_patterns: tuple[str, ...] = ()
require_human_approval: bool = False
max_total_size_bytes: PositiveInt = 100 * 1024 * 1024
@classmethod
def none(cls) -> "WorkspaceAccess":
return cls(enabled=False)
@classmethod
def dynamic(
cls,
*,
max_files: int = 25,
allowed_modes: Sequence[WorkspaceMode] = (WorkspaceMode.READ_ONLY,),
require_reason: bool = True,
deny_patterns: Sequence[str] = (),
require_human_approval: bool = False,
max_total_size_bytes: int = 100 * 1024 * 1024,
) -> "WorkspaceAccess":
return cls(
enabled=True,
max_files=max_files,
allowed_modes=tuple(allowed_modes),
require_reason=require_reason,
deny_patterns=tuple(deny_patterns),
require_human_approval=require_human_approval,
max_total_size_bytes=max_total_size_bytes,
)
class WorkspaceDenied(PermissionError):
"""Raised when a workspace request violates the agent's policy."""
class WorkspaceView(ABC):
"""A bounded view over a granted set of files.
Returned by :meth:`WorkspaceClient.open_view`. Reads always go to the
granted view; writes return :class:`WorkspacePatch` objects that the
runtime will commit (or reject) outside the sandbox.
"""
grant: WorkspaceGrant
@property
def files(self) -> tuple[FileMatch, ...]:
return self.grant.files
@abstractmethod
async def read(self, path: str) -> bytes: ...
@abstractmethod
async def write(self, path: str, content: bytes) -> WorkspacePatch: ...
@abstractmethod
async def delete(self, path: str) -> WorkspacePatch: ...
@abstractmethod
async def patches(self) -> tuple[WorkspacePatch, ...]: ...
class WorkspaceClient(ABC):
"""Negotiation surface handed to the agent via ``ctx.workspace``.
The concrete implementation is provided by the runtime; agents must
program against this interface only.
"""
@abstractmethod
async def search(
self,
*,
query: str,
types: Sequence[FileType] = (),
limit: int = 20,
) -> list[FileMatch]: ...
@abstractmethod
async def request_access(
self,
*,
files: Sequence[FileMatch | str],
mode: WorkspaceMode,
reason: str,
purpose: str = "",
) -> WorkspaceGrant: ...
@abstractmethod
async def open_view(
self,
*,
purpose: str,
hints: Sequence[str] = (),
file_types: Sequence[FileType] = (),
max_files: int = 10,
mode: WorkspaceMode = WorkspaceMode.READ_ONLY,
reason: str | None = None,
) -> WorkspaceView: ...
@abstractmethod
async def list_grants(self) -> list[WorkspaceGrant]: ...
# ---------------------------------------------------------------------------
# Local in-memory implementation, for dev/tests.
# ---------------------------------------------------------------------------
class LocalWorkspaceView(WorkspaceView):
def __init__(
self,
grant: WorkspaceGrant,
client: "LocalWorkspaceClient",
) -> None:
self.grant = grant
self._client = client
self._patches: list[WorkspacePatch] = []
self._granted_paths = {f.path for f in grant.files}
def _check(self, path: str) -> None:
if path not in self._granted_paths:
raise WorkspaceDenied(
f"path {path!r} not in grant {self.grant.grant_id}"
)
async def read(self, path: str) -> bytes:
self._check(path)
return self._client._files[path]
async def write(self, path: str, content: bytes) -> WorkspacePatch:
if self.grant.mode is WorkspaceMode.READ_ONLY:
raise WorkspaceDenied(f"grant is read-only: {self.grant.grant_id}")
self._check(path)
op: Literal["create", "update"] = (
"create" if path not in self._client._files else "update"
)
patch = WorkspacePatch(
grant_id=self.grant.grant_id,
path=path,
operation=op,
content=content,
)
self._patches.append(patch)
return patch
async def delete(self, path: str) -> WorkspacePatch:
if self.grant.mode is WorkspaceMode.READ_ONLY:
raise WorkspaceDenied(f"grant is read-only: {self.grant.grant_id}")
self._check(path)
patch = WorkspacePatch(
grant_id=self.grant.grant_id,
path=path,
operation="delete",
content=None,
)
self._patches.append(patch)
return patch
async def patches(self) -> tuple[WorkspacePatch, ...]:
return tuple(self._patches)
class LocalWorkspaceClient(WorkspaceClient):
"""In-memory workspace for local dev and tests.
Search is naive substring match; ranking is keyword-overlap. Real
runtime implementations replace this with embeddings/dep-graph search.
Policy enforcement (:class:`WorkspaceAccess`) IS applied here so tests
cover the rejection paths.
"""
_EXT_TO_TYPE: dict[str, FileType] = {
".py": FileType.PYTHON,
".ts": FileType.TYPESCRIPT,
".tsx": FileType.TYPESCRIPT,
".js": FileType.JAVASCRIPT,
".jsx": FileType.JAVASCRIPT,
".yaml": FileType.YAML,
".yml": FileType.YAML,
".json": FileType.JSON,
".toml": FileType.TOML,
".md": FileType.MARKDOWN,
".sql": FileType.SQL,
".sh": FileType.SHELL,
}
def __init__(
self,
files: dict[str, bytes],
*,
access: WorkspaceAccess,
) -> None:
self._files: dict[str, bytes] = dict(files)
self._access = access
self._grants: dict[str, WorkspaceGrant] = {}
self._counter = 0
def _detect(self, path: str) -> FileType:
for ext, ft in self._EXT_TO_TYPE.items():
if path.endswith(ext):
return ft
return FileType.OTHER
def _denied(self, path: str) -> bool:
for pat in self._access.deny_patterns:
regex = re.compile(_glob_to_regex(pat))
if regex.fullmatch(path):
return True
return False
async def search(
self,
*,
query: str,
types: Sequence[FileType] = (),
limit: int = 20,
) -> list[FileMatch]:
if not self._access.enabled:
raise WorkspaceDenied("workspace disabled by policy")
type_set = set(types)
terms = [t.lower() for t in re.split(r"\W+", query) if t]
out: list[FileMatch] = []
for path, data in self._files.items():
if self._denied(path):
continue
ft = self._detect(path)
if type_set and ft not in type_set:
continue
haystack = (path + "\n" + data.decode("utf-8", errors="ignore")).lower()
score = sum(haystack.count(t) for t in terms)
if score == 0:
continue
out.append(
FileMatch(
path=path,
file_type=ft,
score=float(score),
size_bytes=len(data),
)
)
out.sort(key=lambda m: -m.score)
return out[:limit]
async def request_access(
self,
*,
files: Sequence[FileMatch | str],
mode: WorkspaceMode,
reason: str,
purpose: str = "",
) -> WorkspaceGrant:
if not self._access.enabled:
raise WorkspaceDenied("workspace disabled by policy")
if mode not in self._access.allowed_modes:
raise WorkspaceDenied(f"mode {mode.value!r} not in allowed_modes")
if self._access.require_reason and not reason.strip():
raise WorkspaceDenied("reason required by policy")
normalized: list[FileMatch] = []
for f in files:
if isinstance(f, str):
if f not in self._files:
raise WorkspaceDenied(f"unknown path: {f!r}")
normalized.append(
FileMatch(
path=f,
file_type=self._detect(f),
size_bytes=len(self._files[f]),
)
)
else:
if f.path not in self._files:
raise WorkspaceDenied(f"unknown path: {f.path!r}")
normalized.append(f)
if len(normalized) > self._access.max_files:
raise WorkspaceDenied(
f"requested {len(normalized)} files, max_files={self._access.max_files}"
)
for m in normalized:
if self._denied(m.path):
raise WorkspaceDenied(f"path denied by policy: {m.path}")
total = sum(m.size_bytes for m in normalized)
if total > self._access.max_total_size_bytes:
raise WorkspaceDenied(
f"total size {total} exceeds max_total_size_bytes"
)
self._counter += 1
grant = WorkspaceGrant(
grant_id=f"grant-{self._counter}",
purpose=purpose,
files=tuple(normalized),
mode=mode,
reason=reason,
requires_human_approval=self._access.require_human_approval,
)
self._grants[grant.grant_id] = grant
return grant
async def open_view(
self,
*,
purpose: str,
hints: Sequence[str] = (),
file_types: Sequence[FileType] = (),
max_files: int = 10,
mode: WorkspaceMode = WorkspaceMode.READ_ONLY,
reason: str | None = None,
) -> WorkspaceView:
query = " ".join([purpose, *hints])
matches = await self.search(query=query, types=file_types, limit=max_files * 3)
chosen = matches[:max_files]
grant = await self.request_access(
files=chosen,
mode=mode,
reason=reason or purpose,
purpose=purpose,
)
return LocalWorkspaceView(grant, self)
async def list_grants(self) -> list[WorkspaceGrant]:
return list(self._grants.values())
def _glob_to_regex(pattern: str) -> str:
"""Translate a simple ``foo/**/*.py`` style glob to a regex."""
out: list[str] = []
i = 0
while i < len(pattern):
c = pattern[i]
if c == "*":
if i + 1 < len(pattern) and pattern[i + 1] == "*":
out.append(".*")
i += 2
if i < len(pattern) and pattern[i] == "/":
i += 1
else:
out.append("[^/]*")
i += 1
elif c == "?":
out.append("[^/]")
i += 1
elif c == ".":
out.append(r"\.")
i += 1
else:
out.append(re.escape(c))
i += 1
return "".join(out)