@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +122 -3
  2. package/README.md +6 -3
  3. package/SECURITY.md +4 -3
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +37 -12
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +20 -9
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +5 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +26 -5
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -2
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +21 -7
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +6 -2
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +15 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +55 -19
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +15 -2
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +27 -6
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +31 -7
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +18 -16
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +9 -5
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +15 -6
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +93 -93
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +37 -12
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +365 -53
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
  105. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  106. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -0,0 +1,404 @@
1
+ """The serving side and a bounded client of A2A 1.0 (FAA-10, DEC-0122): an Agent Card built from the service's
2
+ capabilities (`agent_card`, every skill an A2A `AgentSkill`, checked by `fabric_a2a.agent_card_findings` before it is
3
+ served), the JSON-RPC binding (`A2ABinding`: `SendMessage`, `SendStreamingMessage`, `GetTask`, `CancelTask`) mapped
4
+ onto the interop job store (`fabric_interop.JobStore`: a task is a job, a choice stop is `TASK_STATE_INPUT_REQUIRED`,
5
+ the result envelope an artifact), and `A2AClient` (`discover`, `call`, `get`, `cancel`) that reaches a peer only on an
6
+ origin the operator allowed, with its credential read from the environment `use_secret.py run` fills, never from a
7
+ file. The interface never takes the descriptor's token (DEC-0122 item 3): the card names its own scheme and the
8
+ service's `authorize` decides the caller. Part of the service kit (layer L4). Standard library only."""
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import secrets
15
+ import time
16
+ from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
17
+
18
+ import sys as _sys # noqa: E402
19
+ from pathlib import Path as _Path # noqa: E402
20
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
21
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
22
+
23
+ from fabric_a2a import A2A_PROTOCOL_VERSION, A2A_SURFACE_PATH, agent_card_findings # noqa: E402
24
+ from fabric_base import ServiceError # noqa: E402
25
+ from fabric_interop import JobStore # noqa: E402
26
+ from fabric_interop_rules import InteropError, UnknownJob # noqa: E402
27
+ from fabric_http import request as http_request # noqa: E402
28
+
29
+ #: The JSON-RPC binding's name in a card's `supportedInterfaces` (A2A 1.0).
30
+ JSONRPC_BINDING = "JSONRPC"
31
+ #: The JSON-RPC codes A2A 1.0 section 5.4 maps its errors to, and JSON-RPC 2.0's own.
32
+ PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR = -32700, -32600, -32601, -32602, -32603
33
+ TASK_NOT_FOUND, TASK_NOT_CANCELABLE, UNSUPPORTED_OPERATION, CONTENT_TYPE_NOT_SUPPORTED, VERSION_NOT_SUPPORTED = \
34
+ -32001, -32002, -32004, -32005, -32009
35
+ #: The interop job status each A2A task state reads from.
36
+ TASK_STATES = {"working": "TASK_STATE_WORKING", "input_required": "TASK_STATE_INPUT_REQUIRED",
37
+ "completed": "TASK_STATE_COMPLETED", "failed": "TASK_STATE_FAILED", "cancelled": "TASK_STATE_CANCELED"}
38
+ TERMINAL_STATES = ("TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "TASK_STATE_REJECTED")
39
+ MAX_BODY = 1024 * 1024
40
+ _SKILL_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
41
+
42
+
43
+ class A2AError(ServiceError):
44
+ """A refused A2A call or card; the message never carries a credential."""
45
+
46
+
47
+ def skill_from_capability(capability: Mapping[str, Any]) -> Dict[str, Any]:
48
+ """An A2A `AgentSkill` from one capability ({name, description?, tags?, examples?, inputModes?, outputModes?}):
49
+ its id is the capability's name, the id the contract's A2A peer profile names in `skillIds`."""
50
+ name = capability.get("name") if isinstance(capability, Mapping) else None
51
+ if not isinstance(name, str) or not _SKILL_ID.match(name):
52
+ raise A2AError("a skill's id is the capability's name: letters, digits, '.', '_' or '-', at most 64")
53
+ skill: Dict[str, Any] = {"id": name, "name": capability.get("title") or name,
54
+ "description": capability.get("description") or name,
55
+ "tags": list(capability.get("tags") or [name.split(".")[0]])}
56
+ for key in ("examples", "inputModes", "outputModes"):
57
+ if capability.get(key):
58
+ skill[key] = list(capability[key])
59
+ return skill
60
+
61
+
62
+ def agent_card(*, name: str, description: str, version: str, url: str, capabilities: Sequence[Mapping[str, Any]],
63
+ security_schemes: Optional[Dict[str, Any]] = None, streaming: bool = False,
64
+ extensions: Optional[List[Dict[str, Any]]] = None, remote: bool = False,
65
+ descriptor_header: str = "Authorization") -> Dict[str, Any]:
66
+ """The public Agent Card a service serves at `surfaces.a2a.path` (A2A 1.0): one JSON-RPC interface at `url` on the
67
+ service's origin, one skill per capability. Raises A2AError when `agent_card_findings` finds anything, so a card
68
+ that names a process member, a secret, the descriptor's token header or a required Fabric extension is never
69
+ served. `security_schemes` names the interface's own scheme (never the descriptor's token)."""
70
+ match = re.match(r"\A(https?://[^/?#]+)", url or "")
71
+ card: Dict[str, Any] = {
72
+ "name": name, "description": description, "version": version,
73
+ "supportedInterfaces": [{"url": url, "protocolBinding": JSONRPC_BINDING, "protocolVersion": A2A_PROTOCOL_VERSION}],
74
+ "capabilities": {"streaming": bool(streaming), "pushNotifications": False},
75
+ "defaultInputModes": ["text/plain", "application/json"], "defaultOutputModes": ["text/plain", "application/json"],
76
+ "skills": [skill_from_capability(c) for c in capabilities],
77
+ }
78
+ if extensions:
79
+ card["capabilities"]["extensions"] = [dict(e, required=False) for e in extensions]
80
+ if security_schemes:
81
+ card["securitySchemes"] = security_schemes
82
+ findings = agent_card_findings(card, origin=match.group(1) if match else None, remote=remote,
83
+ descriptor_header=descriptor_header)
84
+ if findings:
85
+ raise A2AError("the card is not served: %s" % "; ".join(f["message"] for f in findings))
86
+ return card
87
+
88
+
89
+ def text_of(message: Any) -> str:
90
+ """The text parts of an A2A message, joined by a line feed."""
91
+ parts = message.get("parts") if isinstance(message, dict) else None
92
+ return "\n".join(p["text"] for p in parts or [] if isinstance(p, dict) and isinstance(p.get("text"), str))
93
+
94
+
95
+ def data_of(message: Any) -> List[Any]:
96
+ """The data parts of an A2A message."""
97
+ parts = message.get("parts") if isinstance(message, dict) else None
98
+ return [p["data"] for p in parts or [] if isinstance(p, dict) and "data" in p]
99
+
100
+
101
+ def agent_message(text: str, *, task_id: Optional[str] = None, context_id: Optional[str] = None,
102
+ data: Any = None) -> Dict[str, Any]:
103
+ """An agent's A2A message: a text part, and a data part when `data` is given."""
104
+ parts: List[Dict[str, Any]] = [{"text": text}]
105
+ if data is not None:
106
+ parts.append({"data": data})
107
+ message: Dict[str, Any] = {"role": "ROLE_AGENT", "parts": parts, "messageId": "msg-" + secrets.token_hex(8)}
108
+ if task_id:
109
+ message["taskId"] = task_id
110
+ if context_id:
111
+ message["contextId"] = context_id
112
+ return message
113
+
114
+
115
+ class TaskStart:
116
+ """What a message handler gets: the message, its caller and `start(skill)`, which makes the job that is the task
117
+ and returns its id; the service then runs the work and moves the job (working, request_input, complete, fail)."""
118
+
119
+ def __init__(self, binding: "A2ABinding", message: Dict[str, Any], caller: Optional[str], context_id: str):
120
+ self.binding, self.message, self.caller, self.context_id = binding, message, caller, context_id
121
+ self.task_id: Optional[str] = None
122
+
123
+ def start(self, skill: str) -> str:
124
+ if skill not in self.binding.skills:
125
+ raise A2AError("no skill %s on this card" % skill)
126
+ handle = self.binding.jobs.create("a2a." + skill, {"a2a": {"message": self.message, "contextId": self.context_id,
127
+ "caller": self.caller}})
128
+ self.task_id = handle["job"]["id"]
129
+ return self.task_id
130
+
131
+
132
+ Handler = Callable[[TaskStart], Optional[Dict[str, Any]]]
133
+
134
+
135
+ class A2ABinding:
136
+ """The JSON-RPC binding over the interop job store. `handler(start)` answers a new message: it returns an agent
137
+ message (`agent_message`) for a reply with no task, or calls `start.start(skill)` and returns None. `authorize`
138
+ turns the request's headers into a caller (or None to refuse with HTTP 401); a task is read, answered or cancelled
139
+ only by the caller that started it, any other gets TaskNotFound. `handle(body, headers)` returns (status, body)."""
140
+
141
+ def __init__(self, jobs: JobStore, handler: Handler, *, skills: Sequence[str],
142
+ authorize: Callable[[Mapping[str, str]], Optional[str]], clock: Callable[[], float] = time.time):
143
+ self.jobs, self.handler, self.skills, self.authorize, self.clock = jobs, handler, set(skills), authorize, clock
144
+
145
+ def task(self, job_id: str, caller: Optional[str]) -> Dict[str, Any]:
146
+ """The A2A Task a job reads as; UnknownJob when it is not an A2A job of this caller."""
147
+ a2a = (self.jobs.private(job_id).get("input") or {}).get("a2a")
148
+ if not isinstance(a2a, dict) or a2a.get("caller") != caller:
149
+ raise UnknownJob(job_id)
150
+ return task_of(self.jobs.get(job_id), a2a.get("contextId"))
151
+
152
+ def handle(self, body: Any, headers: Mapping[str, str]) -> Tuple[int, Optional[Dict[str, Any]]]:
153
+ lowered = {str(k).lower(): v for k, v in (headers or {}).items()}
154
+ caller = self.authorize(lowered)
155
+ if caller is None:
156
+ return 401, None
157
+ if isinstance(body, (bytes, bytearray)):
158
+ if len(body) > MAX_BODY:
159
+ return 413, None
160
+ try:
161
+ body = json.loads(bytes(body).decode("utf-8"))
162
+ except ValueError:
163
+ return 200, _error(None, PARSE_ERROR, "the body is not JSON")
164
+ if not isinstance(body, dict) or body.get("jsonrpc") != "2.0" or not isinstance(body.get("method"), str):
165
+ return 200, _error(body.get("id") if isinstance(body, dict) else None, INVALID_REQUEST, "not a JSON-RPC 2.0 request")
166
+ rid = body.get("id")
167
+ version = lowered.get("a2a-version")
168
+ if version is not None and version.strip() != A2A_PROTOCOL_VERSION:
169
+ return 200, _error(rid, VERSION_NOT_SUPPORTED, "this agent speaks A2A %s" % A2A_PROTOCOL_VERSION)
170
+ params = body.get("params")
171
+ if not isinstance(params, dict):
172
+ return 200, _error(rid, INVALID_PARAMS, "params is an object")
173
+ method = body["method"]
174
+ try:
175
+ if method in ("SendMessage", "SendStreamingMessage"):
176
+ if method == "SendStreamingMessage":
177
+ return 200, _error(rid, UNSUPPORTED_OPERATION, "streaming is served by stream() as text/event-stream")
178
+ return 200, _result(rid, self.send(params, caller))
179
+ if method == "GetTask":
180
+ return 200, _result(rid, self.task(str(params.get("id")), caller))
181
+ if method == "CancelTask":
182
+ return 200, _result(rid, self.cancel(str(params.get("id")), caller))
183
+ except UnknownJob:
184
+ return 200, _error(rid, TASK_NOT_FOUND, "no such task")
185
+ except _NotCancelable:
186
+ return 200, _error(rid, TASK_NOT_CANCELABLE, "the task has ended")
187
+ except _BadParams as exc:
188
+ return 200, _error(rid, INVALID_PARAMS, str(exc))
189
+ except (A2AError, InteropError) as exc:
190
+ return 200, _error(rid, INTERNAL_ERROR, "the agent could not take the message: %s" % exc)
191
+ return 200, _error(rid, METHOD_NOT_FOUND, "no method %s here" % method[:64])
192
+
193
+ def send(self, params: Dict[str, Any], caller: Optional[str]) -> Dict[str, Any]:
194
+ message = params.get("message")
195
+ if not isinstance(message, dict) or not isinstance(message.get("parts"), list) or not message["parts"]:
196
+ raise _BadParams("params.message is a message with parts")
197
+ task_id = message.get("taskId")
198
+ if isinstance(task_id, str) and task_id:
199
+ return {"task": self._continue(task_id, message, caller)}
200
+ context_id = message.get("contextId") if isinstance(message.get("contextId"), str) else "ctx-" + secrets.token_hex(8)
201
+ start = TaskStart(self, message, caller, context_id)
202
+ reply = self.handler(start)
203
+ if start.task_id is not None:
204
+ return {"task": self.task(start.task_id, caller)}
205
+ if not isinstance(reply, dict):
206
+ raise A2AError("the handler neither replied nor started a task")
207
+ return {"message": dict(reply, contextId=context_id)}
208
+
209
+ def _continue(self, task_id: str, message: Dict[str, Any], caller: Optional[str]) -> Dict[str, Any]:
210
+ """A message to an input-required task answers its question: a data part {inputResponses} as fabric.job.get
211
+ takes it, or plain text for a question with one field."""
212
+ task = self.task(task_id, caller)
213
+ if task["status"]["state"] != "TASK_STATE_INPUT_REQUIRED":
214
+ raise _BadParams("the task waits for no answer")
215
+ job = self.jobs.get(task_id)
216
+ responses = next((d["inputResponses"] for d in data_of(message) if isinstance(d, dict) and isinstance(d.get("inputResponses"), dict)), None)
217
+ if responses is None:
218
+ responses = _text_answer(job.get("inputRequests") or {}, text_of(message))
219
+ if not responses or not self.jobs.answer(task_id, responses):
220
+ raise _BadParams("the answer names none of the task's questions")
221
+ return self.task(task_id, caller)
222
+
223
+ def cancel(self, task_id: str, caller: Optional[str]) -> Dict[str, Any]:
224
+ task = self.task(task_id, caller)
225
+ if task["status"]["state"] in TERMINAL_STATES:
226
+ raise _NotCancelable()
227
+ self.jobs.cancel(task_id)
228
+ return self.task(task_id, caller)
229
+
230
+ def stream(self, params: Dict[str, Any], caller: Optional[str], *, rid: Any = None, poll_s: float = 1.0,
231
+ deadline_s: float = 300.0, sleep: Callable[[float], None] = time.sleep) -> Iterator[str]:
232
+ """`SendStreamingMessage` as server-sent events: the task first, then a status update each time its state
233
+ changes, ending at a terminal or input-required state or the deadline. Each event is one `data:` line holding
234
+ a JSON-RPC response whose result is a StreamResponse."""
235
+ first = self.send(params, caller)
236
+ yield _event(rid, first)
237
+ if "task" not in first:
238
+ return
239
+ task, end = first["task"], self.clock() + deadline_s
240
+ while task["status"]["state"] not in TERMINAL_STATES + ("TASK_STATE_INPUT_REQUIRED",) and self.clock() < end:
241
+ sleep(poll_s)
242
+ now = self.task(task["id"], caller)
243
+ if now["status"]["state"] != task["status"]["state"] or now.get("artifacts") != task.get("artifacts"):
244
+ final = now["status"]["state"] in TERMINAL_STATES + ("TASK_STATE_INPUT_REQUIRED",)
245
+ yield _event(rid, {"statusUpdate": {"taskId": now["id"], "contextId": now["contextId"],
246
+ "status": now["status"], "final": final}})
247
+ task = now
248
+
249
+
250
+ class _NotCancelable(Exception):
251
+ pass
252
+
253
+
254
+ class _BadParams(Exception):
255
+ pass
256
+
257
+
258
+ def _text_answer(requests: Dict[str, Any], text: str) -> Optional[Dict[str, Any]]:
259
+ if len(requests) != 1 or not text:
260
+ return None
261
+ key, request = next(iter(requests.items()))
262
+ props = (((request.get("params") or {}).get("requestedSchema") or {}).get("properties") or {}) if isinstance(request, dict) else {}
263
+ if len(props) != 1:
264
+ return None
265
+ return {key: {"action": "accept", "content": {next(iter(props)): text}}}
266
+
267
+
268
+ def task_of(job: Dict[str, Any], context_id: Optional[str]) -> Dict[str, Any]:
269
+ """The A2A Task an interop job reads as: its state, a status message, the result envelope as an artifact."""
270
+ state = TASK_STATES.get(job.get("status"), "TASK_STATE_UNSPECIFIED")
271
+ status: Dict[str, Any] = {"state": state, "timestamp": job.get("updatedAt") or ""}
272
+ text = job.get("statusMessage")
273
+ if job.get("status") == "input_required":
274
+ questions = [(r.get("params") or {}).get("message") for r in (job.get("inputRequests") or {}).values() if isinstance(r, dict)]
275
+ status["message"] = agent_message(text or "; ".join(q for q in questions if isinstance(q, str)) or "an answer is needed",
276
+ task_id=job["id"], context_id=context_id,
277
+ data={"inputRequests": job.get("inputRequests")})
278
+ elif job.get("status") == "failed":
279
+ error = job.get("error") or {}
280
+ status["message"] = agent_message(str(error.get("message") or "the task failed"), task_id=job["id"],
281
+ context_id=context_id, data={"code": error.get("code")})
282
+ elif text:
283
+ status["message"] = agent_message(text, task_id=job["id"], context_id=context_id)
284
+ task: Dict[str, Any] = {"id": job["id"], "contextId": context_id, "status": status}
285
+ if job.get("status") == "completed":
286
+ task["artifacts"] = [{"artifactId": "result", "name": "result", "parts": [{"data": job.get("result")}]}]
287
+ return task
288
+
289
+
290
+ def _result(rid: Any, result: Any) -> Dict[str, Any]:
291
+ return {"jsonrpc": "2.0", "id": rid, "result": result}
292
+
293
+
294
+ def _error(rid: Any, code: int, message: str) -> Dict[str, Any]:
295
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
296
+
297
+
298
+ def _event(rid: Any, result: Any) -> str:
299
+ return "data: %s\n\n" % json.dumps(_result(rid, result), separators=(",", ":"), ensure_ascii=False)
300
+
301
+
302
+ def origin_of(url: str) -> str:
303
+ """`scheme://host[:port]` of a URL, lower-cased, with a default port dropped; A2AError for anything else."""
304
+ match = re.match(r"\A(https?)://([^/?#]+)", url or "", re.IGNORECASE)
305
+ if not match or "@" in match.group(2):
306
+ raise A2AError("a peer's address is an http(s) origin with no userinfo")
307
+ scheme, host = match.group(1).lower(), match.group(2).lower()
308
+ if (scheme, host.rsplit(":", 1)[-1]) in (("https", "443"), ("http", "80")) and ":" in host and not host.endswith("]"):
309
+ host = host.rsplit(":", 1)[0]
310
+ return "%s://%s" % (scheme, host)
311
+
312
+
313
+ class A2AClient:
314
+ """A bounded A2A 1.0 client: every call has a deadline and a size cap (fabric_http), reaches only an origin in
315
+ `allow_origins` (the operator's list), and sends a credential only when `credential_env` names an environment
316
+ variable that holds one (`use_secret.py run --vault-only <project> <NAME> -- <command>` fills it; never a file
317
+ beside the agent). It never follows a redirect and never retries a SendMessage."""
318
+
319
+ def __init__(self, allow_origins: Sequence[str], *, credential_env: Optional[str] = None,
320
+ header: str = "Authorization", scheme: Optional[str] = "Bearer", deadline_s: float = 30.0,
321
+ max_bytes: int = MAX_BODY, transport: Any = None, insecure: bool = False):
322
+ self.allowed = {origin_of(o) for o in allow_origins}
323
+ if not self.allowed:
324
+ raise A2AError("a client needs the operator's list of peer origins")
325
+ self.credential_env, self.header, self.scheme = credential_env, header, scheme
326
+ self.deadline_s, self.max_bytes, self.transport, self.insecure = deadline_s, max_bytes, transport, insecure
327
+ self._ids = 0
328
+
329
+ def _check(self, url: str) -> None:
330
+ if origin_of(url) not in self.allowed:
331
+ raise A2AError("%s is not on the operator's list of A2A peers" % origin_of(url))
332
+
333
+ def _headers(self) -> Dict[str, str]:
334
+ headers = {"A2A-Version": A2A_PROTOCOL_VERSION, "Accept": "application/json"}
335
+ if self.credential_env:
336
+ value = os.environ.get(self.credential_env)
337
+ if not value:
338
+ raise A2AError("%s is empty: run the caller under use_secret.py run, which places the key" % self.credential_env)
339
+ headers[self.header] = "%s %s" % (self.scheme, value) if self.scheme else value
340
+ return headers
341
+
342
+ def _get(self, url: str) -> Any:
343
+ self._check(url)
344
+ kwargs = {"transport": self.transport} if self.transport else {}
345
+ response = http_request("GET", url, headers={"Accept": "application/json"}, deadline_s=self.deadline_s,
346
+ max_bytes=self.max_bytes, insecure=self.insecure, **kwargs)
347
+ if response.status != 200:
348
+ raise A2AError("the peer answered HTTP %d for its card" % response.status)
349
+ try:
350
+ return response.json()
351
+ except ValueError:
352
+ raise A2AError("the peer's card is not JSON") from None
353
+
354
+ def discover(self, origin: str) -> Dict[str, Any]:
355
+ """The peer's public card and the JSON-RPC interface to call: {card, url}."""
356
+ card = self._get(origin_of(origin) + A2A_SURFACE_PATH)
357
+ if not isinstance(card, dict):
358
+ raise A2AError("the peer's card is not a JSON object")
359
+ for interface in card.get("supportedInterfaces") or []:
360
+ if (isinstance(interface, dict) and interface.get("protocolBinding") == JSONRPC_BINDING
361
+ and interface.get("protocolVersion") == A2A_PROTOCOL_VERSION and isinstance(interface.get("url"), str)):
362
+ self._check(interface["url"])
363
+ return {"card": card, "url": interface["url"]}
364
+ raise A2AError("the peer's card names no A2A %s JSON-RPC interface" % A2A_PROTOCOL_VERSION)
365
+
366
+ def _rpc(self, url: str, method: str, params: Dict[str, Any]) -> Any:
367
+ self._check(url)
368
+ self._ids += 1
369
+ kwargs = {"transport": self.transport} if self.transport else {}
370
+ response = http_request("POST", url, headers=self._headers(), body={"jsonrpc": "2.0", "id": self._ids,
371
+ "method": method, "params": params}, deadline_s=self.deadline_s, retries=0,
372
+ max_bytes=self.max_bytes, insecure=self.insecure, **kwargs)
373
+ if response.status != 200:
374
+ raise A2AError("the peer answered HTTP %d to %s" % (response.status, method))
375
+ try:
376
+ answer = response.json()
377
+ except ValueError:
378
+ answer = None
379
+ if not isinstance(answer, dict):
380
+ raise A2AError("the peer's answer to %s is not a JSON-RPC response" % method)
381
+ if "error" in answer:
382
+ error = answer["error"] if isinstance(answer["error"], dict) else {}
383
+ raise A2AError("the peer refused %s: %s %s" % (method, error.get("code"), str(error.get("message"))[:200]))
384
+ return answer.get("result")
385
+
386
+ def call(self, url: str, text: str, *, data: Any = None, task_id: Optional[str] = None,
387
+ context_id: Optional[str] = None) -> Any:
388
+ """SendMessage: a user message with `text` (and a data part), new or continuing `task_id`; the result as sent
389
+ ({task} or {message})."""
390
+ parts: List[Dict[str, Any]] = [{"text": text}]
391
+ if data is not None:
392
+ parts.append({"data": data})
393
+ message: Dict[str, Any] = {"role": "ROLE_USER", "parts": parts, "messageId": "msg-" + secrets.token_hex(8)}
394
+ if task_id:
395
+ message["taskId"] = task_id
396
+ if context_id:
397
+ message["contextId"] = context_id
398
+ return self._rpc(url, "SendMessage", {"message": message})
399
+
400
+ def get(self, url: str, task_id: str) -> Any:
401
+ return self._rpc(url, "GetTask", {"id": task_id})
402
+
403
+ def cancel(self, url: str, task_id: str) -> Any:
404
+ return self._rpc(url, "CancelTask", {"id": task_id})
@@ -40,6 +40,7 @@ from fabric_descriptor import (
40
40
  names_repository,
41
41
  source_refusal,
42
42
  validate_descriptor,
43
+ warn_no_source,
43
44
  write_descriptor,
44
45
  )
45
46
 
@@ -399,7 +400,7 @@ def register_remote(*, service_id: str, name: str, origin: str, token: str, inst
399
400
  if source is None: # a rotation that names no source keeps the installed one (0.62.0 seam reading 1)
400
401
  try:
401
402
  installed = json.loads(descriptor_path(service_id, instance, root).read_text(encoding="utf-8")).get("source")
402
- except (OSError, ValueError, AttributeError):
403
+ except (OSError, ValueError, AttributeError, RecursionError): # nested thousands deep (FAA-200 (10))
403
404
  installed = None
404
405
  # Only one the kit accepts: a hand-edited source never blocks a rotation; it is dropped with the warning.
405
406
  base = set(validate_descriptor(descriptor))
@@ -415,9 +416,12 @@ def register_remote(*, service_id: str, name: str, origin: str, token: str, inst
415
416
  refusal = source_refusal(descriptor, root) # before the token is written: a refused install leaves nothing (FAA-198)
416
417
  if refusal:
417
418
  raise ServiceError(refusal)
419
+ warned = not names_repository(descriptor)
420
+ if warned: # before the token: under warnings as errors neither file changes (FAA-200 (1))
421
+ warn_no_source(descriptor, 2) # the warning names the installer's call
418
422
  ensure_private_dir(token_file.parent)
419
423
  atomic_write(token_file, token.strip().encode(), 0o600)
420
- return write_descriptor(descriptor, root, _stacklevel=3) # the warning names the installer's call
424
+ return write_descriptor(descriptor, root, _warned=warned)
421
425
 
422
426
 
423
427
  def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Optional[str]:
@@ -13,6 +13,7 @@ from pathlib import Path as _Path # noqa: E402
13
13
  if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
14
14
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
15
15
  import fabric_hub as _hub
16
+ from fabric_events import LEVELS as _LEVELS # noqa: E402 - the levels the event log takes (FAA-187 (23))
16
17
  from fabric_board_shapes import ( # noqa: E402 - the kit (FAA-65)
17
18
  COMMS_PROTOCOL,
18
19
  RESPOND_TOOLS,
@@ -55,7 +56,9 @@ def _fixture_answers(variant: str) -> Dict[str, Any]:
55
56
  def self_check() -> List[str]:
56
57
  """One scripted conversation per path through this kit, with no Fabric: the whole conversation, a `fenced` accept
57
58
  and a board session without an epoch. Answers the problems found; an empty list means this kit answers the fixture
58
- conversation as the contract asks. Writes only in a temporary folder it removes."""
59
+ conversation as the contract asks. Writes only in a temporary folder it removes. The responder's notices are recorded
60
+ by an emit that takes a subject, as JsonlEventLog.append does: a notice at a level the log refuses, one without its
61
+ subject, or a fenced attempt that says no warning fails it (FAA-187 (23))."""
59
62
  import os
60
63
  import tempfile
61
64
  problems: List[str] = []
@@ -71,7 +74,10 @@ def self_check() -> List[str]:
71
74
  cred = Path(tmp) / "credential"
72
75
  cred.write_text("c" * 43)
73
76
  os.chmod(cred, 0o600)
74
- answers, calls, handled = _fixture_answers(variant), [], []
77
+ answers, calls, handled, notices = _fixture_answers(variant), [], [], []
78
+
79
+ def emit(kind, level, text, subject=None, **extra):
80
+ notices.append((kind, level, subject))
75
81
 
76
82
  def post(origin, body, bearer, session=None, timeout=15.0, method="POST", protocol_version=None):
77
83
  if method == "DELETE":
@@ -93,9 +99,16 @@ def self_check() -> List[str]:
93
99
  credential_file=cred, checkpoint_file=Path(tmp) / "checkpoint.json",
94
100
  handler=lambda request, ctx: handled.append(request.body) or {"state": "completed", "summary": "Rendered."},
95
101
  admit=lambda request: None, now=lambda: 0.0, rand=lambda: 0.5, spawn=lambda fn: fn(),
96
- session_factory=lambda grant: BoardSession(grant, post=post))
102
+ session_factory=lambda grant: BoardSession(grant, post=post), emit=emit)
97
103
  responder.step()
98
104
  tools = [t for t, _ in calls]
105
+ for kind, level, subject in notices:
106
+ if level not in _LEVELS:
107
+ problems.append("%s: notice %s at level %r, which the event log refuses" % (variant, kind, level))
108
+ if not (isinstance(subject, dict) and subject.get("type") and subject.get("id")):
109
+ problems.append("%s: notice %s without its subject" % (variant, kind))
110
+ if variant == "fenced" and not any(level == "warning" for _, level, _ in notices):
111
+ problems.append("fenced: no warning notice reached the service's emit")
99
112
  if any(t not in RESPOND_TOOLS and t != "fabric.board.join" for t in tools):
100
113
  problems.append("%s: a tool outside the respond scope was sent" % variant)
101
114
  if variant == "whole":
@@ -3,6 +3,7 @@ data. Part of the board connector (FAA-65, layer L4), below the responder in fab
3
3
  names. Standard library only, Python 3.9+."""
4
4
  from __future__ import annotations
5
5
 
6
+ import inspect
6
7
  import json
7
8
  import math
8
9
  import re
@@ -98,17 +99,29 @@ class BoardSession:
98
99
  return value
99
100
 
100
101
  def close(self, timeout: Optional[float] = None) -> bool:
101
- """Sends the DELETE (waiting `timeout` seconds at most when above 0, else the session's); True when Fabric answered it with a
102
+ """Sends the DELETE (waiting `timeout` seconds at most for the whole of it when above 0, FAA-187 (12), else the
103
+ session's per read); True when Fabric answered it with a
102
104
  2xx, so the slots are free now. False otherwise, a 404 (the session was already gone) included: a move made
103
105
  before it ended may be read only at the next com.status, so the slot records stay; the lease lapses in 60 s."""
104
106
  with self._lock:
105
107
  if self.mcp_session is None:
106
108
  return False
109
+ got: List[Any] = []
110
+
111
+ def send() -> None:
112
+ try:
113
+ got.append(self._send({}, method="DELETE", timeout=timeout))
114
+ except Exception: # noqa: BLE001 - any failure is no release, as the Node twin's catch reads it
115
+ pass
107
116
  try:
108
- answer = self._send({}, method="DELETE", timeout=timeout)
117
+ if timeout is not None and timeout > 0: # the whole DELETE, not each socket read (FAA-187 (12))
118
+ worker = threading.Thread(target=send, name="board-leave", daemon=True)
119
+ worker.start()
120
+ worker.join(timeout)
121
+ else:
122
+ send()
123
+ answer = got[0] if got else None
109
124
  status = answer[0] if isinstance(answer, (tuple, list)) and answer and isinstance(answer[0], int) else 0
110
- except Exception: # noqa: BLE001 - any failure is no release, as the Node twin's catch reads it
111
- status = 0
112
125
  finally:
113
126
  self.mcp_session = None # an answer it cannot read is no release, as the Node twin reads it
114
127
  return 200 <= status < 300
@@ -263,6 +276,10 @@ def _bounded_int(literal: str) -> int:
263
276
  return int(literal)
264
277
 
265
278
 
279
+ #: `mandate/1` as a block may write it: its slash plain or JSON-escaped (`\\/`, `\\u002f`).
280
+ _KIND_WRITTEN = re.compile(r"mandate(?:/|\\/|\\u002[fF])1")
281
+
282
+
266
283
  def read_mandate(request: "BoardRequest", mandate_from: Any, agent_id: Optional[str]) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
267
284
  """The mandate a request carries, read only when the service opted in (`agent_id` and `mandate_from` set), the board
268
285
  verified the sender (`provenance: trusted`) and its Project is one `mandate_from` names (or `PERSONAL_BOARD`): a T1
@@ -285,7 +302,7 @@ def read_mandate(request: "BoardRequest", mandate_from: Any, agent_id: Optional[
285
302
  try:
286
303
  value = json.loads(block, parse_constant=_no_constant, parse_int=_bounded_int)
287
304
  except (ValueError, RecursionError):
288
- if "mandate/1" in block:
305
+ if _KIND_WRITTEN.search(block): # the kind as written, a JSON escape of its slash included (FAA-196 (13))
289
306
  return None, "mandate-unreadable"
290
307
  continue
291
308
  if isinstance(value, dict) and value.get("kind") == "mandate/1":
@@ -300,9 +317,10 @@ CALL_MEMBERS = ("capability", "action", "effect", "spendUsd", "project") # what
300
317
 
301
318
 
302
319
  def covers(mandate: Optional[Dict[str, Any]], call: Any, agent_id: Optional[str], wall: Callable[[], float],
303
- caller: Any = None) -> Optional[Dict[str, Any]]:
304
- """`mandate_covers` for a call under a board request's mandate, its sender placed as T1 and no caller agent id (the
305
- board names a Project's principal, not an agent the chain can bind; the chain's end alone binds it, MD-3). A covered
320
+ caller: Any = None, caller_agent: Optional[str] = None) -> Optional[Dict[str, Any]]:
321
+ """`mandate_covers` for a call under a board request's mandate, its sender placed as T1. The caller's agent id is
322
+ `caller_agent`, what the service's identify_agent hook named for the sender (`sender_agent`, FAA-135); without one
323
+ the chain's end alone binds it (the board names a Project's principal, MD-3). A covered
306
324
  answer's `recordAs` names `caller`, the sender Project the board verified (MD-3 records the caller, as the action
307
325
  path does). None when no mandate travelled: the handler asks the operator as it would without one. The call's
308
326
  `spendUsd` and `project` are read failing closed, as the kit's other mandate readers read them (FAA-139): a spend
@@ -334,12 +352,54 @@ def covers(mandate: Optional[Dict[str, Any]], call: Any, agent_id: Optional[str]
334
352
  now = _format_rfc3339(clock * 1000) if math.isfinite(clock) else ""
335
353
  except (OverflowError, ValueError): # finite but outside the dates a clock can name
336
354
  now = ""
337
- answer = _mandate.mandate_covers({"mandate": mandate, "tier": "T1", "now": now, "self": agent_id, "call": call})
355
+ inputs = {"mandate": mandate, "tier": "T1", "now": now, "self": agent_id, "call": call}
356
+ if caller_agent is not None:
357
+ inputs["callerAgent"] = caller_agent
358
+ answer = _mandate.mandate_covers(inputs)
338
359
  if "recordAs" in answer:
339
360
  answer = {"ok": True, "recordAs": dict(answer["recordAs"], caller=caller)}
340
361
  return answer
341
362
 
342
363
 
364
+ def notice_links(links: Any) -> Dict[str, str]:
365
+ """A responder's `links` (FAA-187 (21)): the page on the service that resolves each notify kind, such as
366
+ `{"board.slot.moved": "/#board"}`, passed to a notice that notifies as its `link` (events-and-notifications.md).
367
+ A path on the service, as make_event takes one; None is none. A service that gives links has an emit that takes
368
+ `link` (JsonlEventLog.append does)."""
369
+ if links is None:
370
+ return {}
371
+ if not isinstance(links, Mapping) or not all(isinstance(k, str) and isinstance(v, str) and v.startswith("/")
372
+ and not v.startswith("//") for k, v in links.items()):
373
+ raise BoardError("invalid_arguments", "links maps an event kind to a path on this service, such as /#board.")
374
+ return dict(links)
375
+
376
+
377
+ def read_back_problem(message: Any, entry: Dict[str, Any], project: str) -> Optional[str]:
378
+ """Why a message read back with com.get is not the request a claim took (the first path refuses it, a resume reads
379
+ no mandate from it, FAA-196 (1)): `digest-mismatch` (another digest than the claim's), `not-addressed` (not this
380
+ request, capability and Project), else None."""
381
+ if not isinstance(message, dict) or message.get("digest") != entry["digest"]:
382
+ return "digest-mismatch"
383
+ req = message.get("request")
384
+ if not isinstance(req, dict) or req.get("id") != entry["request"] or req.get("capability") != entry["capability"] \
385
+ or req.get("target") != project:
386
+ return "not-addressed"
387
+ return None
388
+
389
+
390
+ def sender_agent(identify_agent: Optional[Callable[[Any], Optional[str]]], sender: Any, mandate: Any = True) -> Optional[str]:
391
+ """FAA-135: the agent id the service's identify_agent(sender) names for a request's sender (the board's verified
392
+ `{project, principal}`), read as the interop kit reads its own hook (`identified_agent`): None is none, an agent id
393
+ is that id, anything else, or a hook that raises, is an id no chain holds, so the mandate is refused. Asked only
394
+ when a mandate travelled (`mandate` not None)."""
395
+ if identify_agent is None or mandate is None:
396
+ return None
397
+ try:
398
+ return _rules.identified_agent(identify_agent(sender))
399
+ except Exception: # noqa: BLE001 - the service's own hook: fail closed
400
+ return _rules.UNREADABLE_AGENT
401
+
402
+
343
403
  def mandate_options(agent_id: Any, mandate_from: Any) -> Any:
344
404
  """The responder's opt-in, checked: an agent id the registry accepts, and `mandate_from` either `PERSONAL_BOARD` or
345
405
  a collection of Project ids (strings). Answers `mandate_from` as the responder keeps it (a frozenset or the string)."""
@@ -359,3 +419,31 @@ def mandate_options(agent_id: Any, mandate_from: Any) -> Any:
359
419
  def _fold(project: str) -> str:
360
420
  """A Project id as Fabric's board compares it: a uuid lower-cased, any other id as written (_hub._project)."""
361
421
  return project.lower() if _hub.UUID.fullmatch(project) else project
422
+
423
+
424
+ def notice_subject(value: Dict[str, Any], project: str, episode: Optional[str] = None) -> Dict[str, str]:
425
+ """A notice's subject (`{type, id}`): its request, else its capability, else its episode (a cause such as a block's
426
+ code, so two causes are two episodes for a host, seam r31), else the Project."""
427
+ for kind, given in (("request", value.get("request")), ("capability", value.get("capability")), ("episode", episode)):
428
+ if isinstance(given, str) and 0 < len(given) <= 128:
429
+ return {"type": kind, "id": given}
430
+ return {"type": "project", "id": project}
431
+
432
+
433
+ def takes_subject(emit: Callable[..., Any]) -> bool:
434
+ """Whether an emit's signature names a `subject` parameter, as JsonlEventLog.append does. Any other emit (one with
435
+ only **kwargs, which may forward them to a sink without it or set its own, or one whose signature cannot be read)
436
+ is called as 0.55.0 called it, without one (0.56.0 unit r33). A subject already bound into the emit, as with
437
+ functools.partial(log.append, subject=...), stands.
438
+
439
+ The signature is the emit's own, not a wrapped function's: a functools.wraps forwarder that sets its own subject
440
+ would otherwise be passed a second one (0.56.0 unit r34). Nothing here raises, so a notice never stops the
441
+ responder. A positional-only `subject` cannot take the keyword the responder passes, so it counts as none (FAA-187
442
+ (26)); the responder's own module imports both helpers under their old private names (moved here in 0.83.0).
443
+ """
444
+ try:
445
+ params = inspect.signature(emit, follow_wrapped=False).parameters.values()
446
+ return any(p.name == "subject" and p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY)
447
+ and (p.default is inspect.Parameter.empty or p.default is None) for p in params)
448
+ except Exception: # noqa: BLE001 - an unreadable signature (or a default that raises) is called without one
449
+ return False