@passioncode-ai/fabric-agent-adapter 0.60.0 → 0.62.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 +246 -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 +4 -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/scripts/fabric_provider.py +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +11 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +19 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +58 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -0
- 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 +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +6 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +18 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +10 -1
- 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 +24 -5
- 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-delegation.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +14 -2
- 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-secrets.mjs +57 -0
- 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_auth.py +16 -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 +20 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +55 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +5 -0
- 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_secrets.py +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +217 -29
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +42 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +23 -23
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +16 -1
- 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_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))
|
|
@@ -735,7 +746,11 @@ class Responder(SlotKeeper):
|
|
|
735
746
|
return True
|
|
736
747
|
|
|
737
748
|
def _run(self, entry: Dict[str, Any], request: BoardRequest) -> None:
|
|
738
|
-
|
|
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)
|
|
739
754
|
work = _Work(entry, request, ctx)
|
|
740
755
|
work.renewed_at = self.now()
|
|
741
756
|
self.current = work
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py
CHANGED
|
@@ -9,6 +9,7 @@ import os
|
|
|
9
9
|
import re
|
|
10
10
|
import socketserver
|
|
11
11
|
import subprocess
|
|
12
|
+
import warnings
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
14
15
|
|
|
@@ -294,7 +295,9 @@ def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
294
295
|
problems.append("runner.credentialKind is subscription, key, platform-metered or none")
|
|
295
296
|
if "kind" in runner and not (isinstance(runner["kind"], str) and _RUNNER_KIND_NAME.match(runner["kind"])):
|
|
296
297
|
problems.append("runner.kind is a runner kind's name")
|
|
297
|
-
|
|
298
|
+
# a kind that is no string is no runner's (a list would raise as a key: 0.62.0 unit reading 17)
|
|
299
|
+
if runner.get("credentialKind") == "subscription" and not (isinstance(runner.get("kind"), str)
|
|
300
|
+
and runner["kind"] in SUBSCRIPTION_RUNNERS):
|
|
298
301
|
problems.append("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex")
|
|
299
302
|
places += [("/runner/%s" % _pointer(k), v) for k, v in runner.items()]
|
|
300
303
|
if "source" in descriptor:
|
|
@@ -311,7 +314,10 @@ def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
311
314
|
problems.append("source.repository carries no password: it names the repository, never a credential")
|
|
312
315
|
elif not _authority_ok(repository):
|
|
313
316
|
problems.append("source.repository names a host, and a port of digits up to 65535 when it has one")
|
|
314
|
-
|
|
317
|
+
elif "@" in repository and not _HIERARCHICAL.match(repository): # 0.62.0 readings 7: never an scp form's user
|
|
318
|
+
problems.append("source.repository with a user is a URL with an authority (https://user@host/path), never an scp form")
|
|
319
|
+
elif fabric_secrets.repository_credential(repository): # its name is no key (0.62.0 seam reading 6)
|
|
320
|
+
problems.append("FAC-SEM-086: /source/repository holds a value shaped like a credential; it names a repository")
|
|
315
321
|
if "peer" in descriptor:
|
|
316
322
|
peer = descriptor.get("peer")
|
|
317
323
|
if not isinstance(peer, dict):
|
|
@@ -434,13 +440,58 @@ def port_of(origin: str) -> int:
|
|
|
434
440
|
return int(match.group(1))
|
|
435
441
|
|
|
436
442
|
|
|
437
|
-
|
|
438
|
-
|
|
443
|
+
NO_SOURCE_REPOSITORY = ("names no source.repository, the repository its code is built from "
|
|
444
|
+
"(https://github.com/owner/name): it links the agent to its project (FAA-198)")
|
|
445
|
+
PASS_SOURCE = ("Name it in this call: \"source\": {\"repository\": ...} in the descriptor write_descriptor writes, or "
|
|
446
|
+
"source= to register_remote; one written after the call returns is too late for a new install.")
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class NoSourceRepositoryWarning(UserWarning):
|
|
450
|
+
"""FAA-198: an installed descriptor rewritten without source.repository (Node: process.emitWarning, code
|
|
451
|
+
FABRIC_NO_SOURCE_REPOSITORY). A new install without it is refused instead."""
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def names_repository(descriptor: Any) -> bool:
|
|
455
|
+
source = descriptor.get("source") if isinstance(descriptor, dict) else None
|
|
456
|
+
return isinstance(source, dict) and isinstance(source.get("repository"), str) and bool(source["repository"])
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
_HIERARCHICAL = re.compile(r"\A[A-Za-z][A-Za-z0-9+.-]*://")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def source_refusal(descriptor: Dict[str, Any], directory: Optional[Path] = None) -> Optional[str]:
|
|
463
|
+
"""FAA-198: why a new install of this descriptor is refused, or None: it names no source.repository and no
|
|
464
|
+
descriptor of its id.instance is installed yet (a regular file, whatever it holds; a folder, a broken link or a path
|
|
465
|
+
that cannot be examined is none). An installer may check it before it validates or writes anything else."""
|
|
466
|
+
if names_repository(descriptor):
|
|
467
|
+
return None
|
|
468
|
+
d = descriptor if isinstance(descriptor, dict) else {}
|
|
469
|
+
instance = d.get("instance") if d.get("instance") is not None else "default" # as Node's `??` reads it
|
|
470
|
+
me = "%s.%s" % (d.get("id"), instance)
|
|
471
|
+
try: # an id or instance that is no string names no installed file, in both kits (seam reading 3)
|
|
472
|
+
if isinstance(d.get("id"), str) and isinstance(instance, str) and descriptor_path(d["id"], instance, directory).is_file():
|
|
473
|
+
return None
|
|
474
|
+
except (OSError, ValueError): # an unsearchable folder reads as not installed, as Node's statSync does
|
|
475
|
+
pass
|
|
476
|
+
return "Descriptor %s %s. %s" % (me, NO_SOURCE_REPOSITORY, PASS_SOURCE)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None, *, _stacklevel: int = 2) -> Path:
|
|
480
|
+
"""Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim).
|
|
481
|
+
|
|
482
|
+
FAA-198: a new descriptor names its ``source.repository``; rewriting one already installed without it only
|
|
483
|
+
warns (``NoSourceRepositoryWarning``), so an installed agent's update keeps working."""
|
|
439
484
|
problems = validate_descriptor(descriptor)
|
|
440
485
|
if problems:
|
|
441
486
|
raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
|
|
442
487
|
root = directory or services_dir()
|
|
443
488
|
me = "%s.%s" % (descriptor["id"], descriptor["instance"])
|
|
489
|
+
refusal = source_refusal(descriptor, root)
|
|
490
|
+
if refusal:
|
|
491
|
+
raise ServiceError(refusal)
|
|
492
|
+
if not names_repository(descriptor):
|
|
493
|
+
warnings.warn("Descriptor %s %s; a new install is refused without it." % (me, NO_SOURCE_REPOSITORY),
|
|
494
|
+
NoSourceRepositoryWarning, stacklevel=_stacklevel)
|
|
444
495
|
# DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
|
|
445
496
|
port = None if placement_of(descriptor) == "remote" or "origin" not in descriptor else port_of(descriptor["origin"])
|
|
446
497
|
for path, other in read_descriptors(root):
|
|
@@ -157,6 +157,11 @@ def redact_url(url: str) -> str:
|
|
|
157
157
|
# then remove every tab, CR and LF, so ` https://u:pw@h` and `ht\ttps:u:pw@h` lose their password too (0.56.0
|
|
158
158
|
# seam r10; Python 3.9's urlsplit keeps a leading space in the scheme).
|
|
159
159
|
url = re.sub(r"[\t\n\r]", "", str(url).strip("".join(map(chr, range(0x21)))))
|
|
160
|
+
# The user information as written goes first, as git and urllib read it, where WHATWG reads a backslash as a slash:
|
|
161
|
+
# `https://h\\u:pw@x/r` would print `pw` in its path (0.62.0 seam reading 11). The Node twin does the same.
|
|
162
|
+
written = re.match(r"([A-Za-z][A-Za-z0-9+.-]*:[/\\]*)([^/?#]*)", url) # after any run of slashes (seam reading 13)
|
|
163
|
+
if written and "@" in written.group(2):
|
|
164
|
+
url = written.group(1) + written.group(2).rpartition("@")[2] + url[written.end():]
|
|
160
165
|
scheme, colon, rest = url.partition(":")
|
|
161
166
|
if colon and scheme.lower() in ("http", "https", "ws", "wss", "ftp"):
|
|
162
167
|
# A special scheme as WHATWG reads it: the authority starts after every slash or backslash that follows the
|
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:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py
CHANGED
|
@@ -145,11 +145,29 @@ def get_string(value: Any, key: str) -> str:
|
|
|
145
145
|
|
|
146
146
|
|
|
147
147
|
def is_number(value: Any) -> bool:
|
|
148
|
-
|
|
148
|
+
"""An int or a float by its real type, a subclass's included; a bool, or a value that only claims a number through
|
|
149
|
+
`__class__` (a mock, a proxy), is not one (0.61.0 seam reading 19)."""
|
|
150
|
+
kind = type(value)
|
|
151
|
+
return kind is not bool and issubclass(kind, (int, float))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def real_number(value: Any) -> Any:
|
|
155
|
+
"""The plain int or float a number holds (is_number), read by what it stores, never by its own float(), int() or
|
|
156
|
+
comparisons; None for anything else (0.61.0 seam reading 18, unit and product readings 19)."""
|
|
157
|
+
if not is_number(value):
|
|
158
|
+
return None
|
|
159
|
+
return float.__float__(value) if issubclass(type(value), float) else int.__int__(value)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def held(value: Any) -> Any:
|
|
163
|
+
"""A number by what it stores (real_number); any other value as given, so it reads as it did before."""
|
|
164
|
+
number = real_number(value)
|
|
165
|
+
return value if number is None else number
|
|
149
166
|
|
|
150
167
|
|
|
151
168
|
def double(value: Any) -> float:
|
|
152
169
|
"""A JSON number as JavaScript holds it: the nearest double, and an integer beyond the double range ±Infinity."""
|
|
170
|
+
value = held(value)
|
|
153
171
|
try:
|
|
154
172
|
return float(value)
|
|
155
173
|
except OverflowError:
|
|
@@ -184,7 +202,8 @@ def set_has(items: List[Any], value: Any) -> bool:
|
|
|
184
202
|
_JS_SPACE = "\t\n\v\f\r
"
|
|
185
203
|
|
|
186
204
|
|
|
187
|
-
|
|
205
|
+
# One way to split the digits, so a long string that fails to match costs its length (0.61.0 seam reading 22).
|
|
206
|
+
_JS_DECIMAL = re.compile(r"[+-]?(?:(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|Infinity)", re.ASCII)
|
|
188
207
|
|
|
189
208
|
|
|
190
209
|
_JS_RADIX = re.compile(r"0(?:[xX](?P<x>[0-9a-fA-F]+)|[oO](?P<o>[0-7]+)|[bB](?P<b>[01]+))", re.ASCII)
|
|
@@ -196,9 +215,10 @@ def js_to_number(value: Any) -> float:
|
|
|
196
215
|
return math.nan
|
|
197
216
|
if value is None:
|
|
198
217
|
return 0.0
|
|
199
|
-
if isinstance(
|
|
218
|
+
if value is True or value is False: # by identity: isinstance asks a number subclass's own __class__ (unit reading 30)
|
|
200
219
|
return 1.0 if value else 0.0
|
|
201
220
|
if is_number(value):
|
|
221
|
+
value = held(value) # by the number it holds: a subclass's own float() or comparison is never asked
|
|
202
222
|
try:
|
|
203
223
|
return float(value)
|
|
204
224
|
except OverflowError:
|
|
@@ -97,9 +97,23 @@ class InstanceLock:
|
|
|
97
97
|
except BaseException:
|
|
98
98
|
os.close(fd)
|
|
99
99
|
raise
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
try:
|
|
101
|
+
os.ftruncate(fd, 0)
|
|
102
|
+
os.write(fd, str(os.getpid()).encode())
|
|
103
|
+
os.fsync(fd)
|
|
104
|
+
except BaseException: # a full disk or an I/O error: let the lock go, or release() frees nothing
|
|
105
|
+
try:
|
|
106
|
+
os.close(fd)
|
|
107
|
+
except OSError: # a deferred write error (NFS) at the close: the name still goes, the first error stands
|
|
108
|
+
pass
|
|
109
|
+
finally:
|
|
110
|
+
name, self._name = self._name, None
|
|
111
|
+
if name is not None:
|
|
112
|
+
try:
|
|
113
|
+
_drop_lock_name(name)
|
|
114
|
+
except OSError: # as with the close: nothing to add to the first error
|
|
115
|
+
pass
|
|
116
|
+
raise
|
|
103
117
|
self._fd = fd
|
|
104
118
|
return self
|
|
105
119
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
the task, narrowed at each hand-over. Part of the service kit (layer L2); the twin of fabric-mandate.mjs.
|
|
3
3
|
|
|
4
4
|
- ``mandate_covers(inputs)``: whether a callee takes one call as the operator's decision under a mandate (MD-3 to
|
|
5
|
-
MD-5),
|
|
6
|
-
(
|
|
5
|
+
MD-5), as the contract's ``mandateCovers`` answers, but for a spend that is no JSON data a ``json.loads`` gives
|
|
6
|
+
(shared or cyclic), whose lists nest in lists more than MAX_SPEND_DEPTH deep, or that holds a dict with an own
|
|
7
|
+
``toString`` at its top or through lists alone: the core refuses all three, where the contract reads the first,
|
|
8
|
+
reads or throws on the second and throws on the third (FAA-196 (22)). ``inputs`` holds ``mandate``, ``tier``, ``now``,
|
|
9
|
+
``self`` (the callee's own agent id), ``callerAgent`` (only when the callee authenticated one) and ``call`` ({capability,
|
|
7
10
|
action?, effect, spendUsd?, project?}).
|
|
8
11
|
- ``mandate_problem(mandate)``: ``mandate-malformed``, ``mandate-widened`` or None (FAC-SEM-144);
|
|
9
12
|
``mandate_shape(mandate)``: ``mandate.schema.json`` alone; ``mandate_rules(kind, value)``: the findings.
|
|
@@ -18,7 +21,9 @@ the task, narrowed at each hand-over. Part of the service kit (layer L2); the tw
|
|
|
18
21
|
``instruction`` it stays in the receiver's own record: never an event, a usage report, a log line or a page.
|
|
19
22
|
|
|
20
23
|
The kit has no JSON Schema validator: the shape is written here from the schema, lengths in code points and patterns
|
|
21
|
-
over the whole string, as the contract's validator reads them. Never raises on
|
|
24
|
+
over the whole string, as the contract's validator reads them. Never raises on JSON as json.loads gives it (a direct
|
|
25
|
+
caller's key or value that is not exactly the JSON type, with its own methods (__eq__, __hash__, __len__, __class__, a
|
|
26
|
+
dict subclass's get), in the input, the call, the mandate or its scope, is used as given: FAA-196 (21), (22)). Standard library only.
|
|
22
27
|
"""
|
|
23
28
|
from __future__ import annotations
|
|
24
29
|
|
|
@@ -33,7 +38,7 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
|
|
|
33
38
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
34
39
|
|
|
35
40
|
from fabric_base import ServiceError # noqa: E402
|
|
36
|
-
from fabric_jsvalue import MISSING, get, is_number, js_to_number, same, set_has # noqa: E402
|
|
41
|
+
from fabric_jsvalue import MISSING, get, is_number, js_string, js_to_number, real_number, same, set_has # noqa: E402
|
|
37
42
|
from fabric_time import format_rfc3339, parse_rfc3339_ms # noqa: E402
|
|
38
43
|
|
|
39
44
|
#: The longest a mandate stands after the operator gave it (FAC-SEM-144).
|
|
@@ -238,6 +243,66 @@ def _member_of(items: Any, value: Any) -> bool:
|
|
|
238
243
|
return set_has(items, value)
|
|
239
244
|
|
|
240
245
|
|
|
246
|
+
#: A spend whose lists nest in one another more deeply than this, with no dict between them, is no estimate: that
|
|
247
|
+
#: nesting is what JavaScript's String() of a list recurses through (Node and the contract throw RangeError a few
|
|
248
|
+
#: thousand deep, 0.60.0 raised RecursionError a few hundred deep), and the spend a refusal carries as estimateUsd is
|
|
249
|
+
#: then one a serialiser writes. Under a dict there is no bound: Number() never descends a dict, which is NaN at any
|
|
250
|
+
#: depth in all three, but for one with an own toString at the top or through lists alone, on which Node and the
|
|
251
|
+
#: contract throw (0.60.0 read it as NaN; the core refuses it, see _json_data). Stricter than all three between 100
|
|
252
|
+
#: and their limit: FAA-196 (22); no JSON reader of the kit reaches it (0.61.0 readings 25 to 30).
|
|
253
|
+
MAX_SPEND_DEPTH = 100
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _json_data(value: Any) -> bool:
|
|
257
|
+
"""Whether a value is JSON data as json.loads gives it, by exact type all the way down (never `==` or isinstance,
|
|
258
|
+
which a metaclass or `__class__` can answer), walked without recursion in one pass, its lists nested in lists at
|
|
259
|
+
most MAX_SPEND_DEPTH deep (a dict ends the count: its members are walked at any depth), and no dict at its top or
|
|
260
|
+
reached through lists alone with an own toString, on which Number() throws, as strict_string reads it (0.61.0 seam
|
|
261
|
+
reading 28). json.loads never shares a list or dict, so a structure that meets one twice (shared or cyclic) is not
|
|
262
|
+
JSON data (0.61.0 unit reading 21, seam reading 22, readings 25 to 27)."""
|
|
263
|
+
stack, seen = [(value, 0)], set()
|
|
264
|
+
while stack:
|
|
265
|
+
item, depth = stack.pop()
|
|
266
|
+
kind = type(item)
|
|
267
|
+
if item is None or kind is bool or kind is str or kind is int or kind is float:
|
|
268
|
+
continue
|
|
269
|
+
if kind is not list and kind is not dict:
|
|
270
|
+
return False
|
|
271
|
+
if id(item) in seen:
|
|
272
|
+
return False
|
|
273
|
+
seen.add(id(item))
|
|
274
|
+
if kind is list:
|
|
275
|
+
if depth is not None:
|
|
276
|
+
depth += 1
|
|
277
|
+
if depth > MAX_SPEND_DEPTH:
|
|
278
|
+
return False
|
|
279
|
+
stack.extend((sub, depth) for sub in item)
|
|
280
|
+
continue
|
|
281
|
+
for key, sub in item.items():
|
|
282
|
+
if type(key) is not str: # before any comparison: a key's own __eq__ never runs (unit reading 29)
|
|
283
|
+
return False
|
|
284
|
+
if depth is not None and key == "toString": # Number() reaches it: a member of data is never callable
|
|
285
|
+
return False
|
|
286
|
+
stack.append((sub, None))
|
|
287
|
+
return True
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _spend_number(spend: Any) -> float:
|
|
291
|
+
"""Number(spend) for JSON data, without the join JavaScript builds for a list: [] is 0, a list of two or more items
|
|
292
|
+
is NaN (the comma never reads as a number), one item is read for itself, all in one descent (0.61.0 seam
|
|
293
|
+
reading 24: a list repeating one long string cost its length times its references)."""
|
|
294
|
+
value = spend
|
|
295
|
+
while type(value) is list:
|
|
296
|
+
if not value:
|
|
297
|
+
return 0.0
|
|
298
|
+
if len(value) > 1:
|
|
299
|
+
return math.nan
|
|
300
|
+
value = value[0]
|
|
301
|
+
if value is spend:
|
|
302
|
+
return js_to_number(value)
|
|
303
|
+
return 0.0 if value is None else js_to_number(js_string(value))
|
|
304
|
+
|
|
305
|
+
|
|
241
306
|
def mandate_covers(inputs: Any) -> Dict[str, Any]:
|
|
242
307
|
"""Whether a callee takes one call as the operator's decision under a mandate (DEC-0125 MD-3 to MD-5), after it has
|
|
243
308
|
placed the caller's tier (DL-25), as the contract's mandateCovers answers.
|
|
@@ -290,10 +355,24 @@ def mandate_covers(inputs: Any) -> Dict[str, Any]:
|
|
|
290
355
|
if not set_has(list(_READS_AND_DRAFTS), effect) and capabilities is MISSING and (action is MISSING or actions is MISSING):
|
|
291
356
|
return _outside("outside-mandate: capability")
|
|
292
357
|
ceiling, spend = get(scope, "perCallMaxUsd"), get(call, "spendUsd")
|
|
293
|
-
|
|
358
|
+
# A spend is read as the contract reads it only when it is JSON data as json.loads gives it, by exact type all the
|
|
359
|
+
# way down, or an int or float (a subclass read by the number it stores); anything else (a mock or proxy, a
|
|
360
|
+
# Decimal, a numpy.int64, a tuple, bytes, a str, list or dict subclass, a list holding one) is no estimate, which a
|
|
361
|
+
# ceiling refuses (0.61.0 seam readings 19 and 20), as is JSON data the core does not read as Number() does: lists
|
|
362
|
+
# nested more than MAX_SPEND_DEPTH deep (stricter than Number() between 100 and its limit), or a dict with an own
|
|
363
|
+
# toString Number() reaches, on which it throws (readings 25 to 29).
|
|
364
|
+
readable = over = False
|
|
365
|
+
if ceiling is not MISSING and spend is not MISSING: # read only when a ceiling asks (no walk without one)
|
|
366
|
+
readable = is_number(spend) or _json_data(spend)
|
|
367
|
+
if readable:
|
|
368
|
+
try:
|
|
369
|
+
over = _spend_number(spend) > ceiling
|
|
370
|
+
except RecursionError: # the number's read at the stack's edge: no estimate, never a raise (seam reading 21)
|
|
371
|
+
readable = False
|
|
372
|
+
if ceiling is not MISSING and (spend is MISSING or not readable or over):
|
|
294
373
|
data: Dict[str, Any] = {"detail": "outside-mandate: spend"}
|
|
295
|
-
if spend is not MISSING:
|
|
296
|
-
data["estimateUsd"] = spend
|
|
374
|
+
if spend is not MISSING and readable:
|
|
375
|
+
data["estimateUsd"] = real_number(spend) if is_number(spend) else spend
|
|
297
376
|
data["perCallMaxUsd"] = ceiling
|
|
298
377
|
return {"ok": False, "code": OUTSIDE_MANDATE, "data": data}
|
|
299
378
|
project, projects = get(call, "project"), get(scope, "projects")
|