@passioncode-ai/passioncode 0.2.38 → 0.2.40
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 +16 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +29 -11
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +11 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +50 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +5 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +5 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +3 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +13 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +60 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +159 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +8 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +14 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +40 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +67 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +371 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +170 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +275 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +3 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +151 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +38 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +31 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +6 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +68 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +247 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-page-links.mjs +144 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +22 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +30 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +209 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +340 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +170 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +36 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +36 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +7 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +242 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +70 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +324 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_page_links.py +559 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +21 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +9 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +35 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +6 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +413 -25
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -11
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +58 -5
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +11 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +8 -6
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +87 -10
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +12 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -36,6 +36,8 @@ from fabric_protocols import (
|
|
|
36
36
|
protocols_findings,
|
|
37
37
|
)
|
|
38
38
|
from fabric_connections import CONNECTIONS_EXTENSION_KEY, connections_view # noqa: E402 - the kit (FAA-62)
|
|
39
|
+
from fabric_actions import ACTIONS_EXTENSION_KEY, actions_extension, actions_extension_rules # noqa: E402 - DEC-0121
|
|
40
|
+
from fabric_a2a import a2a_surface_problems # noqa: E402 - DEC-0122
|
|
39
41
|
from fabric_usage import (
|
|
40
42
|
ROUTES,
|
|
41
43
|
)
|
|
@@ -46,7 +48,7 @@ from fabric_usage import (
|
|
|
46
48
|
DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
47
49
|
|
|
48
50
|
|
|
49
|
-
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.
|
|
51
|
+
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.5"
|
|
50
52
|
|
|
51
53
|
|
|
52
54
|
REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
|
|
@@ -577,11 +579,20 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
577
579
|
update_available: Optional[str] = None, pid: Optional[int] = None,
|
|
578
580
|
extensions: Optional[Dict[str, Any]] = None,
|
|
579
581
|
configuration: Optional[Dict[str, Any]] = None,
|
|
580
|
-
protocols: Optional[Sequence[str]] = None
|
|
582
|
+
protocols: Optional[Sequence[str]] = None,
|
|
583
|
+
actions: Optional[Sequence[Dict[str, Any]]] = None,
|
|
584
|
+
a2a: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
581
585
|
"""The unauthenticated identity answer. `degraded` is always present; ready means empty.
|
|
582
586
|
`configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
|
|
583
587
|
`extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
|
|
584
|
-
in it must be valid (dashboard_extension builds one).
|
|
588
|
+
in it must be valid (dashboard_extension builds one).
|
|
589
|
+
`actions` (DEC-0121 AP-1) is the service's action table, every button its dashboard, app or page offers with the
|
|
590
|
+
tool that performs it and its gate; it is written under extensions/actions/0.1 by fabric_actions.actions_extension.
|
|
591
|
+
A declaration FAC-SEM-142 finds, passed either way, is refused: one that is not its schema's, an id named twice, a
|
|
592
|
+
humanStep that disagrees with the gate, or a tool `surfaces.mcp.capabilities` does not list.
|
|
593
|
+
`a2a` (DEC-0122) is `surfaces.a2a`, where the service serves its public Agent Card (fabric_a2a.a2a_surface builds
|
|
594
|
+
one); passed here or in `surfaces`, never both, and refused when it is not the schema's. A reader pinned before
|
|
595
|
+
DEC-0122 refuses the member, so a service carries it only once its readers are pinned at or after it."""
|
|
585
596
|
if status not in STATUSES:
|
|
586
597
|
raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
|
|
587
598
|
degraded = list(degraded)
|
|
@@ -589,6 +600,15 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
589
600
|
status = "degraded"
|
|
590
601
|
if "events" not in surfaces:
|
|
591
602
|
raise ServiceError("surfaces.events is required.")
|
|
603
|
+
if a2a is not None:
|
|
604
|
+
if "a2a" in surfaces:
|
|
605
|
+
raise ServiceError("surfaces.a2a: pass it in surfaces or as a2a, not both.")
|
|
606
|
+
surfaces = dict(surfaces, a2a=a2a)
|
|
607
|
+
if "a2a" in surfaces:
|
|
608
|
+
# DEC-0122: a host reads the member by the schema; a service never publishes one it would refuse.
|
|
609
|
+
problems = a2a_surface_problems(surfaces["a2a"])
|
|
610
|
+
if problems:
|
|
611
|
+
raise ServiceError("surfaces.a2a: %s." % "; ".join(problems))
|
|
592
612
|
doc: Dict[str, Any] = {
|
|
593
613
|
"protocol": PROTOCOL,
|
|
594
614
|
"service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
|
|
@@ -600,6 +620,12 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
600
620
|
}
|
|
601
621
|
if summary:
|
|
602
622
|
doc["summary"] = list(summary)[:6]
|
|
623
|
+
if actions is not None:
|
|
624
|
+
if extensions and ACTIONS_EXTENSION_KEY in extensions:
|
|
625
|
+
raise ServiceError("Actions declaration: pass the action table or a ready declaration, not both.")
|
|
626
|
+
mcp = surfaces.get("mcp")
|
|
627
|
+
capabilities = mcp.get("capabilities") if isinstance(mcp, dict) else None
|
|
628
|
+
extensions = dict(extensions or {}, **actions_extension(actions, capabilities if isinstance(capabilities, list) else None))
|
|
603
629
|
if extensions:
|
|
604
630
|
if DASHBOARD_EXTENSION_KEY in extensions:
|
|
605
631
|
problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
|
|
@@ -610,6 +636,13 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
|
|
|
610
636
|
view = connections_view({"extensions": {CONNECTIONS_EXTENSION_KEY: extensions[CONNECTIONS_EXTENSION_KEY]}})
|
|
611
637
|
if view["reported"]:
|
|
612
638
|
raise ServiceError("Connections declaration: %s." % "; ".join(view["errors"]))
|
|
639
|
+
if ACTIONS_EXTENSION_KEY in extensions:
|
|
640
|
+
# DEC-0121 AP-1: a host reports an invalid declaration (FAC-SEM-142); a service never publishes one. The
|
|
641
|
+
# tools are compared with the capabilities this document's MCP surface lists.
|
|
642
|
+
findings = actions_extension_rules({"extensions": {ACTIONS_EXTENSION_KEY: extensions[ACTIONS_EXTENSION_KEY]},
|
|
643
|
+
"surfaces": surfaces})
|
|
644
|
+
if findings:
|
|
645
|
+
raise ServiceError("Actions declaration: %s." % "; ".join(f["message"] for f in findings))
|
|
613
646
|
doc["extensions"] = dict(extensions)
|
|
614
647
|
if configuration is not None:
|
|
615
648
|
problems = configuration_problems(configuration)
|
|
@@ -776,6 +776,13 @@ def _placement_grant_problems(record: Dict[str, Any], placement: Dict[str, Any])
|
|
|
776
776
|
return problems
|
|
777
777
|
|
|
778
778
|
|
|
779
|
+
def placement_grant_findings(record: Any, placement: Any) -> List[Dict[str, str]]:
|
|
780
|
+
"""FAC-SEM-111 for one placement of ``record``, as findings (``instancePath`` relative to the placement). Answers,
|
|
781
|
+
never raises: a record or placement that is no object reads as an empty one (FAA-125)."""
|
|
782
|
+
record, placement = (v if isinstance(v, dict) else {} for v in (record, placement))
|
|
783
|
+
return [{"code": "FAC-SEM-111", "instancePath": at, "message": m} for at, m in _placement_grant_problems(record, placement)]
|
|
784
|
+
|
|
785
|
+
|
|
779
786
|
def _placement_grants(value: Dict[str, Any], flag) -> None:
|
|
780
787
|
for machine, m in _indexed(_get(value, "machines")):
|
|
781
788
|
for placement, p in _indexed(_get(machine, "packages")):
|
|
@@ -20,15 +20,22 @@ from __future__ import annotations
|
|
|
20
20
|
import base64
|
|
21
21
|
import hashlib
|
|
22
22
|
import json
|
|
23
|
+
import math
|
|
23
24
|
from pathlib import Path
|
|
24
25
|
import re
|
|
25
26
|
import secrets
|
|
26
27
|
import sys
|
|
28
|
+
import time
|
|
27
29
|
from typing import Any, Callable, Dict, List, Optional
|
|
28
30
|
|
|
29
31
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
30
32
|
import fabric_service as _fs # noqa: E402
|
|
31
33
|
from fabric_base import store_lock # noqa: E402 - one lock per store path (R3)
|
|
34
|
+
from fabric_actions import action_caller, readable_action # noqa: E402 - DEC-0121
|
|
35
|
+
from fabric_mandate import EFFECT_CLASSES, is_agent_id # noqa: E402 - DEC-0125
|
|
36
|
+
from fabric_time import format_rfc3339 # noqa: E402 - the core's (FAA-65)
|
|
37
|
+
from fabric_page_links import ( # noqa: E402 - DEC-0123
|
|
38
|
+
PageLinkError, dashboards_link, input_request_link_findings, page_link_findings, parse_dashboards_link)
|
|
32
39
|
|
|
33
40
|
from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
|
|
34
41
|
_days_from_civil,
|
|
@@ -77,6 +84,8 @@ from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
|
|
|
77
84
|
intent_idempotency_key,
|
|
78
85
|
reads_null_usage,
|
|
79
86
|
request_meta,
|
|
87
|
+
action_gate,
|
|
88
|
+
action_refusal_tool_result,
|
|
80
89
|
usage_findings,
|
|
81
90
|
usage_toward,
|
|
82
91
|
_job_findings,
|
|
@@ -214,12 +223,32 @@ def choice_request(message: str, field: str, options: List[Any], title: Optional
|
|
|
214
223
|
|
|
215
224
|
|
|
216
225
|
def url_request(message: str, url: str) -> Dict[str, Any]:
|
|
217
|
-
"""URL mode: the person opens `url` out of band; for credentials and payments. Put nothing sensitive in the URL.
|
|
218
|
-
|
|
219
|
-
|
|
226
|
+
"""URL mode: the person opens `url` out of band; for credentials and payments. Put nothing sensitive in the URL.
|
|
227
|
+
A page of this service goes as its open_link (`page_answer`, DEC-0123 PL-4); a raw loopback address and a
|
|
228
|
+
`fabric-dashboards:` link outside the grammar are refused here, when the request is built (FAC-SEM-143)."""
|
|
229
|
+
if not isinstance(url, str):
|
|
230
|
+
raise InteropError("A URL-mode request needs an https URL or Fabric Dashboards' open_link (DEC-0123 PL-4).")
|
|
231
|
+
findings = page_link_findings(url, "/params/url", "the URL-mode request")
|
|
232
|
+
if findings:
|
|
233
|
+
raise InteropError(findings[0]["message"])
|
|
234
|
+
if not url.startswith("https://") and not parse_dashboards_link(url)["ok"]:
|
|
235
|
+
raise InteropError("A URL-mode request needs an https URL or Fabric Dashboards' open_link (DEC-0123 PL-4).")
|
|
220
236
|
return {"method": "elicitation/create", "params": {"mode": "url", "message": message, "url": url}}
|
|
221
237
|
|
|
222
238
|
|
|
239
|
+
def page_answer(service: str, instance: str, path: Optional[str] = None, http_url: Optional[str] = None) -> Dict[str, Any]:
|
|
240
|
+
"""A tool answer that hands a person one of this service's pages (DEC-0123 PL-4): `{"open_link"}`, the page's
|
|
241
|
+
Fabric Dashboards link, and `http_url`, its raw address, only as a diagnostic beside it (never judged as the
|
|
242
|
+
action). Raises InteropError for an id, instance or path the open_link grammar refuses."""
|
|
243
|
+
try:
|
|
244
|
+
answer: Dict[str, Any] = {"open_link": dashboards_link(service, instance, path)}
|
|
245
|
+
except PageLinkError as refused:
|
|
246
|
+
raise InteropError("No open_link for %s.%s: %s (DEC-0123 PL-1)." % (service, instance, refused)) from None
|
|
247
|
+
if http_url is not None:
|
|
248
|
+
answer["http_url"] = http_url
|
|
249
|
+
return answer
|
|
250
|
+
|
|
251
|
+
|
|
223
252
|
# --- C3.2 jobs ---------------------------------------------------------------------
|
|
224
253
|
|
|
225
254
|
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
@@ -361,6 +390,8 @@ class JobStore:
|
|
|
361
390
|
for key, request in input_requests.items():
|
|
362
391
|
if not _KEY.match(key) or request.get("method") != "elicitation/create":
|
|
363
392
|
raise InteropError("Input request %r must be a keyed elicitation/create request." % key)
|
|
393
|
+
for finding in input_request_link_findings({"inputRequests": input_requests}): # FAC-SEM-143 (D12)
|
|
394
|
+
raise InteropError("%s: %s" % (finding["instancePath"], finding["message"]))
|
|
364
395
|
return self._transition(job_id, "input_required", inputRequests=input_requests, statusMessage=message)
|
|
365
396
|
|
|
366
397
|
def answer(self, job_id: str, input_responses: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -430,16 +461,28 @@ class JobStore:
|
|
|
430
461
|
# --- a minimal MCP tool server (streamable HTTP, JSON responses) ------------------------
|
|
431
462
|
|
|
432
463
|
class CallContext:
|
|
433
|
-
"""What a tool handler gets: the child span it runs in, and a way to start a job.
|
|
464
|
+
"""What a tool handler gets: the child span it runs in, and a way to start a job.
|
|
465
|
+
|
|
466
|
+
DEC-0121: `tier` is the caller's (DL-25), `caller` the identity the service authenticated (decision D9), `meta`
|
|
467
|
+
the request's `_meta`. For a tool registered with an action, `record` is action_decision's recordAs: "caller"
|
|
468
|
+
(the caller's own act) or {by, via, caller}, the act to put in the service's audit record, and `decision` the
|
|
469
|
+
operator's decision it took (its `instruction` stays in that record, never in events or a page another party
|
|
470
|
+
reads), else None. `input_records` maps each answered input request to its record when input_actions is on.
|
|
471
|
+
DEC-0125: `mandate` is the operator's mandate the call was taken under (record `via: mandate`), else None."""
|
|
434
472
|
|
|
435
473
|
def __init__(self, server: "McpToolServer", tool: str, arguments: Any, traceparent: str,
|
|
436
|
-
delegation: Optional[Dict[str, Any]] = None
|
|
474
|
+
delegation: Optional[Dict[str, Any]] = None, *, tier: Optional[str] = None, caller: Optional[str] = None,
|
|
475
|
+
meta: Optional[Dict[str, Any]] = None, record: Any = None, decision: Optional[Dict[str, Any]] = None,
|
|
476
|
+
input_records: Optional[Dict[str, Any]] = None, mandate: Optional[Dict[str, Any]] = None):
|
|
437
477
|
self.server = server
|
|
438
478
|
self.tool = tool
|
|
439
479
|
self.arguments = arguments
|
|
440
480
|
self.traceparent = traceparent
|
|
441
481
|
# DEC-0039: the accepted delegation (principal, caller, chain, claims) when the server has a gate.
|
|
442
482
|
self.delegation = delegation
|
|
483
|
+
self.tier, self.caller, self.meta = tier, caller, {} if meta is None else meta
|
|
484
|
+
self.record, self.decision, self.input_records = record, decision, input_records
|
|
485
|
+
self.mandate = mandate
|
|
443
486
|
|
|
444
487
|
def start_job(self, poll_interval_ms: Optional[int] = None) -> Dict[str, Any]:
|
|
445
488
|
if self.server.jobs is None:
|
|
@@ -535,6 +578,10 @@ def mcp_request_headers(method: str, params: Dict[str, Any]) -> Dict[str, str]:
|
|
|
535
578
|
return out
|
|
536
579
|
|
|
537
580
|
|
|
581
|
+
_UNREADABLE = object() # what a service's spend or project function answers when it raised
|
|
582
|
+
_UNREADABLE_PROJECT = "\x00unreadable" # never a member of a mandate's projects, so a scoped mandate refuses
|
|
583
|
+
|
|
584
|
+
|
|
538
585
|
class McpToolServer:
|
|
539
586
|
"""tools/list and tools/call for an MCP 2026-07-28 server, with fabric.job.get/cancel built in.
|
|
540
587
|
|
|
@@ -547,11 +594,32 @@ class McpToolServer:
|
|
|
547
594
|
def __init__(self, name: str, version: str, jobs: Optional[JobStore] = None,
|
|
548
595
|
on_job_started: Optional[Callable[[str, CallContext], None]] = None,
|
|
549
596
|
on_input: Optional[OnInput] = None,
|
|
550
|
-
authorize: Optional[Callable[[str, Any], Dict[str, Any]]] = None
|
|
597
|
+
authorize: Optional[Callable[[str, Any], Dict[str, Any]]] = None, *,
|
|
598
|
+
identify: Optional[Callable[[Any, str], Optional[str]]] = None,
|
|
599
|
+
approve: Optional[Callable[[str, Dict[str, Any], Any, str], Optional[Dict[str, Any]]]] = None,
|
|
600
|
+
elicited: Optional[Callable[[str, Dict[str, Any], Optional[str]], bool]] = None,
|
|
601
|
+
input_actions: bool = False, clock: Callable[[], float] = time.time,
|
|
602
|
+
on_fault: Optional[Callable[[str, str], None]] = None, agent_id: Optional[str] = None):
|
|
551
603
|
# authorize(tool, headers, tier=) runs before every handler (fabric_delegation.delegation_gate, DEC-0039):
|
|
552
604
|
# a refusal is answered in the DL-19 shape and the handler never runs. The built-in job tools are
|
|
553
605
|
# gated by the job's own capability, and answered only for the principal and caller that started it.
|
|
606
|
+
# DEC-0121: a tool added with an action runs action_gate after authorize and before its handler.
|
|
607
|
+
# identify(headers, tier) names the caller the service authenticated when no delegation did (D9; else
|
|
608
|
+
# local:T0 / local:T1); approve(tool, arguments, headers, digest) answers verify_approval's outcome for an
|
|
609
|
+
# approval the call presented, or None; elicited(tool, arguments, caller) is True only on the service's own
|
|
610
|
+
# record of an accepted elicitation. input_actions=True gates each answered input request as an action (D8).
|
|
611
|
+
# on_fault(tool, fault) hears a service fault the caller only sees as unknown-state (e.g. caller-missing: an
|
|
612
|
+
# action-gated server needs a tier, identify or authorize that names who calls).
|
|
613
|
+
# DEC-0125: agent_id is this service's own agent id; with it the server reads a call's _meta.mandate for a
|
|
614
|
+
# relayed action added with its effect (the chain must end in agent_id, and in the delegation's caller before
|
|
615
|
+
# it when authorize accepted a token); without it a mandate is not read, as before.
|
|
616
|
+
if agent_id is not None and not is_agent_id(agent_id):
|
|
617
|
+
raise InteropError("agent_id is this service's agent id (a lower-case id, optionally .instance; DEC-0125).")
|
|
618
|
+
self.agent_id = agent_id
|
|
554
619
|
self._authorize = authorize
|
|
620
|
+
self._identify, self._approve, self._elicited = identify, approve, elicited
|
|
621
|
+
self.input_actions, self._clock, self._on_fault = input_actions is True, clock, on_fault
|
|
622
|
+
self.actions: Dict[str, Dict[str, Any]] = {}
|
|
555
623
|
self.info = {"name": name, "version": version}
|
|
556
624
|
self.jobs = jobs
|
|
557
625
|
self.tools: List[Dict[str, Any]] = []
|
|
@@ -559,14 +627,148 @@ class McpToolServer:
|
|
|
559
627
|
self._on_job_started = on_job_started
|
|
560
628
|
self._on_input = on_input
|
|
561
629
|
|
|
562
|
-
def add_tool(self, tool: Dict[str, Any], handler: Handler
|
|
630
|
+
def add_tool(self, tool: Dict[str, Any], handler: Handler, action: Optional[Dict[str, Any]] = None, *,
|
|
631
|
+
target: Any = None, human_step_url: Optional[str] = None, effect: Optional[str] = None,
|
|
632
|
+
spend: Any = None, project: Any = None) -> None:
|
|
633
|
+
"""Serve a tool. `action` (DEC-0121 AP-1) is the declared action this tool performs, {id, title, tool, gate,
|
|
634
|
+
humanStep?}, its `tool` this tool's name: the server runs action_gate before the handler. `target` names the
|
|
635
|
+
object a call acts on: an argument's name, or a function of the arguments; `human_step_url` is the link of a
|
|
636
|
+
`human` action's step (an https URL or a path on the service's origin, AP-4).
|
|
637
|
+
|
|
638
|
+
DEC-0125, on a server with agent_id: `effect` is the action's effect class (governance's vocabulary), required
|
|
639
|
+
for a relayed action, since a mandate covers a call by its effect; `spend` the service's own estimate of what a
|
|
640
|
+
call spends, USD (a number, or a function of the arguments answering a number or None); `project` the project a
|
|
641
|
+
call acts in (a name, or a function of the arguments)."""
|
|
563
642
|
if tool["name"] in self.handlers:
|
|
564
643
|
raise InteropError("Tool %s is served twice." % tool["name"])
|
|
565
644
|
if "outputSchema" in tool:
|
|
566
645
|
require_object_root(tool["name"], tool["outputSchema"])
|
|
646
|
+
if action is not None:
|
|
647
|
+
if not readable_action(action) or action["tool"] != tool["name"]:
|
|
648
|
+
raise InteropError("Tool %s: its action is not a declared action of this tool (DEC-0121)." % tool["name"])
|
|
649
|
+
if action["id"] in {a["action"]["id"] for a in self.actions.values()}:
|
|
650
|
+
raise InteropError("Action %s is declared twice." % action["id"])
|
|
651
|
+
if action["gate"] == "human" and not (isinstance(human_step_url, str) and re.match(r"https://|/", human_step_url)):
|
|
652
|
+
raise InteropError("Tool %s: a human step needs its link, an https URL or a path (DEC-0121 AP-4)." % tool["name"])
|
|
653
|
+
if not (target is None or isinstance(target, str) or callable(target)):
|
|
654
|
+
raise InteropError("Tool %s: target is an argument's name or a function of the arguments." % tool["name"])
|
|
655
|
+
if self.agent_id is not None and action["gate"] == "relayed" and not (isinstance(effect, str) and effect in EFFECT_CLASSES):
|
|
656
|
+
raise InteropError("Tool %s: a relayed action on a server that reads mandates names its effect class (DEC-0125)." % tool["name"])
|
|
657
|
+
if effect is not None and not (isinstance(effect, str) and effect in EFFECT_CLASSES):
|
|
658
|
+
raise InteropError("Tool %s: effect is one of %s." % (tool["name"], ", ".join(EFFECT_CLASSES)))
|
|
659
|
+
if not (spend is None or callable(spend) or (isinstance(spend, (int, float)) and not isinstance(spend, bool))):
|
|
660
|
+
raise InteropError("Tool %s: spend is a number of USD or a function of the arguments." % tool["name"])
|
|
661
|
+
if not (project is None or isinstance(project, str) or callable(project)):
|
|
662
|
+
raise InteropError("Tool %s: project is a name or a function of the arguments." % tool["name"])
|
|
663
|
+
self.actions[tool["name"]] = {"action": dict(action), "target": target, "url": human_step_url,
|
|
664
|
+
"effect": effect, "spend": spend, "project": project}
|
|
567
665
|
self.tools.append(tool)
|
|
568
666
|
self.handlers[tool["name"]] = handler
|
|
569
667
|
|
|
668
|
+
def action_table(self) -> List[Dict[str, Any]]:
|
|
669
|
+
"""The declared actions in the order they were added: build_well_known(actions=) writes them (DEC-0121 AP-1)."""
|
|
670
|
+
return [dict(entry["action"]) for entry in self.actions.values()]
|
|
671
|
+
|
|
672
|
+
def _caller(self, headers: Any, tier: str, verdict: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
673
|
+
"""D9: the delegation's caller when a gate accepted a token, else identify(headers, tier), else the local floor."""
|
|
674
|
+
authenticated = (verdict or {}).get("caller")
|
|
675
|
+
if not authenticated and self._identify is not None:
|
|
676
|
+
authenticated = self._identify(headers, tier)
|
|
677
|
+
return action_caller(tier, authenticated)
|
|
678
|
+
|
|
679
|
+
def _gate_action(self, action: Dict[str, Any], name: str, arguments: Dict[str, Any], target: Any, url: Any,
|
|
680
|
+
headers: Any, tier: str, meta: Dict[str, Any], caller: Optional[str],
|
|
681
|
+
mandate: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
682
|
+
"""action_gate for one call: the approval the call presented, the digest of {tool, arguments}, the clock, and
|
|
683
|
+
under DEC-0125 what a mandate is read with (`mandate`: self_id, effect, caller_agent, spend_usd, project)."""
|
|
684
|
+
digest, approval = None, None
|
|
685
|
+
if action["gate"] == "relayed":
|
|
686
|
+
import fabric_delegation as _fd # noqa: E402 - only an action-gated server needs it
|
|
687
|
+
try:
|
|
688
|
+
digest = _fd.action_digest({"tool": name, "arguments": arguments})
|
|
689
|
+
except (TypeError, ValueError, RecursionError): # no canonical form (a lone surrogate, a non-finite number,
|
|
690
|
+
# nesting past the recursion limit), as intent_digest reads it: no digest to bind
|
|
691
|
+
return {"ok": False, "invalid": "Invalid params: the arguments have no canonical form."}
|
|
692
|
+
outcome = self._approve(name, arguments, headers, digest) if self._approve is not None else None
|
|
693
|
+
if isinstance(outcome, dict) and outcome.get("ok") is True:
|
|
694
|
+
approval = outcome
|
|
695
|
+
elif isinstance(outcome, dict) and outcome.get("code") not in (None, "approval-required"):
|
|
696
|
+
# DL-18: the presented approval's own refusal; an outcome that is not verify_approval's is the service's.
|
|
697
|
+
readable = outcome.get("code") in _fd.REFUSAL_CODES and isinstance(outcome.get("detail"), str) and outcome["detail"]
|
|
698
|
+
if readable:
|
|
699
|
+
return {"ok": False, "refusal": outcome}
|
|
700
|
+
return {"ok": False, "refusal": {"ok": False, "code": "unknown-state", "detail": "service-fault"},
|
|
701
|
+
"fault": "approval-outcome-unreadable"}
|
|
702
|
+
elicited = action["gate"] == "relayed" and self._elicited is not None and self._elicited(name, arguments, caller) is True
|
|
703
|
+
return action_gate(action, tier=tier, caller=caller, meta=meta, target=target, elicited=elicited, approval=approval,
|
|
704
|
+
action_digest=digest, human_step_url=url, now=format_rfc3339(self._clock() * 1000), **(mandate or {}))
|
|
705
|
+
|
|
706
|
+
def _mandate_inputs(self, entry: Dict[str, Any], arguments: Dict[str, Any], verdict: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
707
|
+
"""DEC-0125: what action_gate reads a mandate with, empty on a server without agent_id. The caller's agent id is
|
|
708
|
+
the delegation's caller when authorize accepted a token, never a name the call carries."""
|
|
709
|
+
if self.agent_id is None:
|
|
710
|
+
return {}
|
|
711
|
+
spend, project = (self._read(entry.get(k), arguments) for k in ("spend", "project"))
|
|
712
|
+
caller_agent = (verdict or {}).get("caller")
|
|
713
|
+
# The estimate is read as a double, as the contract does: an int past the float range is infinite, and an
|
|
714
|
+
# estimate that is not a finite number reads as none, which a mandate's ceiling refuses (fail closed).
|
|
715
|
+
try:
|
|
716
|
+
double = float(spend) if isinstance(spend, (int, float)) and not isinstance(spend, bool) else math.nan
|
|
717
|
+
except OverflowError:
|
|
718
|
+
double = math.inf
|
|
719
|
+
finite = math.isfinite(double) # the service's own number goes on when it is finite (25 stays 25)
|
|
720
|
+
if project is _UNREADABLE or not (project is None or isinstance(project, str)):
|
|
721
|
+
project = _UNREADABLE_PROJECT # a function that raised or named no string: a scoped mandate refuses
|
|
722
|
+
return {"self_id": self.agent_id, "effect": entry.get("effect"),
|
|
723
|
+
"caller_agent": caller_agent if isinstance(caller_agent, str) and caller_agent else None,
|
|
724
|
+
# an estimate that raised or is not finite is none: a mandate with a ceiling then refuses (fail closed)
|
|
725
|
+
"spend_usd": spend if finite else None,
|
|
726
|
+
"project": project if isinstance(project, str) else None}
|
|
727
|
+
|
|
728
|
+
@staticmethod
|
|
729
|
+
def _read(value: Any, arguments: Dict[str, Any]) -> Any:
|
|
730
|
+
"""A spend or project the service gave, or what its function answers; a function that raises answers _UNREADABLE."""
|
|
731
|
+
try:
|
|
732
|
+
return value(arguments) if callable(value) else value
|
|
733
|
+
except Exception: # noqa: BLE001 - the service's own code; the call is still answered
|
|
734
|
+
return _UNREADABLE
|
|
735
|
+
|
|
736
|
+
def _gate_inputs(self, arguments: Dict[str, Any], headers: Any, tier: str, meta: Dict[str, Any],
|
|
737
|
+
caller: Optional[str]) -> Dict[str, Any]:
|
|
738
|
+
"""D8: each input request an answer would take is an action (id its key, tool fabric.job.get, gate relayed),
|
|
739
|
+
one decision per call; the first refusal refuses the call and nothing is applied."""
|
|
740
|
+
job_id = str(arguments.get("id", ""))
|
|
741
|
+
job = self.jobs.get(job_id)
|
|
742
|
+
asked = job.get("inputRequests", {}) if job["status"] == "input_required" else {}
|
|
743
|
+
records: Dict[str, Any] = {}
|
|
744
|
+
for key, value in arguments["inputResponses"].items():
|
|
745
|
+
if key not in asked or not isinstance(value, dict) or value.get("action") not in ("accept", "decline", "cancel"):
|
|
746
|
+
continue
|
|
747
|
+
action = {"id": key, "title": "Answer %s" % key, "tool": "fabric.job.get", "gate": "relayed"}
|
|
748
|
+
gated = self._gate_action(action, "fabric.job.get", {"id": job_id, "inputResponses": {key: value}}, None, None,
|
|
749
|
+
headers, tier, meta, caller)
|
|
750
|
+
if not gated["ok"]:
|
|
751
|
+
return gated
|
|
752
|
+
records[key] = gated["record"]
|
|
753
|
+
return {"ok": True, "records": records}
|
|
754
|
+
|
|
755
|
+
def _fault(self, tool: str, gated: Dict[str, Any]) -> None:
|
|
756
|
+
"""Hand a service fault to on_fault; the caller saw only unknown-state. A hook that raises is the service's."""
|
|
757
|
+
if gated.get("fault") and self._on_fault is not None:
|
|
758
|
+
try:
|
|
759
|
+
self._on_fault(tool, str(gated["fault"]))
|
|
760
|
+
except Exception: # noqa: BLE001 - logging must not change the answer
|
|
761
|
+
pass
|
|
762
|
+
|
|
763
|
+
@staticmethod
|
|
764
|
+
def _target(target: Any, arguments: Dict[str, Any]) -> Optional[str]:
|
|
765
|
+
"""The object a call acts on: the named argument, or what the service's function answers; a string or None."""
|
|
766
|
+
try:
|
|
767
|
+
value = target(arguments) if callable(target) else arguments.get(target) if isinstance(target, str) else None
|
|
768
|
+
except Exception: # noqa: BLE001 - a function that cannot read this call names no object
|
|
769
|
+
return None
|
|
770
|
+
return value if isinstance(value, str) else None
|
|
771
|
+
|
|
570
772
|
def job_started(self, job_id: str, ctx: CallContext) -> None:
|
|
571
773
|
if self._on_job_started:
|
|
572
774
|
self._on_job_started(job_id, ctx)
|
|
@@ -641,7 +843,21 @@ class McpToolServer:
|
|
|
641
843
|
if not verdict.get("ok"):
|
|
642
844
|
import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
|
|
643
845
|
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
|
|
644
|
-
|
|
846
|
+
caller, inputs = self._caller(headers, tier, verdict), None
|
|
847
|
+
responses = arguments.get("inputResponses")
|
|
848
|
+
if name == "fabric.job.get" and self.input_actions and isinstance(responses, dict) and responses:
|
|
849
|
+
try:
|
|
850
|
+
inputs = self._gate_inputs(arguments, headers, tier, meta, caller)
|
|
851
|
+
except UnknownJob:
|
|
852
|
+
return {"jsonrpc": "2.0", "id": rid, "result": unknown_job_result(str(arguments.get("id", "")), traceparent=span)}
|
|
853
|
+
if not inputs["ok"] and "invalid" in inputs:
|
|
854
|
+
return self._error(rid, -32602, inputs["invalid"])
|
|
855
|
+
if not inputs["ok"]:
|
|
856
|
+
self._fault(name, inputs)
|
|
857
|
+
import fabric_delegation as _fd # noqa: E402 - DL-19
|
|
858
|
+
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(inputs["refusal"], traceparent=span)}
|
|
859
|
+
context = {"tier": tier, "caller": caller, "meta": meta, "input_records": inputs["records"] if inputs else None}
|
|
860
|
+
return {"jsonrpc": "2.0", "id": rid, "result": self._job_call(name, arguments, span, verdict, context)}
|
|
645
861
|
handler = self.handlers.get(name)
|
|
646
862
|
if handler is None:
|
|
647
863
|
return self._error(rid, -32602, "Unknown tool: %s." % name)
|
|
@@ -652,14 +868,26 @@ class McpToolServer:
|
|
|
652
868
|
import fabric_delegation as _fd # noqa: E402 - only a gated server needs it
|
|
653
869
|
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(verdict, traceparent=span)}
|
|
654
870
|
delegation = verdict
|
|
871
|
+
caller, gated, entry = self._caller(headers, tier, delegation), {"record": None, "decision": None}, self.actions.get(name)
|
|
872
|
+
if entry is not None: # DEC-0121: after the authorize gate, before the handler
|
|
873
|
+
gated = self._gate_action(entry["action"], name, arguments, self._target(entry["target"], arguments), entry["url"],
|
|
874
|
+
headers, tier, meta, caller, self._mandate_inputs(entry, arguments, delegation))
|
|
875
|
+
if not gated["ok"] and "invalid" in gated:
|
|
876
|
+
return self._error(rid, -32602, gated["invalid"])
|
|
877
|
+
if not gated["ok"]:
|
|
878
|
+
self._fault(name, gated)
|
|
879
|
+
import fabric_delegation as _fd # noqa: E402 - DL-19
|
|
880
|
+
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(gated["refusal"], traceparent=span)}
|
|
881
|
+
context = CallContext(self, name, arguments, span, delegation, tier=tier, caller=caller, meta=meta,
|
|
882
|
+
record=gated["record"], decision=gated["decision"], mandate=gated.get("mandate"))
|
|
655
883
|
try:
|
|
656
|
-
structured = handler(arguments,
|
|
884
|
+
structured = handler(arguments, context)
|
|
657
885
|
except InteropError as exc:
|
|
658
886
|
return {"jsonrpc": "2.0", "id": rid, "result": tool_result({"error": {"code": "tool-error", "message": str(exc)}}, is_error=True, traceparent=span)}
|
|
659
887
|
return {"jsonrpc": "2.0", "id": rid, "result": tool_result(structured, traceparent=span)}
|
|
660
888
|
|
|
661
889
|
def _job_call(self, name: str, arguments: Dict[str, Any], span: str,
|
|
662
|
-
delegation: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
890
|
+
delegation: Optional[Dict[str, Any]] = None, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
663
891
|
job_id = str(arguments.get("id", ""))
|
|
664
892
|
try:
|
|
665
893
|
job_span = self.jobs.traceparent(job_id) or span
|
|
@@ -674,11 +902,14 @@ class McpToolServer:
|
|
|
674
902
|
state = self.jobs.get(job_id)
|
|
675
903
|
return tool_result({"job": state}, traceparent=job_span)
|
|
676
904
|
responses = arguments.get("inputResponses")
|
|
905
|
+
context = context or {}
|
|
906
|
+
if responses and context.get("input_records") is not None: # D8: only the keys the action gate took
|
|
907
|
+
responses = {key: responses[key] for key in context["input_records"]}
|
|
677
908
|
if responses:
|
|
678
909
|
matched = self.jobs.answer(job_id, responses)
|
|
679
910
|
if matched and self._on_input:
|
|
680
911
|
private = self.jobs.private(job_id)
|
|
681
|
-
self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation))
|
|
912
|
+
self._on_input(job_id, matched, CallContext(self, private["capability"], private["input"], job_span, delegation, **context))
|
|
682
913
|
return tool_result({"job": self.jobs.get(job_id)}, traceparent=job_span)
|
|
683
914
|
except UnknownJob:
|
|
684
915
|
return unknown_job_result(job_id, traceparent=span)
|
|
@@ -17,6 +17,8 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
|
|
|
17
17
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
18
18
|
|
|
19
19
|
from fabric_base import ServiceError # noqa: E402
|
|
20
|
+
from fabric_actions import action_decision, decision_shape # noqa: E402 - DEC-0121
|
|
21
|
+
from fabric_mandate import mandate_problem # noqa: E402 - DEC-0125
|
|
20
22
|
|
|
21
23
|
PROTOCOL = "fabric-interop/0.1"
|
|
22
24
|
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
@@ -288,14 +290,80 @@ def reads_null_usage(request_meta: Any) -> bool:
|
|
|
288
290
|
return isinstance(protocols, list) and INTEROP_NULL_USAGE in protocols
|
|
289
291
|
|
|
290
292
|
|
|
291
|
-
def request_meta(traceparent: Optional[str] = None) -> Dict[str, Any]:
|
|
292
|
-
"""A request's `_meta` from this kit as a caller: its trace,
|
|
293
|
+
def request_meta(traceparent: Optional[str] = None, decision: Any = None, mandate: Any = None) -> Dict[str, Any]:
|
|
294
|
+
"""A request's `_meta` from this kit as a caller: its trace, the interop revisions it reads (DEC-0087), for a
|
|
295
|
+
relayed action the operator's decision (DEC-0121 AP-2; build one with fabric_actions.operator_decision), and the
|
|
296
|
+
operator's mandate for the task the call serves (DEC-0125 MD-3; state it with fabric_mandate.operator_mandate and
|
|
297
|
+
hand it on to the callee with fabric_mandate.hand_on before you send it). Both travel here only, never in a tool's
|
|
298
|
+
arguments; a decision decision.schema.json refuses or a mandate FAC-SEM-144 refuses raises InteropError."""
|
|
293
299
|
meta: Dict[str, Any] = {"protocols": list(PROTOCOLS_READ)}
|
|
294
300
|
if parse_traceparent(traceparent):
|
|
295
301
|
meta["traceparent"] = traceparent
|
|
302
|
+
if decision is not None:
|
|
303
|
+
if not decision_shape(decision):
|
|
304
|
+
raise InteropError("The decision is not one decision.schema.json reads; it is not sent.")
|
|
305
|
+
meta["decision"] = dict(decision)
|
|
306
|
+
if mandate is not None:
|
|
307
|
+
problem = mandate_problem(mandate)
|
|
308
|
+
if problem:
|
|
309
|
+
raise InteropError("The mandate is not one FAC-SEM-144 reads (%s); it is not sent." % problem)
|
|
310
|
+
meta["mandate"] = json.loads(json.dumps(mandate))
|
|
296
311
|
return meta
|
|
297
312
|
|
|
298
313
|
|
|
314
|
+
def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target: Optional[str] = None,
|
|
315
|
+
elicited: bool = False, approval: Any = None, action_digest: Optional[str] = None,
|
|
316
|
+
human_step_url: Optional[str] = None, now: Any = None, self_id: Optional[str] = None,
|
|
317
|
+
effect: Optional[str] = None, caller_agent: Optional[str] = None, spend_usd: Any = None,
|
|
318
|
+
project: Optional[str] = None) -> Dict[str, Any]:
|
|
319
|
+
"""One call of a declared action, before its handler runs (DEC-0121 AP-2, AP-4): fabric_actions.action_decision over
|
|
320
|
+
the call's `_meta.decision` (read from `meta` only, never from arguments, decision D11), the service's authenticated
|
|
321
|
+
`caller` (fabric_actions.action_caller), `target` (the object the call acts on), `elicited` (the service's own
|
|
322
|
+
record of an accepted elicitation), `approval` (verify_approval's accepted outcome), `action_digest`,
|
|
323
|
+
`human_step_url` and `now` (RFC 3339). Answers {ok: True, record, decision}, `decision` being the one taken as the
|
|
324
|
+
operator's (None otherwise), or {ok: False, refusal} in the DL-19 form (fabric_delegation.refusal_tool_result
|
|
325
|
+
sends it); a service's own fault is the refusal unknown-state with `fault` beside it for the service's log.
|
|
326
|
+
|
|
327
|
+
DEC-0125: a service that names its own agent id (`self_id`) reads the call's `_meta.mandate` when it carries no
|
|
328
|
+
decision, given `effect` (the action's effect class, required: without it the call is the service's fault
|
|
329
|
+
effect-missing), `caller_agent` (only an agent id the service authenticated: a delegation's caller, its own
|
|
330
|
+
per-agent credential; never a name the call carries), `spend_usd` (its own estimate) and `project`. A covered call
|
|
331
|
+
answers {ok: True, record: {by: operator, via: mandate, task, chain, caller}, decision: None, mandate}; without
|
|
332
|
+
`self_id` the mandate is not read and the call is answered as before it existed."""
|
|
333
|
+
import fabric_delegation as _fd # noqa: E402 - beside this file: DL-19
|
|
334
|
+
inputs: Dict[str, Any] = {"action": action, "tier": tier, "caller": caller, "elicited": elicited is True, "now": now}
|
|
335
|
+
if isinstance(meta, dict) and "decision" in meta:
|
|
336
|
+
inputs["decision"] = meta["decision"]
|
|
337
|
+
for name, value in (("target", target), ("approval", approval), ("actionDigest", action_digest), ("humanStepUrl", human_step_url)):
|
|
338
|
+
if value is not None:
|
|
339
|
+
inputs[name] = value
|
|
340
|
+
if self_id is not None and isinstance(meta, dict) and "mandate" in meta:
|
|
341
|
+
inputs.update({"mandate": meta["mandate"], "self": self_id})
|
|
342
|
+
for name, value in (("effect", effect), ("callerAgent", caller_agent), ("spendUsd", spend_usd), ("project", project)):
|
|
343
|
+
if value is not None:
|
|
344
|
+
inputs[name] = value
|
|
345
|
+
answer = action_decision(inputs)
|
|
346
|
+
if answer.get("ok") is True:
|
|
347
|
+
record = answer["recordAs"]
|
|
348
|
+
taken = isinstance(record, dict) and record.get("via") == "relayed-instruction"
|
|
349
|
+
out = {"ok": True, "record": record, "decision": inputs["decision"] if taken else None}
|
|
350
|
+
if isinstance(record, dict) and record.get("via") == "mandate":
|
|
351
|
+
out["mandate"] = inputs["mandate"]
|
|
352
|
+
return out
|
|
353
|
+
out: Dict[str, Any] = {"ok": False, "refusal": _fd.refusal_from_action(answer)}
|
|
354
|
+
if "fault" in answer:
|
|
355
|
+
out["fault"] = answer["fault"]
|
|
356
|
+
return out
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def action_refusal_tool_result(answer: Any, traceparent: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
360
|
+
"""The tools/call result a service's own MCP server answers for an action_decision answer: None when the action
|
|
361
|
+
takes the call, else DL-19's refusal as structuredContent with isError true (DEC-0121)."""
|
|
362
|
+
import fabric_delegation as _fd # noqa: E402 - beside this file: DL-19
|
|
363
|
+
refusal = _fd.refusal_from_action(answer)
|
|
364
|
+
return None if refusal is None else _fd.refusal_tool_result(refusal, traceparent=traceparent)
|
|
365
|
+
|
|
366
|
+
|
|
299
367
|
def usage_findings(usage: Any, at: str) -> List[Dict[str, str]]:
|
|
300
368
|
"""FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null."""
|
|
301
369
|
if not isinstance(usage, dict) or usage.get("partial", _ABSENT) is not False:
|