163163logging.basicConfig(level=logging.INFO, format="%(asctime)s [acp] %(message)s", datefmt="%H:%M:%S")
164164log = logging.getLogger("acp-p2p")
165165
166- VERSION = "3.18 .0" # v3.18: POST /verify/http-signature — RFC 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):
278278ERR_CONFIRM_NOT_PENDING = "ERR_CONFIRM_NOT_PENDING" # v2.51: task not in input_required/confirmation_pending state
279279ERR_SKILL_DEPRECATED = "ERR_SKILL_DEPRECATED" # v2.52: informational — skill is deprecated (task still created, warning injected)
280280ERR_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)
281282ERR_ACK_TIMEOUT = "ERR_ACK_TIMEOUT" # v3.16: waiting for acp.ack timed out (408)
282283
283284def _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