@passioncode-ai/fabric-agent-adapter 0.59.0 → 0.61.0
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 +195 -0
- package/README.md +2 -1
- 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 +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +93 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +23 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +117 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +33 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +80 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +24 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +146 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +31 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +80 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +33 -25
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +19 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +23 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +17 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +86 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +14 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +210 -22
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +2 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -27
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py
CHANGED
|
@@ -4,18 +4,26 @@ names. Standard library only, Python 3.9+."""
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Mapping
|
|
7
10
|
import threading
|
|
8
11
|
from pathlib import Path
|
|
9
12
|
from typing import Any
|
|
10
13
|
from typing import Callable
|
|
11
14
|
from typing import Dict
|
|
15
|
+
from typing import List
|
|
12
16
|
from typing import Optional
|
|
17
|
+
from typing import Tuple
|
|
13
18
|
|
|
14
19
|
import sys as _sys # noqa: E402
|
|
15
20
|
from pathlib import Path as _Path # noqa: E402
|
|
16
21
|
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
17
22
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
18
23
|
import fabric_hub as _hub
|
|
24
|
+
import fabric_interop_rules as _rules
|
|
25
|
+
import fabric_mandate as _mandate
|
|
26
|
+
from fabric_time import format_rfc3339 as _format_rfc3339 # noqa: E402 - the core's (FAA-65)
|
|
19
27
|
import fabric_service as _fs
|
|
20
28
|
from fabric_board_shapes import ( # noqa: E402 - the kit (FAA-65)
|
|
21
29
|
BoardError,
|
|
@@ -210,3 +218,141 @@ class BoardRequest:
|
|
|
210
218
|
|
|
211
219
|
def __repr__(self) -> str: # never the body
|
|
212
220
|
return "BoardRequest(%r, %r)" % (self.request_id, self.capability)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# -- the operator's mandate in a board request (FAA-135; contract project-comms.md, DEC-0125 MD-3) ---------------------
|
|
224
|
+
|
|
225
|
+
#: `mandate_from` naming every trusted sender of the board the service is installed on: the operator's personal board.
|
|
226
|
+
PERSONAL_BOARD = "personal-board"
|
|
227
|
+
_OPENER = re.compile(r"```json[ \t]*")
|
|
228
|
+
_CLOSER = re.compile(r"```[ \t]*")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _json_blocks(body: str) -> List[str]:
|
|
232
|
+
"""The bodies of the ```json blocks, each fence on its own line: one pass over the lines, so linear in the body
|
|
233
|
+
(an anchored regex rescanned to the end from every unclosed opener). Lines end at "\\n" alone, one "\\r" before it
|
|
234
|
+
dropped, as in the Node twin; an unclosed opener ends the scan."""
|
|
235
|
+
lines = [line[:-1] if line.endswith("\r") else line for line in body.split("\n")]
|
|
236
|
+
blocks: List[str] = []
|
|
237
|
+
i = 0
|
|
238
|
+
while i < len(lines):
|
|
239
|
+
if _OPENER.fullmatch(lines[i]):
|
|
240
|
+
j = i + 1
|
|
241
|
+
while j < len(lines) and not _CLOSER.fullmatch(lines[j]):
|
|
242
|
+
j += 1
|
|
243
|
+
if j == len(lines):
|
|
244
|
+
break
|
|
245
|
+
blocks.append("\n".join(lines[i + 1:j]))
|
|
246
|
+
i = j
|
|
247
|
+
i += 1
|
|
248
|
+
return blocks
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _no_constant(name: str) -> Any:
|
|
252
|
+
raise ValueError(name) # NaN and Infinity are not JSON
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _bounded_int(literal: str) -> int:
|
|
256
|
+
"""An integer of at most 4300 digits on every Python: 3.9 without int_max_str_digits converts a longer one in
|
|
257
|
+
quadratic time, and later ones refuse it, so all of them now refuse it alike."""
|
|
258
|
+
if len(literal) - literal.startswith("-") > 4300:
|
|
259
|
+
raise ValueError("an integer longer than 4300 digits")
|
|
260
|
+
return int(literal)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def read_mandate(request: "BoardRequest", mandate_from: Any, agent_id: Optional[str]) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
264
|
+
"""The mandate a request carries, read only when the service opted in (`agent_id` and `mandate_from` set), the board
|
|
265
|
+
verified the sender (`provenance: trusted`) and its Project is one `mandate_from` names (or `PERSONAL_BOARD`): a T1
|
|
266
|
+
caller by project-comms. Answers `(mandate, None)`, `(None, None)` when none is read, or `(None, code)` for a body
|
|
267
|
+
that carries one the kit will not read: `mandate-blocks` (more than one) or `mandate-unreadable` (a block naming
|
|
268
|
+
`mandate/1` that is not JSON). A `source` in the block is dropped: a board block carries none (OA-7). Whether it
|
|
269
|
+
covers a call is `covers`' answer, never this one's."""
|
|
270
|
+
if agent_id is None or mandate_from is None or not isinstance(request.body, str):
|
|
271
|
+
return None, None
|
|
272
|
+
sender = request.sender if isinstance(request.sender, dict) else {}
|
|
273
|
+
principal = sender.get("principal") if isinstance(sender.get("principal"), dict) else {}
|
|
274
|
+
if principal.get("provenance") != "trusted":
|
|
275
|
+
return None, None
|
|
276
|
+
if not isinstance(sender.get("project"), str) or not sender["project"]: # comms-message requires it; the caller
|
|
277
|
+
return None, None
|
|
278
|
+
if mandate_from != PERSONAL_BOARD and _fold(sender["project"]) not in mandate_from:
|
|
279
|
+
return None, None
|
|
280
|
+
found = []
|
|
281
|
+
for block in _json_blocks(request.body):
|
|
282
|
+
try:
|
|
283
|
+
value = json.loads(block, parse_constant=_no_constant, parse_int=_bounded_int)
|
|
284
|
+
except (ValueError, RecursionError):
|
|
285
|
+
if "mandate/1" in block:
|
|
286
|
+
return None, "mandate-unreadable"
|
|
287
|
+
continue
|
|
288
|
+
if isinstance(value, dict) and value.get("kind") == "mandate/1":
|
|
289
|
+
value.pop("source", None) # a Project declares no protocols: a board block carries no source (OA-7)
|
|
290
|
+
found.append(value)
|
|
291
|
+
if len(found) > 1:
|
|
292
|
+
return None, "mandate-blocks"
|
|
293
|
+
return (found[0], None) if found else (None, None)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
CALL_MEMBERS = ("capability", "action", "effect", "spendUsd", "project") # what mandate_covers reads of a call
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def covers(mandate: Optional[Dict[str, Any]], call: Any, agent_id: Optional[str], wall: Callable[[], float],
|
|
300
|
+
caller: Any = None) -> Optional[Dict[str, Any]]:
|
|
301
|
+
"""`mandate_covers` for a call under a board request's mandate, its sender placed as T1 and no caller agent id (the
|
|
302
|
+
board names a Project's principal, not an agent the chain can bind; the chain's end alone binds it, MD-3). A covered
|
|
303
|
+
answer's `recordAs` names `caller`, the sender Project the board verified (MD-3 records the caller, as the action
|
|
304
|
+
path does). None when no mandate travelled: the handler asks the operator as it would without one. The call's
|
|
305
|
+
`spendUsd` and `project` are read failing closed, as the kit's other mandate readers read them (FAA-139): a spend
|
|
306
|
+
that is not a finite number is none, which a ceiling refuses; a `project` that is not a string, None included (the
|
|
307
|
+
contract's mandateCovers refuses a null project), is unreadable, which a mandate limited to projects refuses; a call
|
|
308
|
+
that names none leaves `project` out. Only the five members `mandate_covers` reads are read, each once, the way the
|
|
309
|
+
handler's own `call[name]` reads it (a `__getitem__` or a default answers, never the mapping's storage), and the
|
|
310
|
+
rest of the call is never touched."""
|
|
311
|
+
if mandate is None or agent_id is None:
|
|
312
|
+
return None
|
|
313
|
+
if isinstance(call, Mapping):
|
|
314
|
+
read: Dict[str, Any] = {}
|
|
315
|
+
for name in CALL_MEMBERS:
|
|
316
|
+
try:
|
|
317
|
+
value = call[name]
|
|
318
|
+
except KeyError: # not named; any other error the mapping raises passes to the handler
|
|
319
|
+
continue
|
|
320
|
+
if name == "spendUsd":
|
|
321
|
+
value = _rules.mandate_spend(value)
|
|
322
|
+
if value is None:
|
|
323
|
+
continue
|
|
324
|
+
elif name == "project":
|
|
325
|
+
value = value if type(value) is str else _rules.UNREADABLE_PROJECT
|
|
326
|
+
read[name] = value
|
|
327
|
+
call = read
|
|
328
|
+
clock, now = wall(), ""
|
|
329
|
+
if isinstance(clock, (int, float)) and not isinstance(clock, bool):
|
|
330
|
+
try:
|
|
331
|
+
now = _format_rfc3339(clock * 1000) if math.isfinite(clock) else ""
|
|
332
|
+
except (OverflowError, ValueError): # finite but outside the dates a clock can name
|
|
333
|
+
now = ""
|
|
334
|
+
answer = _mandate.mandate_covers({"mandate": mandate, "tier": "T1", "now": now, "self": agent_id, "call": call})
|
|
335
|
+
if "recordAs" in answer:
|
|
336
|
+
answer = {"ok": True, "recordAs": dict(answer["recordAs"], caller=caller)}
|
|
337
|
+
return answer
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def mandate_options(agent_id: Any, mandate_from: Any) -> Any:
|
|
341
|
+
"""The responder's opt-in, checked: an agent id the registry accepts, and `mandate_from` either `PERSONAL_BOARD` or
|
|
342
|
+
a collection of Project ids (strings). Answers `mandate_from` as the responder keeps it (a frozenset or the string)."""
|
|
343
|
+
if agent_id is not None and not _mandate.is_agent_id(agent_id):
|
|
344
|
+
raise BoardError("invalid_arguments", "agent_id must be the service's registry agent id.")
|
|
345
|
+
if mandate_from is None or mandate_from == PERSONAL_BOARD:
|
|
346
|
+
return mandate_from
|
|
347
|
+
try:
|
|
348
|
+
projects = None if isinstance(mandate_from, (str, bytes, Mapping)) else list(mandate_from)
|
|
349
|
+
except TypeError:
|
|
350
|
+
projects = None
|
|
351
|
+
if projects is None or not all(isinstance(p, str) and p for p in projects):
|
|
352
|
+
raise BoardError("invalid_arguments", "mandate_from is %r or a collection of Project ids." % PERSONAL_BOARD)
|
|
353
|
+
return frozenset(_fold(p) for p in projects)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _fold(project: str) -> str:
|
|
357
|
+
"""A Project id as Fabric's board compares it: a uuid lower-cased, any other id as written (_hub._project)."""
|
|
358
|
+
return project.lower() if _hub.UUID.fullmatch(project) else project
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py
CHANGED
|
@@ -17,6 +17,7 @@ import sys as _sys # noqa: E402
|
|
|
17
17
|
from pathlib import Path as _Path # noqa: E402
|
|
18
18
|
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
19
19
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
20
|
+
import fabric_board_link as _link
|
|
20
21
|
import fabric_hub as _hub
|
|
21
22
|
import fabric_service as _fs
|
|
22
23
|
from fabric_board_shapes import ( # noqa: E402 - the kit (FAA-65)
|
|
@@ -26,6 +27,7 @@ from fabric_board_link import ( # noqa: E402,F401 - re-exported: callers import
|
|
|
26
27
|
BoardRequest,
|
|
27
28
|
BoardSession,
|
|
28
29
|
Checkpoint,
|
|
30
|
+
PERSONAL_BOARD,
|
|
29
31
|
)
|
|
30
32
|
from fabric_board_slots import ( # noqa: E402,F401 - the slot records (FAA-155, FAA-163); the constants re-exported
|
|
31
33
|
HOLD_RETRY_MAX_SECONDS,
|
|
@@ -63,12 +65,18 @@ NOT_OURS = frozenset(["queued", "claimed", "answered"])
|
|
|
63
65
|
class WorkContext:
|
|
64
66
|
"""What a handler may do while it works: `progress(note)` posts a note on the request's thread (and renews the
|
|
65
67
|
lease); `begin_effect(summary)` must be called (and must return) before the handler spends or acts outside
|
|
66
|
-
the service; `stopped` is set when the attempt is no longer this responder's. Both raise `Stopped` then.
|
|
68
|
+
the service; `stopped` is set when the attempt is no longer this responder's. Both raise `Stopped` then.
|
|
69
|
+
`mandate` is the operator's mandate the request carried, when the responder reads one (FAA-135), and
|
|
70
|
+
`mandate_covers(call)` whether it covers a call (`mandate_covers`' answer), None when none travelled."""
|
|
67
71
|
|
|
68
|
-
def __init__(self, responder: "Responder", entry: Dict[str, Any]
|
|
69
|
-
|
|
72
|
+
def __init__(self, responder: "Responder", entry: Dict[str, Any], mandate: Optional[Dict[str, Any]] = None,
|
|
73
|
+
caller: Optional[str] = None):
|
|
74
|
+
self._responder, self._entry, self.mandate, self._caller = responder, entry, mandate, caller
|
|
70
75
|
self.stopped = threading.Event()
|
|
71
76
|
|
|
77
|
+
def mandate_covers(self, call: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
78
|
+
return _link.covers(self.mandate, call, self._responder.agent_id, self._responder.wall, self._caller)
|
|
79
|
+
|
|
72
80
|
def progress(self, note: Optional[str] = None) -> None:
|
|
73
81
|
if self.stopped.is_set():
|
|
74
82
|
raise Stopped()
|
|
@@ -133,13 +141,16 @@ class Responder(SlotKeeper):
|
|
|
133
141
|
observe: Optional[Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]] = None,
|
|
134
142
|
emit: Optional[Callable[..., Any]] = None, service: str = "service",
|
|
135
143
|
now: Callable[[], float] = time.monotonic, rand: Callable[[], float] = random.random,
|
|
136
|
-
spawn: Optional[Callable[[Callable[[], None]], Any]] = None, session_factory: Optional[Callable[..., BoardSession]] = None
|
|
144
|
+
spawn: Optional[Callable[[Callable[[], None]], Any]] = None, session_factory: Optional[Callable[..., BoardSession]] = None,
|
|
145
|
+
agent_id: Optional[str] = None, mandate_from: Any = None, wall: Callable[[], float] = time.time):
|
|
137
146
|
if admit is None or handler is None:
|
|
138
147
|
raise BoardError("invalid_arguments", "A responder needs the service's own input gate (admit) and a handler.")
|
|
139
148
|
caps = list(capabilities)
|
|
140
149
|
if not caps or any(not isinstance(c, str) or not _CAPABILITY.fullmatch(c) for c in caps) or len(set(caps)) != len(caps):
|
|
141
150
|
raise BoardError("invalid_arguments", "Name each capability once, [a-z][a-z0-9._-]{1,127}.")
|
|
142
151
|
self.hub, self.project = hub, _hub._project(project_id)
|
|
152
|
+
#: FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; None reads none).
|
|
153
|
+
self.mandate_from, self.agent_id, self.wall = _link.mandate_options(agent_id, mandate_from), agent_id, wall
|
|
143
154
|
self.capabilities = caps
|
|
144
155
|
self.credential_file, self.handler, self.admit, self.observe = Path(credential_file), handler, admit, observe
|
|
145
156
|
self.checkpoint = Checkpoint(Path(checkpoint_file))
|
|
@@ -155,6 +166,7 @@ class Responder(SlotKeeper):
|
|
|
155
166
|
self.wait_until = 0.0
|
|
156
167
|
self.stopping = False
|
|
157
168
|
self.claim_keys: Dict[str, str] = {}
|
|
169
|
+
self._probing: set = set() # moved slots whose move-back claim lost its answer: asked again under the same key
|
|
158
170
|
self._said: Dict[str, str] = {}
|
|
159
171
|
self._rr = 0
|
|
160
172
|
#: The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS.
|
|
@@ -237,6 +249,7 @@ class Responder(SlotKeeper):
|
|
|
237
249
|
self.state = "connecting"
|
|
238
250
|
self.slots = {} # the previous session's slots are not this one's: a refusal now reconnects (FAA-163)
|
|
239
251
|
self._confirmed = {} # nor its confirmations: a slot may have moved between the two sessions
|
|
252
|
+
self._probing = set() # nor its lost move-back claims
|
|
240
253
|
grant = self.hub.join_board(self.project, self.credential_file)
|
|
241
254
|
missing = [c for c in self.capabilities if c not in grant.capabilities]
|
|
242
255
|
session = self.session_factory(grant)
|
|
@@ -516,7 +529,11 @@ class Responder(SlotKeeper):
|
|
|
516
529
|
"""Seconds this session's leases still run, by its own clock: the running attempt's, else the oldest held slot's."""
|
|
517
530
|
renewed = [self.current.renewed_at or 0.0] if self.current is not None else \
|
|
518
531
|
[s.get("renewed_at", 0.0) for s in self.slots.values() if s.get("state") == "held"
|
|
519
|
-
and s.get("renewed_at", 0.0) != float("-inf")]
|
|
532
|
+
and s.get("renewed_at", 0.0) != float("-inf")] + \
|
|
533
|
+
[s["probe_at"] for c, s in self.slots.items() if c in self._probing and "probe_at" in s
|
|
534
|
+
and self.now() - s["probe_at"] < LEASE_SECONDS - LEASE_MARGIN_SECONDS]
|
|
535
|
+
# a lapsed slot holds no lease (FAA-163); a move-back claim whose answer was lost holds one from its send, while
|
|
536
|
+
# more than the 10 s margin of that lease is left (FAA-165)
|
|
520
537
|
return min(renewed) + LEASE_SECONDS - self.now() if renewed and self.session is not None else 0.0
|
|
521
538
|
|
|
522
539
|
def _fault(self, exc: Exception) -> float:
|
|
@@ -588,8 +605,10 @@ class Responder(SlotKeeper):
|
|
|
588
605
|
self._gone(capability)
|
|
589
606
|
self._suspect.discard(capability)
|
|
590
607
|
for capability, slot in list(self.slots.items()):
|
|
591
|
-
if slot.get("state") == "moved":
|
|
592
|
-
|
|
608
|
+
if slot.get("state") == "moved": # never enrolled again on its own, even when the new holder lapses (FAA-155)
|
|
609
|
+
if self.now() - slot.get("renewed_at", 0) >= RENEW_SECONDS - 0.5:
|
|
610
|
+
self._moved_back(capability) # unless com.status shows a later generation it may hold (FAA-165)
|
|
611
|
+
continue
|
|
593
612
|
if slot.get("state") == "paused":
|
|
594
613
|
if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0) and not self._gone(capability):
|
|
595
614
|
self._enroll(capability) # still held: a longer pause; lifted: the slot is ours again (moved: read first)
|
|
@@ -727,7 +746,11 @@ class Responder(SlotKeeper):
|
|
|
727
746
|
return True
|
|
728
747
|
|
|
729
748
|
def _run(self, entry: Dict[str, Any], request: BoardRequest) -> None:
|
|
730
|
-
|
|
749
|
+
mandate, unread = _link.read_mandate(request, self.mandate_from, self.agent_id)
|
|
750
|
+
if unread: # by its code alone: the block holds the operator's words
|
|
751
|
+
self._emit("board.request.mandate_unread", "warning", "Request %s carries a mandate the kit does not read (%s); "
|
|
752
|
+
"it is worked without one." % (entry["request"], unread), request=entry["request"], reason=unread)
|
|
753
|
+
ctx = WorkContext(self, entry, mandate, request.sender.get("project") if isinstance(request.sender, dict) else None)
|
|
731
754
|
work = _Work(entry, request, ctx)
|
|
732
755
|
work.renewed_at = self.now()
|
|
733
756
|
self.current = work
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py
CHANGED
|
@@ -120,22 +120,98 @@ class SlotKeeper:
|
|
|
120
120
|
self._to_resume = [r for r in self._to_resume if (self.checkpoint.entries.get(r) or {}).get("capability") != capability]
|
|
121
121
|
self.slots[capability] = {"state": "moved", "generation": now, "renewed_at": self.now()}
|
|
122
122
|
self._confirmed.pop(capability, None)
|
|
123
|
+
self.claim_keys.pop(capability, None) # the move returned that claim to the queue: its receipt is dead
|
|
123
124
|
if "moved" not in held:
|
|
124
125
|
self._note(capability, dict(held, moved=now)) # said again at the next start if it cannot be written
|
|
125
126
|
if "moved" not in held: # once, even across restarts
|
|
126
|
-
self._emit("board.slot.moved", "warning", "The %s slot on this Project moved to another session. This service stopped answering it
|
|
127
|
-
"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
127
|
+
self._emit("board.slot.moved", "warning", "The %s slot on this Project moved to another session. This service stopped answering it; accepted "
|
|
128
|
+
"work stays `accepted` (work begun is the new holder's). The operator brings it back by take-back (once the "
|
|
129
|
+
"other session lets it go, or at a restart with accepted work waiting; a restart alone does not) or, where "
|
|
130
|
+
"Fabric offers it, a move back here. Else ask its maintainer." % capability,
|
|
130
131
|
notify=True, episode=capability, capability=capability, generation=now, held=held["generation"])
|
|
131
132
|
return True
|
|
132
133
|
|
|
134
|
+
def _moved_back(self, capability: str) -> None:
|
|
135
|
+
"""A moved slot asked about once per later generation (FAA-165): the operator may move it back to this session
|
|
136
|
+
(DEC-0140 PR-2), which this client cannot hear, so when com.status shows a generation past the recorded move, one
|
|
137
|
+
`com.claim` asks whether the slot is its own. A claim that lands makes it held again and starts what it handed
|
|
138
|
+
out; a `lease_expired` one (the move reached this session but its lease lapsed) enrolls again; a fenced one is
|
|
139
|
+
another session's, recorded so the same generation is not asked about twice. Never an enroll or a replace on a
|
|
140
|
+
slot another session holds: a slot moved off stays off unless it is handed back."""
|
|
141
|
+
if self.current is not None or self.stopping:
|
|
142
|
+
return # a claim now could hand out a second request, or start work while the responder stops
|
|
143
|
+
held = self.checkpoint.slots.get(capability) or {}
|
|
144
|
+
seen = held.get("moved")
|
|
145
|
+
if held.get("project") != self.project or not _is_int(seen, 1):
|
|
146
|
+
return
|
|
147
|
+
(self.slots.get(capability) or {})["renewed_at"] = self.now()
|
|
148
|
+
status = self._status()
|
|
149
|
+
now = _whole(next((r.get("generation") for r in status.get("responders") or [] if isinstance(r, dict) and r.get("capability") == capability), None))
|
|
150
|
+
if not _is_int(now, 1) or now <= seen:
|
|
151
|
+
return
|
|
152
|
+
key = self.claim_keys.get(capability) if capability in self._probing else None # read once: take_back may pop it
|
|
153
|
+
if key is None:
|
|
154
|
+
key = self.claim_keys[capability] = self._key("claim") # a fresh key: one from before the move may hold a dead receipt
|
|
155
|
+
(self.slots.get(capability) or {}).pop("probe_at", None) # and a send time of its own
|
|
156
|
+
sent = self.now()
|
|
157
|
+
try:
|
|
158
|
+
answer = self._receipt("com.claim", self._call("com.claim", {"idempotency": self._idem(key), "capability": capability}))
|
|
159
|
+
except BoardError as exc:
|
|
160
|
+
if exc.code == "unreachable": # the claim may have landed: asked again under this key, never left to the fault
|
|
161
|
+
self._probing.add(capability)
|
|
162
|
+
(self.slots.get(capability) or {}).setdefault("probe_at", sent) # its lease runs from the first send
|
|
163
|
+
return
|
|
164
|
+
self._probing.discard(capability)
|
|
165
|
+
if exc.code == "held":
|
|
166
|
+
return # the operator's hold: asked again at the next renewal
|
|
167
|
+
if exc.code in ("idempotency_window_expired", "capacity_exceeded"):
|
|
168
|
+
self.claim_keys.pop(capability, None)
|
|
169
|
+
return
|
|
170
|
+
if exc.code == "lease_expired":
|
|
171
|
+
self.claim_keys.pop(capability, None) # a dead attempt's receipt may sit under it
|
|
172
|
+
if self._back(capability, held, now):
|
|
173
|
+
self.slots[capability]["renewed_at"] = float("-inf") # lapsed: the enroll below is recorded first
|
|
174
|
+
self._enroll(capability)
|
|
175
|
+
return
|
|
176
|
+
if exc.code in SESSION_CODES and exc.code != "not_authorized":
|
|
177
|
+
raise # the board session's fault, not this slot's
|
|
178
|
+
with self._lock: # fenced, or another refusal of this claim: not this session's slot at this generation
|
|
179
|
+
if self.checkpoint.slots.get(capability) == held: # take_back meanwhile wins (FAA-158)
|
|
180
|
+
self._note(capability, dict(held, moved=now))
|
|
181
|
+
return
|
|
182
|
+
self._probing.discard(capability)
|
|
183
|
+
attempt = answer.get("attempt") if isinstance(answer.get("attempt"), dict) else {}
|
|
184
|
+
generation = _whole(attempt.get("generation"))
|
|
185
|
+
if _is_int(generation, 1) and generation < now: # a replay from before a later move: dead, asked again fresh
|
|
186
|
+
self.claim_keys.pop(capability, None)
|
|
187
|
+
return
|
|
188
|
+
if not self._back(capability, held, generation if _is_int(generation, 1) else now):
|
|
189
|
+
return
|
|
190
|
+
if "request" in answer:
|
|
191
|
+
self.claim_keys.pop(capability, None)
|
|
192
|
+
self.slots[capability]["renewed_at"] = self.now() # the claim just landed: its lease runs (finding 38)
|
|
193
|
+
self._start(capability, answer)
|
|
194
|
+
else:
|
|
195
|
+
self._enroll(capability) # renewed now: whether an empty com.claim renews the lease is not the contract's to say
|
|
196
|
+
|
|
197
|
+
def _back(self, capability: str, held: Dict[str, Any], generation: int) -> bool:
|
|
198
|
+
"""The slot is this session's again at `generation`: held, its record rewritten without the move (FAA-165)."""
|
|
199
|
+
with self._lock:
|
|
200
|
+
if self.checkpoint.slots.get(capability) != held:
|
|
201
|
+
return False # take_back (an action thread) forgot the record meanwhile; its own next tick enrolls
|
|
202
|
+
self.slots[capability] = {"state": "held", "generation": generation, "renewed_at": float("-inf")} # no lease yet
|
|
203
|
+
self._held_at(capability, generation)
|
|
204
|
+
self._said.pop("board.slot.moved", None) # a later move is said again
|
|
205
|
+
return True
|
|
206
|
+
|
|
133
207
|
def take_back(self, capability: str) -> None:
|
|
134
208
|
"""The operator's act, never the kit's: forget the move; the next tick enrolls once the slot is free (FAA-155)."""
|
|
135
209
|
if capability not in self.capabilities:
|
|
136
210
|
raise BoardError("invalid_arguments", "This responder does not answer %s." % capability)
|
|
137
211
|
with self._lock:
|
|
138
212
|
self.checkpoint.slot(capability, None)
|
|
213
|
+
self.claim_keys.pop(capability, None) # a receipt from before the take-back may be dead
|
|
214
|
+
self._probing.discard(capability)
|
|
139
215
|
if (self.slots.get(capability) or {}).get("state") == "moved":
|
|
140
216
|
self.slots[capability] = {"state": "contested", "renewed_at": 0.0}
|
|
141
217
|
self._said.pop("board.slot.moved", None)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -33,6 +33,7 @@ import fabric_service as _fs # noqa: E402
|
|
|
33
33
|
from fabric_base import store_lock # noqa: E402 - one lock per store path (R3)
|
|
34
34
|
from fabric_actions import action_caller, readable_action # noqa: E402 - DEC-0121
|
|
35
35
|
from fabric_mandate import EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, is_agent_id # noqa: E402 - DEC-0125, DEC-0126
|
|
36
|
+
from fabric_jsvalue import real_number as _real_number # noqa: E402 - the core's (FAA-65)
|
|
36
37
|
from fabric_time import format_rfc3339 # noqa: E402 - the core's (FAA-65)
|
|
37
38
|
from fabric_page_links import ( # noqa: E402 - DEC-0123
|
|
38
39
|
PageLinkError, dashboards_link, input_request_link_findings, page_link_findings, parse_dashboards_link)
|
|
@@ -40,6 +41,7 @@ from fabric_page_links import ( # noqa: E402 - DEC-0123
|
|
|
40
41
|
from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
|
|
41
42
|
_days_from_civil,
|
|
42
43
|
UNREADABLE_PROJECT,
|
|
44
|
+
kept_step,
|
|
43
45
|
mandate_spend,
|
|
44
46
|
PROTOCOL,
|
|
45
47
|
EXTENSION_KEY,
|
|
@@ -393,30 +395,33 @@ class JobStore:
|
|
|
393
395
|
steps: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Any]:
|
|
394
396
|
"""input_required with MCP elicitation requests, keyed; build them with choice_request / form_request / url_request.
|
|
395
397
|
`steps` (DEC-0125) maps a request key to the approval step it asks for, {effect, spendUsd?, project?}, kept in the
|
|
396
|
-
job's private part so an answer's `_meta.mandate` is read for that step (CallContext.approval_step writes it).
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
if not
|
|
404
|
-
raise InteropError("
|
|
398
|
+
job's private part so an answer's `_meta.mandate` is read for that step (CallContext.approval_step writes it).
|
|
399
|
+
A step is kept as the gate reads it (kept_step), so a job's start and its answer agree."""
|
|
400
|
+
if not isinstance(input_requests, dict) or not input_requests: # a JSON object of requests, first (readings 38, 39)
|
|
401
|
+
raise InteropError("input_required needs a JSON object of at least one input request.")
|
|
402
|
+
kept: Dict[str, Dict[str, Any]] = {}
|
|
403
|
+
for key, step in (steps or {}).items(): # the value checked is the value kept (seam reading 34)
|
|
404
|
+
kept[key] = kept_step(step) if key in input_requests and isinstance(step, dict) else {}
|
|
405
|
+
if not (type(kept[key].get("effect")) is str and kept[key]["effect"] in EFFECT_CLASSES):
|
|
406
|
+
raise InteropError("Step %r: an input request's key with its effect class (DEC-0125)." % (key,))
|
|
407
|
+
for key, request in input_requests.items(): # each a keyed object, as Node refuses one (product reading 39)
|
|
408
|
+
if not isinstance(key, str) or not _KEY.match(key) or not isinstance(request, dict) or request.get("method") != "elicitation/create":
|
|
409
|
+
raise InteropError("Input request %r must be a keyed elicitation/create request." % (key,))
|
|
405
410
|
for finding in input_request_link_findings({"inputRequests": input_requests}): # FAC-SEM-143 (D12)
|
|
406
411
|
raise InteropError("%s: %s" % (finding["instancePath"], finding["message"]))
|
|
407
|
-
return self._transition(job_id, "input_required", {"steps":
|
|
412
|
+
return self._transition(job_id, "input_required", {"steps": kept},
|
|
408
413
|
inputRequests=input_requests, statusMessage=message)
|
|
409
414
|
|
|
410
415
|
def answer(self, job_id: str, input_responses: Dict[str, Any]) -> Dict[str, Any]:
|
|
411
|
-
"""Apply inputResponses to an input_required job; unknown keys are ignored (MCP Tasks rule).
|
|
416
|
+
"""Apply inputResponses (a JSON object, a dict) to an input_required job; unknown keys are ignored (MCP Tasks rule).
|
|
412
417
|
|
|
413
418
|
Returns the responses that matched; when any did, the job is working again."""
|
|
414
419
|
with self._lock:
|
|
415
420
|
job = self.get(job_id)
|
|
416
421
|
if job["status"] != "input_required":
|
|
417
422
|
return {}
|
|
418
|
-
matched = {key: value for key, value in (input_responses
|
|
419
|
-
if
|
|
423
|
+
matched = {key: value for key, value in (input_responses if isinstance(input_responses, dict) else {}).items()
|
|
424
|
+
if isinstance(job.get("inputRequests"), dict) and key in job["inputRequests"] and isinstance(value, dict)
|
|
420
425
|
and value.get("action") in ("accept", "decline", "cancel")}
|
|
421
426
|
if matched: # the answered question's kept steps go in the same write: none outlives its answer
|
|
422
427
|
self._transition(job_id, "working", {"steps": {}})
|
|
@@ -532,9 +537,7 @@ class CallContext:
|
|
|
532
537
|
if step["covered"]:
|
|
533
538
|
return step["record"]
|
|
534
539
|
self.server._fault(capability, step) # the start hears it as the answer does, under the same name (MJ-1)
|
|
535
|
-
|
|
536
|
-
kept = {"effect": effect, **({"spendUsd": spend} if spend is not None else {}),
|
|
537
|
-
**({"project": project if isinstance(project, str) else UNREADABLE_PROJECT} if project is not None else {})}
|
|
540
|
+
kept = {"effect": effect, "spendUsd": spend_usd, "project": project} # request_input keeps it as the gate reads it
|
|
538
541
|
self.server.jobs.request_input(job_id, {key: request}, step["message"] or message, steps={key: kept})
|
|
539
542
|
return None
|
|
540
543
|
|
|
@@ -712,9 +715,9 @@ class McpToolServer:
|
|
|
712
715
|
raise InteropError("Tool %s: a human step needs its link, an https URL or a path (DEC-0121 AP-4)." % tool["name"])
|
|
713
716
|
if not (target is None or isinstance(target, str) or callable(target)):
|
|
714
717
|
raise InteropError("Tool %s: target is an argument's name or a function of the arguments." % tool["name"])
|
|
715
|
-
if self.agent_id is not None and action["gate"] == "relayed" and not (
|
|
718
|
+
if self.agent_id is not None and action["gate"] == "relayed" and not (type(effect) is str and effect in EFFECT_CLASSES):
|
|
716
719
|
raise InteropError("Tool %s: a relayed action on a server that reads mandates names its effect class (DEC-0125)." % tool["name"])
|
|
717
|
-
if effect is not None and not (
|
|
720
|
+
if effect is not None and not (type(effect) is str and effect in EFFECT_CLASSES):
|
|
718
721
|
raise InteropError("Tool %s: effect is one of %s." % (tool["name"], ", ".join(EFFECT_CLASSES)))
|
|
719
722
|
if not (spend is None or callable(spend) or (isinstance(spend, (int, float)) and not isinstance(spend, bool))):
|
|
720
723
|
raise InteropError("Tool %s: spend is a number of USD or a function of the arguments." % tool["name"])
|
|
@@ -772,18 +775,20 @@ class McpToolServer:
|
|
|
772
775
|
caller_agent = (verdict or {}).get("caller")
|
|
773
776
|
# The estimate is read as a double, as the contract does: an int past the float range is infinite, and an
|
|
774
777
|
# estimate that is not a finite number reads as none, which a mandate's ceiling refuses (fail closed).
|
|
778
|
+
number = _real_number(spend) # by what it stores; a bool, a mock or not a number is none (seam reading 18)
|
|
775
779
|
try:
|
|
776
|
-
double = float(
|
|
780
|
+
double = float(number) if number is not None else math.nan
|
|
777
781
|
except OverflowError:
|
|
778
782
|
double = math.inf
|
|
783
|
+
spend = number
|
|
779
784
|
finite = math.isfinite(double) # the service's own number goes on when it is finite (25 stays 25)
|
|
780
|
-
if project is _UNREADABLE or not (project is None or
|
|
781
|
-
project = _UNREADABLE_PROJECT # a function that raised or named no
|
|
785
|
+
if project is _UNREADABLE or not (project is None or type(project) is str): # an exact str, as the core compares
|
|
786
|
+
project = _UNREADABLE_PROJECT # a function that raised or named no exact str: a scoped mandate refuses
|
|
782
787
|
return {"self_id": self.agent_id, "effect": entry.get("effect"),
|
|
783
788
|
"caller_agent": caller_agent if isinstance(caller_agent, str) and caller_agent else None,
|
|
784
789
|
# an estimate that raised or is not finite is none: a mandate with a ceiling then refuses (fail closed)
|
|
785
790
|
"spend_usd": spend if finite else None,
|
|
786
|
-
"project": project
|
|
791
|
+
"project": project} # None or an exact str, read once above: never re-decided into no project (unit r33)
|
|
787
792
|
|
|
788
793
|
@staticmethod
|
|
789
794
|
def _read(value: Any, arguments: Dict[str, Any]) -> Any:
|
|
@@ -800,8 +805,9 @@ class McpToolServer:
|
|
|
800
805
|
agent_id, a step approval_step kept is read under the call's `_meta.mandate` too (C3.3)."""
|
|
801
806
|
job_id = str(arguments.get("id", ""))
|
|
802
807
|
job = self.jobs.get(job_id)
|
|
803
|
-
|
|
804
|
-
|
|
808
|
+
private = self.jobs.private(job_id) # one read of the job's private part (unit reading 39)
|
|
809
|
+
steps = private.get("steps") if isinstance(private.get("steps"), dict) else {}
|
|
810
|
+
asked = job.get("inputRequests") if job["status"] == "input_required" and isinstance(job.get("inputRequests"), dict) else {}
|
|
805
811
|
records: Dict[str, Any] = {}
|
|
806
812
|
for key, value in arguments["inputResponses"].items():
|
|
807
813
|
if key not in asked or not isinstance(value, dict) or value.get("action") not in ("accept", "decline", "cancel"):
|
|
@@ -811,7 +817,7 @@ class McpToolServer:
|
|
|
811
817
|
if self.agent_id is not None and isinstance(step, dict):
|
|
812
818
|
# A kept step's mandate is read as approval_step read it, under the job's own capability, which must
|
|
813
819
|
# be a capability name (MJ-1); the action itself stays fabric.job.get, so it is a readable action.
|
|
814
|
-
capability =
|
|
820
|
+
capability = private.get("capability")
|
|
815
821
|
agent = (verdict or {}).get("caller")
|
|
816
822
|
mandate = {"self_id": self.agent_id, "effect": step.get("effect"), "spend_usd": step.get("spendUsd"),
|
|
817
823
|
"project": step.get("project"), "caller_agent": agent if isinstance(agent, str) and agent else None,
|
|
@@ -917,6 +923,8 @@ class McpToolServer:
|
|
|
917
923
|
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
|
|
918
924
|
caller, inputs = self._caller(headers, tier, verdict), None
|
|
919
925
|
responses = arguments.get("inputResponses")
|
|
926
|
+
if name == "fabric.job.get" and responses is not None and not isinstance(responses, dict): # seam r37: never a crash
|
|
927
|
+
return self._error(rid, -32602, "fabric.job.get takes inputResponses as a JSON object (interop C3.3).")
|
|
920
928
|
if name == "fabric.job.get" and self.input_actions and isinstance(responses, dict) and responses:
|
|
921
929
|
try:
|
|
922
930
|
inputs = self._gate_inputs(arguments, headers, tier, meta, caller, verdict)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py
CHANGED
|
@@ -150,6 +150,7 @@ def _own(value: Any, key: str) -> Any:
|
|
|
150
150
|
return value.get(key, _ABSENT) if isinstance(value, dict) else _ABSENT
|
|
151
151
|
|
|
152
152
|
|
|
153
|
+
import fabric_jsvalue as _jsvalue # noqa: E402 - the core's (FAA-65)
|
|
153
154
|
from fabric_time import days_from_civil as _days_from_civil # noqa: E402 - the core's (FAA-65)
|
|
154
155
|
|
|
155
156
|
|
|
@@ -341,14 +342,14 @@ def job_step(mandate: Any, *, tier: Any, now: Any, self_id: Any, capability: Any
|
|
|
341
342
|
it reads as mandate-malformed here as it does at the answer (FAA-178; McpToolServer does)."""
|
|
342
343
|
if mandate is None:
|
|
343
344
|
return {"covered": False, "message": None}
|
|
344
|
-
if not (
|
|
345
|
+
if not (type(effect) is str and effect in EFFECT_CLASSES): # exact, as the core compares it (seam reading 34)
|
|
345
346
|
raise InteropError("A job's approval step names its effect class (one of %s; DEC-0125)." % ", ".join(EFFECT_CLASSES))
|
|
346
347
|
call: Dict[str, Any] = {"capability": capability, "action": key, "effect": effect}
|
|
347
348
|
spend = mandate_spend(spend_usd) # read as action_gate reads it, so the start and the answer agree (FAA-139)
|
|
348
349
|
if spend is not None:
|
|
349
350
|
call["spendUsd"] = spend
|
|
350
351
|
if project is not None:
|
|
351
|
-
call["project"] = project if
|
|
352
|
+
call["project"] = project if type(project) is str else UNREADABLE_PROJECT # exact, as the kept step (seam r32)
|
|
352
353
|
covers: Dict[str, Any] = {"mandate": mandate, "tier": tier, "now": now, "self": self_id, "call": call}
|
|
353
354
|
if isinstance(caller_agent, str) and caller_agent:
|
|
354
355
|
covers["callerAgent"] = caller_agent
|
|
@@ -379,8 +380,11 @@ UNREADABLE_PROJECT = "\x00unreadable"
|
|
|
379
380
|
def mandate_spend(value: Any) -> Any:
|
|
380
381
|
"""A spend estimate as a mandate reads it (FAA-139): a number read as a double, as the contract does; one that is
|
|
381
382
|
not a finite double (an int past the float range, NaN, infinity) or not a number is none, which a mandate's
|
|
382
|
-
ceiling refuses (fail closed). A finite number stays as given (25 stays 25).
|
|
383
|
-
|
|
383
|
+
ceiling refuses (fail closed). A finite number stays as given (25 stays 25). A subclass of int or float is read by
|
|
384
|
+
the number it holds, never by an overridden `float()` or `int()` (numpy.float64 3.0 reads 3.0; one whose `float()`
|
|
385
|
+
answers 0.0 for 100 reads 100); a bool is none."""
|
|
386
|
+
value = _jsvalue.real_number(value) # a bool, a mock that claims a number, or not a number: none
|
|
387
|
+
if value is None:
|
|
384
388
|
return None
|
|
385
389
|
try:
|
|
386
390
|
return value if math.isfinite(float(value)) else None
|
|
@@ -388,6 +392,15 @@ def mandate_spend(value: Any) -> Any:
|
|
|
388
392
|
return None
|
|
389
393
|
|
|
390
394
|
|
|
395
|
+
def kept_step(step: Dict[str, Any]) -> Dict[str, Any]:
|
|
396
|
+
"""A job's approval step {effect, spendUsd?, project?} as a job file keeps it: read as the gate reads it, so a job's
|
|
397
|
+
start and its answer agree (0.61.0 seam reading 32, readings 33 and 34): a spend through mandate_spend (dropped
|
|
398
|
+
when none), a project as an exact str, else UNREADABLE_PROJECT (dropped when None); each member read once."""
|
|
399
|
+
effect, spend, project = step.get("effect"), mandate_spend(step.get("spendUsd")), step.get("project")
|
|
400
|
+
return {"effect": effect, **({"spendUsd": spend} if spend is not None else {}),
|
|
401
|
+
**({"project": project if type(project) is str else UNREADABLE_PROJECT} if project is not None else {})}
|
|
402
|
+
|
|
403
|
+
|
|
391
404
|
def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target: Optional[str] = None,
|
|
392
405
|
elicited: bool = False, approval: Any = None, action_digest: Optional[str] = None,
|
|
393
406
|
human_step_url: Optional[str] = None, now: Any = None, self_id: Optional[str] = None,
|
|
@@ -408,7 +421,7 @@ def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target
|
|
|
408
421
|
per-agent credential; never a name the call carries), `spend_usd` (its own estimate), `project`, and `mandate_capability` (a job's kept step: the job's capability
|
|
409
422
|
the mandate is read under, DEC-0141 MJ-1; unusable, the refusal is operator-decision-required). Both are read failing
|
|
410
423
|
closed, as McpToolServer reads them (FAA-139): a spend that is not a finite double is none (`mandate_spend`), a
|
|
411
|
-
project that is not a
|
|
424
|
+
project that is not an exact str (a subclass included) is UNREADABLE_PROJECT, which a mandate limited to projects refuses. A covered call
|
|
412
425
|
answers {ok: True, record: {by: operator, via: mandate, task, chain, caller}, decision: None, mandate}; without
|
|
413
426
|
`self_id` the mandate is not read and the call is answered as before it existed."""
|
|
414
427
|
import fabric_delegation as _fd # noqa: E402 - beside this file: DL-19
|
|
@@ -421,7 +434,7 @@ def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target
|
|
|
421
434
|
if self_id is not None and isinstance(meta, dict) and "mandate" in meta:
|
|
422
435
|
inputs.update({"mandate": meta["mandate"], "self": self_id})
|
|
423
436
|
spend_usd = mandate_spend(spend_usd)
|
|
424
|
-
project = project if project is None or
|
|
437
|
+
project = project if project is None or type(project) is str else UNREADABLE_PROJECT
|
|
425
438
|
for name, value in (("effect", effect), ("callerAgent", caller_agent), ("spendUsd", spend_usd), ("project", project),
|
|
426
439
|
("mandateCapability", mandate_capability)):
|
|
427
440
|
if value is not None:
|