@passioncode-ai/fabric-agent-adapter 0.81.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.
- package/CHANGELOG.md +200 -3
- package/README.md +11 -6
- package/SECURITY.md +8 -5
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +25 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +274 -17
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +24 -151
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +17 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +24 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +23 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +28 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +30 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +29 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +27 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +21 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +88 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +133 -30
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +44 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +35 -159
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +43 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +21 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +39 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +92 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +180 -162
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +274 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +465 -55
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +30 -17
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +53 -35
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +34 -21
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +96 -15
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py
ADDED
|
@@ -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,
|
|
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]:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py
CHANGED
|
@@ -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":
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
|
305
|
-
|
|
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
|
-
|
|
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
|