@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.84.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 (165) hide show
  1. package/CHANGELOG.md +169 -3
  2. package/README.md +7 -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_identifiers.py +1 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +38 -12
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +4 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +2 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +3 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +28 -12
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +13 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +11 -7
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +275 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +2 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +1 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +5 -2
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +1 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +1 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +15 -2
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +55 -11
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +47 -31
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +1 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +25 -7
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +27 -5
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +1 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +1 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +4 -2
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +19 -12
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate-rules.mjs +1 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +1 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +7 -1
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +22 -7
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +4 -5
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +7 -2
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-identifiers.mjs +1 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +21 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +6 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +1 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +1 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +16 -2
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +1 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +6 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +25 -3
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +456 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +1 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +18 -2
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +1 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +19 -5
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +56 -19
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +1 -0
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -0
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +16 -2
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -0
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +181 -0
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +20 -15
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +469 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet.mjs +1 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +1 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +1 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +3 -1
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +405 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +1 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +7 -2
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +1 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +1 -0
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +17 -3
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +98 -9
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +37 -48
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +1 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +25 -8
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +1 -0
  106. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +28 -6
  107. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +1 -0
  108. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +1 -0
  109. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +19 -13
  110. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +24 -14
  111. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +1 -0
  112. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate_rules.py +1 -0
  113. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -1
  114. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +32 -7
  115. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +19 -16
  116. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +10 -5
  117. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +1 -0
  118. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +56 -96
  119. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +99 -1
  120. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +1 -0
  121. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +1 -0
  122. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +16 -6
  123. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +1 -0
  124. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +1 -0
  125. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +44 -1
  126. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +648 -0
  127. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +17 -4
  128. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +1 -0
  129. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +1 -0
  130. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -5
  131. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +108 -108
  132. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +1 -0
  133. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -0
  134. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -0
  135. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +38 -12
  136. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +1 -0
  137. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +1 -0
  138. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +391 -0
  139. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +19 -16
  140. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet.py +1 -0
  141. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +493 -0
  142. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +1 -0
  143. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -0
  144. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +632 -104
  145. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  146. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +17 -7
  147. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  148. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +6 -5
  149. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  150. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  151. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  152. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  153. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  154. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +4 -3
  155. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +182 -0
  156. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  157. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  158. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  159. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
  160. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
  161. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  162. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  163. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
  164. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
  165. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -0,0 +1,405 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
2
+ """The serving side and a bounded client of A2A 1.0 (FAA-10, DEC-0122): an Agent Card built from the service's
3
+ capabilities (`agent_card`, every skill an A2A `AgentSkill`, checked by `fabric_a2a.agent_card_findings` before it is
4
+ served), the JSON-RPC binding (`A2ABinding`: `SendMessage`, `SendStreamingMessage`, `GetTask`, `CancelTask`) mapped
5
+ onto the interop job store (`fabric_interop.JobStore`: a task is a job, a choice stop is `TASK_STATE_INPUT_REQUIRED`,
6
+ the result envelope an artifact), and `A2AClient` (`discover`, `call`, `get`, `cancel`) that reaches a peer only on an
7
+ origin the operator allowed, with its credential read from the environment `use_secret.py run` fills, never from a
8
+ file. The interface never takes the descriptor's token (DEC-0122 item 3): the card names its own scheme and the
9
+ service's `authorize` decides the caller. Part of the service kit (layer L4). Standard library only."""
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import secrets
16
+ import time
17
+ from typing import Any, Callable, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
18
+
19
+ import sys as _sys # noqa: E402
20
+ from pathlib import Path as _Path # noqa: E402
21
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
22
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
23
+
24
+ from fabric_a2a import A2A_PROTOCOL_VERSION, A2A_SURFACE_PATH, agent_card_findings # noqa: E402
25
+ from fabric_base import ServiceError # noqa: E402
26
+ from fabric_interop import JobStore # noqa: E402
27
+ from fabric_interop_rules import InteropError, UnknownJob # noqa: E402
28
+ from fabric_http import request as http_request # noqa: E402
29
+
30
+ #: The JSON-RPC binding's name in a card's `supportedInterfaces` (A2A 1.0).
31
+ JSONRPC_BINDING = "JSONRPC"
32
+ #: The JSON-RPC codes A2A 1.0 section 5.4 maps its errors to, and JSON-RPC 2.0's own.
33
+ PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR = -32700, -32600, -32601, -32602, -32603
34
+ TASK_NOT_FOUND, TASK_NOT_CANCELABLE, UNSUPPORTED_OPERATION, CONTENT_TYPE_NOT_SUPPORTED, VERSION_NOT_SUPPORTED = \
35
+ -32001, -32002, -32004, -32005, -32009
36
+ #: The interop job status each A2A task state reads from.
37
+ TASK_STATES = {"working": "TASK_STATE_WORKING", "input_required": "TASK_STATE_INPUT_REQUIRED",
38
+ "completed": "TASK_STATE_COMPLETED", "failed": "TASK_STATE_FAILED", "cancelled": "TASK_STATE_CANCELED"}
39
+ TERMINAL_STATES = ("TASK_STATE_COMPLETED", "TASK_STATE_FAILED", "TASK_STATE_CANCELED", "TASK_STATE_REJECTED")
40
+ MAX_BODY = 1024 * 1024
41
+ _SKILL_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
42
+
43
+
44
+ class A2AError(ServiceError):
45
+ """A refused A2A call or card; the message never carries a credential."""
46
+
47
+
48
+ def skill_from_capability(capability: Mapping[str, Any]) -> Dict[str, Any]:
49
+ """An A2A `AgentSkill` from one capability ({name, description?, tags?, examples?, inputModes?, outputModes?}):
50
+ its id is the capability's name, the id the contract's A2A peer profile names in `skillIds`."""
51
+ name = capability.get("name") if isinstance(capability, Mapping) else None
52
+ if not isinstance(name, str) or not _SKILL_ID.match(name):
53
+ raise A2AError("a skill's id is the capability's name: letters, digits, '.', '_' or '-', at most 64")
54
+ skill: Dict[str, Any] = {"id": name, "name": capability.get("title") or name,
55
+ "description": capability.get("description") or name,
56
+ "tags": list(capability.get("tags") or [name.split(".")[0]])}
57
+ for key in ("examples", "inputModes", "outputModes"):
58
+ if capability.get(key):
59
+ skill[key] = list(capability[key])
60
+ return skill
61
+
62
+
63
+ def agent_card(*, name: str, description: str, version: str, url: str, capabilities: Sequence[Mapping[str, Any]],
64
+ security_schemes: Optional[Dict[str, Any]] = None, streaming: bool = False,
65
+ extensions: Optional[List[Dict[str, Any]]] = None, remote: bool = False,
66
+ descriptor_header: str = "Authorization") -> Dict[str, Any]:
67
+ """The public Agent Card a service serves at `surfaces.a2a.path` (A2A 1.0): one JSON-RPC interface at `url` on the
68
+ service's origin, one skill per capability. Raises A2AError when `agent_card_findings` finds anything, so a card
69
+ that names a process member, a secret, the descriptor's token header or a required Fabric extension is never
70
+ served. `security_schemes` names the interface's own scheme (never the descriptor's token)."""
71
+ match = re.match(r"\A(https?://[^/?#]+)", url or "")
72
+ card: Dict[str, Any] = {
73
+ "name": name, "description": description, "version": version,
74
+ "supportedInterfaces": [{"url": url, "protocolBinding": JSONRPC_BINDING, "protocolVersion": A2A_PROTOCOL_VERSION}],
75
+ "capabilities": {"streaming": bool(streaming), "pushNotifications": False},
76
+ "defaultInputModes": ["text/plain", "application/json"], "defaultOutputModes": ["text/plain", "application/json"],
77
+ "skills": [skill_from_capability(c) for c in capabilities],
78
+ }
79
+ if extensions:
80
+ card["capabilities"]["extensions"] = [dict(e, required=False) for e in extensions]
81
+ if security_schemes:
82
+ card["securitySchemes"] = security_schemes
83
+ findings = agent_card_findings(card, origin=match.group(1) if match else None, remote=remote,
84
+ descriptor_header=descriptor_header)
85
+ if findings:
86
+ raise A2AError("the card is not served: %s" % "; ".join(f["message"] for f in findings))
87
+ return card
88
+
89
+
90
+ def text_of(message: Any) -> str:
91
+ """The text parts of an A2A message, joined by a line feed."""
92
+ parts = message.get("parts") if isinstance(message, dict) else None
93
+ return "\n".join(p["text"] for p in parts or [] if isinstance(p, dict) and isinstance(p.get("text"), str))
94
+
95
+
96
+ def data_of(message: Any) -> List[Any]:
97
+ """The data parts of an A2A message."""
98
+ parts = message.get("parts") if isinstance(message, dict) else None
99
+ return [p["data"] for p in parts or [] if isinstance(p, dict) and "data" in p]
100
+
101
+
102
+ def agent_message(text: str, *, task_id: Optional[str] = None, context_id: Optional[str] = None,
103
+ data: Any = None) -> Dict[str, Any]:
104
+ """An agent's A2A message: a text part, and a data part when `data` is given."""
105
+ parts: List[Dict[str, Any]] = [{"text": text}]
106
+ if data is not None:
107
+ parts.append({"data": data})
108
+ message: Dict[str, Any] = {"role": "ROLE_AGENT", "parts": parts, "messageId": "msg-" + secrets.token_hex(8)}
109
+ if task_id:
110
+ message["taskId"] = task_id
111
+ if context_id:
112
+ message["contextId"] = context_id
113
+ return message
114
+
115
+
116
+ class TaskStart:
117
+ """What a message handler gets: the message, its caller and `start(skill)`, which makes the job that is the task
118
+ and returns its id; the service then runs the work and moves the job (working, request_input, complete, fail)."""
119
+
120
+ def __init__(self, binding: "A2ABinding", message: Dict[str, Any], caller: Optional[str], context_id: str):
121
+ self.binding, self.message, self.caller, self.context_id = binding, message, caller, context_id
122
+ self.task_id: Optional[str] = None
123
+
124
+ def start(self, skill: str) -> str:
125
+ if skill not in self.binding.skills:
126
+ raise A2AError("no skill %s on this card" % skill)
127
+ handle = self.binding.jobs.create("a2a." + skill, {"a2a": {"message": self.message, "contextId": self.context_id,
128
+ "caller": self.caller}})
129
+ self.task_id = handle["job"]["id"]
130
+ return self.task_id
131
+
132
+
133
+ Handler = Callable[[TaskStart], Optional[Dict[str, Any]]]
134
+
135
+
136
+ class A2ABinding:
137
+ """The JSON-RPC binding over the interop job store. `handler(start)` answers a new message: it returns an agent
138
+ message (`agent_message`) for a reply with no task, or calls `start.start(skill)` and returns None. `authorize`
139
+ turns the request's headers into a caller (or None to refuse with HTTP 401); a task is read, answered or cancelled
140
+ only by the caller that started it, any other gets TaskNotFound. `handle(body, headers)` returns (status, body)."""
141
+
142
+ def __init__(self, jobs: JobStore, handler: Handler, *, skills: Sequence[str],
143
+ authorize: Callable[[Mapping[str, str]], Optional[str]], clock: Callable[[], float] = time.time):
144
+ self.jobs, self.handler, self.skills, self.authorize, self.clock = jobs, handler, set(skills), authorize, clock
145
+
146
+ def task(self, job_id: str, caller: Optional[str]) -> Dict[str, Any]:
147
+ """The A2A Task a job reads as; UnknownJob when it is not an A2A job of this caller."""
148
+ a2a = (self.jobs.private(job_id).get("input") or {}).get("a2a")
149
+ if not isinstance(a2a, dict) or a2a.get("caller") != caller:
150
+ raise UnknownJob(job_id)
151
+ return task_of(self.jobs.get(job_id), a2a.get("contextId"))
152
+
153
+ def handle(self, body: Any, headers: Mapping[str, str]) -> Tuple[int, Optional[Dict[str, Any]]]:
154
+ lowered = {str(k).lower(): v for k, v in (headers or {}).items()}
155
+ caller = self.authorize(lowered)
156
+ if caller is None:
157
+ return 401, None
158
+ if isinstance(body, (bytes, bytearray)):
159
+ if len(body) > MAX_BODY:
160
+ return 413, None
161
+ try:
162
+ body = json.loads(bytes(body).decode("utf-8"))
163
+ except ValueError:
164
+ return 200, _error(None, PARSE_ERROR, "the body is not JSON")
165
+ if not isinstance(body, dict) or body.get("jsonrpc") != "2.0" or not isinstance(body.get("method"), str):
166
+ return 200, _error(body.get("id") if isinstance(body, dict) else None, INVALID_REQUEST, "not a JSON-RPC 2.0 request")
167
+ rid = body.get("id")
168
+ version = lowered.get("a2a-version")
169
+ if version is not None and version.strip() != A2A_PROTOCOL_VERSION:
170
+ return 200, _error(rid, VERSION_NOT_SUPPORTED, "this agent speaks A2A %s" % A2A_PROTOCOL_VERSION)
171
+ params = body.get("params")
172
+ if not isinstance(params, dict):
173
+ return 200, _error(rid, INVALID_PARAMS, "params is an object")
174
+ method = body["method"]
175
+ try:
176
+ if method in ("SendMessage", "SendStreamingMessage"):
177
+ if method == "SendStreamingMessage":
178
+ return 200, _error(rid, UNSUPPORTED_OPERATION, "streaming is served by stream() as text/event-stream")
179
+ return 200, _result(rid, self.send(params, caller))
180
+ if method == "GetTask":
181
+ return 200, _result(rid, self.task(str(params.get("id")), caller))
182
+ if method == "CancelTask":
183
+ return 200, _result(rid, self.cancel(str(params.get("id")), caller))
184
+ except UnknownJob:
185
+ return 200, _error(rid, TASK_NOT_FOUND, "no such task")
186
+ except _NotCancelable:
187
+ return 200, _error(rid, TASK_NOT_CANCELABLE, "the task has ended")
188
+ except _BadParams as exc:
189
+ return 200, _error(rid, INVALID_PARAMS, str(exc))
190
+ except (A2AError, InteropError) as exc:
191
+ return 200, _error(rid, INTERNAL_ERROR, "the agent could not take the message: %s" % exc)
192
+ return 200, _error(rid, METHOD_NOT_FOUND, "no method %s here" % method[:64])
193
+
194
+ def send(self, params: Dict[str, Any], caller: Optional[str]) -> Dict[str, Any]:
195
+ message = params.get("message")
196
+ if not isinstance(message, dict) or not isinstance(message.get("parts"), list) or not message["parts"]:
197
+ raise _BadParams("params.message is a message with parts")
198
+ task_id = message.get("taskId")
199
+ if isinstance(task_id, str) and task_id:
200
+ return {"task": self._continue(task_id, message, caller)}
201
+ context_id = message.get("contextId") if isinstance(message.get("contextId"), str) else "ctx-" + secrets.token_hex(8)
202
+ start = TaskStart(self, message, caller, context_id)
203
+ reply = self.handler(start)
204
+ if start.task_id is not None:
205
+ return {"task": self.task(start.task_id, caller)}
206
+ if not isinstance(reply, dict):
207
+ raise A2AError("the handler neither replied nor started a task")
208
+ return {"message": dict(reply, contextId=context_id)}
209
+
210
+ def _continue(self, task_id: str, message: Dict[str, Any], caller: Optional[str]) -> Dict[str, Any]:
211
+ """A message to an input-required task answers its question: a data part {inputResponses} as fabric.job.get
212
+ takes it, or plain text for a question with one field."""
213
+ task = self.task(task_id, caller)
214
+ if task["status"]["state"] != "TASK_STATE_INPUT_REQUIRED":
215
+ raise _BadParams("the task waits for no answer")
216
+ job = self.jobs.get(task_id)
217
+ responses = next((d["inputResponses"] for d in data_of(message) if isinstance(d, dict) and isinstance(d.get("inputResponses"), dict)), None)
218
+ if responses is None:
219
+ responses = _text_answer(job.get("inputRequests") or {}, text_of(message))
220
+ if not responses or not self.jobs.answer(task_id, responses):
221
+ raise _BadParams("the answer names none of the task's questions")
222
+ return self.task(task_id, caller)
223
+
224
+ def cancel(self, task_id: str, caller: Optional[str]) -> Dict[str, Any]:
225
+ task = self.task(task_id, caller)
226
+ if task["status"]["state"] in TERMINAL_STATES:
227
+ raise _NotCancelable()
228
+ self.jobs.cancel(task_id)
229
+ return self.task(task_id, caller)
230
+
231
+ def stream(self, params: Dict[str, Any], caller: Optional[str], *, rid: Any = None, poll_s: float = 1.0,
232
+ deadline_s: float = 300.0, sleep: Callable[[float], None] = time.sleep) -> Iterator[str]:
233
+ """`SendStreamingMessage` as server-sent events: the task first, then a status update each time its state
234
+ changes, ending at a terminal or input-required state or the deadline. Each event is one `data:` line holding
235
+ a JSON-RPC response whose result is a StreamResponse."""
236
+ first = self.send(params, caller)
237
+ yield _event(rid, first)
238
+ if "task" not in first:
239
+ return
240
+ task, end = first["task"], self.clock() + deadline_s
241
+ while task["status"]["state"] not in TERMINAL_STATES + ("TASK_STATE_INPUT_REQUIRED",) and self.clock() < end:
242
+ sleep(poll_s)
243
+ now = self.task(task["id"], caller)
244
+ if now["status"]["state"] != task["status"]["state"] or now.get("artifacts") != task.get("artifacts"):
245
+ final = now["status"]["state"] in TERMINAL_STATES + ("TASK_STATE_INPUT_REQUIRED",)
246
+ yield _event(rid, {"statusUpdate": {"taskId": now["id"], "contextId": now["contextId"],
247
+ "status": now["status"], "final": final}})
248
+ task = now
249
+
250
+
251
+ class _NotCancelable(Exception):
252
+ pass
253
+
254
+
255
+ class _BadParams(Exception):
256
+ pass
257
+
258
+
259
+ def _text_answer(requests: Dict[str, Any], text: str) -> Optional[Dict[str, Any]]:
260
+ if len(requests) != 1 or not text:
261
+ return None
262
+ key, request = next(iter(requests.items()))
263
+ props = (((request.get("params") or {}).get("requestedSchema") or {}).get("properties") or {}) if isinstance(request, dict) else {}
264
+ if len(props) != 1:
265
+ return None
266
+ return {key: {"action": "accept", "content": {next(iter(props)): text}}}
267
+
268
+
269
+ def task_of(job: Dict[str, Any], context_id: Optional[str]) -> Dict[str, Any]:
270
+ """The A2A Task an interop job reads as: its state, a status message, the result envelope as an artifact."""
271
+ state = TASK_STATES.get(job.get("status"), "TASK_STATE_UNSPECIFIED")
272
+ status: Dict[str, Any] = {"state": state, "timestamp": job.get("updatedAt") or ""}
273
+ text = job.get("statusMessage")
274
+ if job.get("status") == "input_required":
275
+ questions = [(r.get("params") or {}).get("message") for r in (job.get("inputRequests") or {}).values() if isinstance(r, dict)]
276
+ status["message"] = agent_message(text or "; ".join(q for q in questions if isinstance(q, str)) or "an answer is needed",
277
+ task_id=job["id"], context_id=context_id,
278
+ data={"inputRequests": job.get("inputRequests")})
279
+ elif job.get("status") == "failed":
280
+ error = job.get("error") or {}
281
+ status["message"] = agent_message(str(error.get("message") or "the task failed"), task_id=job["id"],
282
+ context_id=context_id, data={"code": error.get("code")})
283
+ elif text:
284
+ status["message"] = agent_message(text, task_id=job["id"], context_id=context_id)
285
+ task: Dict[str, Any] = {"id": job["id"], "contextId": context_id, "status": status}
286
+ if job.get("status") == "completed":
287
+ task["artifacts"] = [{"artifactId": "result", "name": "result", "parts": [{"data": job.get("result")}]}]
288
+ return task
289
+
290
+
291
+ def _result(rid: Any, result: Any) -> Dict[str, Any]:
292
+ return {"jsonrpc": "2.0", "id": rid, "result": result}
293
+
294
+
295
+ def _error(rid: Any, code: int, message: str) -> Dict[str, Any]:
296
+ return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
297
+
298
+
299
+ def _event(rid: Any, result: Any) -> str:
300
+ return "data: %s\n\n" % json.dumps(_result(rid, result), separators=(",", ":"), ensure_ascii=False)
301
+
302
+
303
+ def origin_of(url: str) -> str:
304
+ """`scheme://host[:port]` of a URL, lower-cased, with a default port dropped; A2AError for anything else."""
305
+ match = re.match(r"\A(https?)://([^/?#]+)", url or "", re.IGNORECASE)
306
+ if not match or "@" in match.group(2):
307
+ raise A2AError("a peer's address is an http(s) origin with no userinfo")
308
+ scheme, host = match.group(1).lower(), match.group(2).lower()
309
+ if (scheme, host.rsplit(":", 1)[-1]) in (("https", "443"), ("http", "80")) and ":" in host and not host.endswith("]"):
310
+ host = host.rsplit(":", 1)[0]
311
+ return "%s://%s" % (scheme, host)
312
+
313
+
314
+ class A2AClient:
315
+ """A bounded A2A 1.0 client: every call has a deadline and a size cap (fabric_http), reaches only an origin in
316
+ `allow_origins` (the operator's list), and sends a credential only when `credential_env` names an environment
317
+ variable that holds one (`use_secret.py run --vault-only <project> <NAME> -- <command>` fills it; never a file
318
+ beside the agent). It never follows a redirect and never retries a SendMessage."""
319
+
320
+ def __init__(self, allow_origins: Sequence[str], *, credential_env: Optional[str] = None,
321
+ header: str = "Authorization", scheme: Optional[str] = "Bearer", deadline_s: float = 30.0,
322
+ max_bytes: int = MAX_BODY, transport: Any = None, insecure: bool = False):
323
+ self.allowed = {origin_of(o) for o in allow_origins}
324
+ if not self.allowed:
325
+ raise A2AError("a client needs the operator's list of peer origins")
326
+ self.credential_env, self.header, self.scheme = credential_env, header, scheme
327
+ self.deadline_s, self.max_bytes, self.transport, self.insecure = deadline_s, max_bytes, transport, insecure
328
+ self._ids = 0
329
+
330
+ def _check(self, url: str) -> None:
331
+ if origin_of(url) not in self.allowed:
332
+ raise A2AError("%s is not on the operator's list of A2A peers" % origin_of(url))
333
+
334
+ def _headers(self) -> Dict[str, str]:
335
+ headers = {"A2A-Version": A2A_PROTOCOL_VERSION, "Accept": "application/json"}
336
+ if self.credential_env:
337
+ value = os.environ.get(self.credential_env)
338
+ if not value:
339
+ raise A2AError("%s is empty: run the caller under use_secret.py run, which places the key" % self.credential_env)
340
+ headers[self.header] = "%s %s" % (self.scheme, value) if self.scheme else value
341
+ return headers
342
+
343
+ def _get(self, url: str) -> Any:
344
+ self._check(url)
345
+ kwargs = {"transport": self.transport} if self.transport else {}
346
+ response = http_request("GET", url, headers={"Accept": "application/json"}, deadline_s=self.deadline_s,
347
+ max_bytes=self.max_bytes, insecure=self.insecure, **kwargs)
348
+ if response.status != 200:
349
+ raise A2AError("the peer answered HTTP %d for its card" % response.status)
350
+ try:
351
+ return response.json()
352
+ except ValueError:
353
+ raise A2AError("the peer's card is not JSON") from None
354
+
355
+ def discover(self, origin: str) -> Dict[str, Any]:
356
+ """The peer's public card and the JSON-RPC interface to call: {card, url}."""
357
+ card = self._get(origin_of(origin) + A2A_SURFACE_PATH)
358
+ if not isinstance(card, dict):
359
+ raise A2AError("the peer's card is not a JSON object")
360
+ for interface in card.get("supportedInterfaces") or []:
361
+ if (isinstance(interface, dict) and interface.get("protocolBinding") == JSONRPC_BINDING
362
+ and interface.get("protocolVersion") == A2A_PROTOCOL_VERSION and isinstance(interface.get("url"), str)):
363
+ self._check(interface["url"])
364
+ return {"card": card, "url": interface["url"]}
365
+ raise A2AError("the peer's card names no A2A %s JSON-RPC interface" % A2A_PROTOCOL_VERSION)
366
+
367
+ def _rpc(self, url: str, method: str, params: Dict[str, Any]) -> Any:
368
+ self._check(url)
369
+ self._ids += 1
370
+ kwargs = {"transport": self.transport} if self.transport else {}
371
+ response = http_request("POST", url, headers=self._headers(), body={"jsonrpc": "2.0", "id": self._ids,
372
+ "method": method, "params": params}, deadline_s=self.deadline_s, retries=0,
373
+ max_bytes=self.max_bytes, insecure=self.insecure, **kwargs)
374
+ if response.status != 200:
375
+ raise A2AError("the peer answered HTTP %d to %s" % (response.status, method))
376
+ try:
377
+ answer = response.json()
378
+ except ValueError:
379
+ answer = None
380
+ if not isinstance(answer, dict):
381
+ raise A2AError("the peer's answer to %s is not a JSON-RPC response" % method)
382
+ if "error" in answer:
383
+ error = answer["error"] if isinstance(answer["error"], dict) else {}
384
+ raise A2AError("the peer refused %s: %s %s" % (method, error.get("code"), str(error.get("message"))[:200]))
385
+ return answer.get("result")
386
+
387
+ def call(self, url: str, text: str, *, data: Any = None, task_id: Optional[str] = None,
388
+ context_id: Optional[str] = None) -> Any:
389
+ """SendMessage: a user message with `text` (and a data part), new or continuing `task_id`; the result as sent
390
+ ({task} or {message})."""
391
+ parts: List[Dict[str, Any]] = [{"text": text}]
392
+ if data is not None:
393
+ parts.append({"data": data})
394
+ message: Dict[str, Any] = {"role": "ROLE_USER", "parts": parts, "messageId": "msg-" + secrets.token_hex(8)}
395
+ if task_id:
396
+ message["taskId"] = task_id
397
+ if context_id:
398
+ message["contextId"] = context_id
399
+ return self._rpc(url, "SendMessage", {"message": message})
400
+
401
+ def get(self, url: str, task_id: str) -> Any:
402
+ return self._rpc(url, "GetTask", {"id": task_id})
403
+
404
+ def cancel(self, url: str, task_id: str) -> Any:
405
+ return self._rpc(url, "CancelTask", {"id": task_id})
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """Every action a person has, its agent tool and who decides it: `fabric-actions/0.1`, the extension keyed
2
3
  extensions/actions/0.1 (DEC-0121, FAC-SEM-142). Part of the service kit (layer L3); the twin of fabric-actions.mjs.
3
4
 
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """Who may call a service: the descriptor token, the network guard,
2
3
  the operator's one-time login and its session, remote placement (DEC-0019) and the feed client's token header.
3
4
  Part of the service kit (FAA-65, layer L3)."""
@@ -40,6 +41,7 @@ from fabric_descriptor import (
40
41
  names_repository,
41
42
  source_refusal,
42
43
  validate_descriptor,
44
+ warn_no_source,
43
45
  write_descriptor,
44
46
  )
45
47
 
@@ -399,7 +401,7 @@ def register_remote(*, service_id: str, name: str, origin: str, token: str, inst
399
401
  if source is None: # a rotation that names no source keeps the installed one (0.62.0 seam reading 1)
400
402
  try:
401
403
  installed = json.loads(descriptor_path(service_id, instance, root).read_text(encoding="utf-8")).get("source")
402
- except (OSError, ValueError, AttributeError):
404
+ except (OSError, ValueError, AttributeError, RecursionError): # nested thousands deep (FAA-200 (10))
403
405
  installed = None
404
406
  # Only one the kit accepts: a hand-edited source never blocks a rotation; it is dropped with the warning.
405
407
  base = set(validate_descriptor(descriptor))
@@ -415,9 +417,12 @@ def register_remote(*, service_id: str, name: str, origin: str, token: str, inst
415
417
  refusal = source_refusal(descriptor, root) # before the token is written: a refused install leaves nothing (FAA-198)
416
418
  if refusal:
417
419
  raise ServiceError(refusal)
420
+ warned = not names_repository(descriptor)
421
+ if warned: # before the token: under warnings as errors neither file changes (FAA-200 (1))
422
+ warn_no_source(descriptor, 2) # the warning names the installer's call
418
423
  ensure_private_dir(token_file.parent)
419
424
  atomic_write(token_file, token.strip().encode(), 0o600)
420
- return write_descriptor(descriptor, root, _stacklevel=3) # the warning names the installer's call
425
+ return write_descriptor(descriptor, root, _warned=warned)
421
426
 
422
427
 
423
428
  def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Optional[str]:
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """Settings backups (DEC-0025, FAC-SEM-035): the canonical form, the digest, writing, listing, checking and
2
3
  restoring them. Part of the service kit (FAA-65, layer L3)."""
3
4
  from __future__ import annotations
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """What every module of the service kit shares (FAA-65, layer L1): the protocol name, ServiceError, the
2
3
  platform, ids and now_iso. Standard library only."""
3
4
  from __future__ import annotations
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """The project board connector, Python: the facade (fabric-project-comms/0.1).
2
3
 
3
4
  Since 0.40.0 (FAA-65) the connector is layered modules, lowest first: fabric_board_shapes (the wire), fabric_board_link
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
1
2
  """A fixture conversation for the probe (check_service.py, rule board.responder): a scripted board that a responder
2
3
  can be run against without Fabric. Part of the board connector (FAA-65, layer L4)."""
3
4
  from __future__ import annotations
@@ -13,6 +14,7 @@ from pathlib import Path as _Path # noqa: E402
13
14
  if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
14
15
  _sys.path.insert(0, str(_Path(__file__).resolve().parent))
15
16
  import fabric_hub as _hub
17
+ from fabric_events import LEVELS as _LEVELS # noqa: E402 - the levels the event log takes (FAA-187 (23))
16
18
  from fabric_board_shapes import ( # noqa: E402 - the kit (FAA-65)
17
19
  COMMS_PROTOCOL,
18
20
  RESPOND_TOOLS,
@@ -55,7 +57,9 @@ def _fixture_answers(variant: str) -> Dict[str, Any]:
55
57
  def self_check() -> List[str]:
56
58
  """One scripted conversation per path through this kit, with no Fabric: the whole conversation, a `fenced` accept
57
59
  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."""
60
+ conversation as the contract asks. Writes only in a temporary folder it removes. The responder's notices are recorded
61
+ by an emit that takes a subject, as JsonlEventLog.append does: a notice at a level the log refuses, one without its
62
+ subject, or a fenced attempt that says no warning fails it (FAA-187 (23))."""
59
63
  import os
60
64
  import tempfile
61
65
  problems: List[str] = []
@@ -71,7 +75,10 @@ def self_check() -> List[str]:
71
75
  cred = Path(tmp) / "credential"
72
76
  cred.write_text("c" * 43)
73
77
  os.chmod(cred, 0o600)
74
- answers, calls, handled = _fixture_answers(variant), [], []
78
+ answers, calls, handled, notices = _fixture_answers(variant), [], [], []
79
+
80
+ def emit(kind, level, text, subject=None, **extra):
81
+ notices.append((kind, level, subject))
75
82
 
76
83
  def post(origin, body, bearer, session=None, timeout=15.0, method="POST", protocol_version=None):
77
84
  if method == "DELETE":
@@ -93,9 +100,16 @@ def self_check() -> List[str]:
93
100
  credential_file=cred, checkpoint_file=Path(tmp) / "checkpoint.json",
94
101
  handler=lambda request, ctx: handled.append(request.body) or {"state": "completed", "summary": "Rendered."},
95
102
  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))
103
+ session_factory=lambda grant: BoardSession(grant, post=post), emit=emit)
97
104
  responder.step()
98
105
  tools = [t for t, _ in calls]
106
+ for kind, level, subject in notices:
107
+ if level not in _LEVELS:
108
+ problems.append("%s: notice %s at level %r, which the event log refuses" % (variant, kind, level))
109
+ if not (isinstance(subject, dict) and subject.get("type") and subject.get("id")):
110
+ problems.append("%s: notice %s without its subject" % (variant, kind))
111
+ if variant == "fenced" and not any(level == "warning" for _, level, _ in notices):
112
+ problems.append("fenced: no warning notice reached the service's emit")
99
113
  if any(t not in RESPOND_TOOLS and t != "fabric.board.join" for t in tools):
100
114
  problems.append("%s: a tool outside the respond scope was sent" % variant)
101
115
  if variant == "whole":