Skip to content

Commit 8060257

Browse files
committed
feat: Migrate server core to a2a-sdk components, expand public API exports, and update documentation for Python 3.11+ and apcore 0.9.0+.
1 parent b556d1c commit 8060257

File tree

11 files changed

+207
-101
lines changed

11 files changed

+207
-101
lines changed

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.0] - 2026-03-10
9+
10+
### Changed
11+
12+
- Updated Python requirement from 3.10+ to 3.11+ to match pyproject.toml.
13+
- Updated apcore dependency requirement from 0.6.0+ to 0.9.0+.
14+
- Delegated TaskStore and InMemoryTaskStore to a2a-sdk (replaces custom protocol).
15+
- Replaced ExecutionRouter, TaskManager, and TransportManager with a2a-sdk components (ApCoreAgentExecutor, DefaultRequestHandler, A2AStarletteApplication).
16+
- CLI `--host` default changed from `0.0.0.0` to `127.0.0.1` for safer defaults.
17+
- Default agent name fallback changed from `"apcore-agent"` to `"Apcore Agent"`.
18+
- Auth 401 response body now returns `{"error": "Unauthorized", "detail": "Missing or invalid Bearer token"}`.
19+
- AgentCard `defaultOutputModes` now includes both `"text/plain"` and `"application/json"`.
20+
21+
### Added
22+
23+
- Expanded public API exports: auth classes, adapter classes, and server factory now re-exported from top-level `__init__.py`.
24+
- Documented that SkillMapper `_build_extensions()` cannot wire annotations into AgentSkill (a2a-sdk lacks `extensions` field); annotations available via Explorer UI instead.
25+
- ErrorMapper `_sanitize_message()` now strips traceback lines in addition to file paths.
26+
- Explorer `create_explorer_mount()` accepts optional `registry` parameter to enrich agent card with input schemas.
27+
- Path-based registry resolution: `serve()` and `async_serve()` accept `str`/`Path` for auto-discovery.
28+
29+
### Fixed
30+
31+
- Feature specs updated to match actual implementation (F-01 through F-11).
32+
- Documentation version references corrected (Python 3.11+, apcore 0.9.0+).
33+
834
## [0.1.0] - 2026-03-07
935

1036
### Added

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# apcore-a2a
66

77
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
8-
[![Python Version](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/downloads/)
8+
[![Python Version](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/)
99
[![TypeScript](https://img.shields.io/badge/TypeScript-Node_18%2B-blue)](https://github.com/aipartnerup/apcore-a2a-typescript)
1010

1111
**apcore-a2a** is an automatic [A2A (Agent-to-Agent)](https://google.github.io/A2A/) protocol adapter for the [apcore](https://github.com/aipartnerup/apcore-python) ecosystem. It allows you to expose any apcore Module Registry as a fully functional, standards-compliant A2A agent with zero manual effort.
@@ -33,7 +33,7 @@ By reading the existing apcore metadata—including `input_schema`, `output_sche
3333
```bash
3434
pip install apcore-a2a
3535
```
36-
Requires Python 3.10+ and apcore-python 0.6.0+.
36+
Requires Python 3.11+ and apcore 0.9.0+.
3737

3838
**TypeScript**
3939
```bash

docs/features/adapters.md

Lines changed: 58 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,30 @@ class AgentCardBuilder:
2626

2727
def build(
2828
self,
29-
registry: object, # duck-typed: has list() + get_definition()
29+
registry: Any, # duck-typed: has list() + get_definition()
3030
*,
3131
name: str,
3232
description: str,
3333
version: str,
3434
url: str,
3535
capabilities: AgentCapabilities,
36-
security_schemes: dict | None = None,
36+
security_schemes: Any | None = None,
3737
) -> AgentCard:
3838
"""Returns a2a.types.AgentCard Pydantic model."""
3939

40+
def get_cached_or_build(
41+
self,
42+
registry: Any,
43+
*,
44+
name: str,
45+
description: str,
46+
version: str,
47+
url: str,
48+
capabilities: AgentCapabilities,
49+
security_schemes: Any | None = None,
50+
) -> AgentCard:
51+
"""Return cached card if available, otherwise build a new one."""
52+
4053
def build_extended(
4154
self,
4255
*,
@@ -64,7 +77,7 @@ class AgentCardBuilder:
6477
"skills": [...],
6578
"capabilities": {"streaming": bool, "pushNotifications": bool, "stateTransitionHistory": bool},
6679
"defaultInputModes": ["text/plain", "application/json"],
67-
"defaultOutputModes": ["application/json"]
80+
"defaultOutputModes": ["text/plain", "application/json"]
6881
}
6982
```
7083
7. Cache in `self._cached_card`. Extended card in `self._cached_extended_card`.
@@ -101,7 +114,7 @@ class SkillMapper:
101114
| `examples[:10]` | `examples` | `title``name`, `inputs` → JSON string in TextPart |
102115
| computed | `inputModes` | See mode logic below |
103116
| computed | `outputModes` | See mode logic below |
104-
| `annotations` | `extensions.apcore.annotations` | All 5 boolean flags |
117+
| `annotations` | *(not mapped)* | `_build_extensions()` exists but `a2a.types.AgentSkill` has no `extensions` field; annotations are available via the Explorer UI's `_inputSchemas` enrichment instead |
105118

106119
**Input/output mode logic:**
107120

@@ -132,9 +145,9 @@ class SkillMapper:
132145
Converts apcore JSON Schemas for A2A DataPart usage. Reuses logic from apcore-mcp's SchemaConverter.
133146

134147
```python
135-
class SchemaConverter:
136-
MAX_DEPTH = 32
148+
_MAX_REF_DEPTH = 32 # module-level constant
137149

150+
class SchemaConverter:
138151
def convert_input_schema(self, descriptor: object) -> dict:
139152
"""Convert input_schema: inline $refs, strip $defs, ensure root type=object."""
140153

@@ -144,8 +157,15 @@ class SchemaConverter:
144157
def detect_root_type(self, schema: dict | None) -> str:
145158
"""Return 'string', 'object', or 'unknown'."""
146159

147-
def _inline_refs(self, schema: dict, defs: dict, depth: int, visited: set) -> dict:
160+
def _inline_refs(self, schema: Any, defs: dict[str, Any],
161+
_seen: set[str] | None = None, _depth: int = 0) -> Any:
148162
"""Recursively resolve $ref. Raises ValueError on circular refs or depth > 32."""
163+
164+
def _resolve_ref(self, ref: str, defs: dict[str, Any]) -> dict:
165+
"""Resolve a single $ref string against $defs."""
166+
167+
def _ensure_object_type(self, schema: dict) -> dict:
168+
"""Ensure root schema has type: object."""
149169
```
150170

151171
**Conversion rules:**
@@ -165,33 +185,33 @@ Maps apcore exceptions to A2A JSON-RPC error dicts with security-aware sanitizat
165185
```python
166186
class ErrorMapper:
167187
def to_jsonrpc_error(self, error: Exception) -> dict:
168-
"""Returns: {"code": int, "message": str, "data": dict | None}"""
188+
"""Returns: {"code": int, "message": str}"""
169189

170-
def _build_validation_data(self, error: Exception) -> dict:
171-
"""For SchemaValidationError: extract field-level detail."""
190+
def _handle_apcore_error(self, error: Exception, error_code: str) -> dict:
191+
"""Handle apcore errors with a .code attribute (string matching)."""
172192

173193
def _sanitize_message(self, message: str) -> str:
174194
"""Strip paths, tracebacks. Truncate to 500 chars."""
175195
```
176196

197+
**Error dispatch:** Errors are matched by string `.code` attribute (e.g., `"MODULE_NOT_FOUND"`),
198+
not by exception class names.
199+
177200
**Error map:**
178201

179-
| apcore Exception | code | message | sanitized |
202+
| apcore `.code` | JSON-RPC code | message | sanitized |
180203
|---|---|---|---|
181-
| `ModuleNotFoundError` | -32601 | `"Skill not found: {module_id}"` | No |
182-
| `SchemaValidationError` | -32602 | `"Invalid params"` | No (field details in `data.errors`) |
183-
| `ACLDeniedError` | -32001 | `"Task not found"` | **Yes** (masks real type) |
184-
| `ModuleExecuteError` | -32603 | `"Internal error"` | Yes |
185-
| `ModuleTimeoutError` | -32603 | `"Execution timed out"` | Yes |
186-
| `InvalidInputError` | -32602 | `"Invalid input: {description}"` | No |
187-
| `CallDepthExceededError` | -32603 | `"Safety limit exceeded"` | Yes |
188-
| `CircularCallError` | -32603 | `"Safety limit exceeded"` | Yes |
189-
| `CallFrequencyExceededError` | -32603 | `"Safety limit exceeded"` | Yes |
190-
| `ApprovalPendingError` | N/A | Task transitions to `input_required` | N/A |
191-
| Any other `Exception` | -32603 | `"Internal error"` | Yes |
204+
| `MODULE_NOT_FOUND` | -32601 | sanitized original message | Yes |
205+
| `SCHEMA_VALIDATION_ERROR` | -32602 | sanitized original message | Yes |
206+
| `ACL_DENIED` | -32001 | `"Task not found"` | **Yes** (masks real type) |
207+
| `MODULE_TIMEOUT` / `EXECUTION_TIMEOUT` | -32603 | `"Execution timeout"` | No |
208+
| `INVALID_INPUT` | -32602 | `"Invalid input: {sanitized description}"` | Yes |
209+
| `CALL_DEPTH_EXCEEDED` / `CIRCULAR_CALL` / `CALL_FREQUENCY_EXCEEDED` | -32603 | `"Safety limit exceeded"` | No |
210+
| `asyncio.TimeoutError` | -32603 | `"Execution timeout"` | No |
211+
| Any other `Exception` | -32603 | `"Internal server error"` | No |
192212

193213
**Sanitization rules:**
194-
1. Strip substrings matching file path pattern `r'/[^\s]+/[^\s]+'`.
214+
1. Strip substrings matching file path pattern `r'~?/[^\s]*'` (Unix paths and `~` paths).
195215
2. Strip traceback lines (`Traceback`, `File "`, `line \d+`).
196216
3. Truncate to 500 characters.
197217
4. Log full unsanitized exception at ERROR level with stack trace.
@@ -204,28 +224,31 @@ Bidirectional converter between A2A Parts and apcore module inputs/outputs.
204224

205225
```python
206226
class PartConverter:
207-
def __init__(self, schema_converter: SchemaConverter) -> None: ...
227+
def __init__(self, schema_converter: SchemaConverter | None = None) -> None:
228+
"""schema_converter defaults to SchemaConverter() if not provided."""
208229

209-
def parts_to_input(self, parts: list[dict], descriptor: object) -> dict | str:
230+
def parts_to_input(self, parts: list[Part], descriptor: Any) -> dict | str:
210231
"""Convert A2A message Parts to apcore module input.
211232
212233
Rules:
213234
1. Empty parts → raise ValueError("Message must contain at least one Part")
214-
2. First DataPart(mediaType="application/json") → return data dict
215-
3. TextPart + input_schema root type 'string' → return text string
216-
4. TextPart + input_schema root type 'object' → JSON.parse(text)
217-
- parse failure → raise ValueError("Invalid JSON in TextPart")
218-
5. FilePart → return {"uri": ..., "name": ..., "mimeType": ...}
235+
2. Multiple parts → raise ValueError("Multiple parts are not supported; expected exactly one Part")
236+
3. DataPart → return data dict
237+
4. TextPart + input_schema root type 'string' → return text string
238+
5. TextPart + input_schema root type 'object' → JSON.parse(text)
239+
- parse failure → raise ValueError("TextPart text is not valid JSON: {error}")
240+
6. FilePart → raise ValueError("FilePart is not supported")
219241
"""
220242

221-
def output_to_parts(self, output: object) -> a2a.types.Artifact:
243+
def output_to_parts(self, output: Any, task_id: str = "") -> Artifact:
222244
"""Convert apcore module output to an a2a.types.Artifact Pydantic model.
223245
224246
Rules:
225-
1. None → []
226-
2. dict → [DataPart(data=output, mediaType="application/json")]
227-
3. str → [TextPart(text=output)]
228-
4. bytes → [FilePart(bytes=base64(output), mimeType=detected)]
247+
1. None → Artifact(artifact_id=..., parts=[])
248+
2. dict → Artifact with [DataPart(data=output)]
249+
3. str → Artifact with [TextPart(text=output)]
250+
4. list → Artifact with [TextPart(text=json.dumps(output))]
251+
5. Other → Artifact with [TextPart(text=str(output))]
229252
"""
230253
```
231254

docs/features/auth.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ class AuthMiddleware:
145145
3. Extract headers dict (lowercase keys) from scope["headers"].
146146
4. identity = authenticator.authenticate(headers).
147147
5. If identity is None and require_auth:
148-
→ send 401 with body {"error": "Authentication required"}
148+
→ send 401 with body {"error": "Unauthorized", "detail": "Missing or invalid Bearer token"}
149149
and header WWW-Authenticate: Bearer.
150150
6. Set auth_identity_var.set(identity) (token for ContextVar).
151151
7. Await downstream app.

docs/features/cli.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ def main() -> None:
3636
help="Path to directory containing apcore module extensions",
3737
)
3838
serve_parser.add_argument(
39-
"--host", default="0.0.0.0",
40-
help="Bind host (default: 0.0.0.0)",
39+
"--host", default="127.0.0.1",
40+
help="Bind host (default: 127.0.0.1)",
4141
)
4242
serve_parser.add_argument(
4343
"--port", type=int, default=8000,
@@ -163,6 +163,15 @@ if args.auth_type == "bearer":
163163
url = args.url or f"http://{args.host}:{args.port}"
164164
```
165165

166+
**Step 4b — Security warning:**
167+
```python
168+
if args.host == "0.0.0.0" and auth is None:
169+
logger.warning(
170+
"--host 0.0.0.0 binds to all network interfaces without authentication; "
171+
"consider using --host 127.0.0.1 or enabling --auth-type bearer"
172+
)
173+
```
174+
166175
**Step 5 — Call `serve()`:**
167176
```python
168177
from apcore_a2a import serve

docs/features/explorer.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ Browser-based interactive UI for exploring and testing an A2A agent. Mounted as
1818

1919
```python
2020
def create_explorer_mount(
21-
agent_card: dict,
21+
agent_card: AgentCard | dict,
2222
router: object, # duck-typed: DefaultRequestHandler or ExecutionRouter
2323
*,
2424
explorer_prefix: str = "/explorer",
2525
authenticator: object | None = None,
26+
registry: object | None = None, # optional: enriches agent card with input schemas
2627
) -> Mount:
2728
"""Create a Starlette Mount for the Explorer UI.
2829
@@ -104,14 +105,23 @@ from starlette.routing import Mount, Route
104105
from starlette.responses import HTMLResponse
105106
from starlette.staticfiles import StaticFiles
106107

107-
def create_explorer_mount(agent_card, router, *, explorer_prefix="/explorer", authenticator=None):
108+
def create_explorer_mount(agent_card, router, *, explorer_prefix="/explorer",
109+
authenticator=None, registry=None):
108110
html_path = Path(__file__).parent / "index.html"
109111

110112
async def serve_index(request):
111113
return HTMLResponse(html_path.read_text())
112114

113115
async def serve_agent_card(request):
114-
return JSONResponse(agent_card)
116+
# If agent_card is a Pydantic model, serialize to dict via model_dump()
117+
card_data = agent_card.model_dump() if hasattr(agent_card, "model_dump") else agent_card
118+
# If registry provided, enrich skills with _inputSchemas
119+
if registry is not None:
120+
for skill in card_data.get("skills", []):
121+
defn = registry.get_definition(skill.get("id"))
122+
if defn and getattr(defn, "input_schema", None):
123+
skill["_inputSchemas"] = defn.input_schema
124+
return JSONResponse(card_data)
115125

116126
return Mount(explorer_prefix, routes=[
117127
Route("/", endpoint=serve_index),

docs/features/ops.md

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async def handle_health(self, request: Request) -> JSONResponse:
3535
return JSONResponse({
3636
"status": "healthy",
3737
"module_count": len(self._registry.list()),
38-
"uptime_seconds": int(uptime),
38+
"uptime_seconds": uptime, # float (seconds since start)
3939
"version": self._version,
4040
})
4141
```
@@ -45,7 +45,7 @@ HTTP 200:
4545
{
4646
"status": "healthy",
4747
"module_count": 5,
48-
"uptime_seconds": 3600,
48+
"uptime_seconds": 3600.5,
4949
"version": "1.0.0"
5050
}
5151
```
@@ -106,28 +106,25 @@ HTTP 404 (when `metrics=False`, the default):
106106

107107
### Counter Implementation
108108

109-
Task state counters are maintained in `TransportManager` via callback from `TaskManager`:
109+
Task state counters are maintained in a `_MetricsState` dataclass within `server/factory.py`, updated via `on_state_change` callback from `ApCoreAgentExecutor`:
110110

111111
```python
112-
class TransportManager:
113-
def __init__(self, ...) -> None:
114-
self._counters = {
115-
"active": 0,
116-
"completed": 0,
117-
"failed": 0,
118-
"canceled": 0,
119-
"requests": 0,
120-
}
121-
self._started_at = time.monotonic()
112+
@dataclass
113+
class _MetricsState:
114+
active: int = 0
115+
completed: int = 0
116+
failed: int = 0
117+
canceled: int = 0
118+
input_required: int = 0
119+
started_at: float = field(default_factory=time.monotonic)
122120
```
123121

124-
`TaskManager` increments counters on `transition()` callbacks:
122+
State change callbacks update counters:
125123
- `→ working` : `active += 1`
126124
- `→ completed`: `active -= 1`, `completed += 1`
127125
- `→ failed` : `active -= 1`, `failed += 1`
128126
- `→ canceled` : `active -= 1`, `canceled += 1`
129-
130-
`TransportManager.handle_jsonrpc()` increments `requests` on each valid JSON-RPC call.
127+
- `→ input_required`: `input_required += 1`
131128

132129
### `serve()` kwarg
133130

@@ -177,18 +174,12 @@ def invalidate_cache(self) -> None:
177174

178175
After invalidation, the next `GET /.well-known/agent.json` call triggers a full rebuild from the registry.
179176

180-
### Transport Hot-Swap
181-
182-
The `TransportManager` holds a reference to the agent card dict:
183-
```python
184-
class TransportManager:
185-
def update_agent_card(self, agent_card: dict) -> None:
186-
"""Replace agent card. Thread-safe for async event loop."""
187-
self._agent_card = agent_card
188-
self._extended_card = None # Reset extended card too
189-
```
177+
### Agent Card Hot-Swap
190178

191-
Since this is an async event loop context (single-threaded), the dict replacement is atomic.
179+
Agent Card updates are handled via `AgentCardBuilder.invalidate_cache()` combined with a
180+
`card_modifier` callback passed to `A2AStarletteApplication`. The a2a-sdk lazily rebuilds
181+
the card on the next request after cache invalidation. There is no `TransportManager` —
182+
transport is handled by a2a-sdk's `A2AStarletteApplication`.
192183

193184
---
194185

0 commit comments

Comments
 (0)