Skip to content

Commit 49f5d51

Browse files
authored
Add template (#1)
* Add template * Update naming in Dockerfile * Remove unused imports; improve comments
1 parent 65134e5 commit 49f5d51

File tree

10 files changed

+897
-0
lines changed

10 files changed

+897
-0
lines changed

.github/workflows/publish.yml

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
name: Publish Agent
2+
3+
# Trigger this workflow when pushing main branch and tags
4+
on:
5+
push:
6+
branches:
7+
- main
8+
tags:
9+
- 'v*' # Trigger on version tags like v1.0.0, v1.1.0
10+
11+
jobs:
12+
publish:
13+
runs-on: ubuntu-latest
14+
15+
# These permissions are required for the workflow to:
16+
# - Read repository contents (checkout code)
17+
# - Write to GitHub Container Registry (push Docker images)
18+
permissions:
19+
contents: read
20+
packages: write
21+
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@v4
25+
26+
- name: Log in to GitHub Container Registry
27+
uses: docker/login-action@v3
28+
with:
29+
registry: ghcr.io
30+
username: ${{ github.actor }}
31+
# GITHUB_TOKEN is automatically provided by GitHub Actions
32+
# No manual secret configuration needed!
33+
# It has permissions based on the 'permissions' block above
34+
password: ${{ secrets.GITHUB_TOKEN }}
35+
36+
- name: Extract metadata for Docker
37+
id: meta
38+
uses: docker/metadata-action@v5
39+
with:
40+
images: ghcr.io/${{ github.repository }}
41+
tags: |
42+
# For tags like v1.0, create tag '1.0'
43+
type=semver,pattern={{version}}
44+
# For tags like v1.0, create tag '1'
45+
type=semver,pattern={{major}}
46+
# For main branch, create tag 'latest'
47+
type=raw,value=latest,enable={{is_default_branch}}
48+
# For PRs, create tag 'pr-123'
49+
type=ref,event=pr
50+
51+
- name: Build and push Docker image
52+
id: build
53+
uses: docker/build-push-action@v5
54+
with:
55+
context: .
56+
file: Dockerfile
57+
# Only push if this is a push event (not a PR)
58+
# PRs will build but not push to avoid polluting the registry
59+
push: ${{ github.event_name != 'pull_request' }}
60+
tags: ${{ steps.meta.outputs.tags }}
61+
labels: ${{ steps.meta.outputs.labels }}
62+
# Explicitly build for linux/amd64 (GitHub Actions default)
63+
platforms: linux/amd64
64+
65+
- name: Output image digest
66+
if: github.event_name != 'pull_request'
67+
run: |
68+
echo "## Docker Image Published :rocket:" >> $GITHUB_STEP_SUMMARY
69+
echo "" >> $GITHUB_STEP_SUMMARY
70+
echo "**Tags:** ${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
71+
echo "" >> $GITHUB_STEP_SUMMARY
72+
echo "**Digest:** \`${{ steps.build.outputs.digest }}\`" >> $GITHUB_STEP_SUMMARY
73+
echo "" >> $GITHUB_STEP_SUMMARY
74+
echo "Use this digest in your MANIFEST.json for reproducibility." >> $GITHUB_STEP_SUMMARY

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.DS_Store
2+
.env
3+
.python-version
4+
5+
# Python-generated files
6+
__pycache__/
7+
*.py[oc]
8+
build/
9+
dist/
10+
wheels/
11+
*.egg-info
12+
13+
# Virtual environments
14+
.venv

Dockerfile

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
FROM ghcr.io/astral-sh/uv:python3.13-bookworm
2+
3+
RUN adduser agent
4+
USER agent
5+
WORKDIR /home/agent
6+
7+
COPY pyproject.toml uv.lock README.md ./
8+
COPY src src
9+
10+
RUN \
11+
--mount=type=cache,target=/home/agent/.cache/uv,uid=1000 \
12+
uv sync --locked
13+
14+
ENTRYPOINT ["uv", "run", "src/server.py"]
15+
CMD ["--host", "0.0.0.0"]
16+
EXPOSE 9009

README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# A2A Agent Template
2+
3+
A minimal template for building [A2A (Agent-to-Agent)](https://a2a-protocol.org/latest/) agents.
4+
5+
## Project Structure
6+
7+
```
8+
src/
9+
├─ server.py # Server setup and agent card configuration
10+
├─ executor.py # A2A request handling
11+
├─ agent.py # Your agent implementation goes here
12+
└─ messenger.py # A2A messaging utilities
13+
Dockerfile # Docker configuration
14+
pyproject.toml # Python dependencies
15+
uv.lock # Locked dependencies
16+
```
17+
18+
## Getting Started
19+
20+
1. **Create your repository** - Click "Use this template" to create your own repository from this template
21+
22+
2. **Implement your agent** - Add your agent logic to the `run` method in [`src/agent.py`](src/agent.py)
23+
24+
3. **Configure your agent card** - Fill in your agent's metadata (name, skills, description) in [`src/server.py`](src/server.py)
25+
26+
## Running Locally
27+
28+
```bash
29+
# Install dependencies
30+
uv sync
31+
32+
# Run the server
33+
uv run src/server.py
34+
```
35+
36+
## Running with Docker
37+
38+
```bash
39+
# Build the image
40+
docker build -t my-agent .
41+
42+
# Run the container
43+
docker run -p 9009:9009 my-agent
44+
```
45+
46+
## Publishing
47+
48+
The repository includes a GitHub Actions workflow that automatically builds and publishes a Docker image of your agent to GitHub Container Registry:
49+
50+
- **Push to `main`** → publishes `latest` tag:
51+
```
52+
ghcr.io/<your-username>/<your-repo-name>:latest
53+
```
54+
55+
- **Create a git tag** (e.g. `git tag v1.0.0 && git push origin v1.0.0`) → publishes version tags:
56+
```
57+
ghcr.io/<your-username>/<your-repo-name>:1.0.0
58+
ghcr.io/<your-username>/<your-repo-name>:1
59+
```

pyproject.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[project]
2+
name = "agent-template"
3+
version = "0.1.0"
4+
description = "A template for A2A agents"
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
dependencies = [
8+
"a2a-sdk[http-server]>=0.3.20",
9+
"uvicorn>=0.38.0",
10+
]

src/agent.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from a2a.server.tasks import TaskUpdater
2+
from messenger import Messenger
3+
4+
5+
class Agent:
6+
def __init__(self):
7+
self.messenger = Messenger()
8+
# initialize other state here
9+
10+
async def run(self, input_text: str, updater: TaskUpdater) -> None:
11+
"""Implement your agent logic here.
12+
13+
Args:
14+
input_text: The incoming message text
15+
updater: Report progress (update_status) and results (add_artifact)
16+
17+
Use self.messenger.talk_to_agent(message, url) to call other agents.
18+
"""
19+
raise NotImplementedError("Agent not implemented.")

src/executor.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from a2a.server.agent_execution import AgentExecutor, RequestContext
2+
from a2a.server.events import EventQueue
3+
from a2a.server.tasks import TaskUpdater
4+
from a2a.types import (
5+
Task,
6+
UnsupportedOperationError,
7+
InvalidRequestError,
8+
)
9+
from a2a.utils.errors import ServerError
10+
from a2a.utils import (
11+
new_agent_text_message,
12+
new_task,
13+
)
14+
15+
from agent import Agent
16+
17+
18+
TERMINAL_STATES = ["completed", "canceled", "rejected", "failed"]
19+
20+
21+
class Executor(AgentExecutor):
22+
def __init__(self):
23+
self.agent_store: dict[str, Agent] = {}
24+
25+
async def execute(
26+
self,
27+
context: RequestContext,
28+
event_queue: EventQueue,
29+
) -> None:
30+
msg = context.message
31+
if not msg:
32+
raise ServerError(error=InvalidRequestError(message="Missing message in request"))
33+
34+
task = context.current_task
35+
if not task or task.status.state in TERMINAL_STATES:
36+
task = new_task(msg)
37+
38+
context_id = task.context_id
39+
agent = self.agent_store.get(context_id)
40+
if not agent:
41+
agent = Agent()
42+
self.agent_store[context_id] = agent
43+
44+
await event_queue.enqueue_event(task)
45+
updater = TaskUpdater(event_queue, task.id, context_id)
46+
await updater.start_work(new_agent_text_message(text="Thinking...", context_id=context_id, task_id=task.id))
47+
48+
try:
49+
await agent.run(context.get_user_input(), updater)
50+
await updater.complete()
51+
except Exception as e:
52+
print(f"Task failed with agent error: {e}")
53+
await updater.failed(new_agent_text_message(f"Agent error: {e}", context_id=context_id))
54+
55+
async def cancel(
56+
self, request: RequestContext, event_queue: EventQueue
57+
) -> Task | None:
58+
raise ServerError(error=UnsupportedOperationError())

src/messenger.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import json
2+
from uuid import uuid4
3+
4+
import httpx
5+
from a2a.client import (
6+
A2ACardResolver,
7+
ClientConfig,
8+
ClientFactory,
9+
Consumer,
10+
)
11+
from a2a.types import (
12+
Message,
13+
Part,
14+
Role,
15+
TextPart,
16+
DataPart,
17+
)
18+
19+
20+
DEFAULT_TIMEOUT = 300
21+
22+
23+
def create_message(*, role: Role = Role.user, text: str, context_id: str | None = None) -> Message:
24+
return Message(
25+
kind="message",
26+
role=role,
27+
parts=[Part(TextPart(kind="text", text=text))],
28+
message_id=uuid4().hex,
29+
context_id=context_id
30+
)
31+
32+
def merge_parts(parts: list[Part]) -> str:
33+
chunks = []
34+
for part in parts:
35+
if isinstance(part.root, TextPart):
36+
chunks.append(part.root.text)
37+
elif isinstance(part.root, DataPart):
38+
chunks.append(json.dumps(part.root.data, indent=2))
39+
return "\n".join(chunks)
40+
41+
async def send_message(message: str, base_url: str, context_id: str | None = None, streaming=False, consumer: Consumer | None = None):
42+
"""Returns dict with context_id, response and status (if exists)"""
43+
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as httpx_client:
44+
resolver = A2ACardResolver(httpx_client=httpx_client, base_url=base_url)
45+
agent_card = await resolver.get_agent_card()
46+
config = ClientConfig(
47+
httpx_client=httpx_client,
48+
streaming=streaming,
49+
)
50+
factory = ClientFactory(config)
51+
client = factory.create(agent_card)
52+
if consumer:
53+
await client.add_event_consumer(consumer)
54+
55+
outbound_msg = create_message(text=message, context_id=context_id)
56+
last_event = None
57+
outputs = {
58+
"response": "",
59+
"context_id": None
60+
}
61+
62+
# if streaming == False, only one event is generated
63+
async for event in client.send_message(outbound_msg):
64+
last_event = event
65+
66+
match last_event:
67+
case Message() as msg:
68+
outputs["context_id"] = msg.context_id
69+
outputs["response"] += merge_parts(msg.parts)
70+
71+
case (task, update):
72+
outputs["context_id"] = task.context_id
73+
outputs["status"] = task.status.state.value
74+
msg = task.status.message
75+
if msg:
76+
outputs["response"] += merge_parts(msg.parts)
77+
if task.artifacts:
78+
for artifact in task.artifacts:
79+
outputs["response"] += merge_parts(artifact.parts)
80+
81+
case _:
82+
pass
83+
84+
return outputs
85+
86+
87+
class Messenger:
88+
def __init__(self):
89+
self._context_ids = {}
90+
91+
async def talk_to_agent(self, message: str, url: str, new_conversation: bool = False):
92+
"""
93+
Communicate with another agent by sending a message and receiving their response.
94+
95+
Args:
96+
message: The message to send to the agent
97+
url: The agent's URL endpoint
98+
new_conversation: If True, start fresh conversation; if False, continue existing conversation
99+
100+
Returns:
101+
str: The agent's response message
102+
"""
103+
outputs = await send_message(message=message, base_url=url, context_id=None if new_conversation else self._context_ids.get(url, None))
104+
if outputs.get("status", "completed") != "completed":
105+
raise RuntimeError(f"{url} responded with: {outputs}")
106+
self._context_ids[url] = outputs.get("context_id", None)
107+
return outputs["response"]
108+
109+
def reset(self):
110+
self._context_ids = {}

0 commit comments

Comments
 (0)