Skip to content

Commit 0b2fa91

Browse files
author
J.A.R.V.I.S.
committed
feat(v3.19): skill_id routing — POST /message/send supports skill_id field; client-directed skill selection (A2A #1989 aligned); 8/8 tests pass
1 parent 1b9000c commit 0b2fa91

3 files changed

Lines changed: 353 additions & 7 deletions

File tree

heartbeat-state.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
"calendar": null,
66
"weather": null
77
},
8-
"notes": "2026-06-26 05:34 — 测试轮 Round 47 完成。v3.18 全量回归:196 passed, 0 failed, 8 timeout (test_context_query.py, BUG-026 已知多relay端口竞争). 无新Bug",
9-
"next_round": "research",
10-
"today_rounds_completed": 48,
11-
"last_research": "scan46",
8+
"notes": "2026-06-28 05:34 — 研究轮 scan47 完成。A2A v1.1 规划启动(#1942,21评论)、官方CLI(#1929,17评论)、skill调用标准(#2008);ANP 多语言SDK 0.8.8;A2A 24.5k stars",
9+
"next_round": "develop",
10+
"today_rounds_completed": 49,
11+
"last_research": "scan47",
1212
"pending_push": null
1313
}

relay/acp_relay.py

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@
163163
logging.basicConfig(level=logging.INFO, format="%(asctime)s [acp] %(message)s", datefmt="%H:%M:%S")
164164
log = logging.getLogger("acp-p2p")
165165

166-
VERSION = "3.18.0" # v3.18: POST /verify/http-signatureRFC 9421 HTTP Message Signatures; Ed25519 keyid from DID doc; POST /sign/http-request for test/SDK; transport-layer security complementing per-message signing (v3.0); capabilities.http_signatures=true; aligned with A2A #1829
166+
VERSION = "3.19.0" # v3.19: skill_id routing — POST /message/send & /tasks/create 支持 skill_id 字段; client-directed skill selection (A2A #1989 aligned); capabilities.skill_id_routing=true
167167

168168
_heartbeat_period_ms = None # v2.80: optional heartbeat period in ms declared in AgentCard
169169

@@ -278,6 +278,7 @@ async def _proxy_ws_connect(uri, **kwargs):
278278
ERR_CONFIRM_NOT_PENDING = "ERR_CONFIRM_NOT_PENDING" # v2.51: task not in input_required/confirmation_pending state
279279
ERR_SKILL_DEPRECATED = "ERR_SKILL_DEPRECATED" # v2.52: informational — skill is deprecated (task still created, warning injected)
280280
ERR_RATE_LIMIT = "ERR_RATE_LIMIT" # v2.53: caller has exceeded skill.rate_limit (429)
281+
ERR_SKILL_NOT_FOUND = "ERR_SKILL_NOT_FOUND" # v3.19: skill_id specified in /message/send but skill not declared in AgentCard (400)
281282
ERR_ACK_TIMEOUT = "ERR_ACK_TIMEOUT" # v3.16: waiting for acp.ack timed out (408)
282283

283284
def _err(code: str, message: str, http_status: int = 400,
@@ -2355,6 +2356,7 @@ def _make_agent_card(name, skills):
23552356
"acp_json_media_type": True, # v3.14: application/acp+json; charset=utf-8 Content-Type support (A2A application/a2a+json aligned)
23562357
"transport_bindings": True, # v3.5: AgentCard.transport_bindings (supported/experimental); --experimental-transport flag (pre-SlimRPC #1723)
23572358
"http_signatures": True, # v3.18: RFC 9421 HTTP Message Signatures — POST /verify/http-signature validates transport-layer Ed25519 signatures; POST /sign/http-request for test/SDK
2359+
"skill_id_routing": True, # v3.19: skill_id field in /message/send and /tasks/create — client-directed skill selection (A2A #1989 aligned)
23582360
},
23592361

23602362
"identity": ({
@@ -2457,6 +2459,7 @@ def _make_agent_card(name, skills):
24572459
"jwks": "/.well-known/jwks.json", # v2.18: JWKS endpoint (RFC 7517) — public key set for Ed25519 identity
24582460
"http_signatures_verify": "/verify/http-signature", # v3.18: POST — verify RFC 9421 HTTP Message Signatures (transport-layer Ed25519)
24592461
"http_signatures_sign": "/sign/http-request", # v3.18: POST — generate RFC 9421 signature for test/SDK
2462+
"skill_id_routing": "/message:send,/tasks/create", # v3.19: skill_id field supported on these endpoints (A2A #1989 aligned)
24602463
},
24612464
}
24622465
# v0.9: inject capabilities.groups — structured grouping of flat capabilities (A2A v1.0 aligned)
@@ -5680,7 +5683,7 @@ def _append_audit(task: dict, event_type: str, detail: dict | None = None):
56805683
log_list.append(entry)
56815684

56825685

5683-
def _create_task(payload, message_id=None, task_id=None, context_id=None, initial_state=None):
5686+
def _create_task(payload, message_id=None, task_id=None, context_id=None, initial_state=None, skill_id=None):
56845687
# BUG-006 fix: honour client-supplied task_id (idempotent — return existing if already known)
56855688
if task_id and task_id in _tasks:
56865689
return _tasks[task_id]
@@ -5704,6 +5707,8 @@ def _create_task(payload, message_id=None, task_id=None, context_id=None, initia
57045707
task["origin_message_id"] = message_id
57055708
if context_id:
57065709
task["context_id"] = context_id
5710+
if skill_id:
5711+
task["skill_id"] = skill_id
57075712
if state == TASK_CONFIRMATION_PENDING:
57085713
task["confirmation_required"] = True # v2.51: flag for callers
57095714
# v2.52: seed audit_log with creation event
@@ -5713,6 +5718,8 @@ def _create_task(payload, message_id=None, task_id=None, context_id=None, initia
57135718
evt: dict = {"task_id": task_id, "state": state}
57145719
if context_id:
57155720
evt["context_id"] = context_id
5721+
if skill_id:
5722+
evt["skill_id"] = skill_id
57165723
if state == TASK_CONFIRMATION_PENDING:
57175724
evt["confirmation_required"] = True
57185725
_broadcast_sse_event("status", evt)
@@ -5749,16 +5756,52 @@ def _update_task(task_id, state, artifact=None, error=None, message=None):
57495756
evt: dict = {"task_id": task_id, "state": state, "error": error}
57505757
if ctx:
57515758
evt["context_id"] = ctx
5759+
task_skill = task.get("skill_id")
5760+
if task_skill:
5761+
evt["skill_id"] = task_skill
57525762
_broadcast_sse_event("status", evt)
57535763
if artifact:
57545764
aevt: dict = {"task_id": task_id, "artifact": artifact}
57555765
if ctx:
57565766
aevt["context_id"] = ctx
5767+
task_skill = task.get("skill_id")
5768+
if task_skill:
5769+
aevt["skill_id"] = task_skill
57575770
_broadcast_sse_event("artifact", aevt)
57585771

57595772
return task
57605773

57615774

5775+
def _validate_skill_id(skill_id: str | None, peer_id: str | None = None) -> tuple[bool, str | None]:
5776+
"""v3.19: Validate that a skill_id exists in the peer's AgentCard.
5777+
5778+
If skill_id is None or empty → always valid (no routing required).
5779+
If peer_id is provided → check that peer's agent_card skills.
5780+
If no peer_id → check this relay's own skills (for self-routing).
5781+
5782+
Returns (valid: bool, error_message: str or None)
5783+
"""
5784+
if not skill_id:
5785+
return True, None
5786+
5787+
skills = []
5788+
if peer_id and peer_id in _peers:
5789+
peer_card = _peers[peer_id].get("agent_card") or {}
5790+
skills = list(peer_card.get("skills", []))
5791+
else:
5792+
# No peer_id or peer not found — check this relay's own skills
5793+
card = _status.get("agent_card") or {}
5794+
skills = list(card.get("skills", []))
5795+
5796+
known_skill_ids = [s.get("id", "") if isinstance(s, dict) else str(s) for s in skills]
5797+
if skill_id not in known_skill_ids:
5798+
if peer_id:
5799+
return False, f"skill_id '{skill_id}' not found in peer '{peer_id}' AgentCard (known: {known_skill_ids})"
5800+
else:
5801+
return False, f"skill_id '{skill_id}' not found in AgentCard (known: {known_skill_ids})"
5802+
return True, None
5803+
5804+
57625805
# ══════════════════════════════════════════════════════════════════════════════
57635806
# Persistence
57645807
# ══════════════════════════════════════════════════════════════════════════════
@@ -11970,6 +12013,18 @@ def _do_POST_inner(self):
1197012013
message_id = _explicit_id or _make_id("msg")
1197112014
_client_supplied_id = bool(_explicit_id)
1197212015

12016+
# ── v3.19: skill_id routing — client-directed skill selection ────────
12017+
# If skill_id is provided, validate it against peer's AgentCard skills.
12018+
# If invalid, return 400 ERR_SKILL_NOT_FOUND.
12019+
_req_skill_id = body.get("skill_id")
12020+
if _req_skill_id:
12021+
_skill_valid, _skill_err = _validate_skill_id(_req_skill_id, peer_id=_req_peer_id)
12022+
if not _skill_valid:
12023+
e_body, e_code = _err(ERR_SKILL_NOT_FOUND, _skill_err, 400,
12024+
failed_message_id=_client_msg_id)
12025+
self._json(e_body, e_code)
12026+
return
12027+
1197312028
# ── v2.32: HTTP-level message idempotency (30s TTL dedup) ────
1197412029
# Only applies when the client supplies their own message_id.
1197512030
# Auto-generated IDs are never duplicates.
@@ -12061,6 +12116,8 @@ def _do_POST_inner(self):
1206112116
msg["task_id"] = body["task_id"]
1206212117
if body.get("context_id"):
1206312118
msg["context_id"] = body["context_id"]
12119+
if _req_skill_id:
12120+
msg["skill_id"] = _req_skill_id # v3.19: skill_id routing
1206412121
# v2.56: on_behalf_of — OBO principal chain
1206512122
# If caller supplies "on_behalf_of" (str DID or list[str] DIDs),
1206612123
# attach principal_chain to the outgoing message.
@@ -12193,6 +12250,7 @@ def _do_POST_inner(self):
1219312250
"parts": parts,
1219412251
"task_id": msg.get("task_id"),
1219512252
"context_id": msg.get("context_id"), # v2.15: include context_id for context query
12253+
"skill_id": msg.get("skill_id"), # v3.19: skill_id routing
1219612254
"direction": "outbound",
1219712255
})
1219812256
# v2.32: store server_seq in dedup cache so replay returns it
@@ -12329,6 +12387,25 @@ def _do_POST_inner(self):
1232912387
# ── Build message_id ───────────────────────────────────────
1233012388
message_id = _client_msg_id or _make_id("msg")
1233112389

12390+
# ── v3.19: skill_id routing — client-directed skill selection ──────────
12391+
# When a client specifies skill_id, the message is routed to the target skill.
12392+
# This complements QuerySkill() (capability query) with actual skill invocation routing.
12393+
# Aligned with A2A #1989 (Client-directed skill selection).
12394+
skill_id = body.get("skill_id")
12395+
if skill_id:
12396+
# Validate: skill must be declared in this relay's AgentCard skills list
12397+
card_skills = (_status.get("agent_card") or {}).get("skills", [])
12398+
declared_skill_ids = set()
12399+
for s in card_skills:
12400+
if isinstance(s, dict):
12401+
declared_skill_ids.add(s.get("id"))
12402+
if skill_id not in declared_skill_ids:
12403+
e_body, e_code = _err(ERR_SKILL_NOT_FOUND,
12404+
f"skill_id '{skill_id}' not declared in AgentCard",
12405+
400, failed_message_id=_client_msg_id)
12406+
self._json(e_body, e_code)
12407+
return
12408+
1233212409
# ── Construct Direct Message response ──────────────────────
1233312410
# Aligned with A2A Message data model:
1233412411
# { message_id, context_id?, task_id?, role, parts[], metadata? }
@@ -12346,11 +12423,13 @@ def _do_POST_inner(self):
1234612423
task_id = body.get("task_id")
1234712424
if task_id:
1234812425
dm_response["task_id"] = task_id
12426+
if skill_id:
12427+
dm_response["skill_id"] = skill_id
1234912428
metadata = body.get("metadata")
1235012429
if metadata and isinstance(metadata, dict):
1235112430
dm_response["metadata"] = metadata
1235212431

12353-
log.debug(f"[direct_message] {message_id} role={role_raw} parts={len(parts)}")
12432+
log.debug(f"[direct_message] {message_id} role={role_raw} parts={len(parts)} skill_id={skill_id or 'none'}")
1235412433
self._json(dm_response, 200)
1235512434

1235612435
except Exception as e:

0 commit comments

Comments
 (0)