Skip to content

Commit 45ff265

Browse files
committed
Initial commit
0 parents  commit 45ff265

File tree

10 files changed

+927
-0
lines changed

10 files changed

+927
-0
lines changed

.github/workflows/publish.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Publish Docker Image
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
tags:
8+
- 'v*'
9+
10+
jobs:
11+
publish:
12+
runs-on: ubuntu-latest
13+
permissions:
14+
contents: read
15+
packages: write
16+
17+
steps:
18+
- name: Checkout repository
19+
uses: actions/checkout@v4
20+
21+
- name: Log in to GitHub Container Registry
22+
uses: docker/login-action@v3
23+
with:
24+
registry: ghcr.io
25+
username: ${{ github.actor }}
26+
password: ${{ secrets.GITHUB_TOKEN }}
27+
28+
- name: Extract metadata for Docker
29+
id: meta
30+
uses: docker/metadata-action@v5
31+
with:
32+
images: ghcr.io/${{ github.repository }}
33+
tags: |
34+
type=semver,pattern={{version}}
35+
type=semver,pattern={{major}}
36+
type=raw,value=latest,enable={{is_default_branch}}
37+
38+
- name: Build and push Docker image
39+
id: build
40+
uses: docker/build-push-action@v5
41+
with:
42+
context: .
43+
file: Dockerfile
44+
push: true
45+
tags: ${{ steps.meta.outputs.tags }}
46+
labels: ${{ steps.meta.outputs.labels }}
47+
platforms: linux/amd64
48+
49+
- name: Output image digest
50+
run: |
51+
echo "## Docker Image Published" >> $GITHUB_STEP_SUMMARY
52+
echo "**Digest:** \`${{ steps.build.outputs.digest }}\`" >> $GITHUB_STEP_SUMMARY

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.DS_Store
2+
__pycache__/
3+
*.pyc

Dockerfile

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
FROM ghcr.io/astral-sh/uv:python3.13-bookworm
2+
3+
ENV PYTHONUNBUFFERED=1
4+
5+
RUN adduser assessment && mkdir -p /output && chown assessment:assessment /output
6+
USER assessment
7+
WORKDIR /home/assessment
8+
9+
COPY pyproject.toml uv.lock ./
10+
COPY src src
11+
12+
RUN uv sync --locked
13+
14+
ENTRYPOINT ["uv", "run", "python", "src/main.py"]
15+
EXPOSE 8080

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# AgentBeats Assessment Service
2+
3+
Initiates an assessment by sending participants and assessment configuration to the Green agent via A2A. Parses the Green agent's response and serves results as JSON.
4+
5+
The endpoint at `/` returns JSON with a `status` field set to `running` while the assessment is in progress. Once the Green agent finishes, the endpoint returns `status` set to the final A2A task state (e.g. `completed`, `failed`) and `results` containing the parsed artifact data.
6+
7+
## Configuration
8+
9+
| Source | Name | Description |
10+
|---|---|---|
11+
| Env | `GREEN_URL` | Base URL of the Green agent (required) |
12+
| Env | `PARTICIPANTS` | JSON mapping role to participant URL |
13+
| Env | `ASSESSMENT_CONFIG` | JSON assessment configuration |
14+
| Arg | `--port` | HTTP server port |

amber-manifest.json5

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
manifest_version: "0.1.0",
3+
program: {
4+
image: "ghcr.io/rdi-foundation/agentbeats-assessment:latest",
5+
entrypoint: "uv run --no-sync python src/main.py --port 8080",
6+
env: {
7+
GREEN_URL: "${slots.green.url}",
8+
PARTICIPANTS: "${config.participants}",
9+
ASSESSMENT_CONFIG: "${config.assessment_config}",
10+
},
11+
network: {
12+
endpoints: [
13+
{ name: "results", port: 8080 },
14+
],
15+
},
16+
},
17+
config_schema: {
18+
type: "object",
19+
properties: {
20+
participants: { type: "object" },
21+
assessment_config: { type: "object" },
22+
},
23+
required: ["participants", "assessment_config"],
24+
},
25+
slots: {
26+
green: { kind: "a2a" },
27+
},
28+
provides: {
29+
results: { kind: "http", endpoint: "results" },
30+
},
31+
exports: { results: "results" },
32+
}

pyproject.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[project]
2+
name = "agentbeats-assessment"
3+
version = "0.1.0"
4+
description = "Add your description here"
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
dependencies = [
8+
"a2a-sdk>=0.3.22",
9+
"httpx>=0.28.1",
10+
"starlette>=0.52.1",
11+
"uvicorn>=0.40.0",
12+
]

src/assessment.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import json
2+
from typing import Any
3+
from uuid import uuid4
4+
5+
import httpx
6+
from a2a.client import (
7+
A2ACardResolver,
8+
ClientConfig,
9+
ClientFactory,
10+
)
11+
from a2a.types import (
12+
Artifact,
13+
DataPart,
14+
Message,
15+
Part,
16+
Role,
17+
TaskArtifactUpdateEvent,
18+
TaskStatusUpdateEvent,
19+
TextPart,
20+
)
21+
22+
23+
DEFAULT_TIMEOUT = 300
24+
25+
26+
def print_parts(parts, task_state: str | None = None):
27+
text_parts, data_parts = parse_parts(parts)
28+
29+
output = []
30+
if task_state:
31+
output.append(f"[Status: {task_state}]")
32+
if text_parts:
33+
output.append("\n".join(text_parts))
34+
if data_parts:
35+
output.extend(json.dumps(item, indent=2) for item in data_parts)
36+
37+
print("\n".join(output) + "\n")
38+
39+
40+
async def send_message(
41+
message: str, base_url: str
42+
) -> tuple[str | None, list[Artifact] | None]:
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=True,
49+
)
50+
factory = ClientFactory(config)
51+
agent_card.url = base_url
52+
client = factory.create(agent_card)
53+
outbound_msg = Message(
54+
kind="message",
55+
role=Role.user,
56+
parts=[Part(root=TextPart(kind="text", text=message))],
57+
message_id=uuid4().hex,
58+
context_id=None,
59+
)
60+
61+
artifacts: list[Artifact] | None = None
62+
final_status: str | None = None
63+
64+
async for event in client.send_message(outbound_msg):
65+
match event:
66+
case Message() as msg:
67+
print_parts(msg.parts)
68+
69+
case (task, TaskStatusUpdateEvent() as status_event):
70+
status = status_event.status
71+
parts = status.message.parts if status.message else []
72+
print_parts(parts, status.state.value)
73+
final_status = status.state.value
74+
if status.state.value == "completed":
75+
print(task.artifacts)
76+
artifacts = task.artifacts
77+
78+
case (task, TaskArtifactUpdateEvent() as artifact_event):
79+
print_parts(artifact_event.artifact.parts, "Artifact update")
80+
81+
case task, None:
82+
status = task.status
83+
parts = status.message.parts if status.message else []
84+
print_parts(parts, task.status.state.value)
85+
final_status = status.state.value
86+
if status.state.value == "completed":
87+
print(task.artifacts)
88+
artifacts = task.artifacts
89+
90+
case _:
91+
print("Unhandled event")
92+
93+
return final_status, artifacts
94+
95+
96+
def parse_parts(parts) -> tuple[list, list]:
97+
text_parts = []
98+
data_parts = []
99+
100+
for part in parts:
101+
if isinstance(part.root, TextPart):
102+
try:
103+
data_item = json.loads(part.root.text)
104+
data_parts.append(data_item)
105+
except Exception:
106+
text_parts.append(part.root.text.strip())
107+
elif isinstance(part.root, DataPart):
108+
data_parts.append(part.root.data)
109+
110+
return text_parts, data_parts
111+
112+
113+
async def run_assessment(
114+
green_url: str, participants: dict[str, str], assessment_config: dict[str, Any]
115+
):
116+
assessment_request = {"participants": participants, "config": assessment_config}
117+
msg = json.dumps(assessment_request)
118+
119+
status = None
120+
artifacts = []
121+
try:
122+
status, artifacts = await send_message(msg, green_url)
123+
except Exception as e:
124+
print(f"Assessment failed: {e}")
125+
status = "failed"
126+
finally:
127+
all_data_parts = []
128+
for artifact in artifacts or []:
129+
_, data_parts = parse_parts(artifact.parts)
130+
all_data_parts.extend(data_parts)
131+
132+
output_data = {"status": status, "results": all_data_parts}
133+
134+
return output_data

src/config.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import argparse
2+
import json
3+
import os
4+
from dataclasses import dataclass
5+
6+
7+
@dataclass
8+
class Config:
9+
port: int
10+
green_url: str
11+
participants: dict[str, str] # role -> url
12+
assessment_config: dict
13+
14+
15+
def load_config() -> Config:
16+
parser = argparse.ArgumentParser()
17+
parser.add_argument("--port", type=int, default=8080)
18+
args = parser.parse_args()
19+
20+
green_url = os.environ.get("GREEN_URL")
21+
if not green_url:
22+
raise ValueError("GREEN_URL is required")
23+
24+
participant_urls_raw = os.environ.get("PARTICIPANTS", "{}")
25+
participants = json.loads(participant_urls_raw)
26+
27+
assessment_config_raw = os.environ.get("ASSESSMENT_CONFIG", "{}")
28+
assessment_config = json.loads(assessment_config_raw)
29+
30+
return Config(
31+
port=args.port,
32+
green_url=green_url,
33+
participants=participants,
34+
assessment_config=assessment_config,
35+
)

0 commit comments

Comments
 (0)