@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.42.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +160 -0
- package/README.md +9 -3
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +47 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +68 -24
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +157 -22
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +270 -35
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +36 -16
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +44 -22
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +54 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +28 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +277 -49
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -18,6 +18,7 @@ Normative source: fabric-agent-contract docs/specification/interop.md (DEC-0016,
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import base64
|
|
21
|
+
import hashlib
|
|
21
22
|
import json
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
import re
|
|
@@ -27,6 +28,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
27
28
|
|
|
28
29
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
29
30
|
import fabric_service as _fs # noqa: E402
|
|
31
|
+
from fabric_base import store_lock # noqa: E402 - one lock per store path (R3)
|
|
30
32
|
|
|
31
33
|
PROTOCOL = "fabric-interop/0.1"
|
|
32
34
|
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
@@ -49,11 +51,11 @@ JOB_HANDLE_SCHEMA: Dict[str, Any] = {
|
|
|
49
51
|
}
|
|
50
52
|
TERMINAL = ("completed", "failed", "cancelled")
|
|
51
53
|
|
|
52
|
-
_TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})
|
|
53
|
-
_JOB_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}
|
|
54
|
-
_KEY = re.compile(r"^[A-Za-z0-9_.-]{1,64}
|
|
54
|
+
_TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})\Z")
|
|
55
|
+
_JOB_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}\Z")
|
|
56
|
+
_KEY = re.compile(r"^[A-Za-z0-9_.-]{1,64}\Z")
|
|
55
57
|
_SECRET_WORDS = re.compile(r"pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|"
|
|
56
|
-
r"(access|auth|bearer|refresh|session)[\s_-]?token|^token
|
|
58
|
+
r"(access|auth|bearer|refresh|session)[\s_-]?token|^token\Z|credential", re.IGNORECASE)
|
|
57
59
|
|
|
58
60
|
|
|
59
61
|
class InteropError(_fs.ServiceError):
|
|
@@ -365,7 +367,6 @@ def intent_idempotency_key(intent: Any) -> Optional[str]:
|
|
|
365
367
|
"""The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 from the first 16
|
|
366
368
|
bytes of the SHA-256 of its RFC 8785 form. The same intent gives the same key on every resend and machine; None
|
|
367
369
|
for another shape or an input with no RFC 8785 form."""
|
|
368
|
-
import hashlib
|
|
369
370
|
import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
|
|
370
371
|
names = ("agentId", "capability", "input", "request")
|
|
371
372
|
if not isinstance(intent, dict) or any(name not in names for name in intent):
|
|
@@ -469,6 +470,16 @@ def pause_message(resets_at: Optional[str] = None, text: Optional[str] = None) -
|
|
|
469
470
|
return message
|
|
470
471
|
|
|
471
472
|
|
|
473
|
+
def intent_digest(capability: str, arguments: Any) -> str:
|
|
474
|
+
"""What a key is kept beside (FAA-77): the SHA-256 of the RFC 8785 form of [capability, input], so a key admits
|
|
475
|
+
only the intent it was first sent with. An input with no RFC 8785 form is refused."""
|
|
476
|
+
import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
|
|
477
|
+
try:
|
|
478
|
+
return "sha256:" + hashlib.sha256(_fd.jcs([capability, arguments]).encode("utf-8")).hexdigest()
|
|
479
|
+
except (TypeError, ValueError, RecursionError):
|
|
480
|
+
raise InteropError("A job's input with an idempotency key needs a JSON form (RFC 8785).") from None
|
|
481
|
+
|
|
482
|
+
|
|
472
483
|
def repeated_key_decision(job: Any, now: Any, limit_holds: Optional[bool] = None, approval_granted: bool = False) -> str:
|
|
473
484
|
"""The provider's answer to a repeated key: "run" (the same intent runs again, once) or "same" (answer with that job).
|
|
474
485
|
A quota-exhausted job runs again once the limit no longer holds: `limit_holds` False when the provider knows it, else
|
|
@@ -525,17 +536,17 @@ def url_request(message: str, url: str) -> Dict[str, Any]:
|
|
|
525
536
|
|
|
526
537
|
# --- C3.2 jobs ---------------------------------------------------------------------
|
|
527
538
|
|
|
528
|
-
|
|
529
|
-
"""Durable jobs, one private JSON file each under `directory`, written atomically.
|
|
539
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
530
540
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
"""
|
|
541
|
+
|
|
542
|
+
class JobStore:
|
|
543
|
+
"""Durable jobs, one private JSON file each under `directory`, written atomically and under the directory's lock.
|
|
544
|
+
A job id survives a restart. The file holds the public `job` object exactly as fabric.job.get returns it, and a
|
|
545
|
+
`private` part (the capability, its input, the trace) that is never returned; the Node kit uses the same format."""
|
|
536
546
|
|
|
537
547
|
def __init__(self, directory: Path):
|
|
538
548
|
self.dir = Path(directory)
|
|
549
|
+
self._lock = store_lock(self.dir)
|
|
539
550
|
|
|
540
551
|
def _path(self, job_id: str) -> Path:
|
|
541
552
|
if not _JOB_ID.match(job_id or ""):
|
|
@@ -562,48 +573,76 @@ class JobStore:
|
|
|
562
573
|
limit_holds: Optional[bool] = None, approval_granted: bool = False) -> Dict[str, Any]:
|
|
563
574
|
"""Start a job; returns the handle a job-capable tool puts in structuredContent.
|
|
564
575
|
|
|
565
|
-
With `idempotency_key` the key is kept per `caller` (the authenticated caller, DEC-0087)
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
if
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
576
|
+
With `idempotency_key` the key is kept per `caller` (the authenticated caller, DEC-0087) beside the digest of the
|
|
577
|
+
intent (the capability and its input): a key that names a job of another intent raises `idempotency-conflict`;
|
|
578
|
+
one that names a job of this intent starts nothing and raises InteropError, unless `repeat` would answer "run"
|
|
579
|
+
(a limit no longer holds, an approval granted); then the key names the new job. A 0.40.0 key file: see `repeat`."""
|
|
580
|
+
with self._lock:
|
|
581
|
+
intent = None
|
|
582
|
+
if idempotency_key is not None:
|
|
583
|
+
intent = intent_digest(capability, arguments)
|
|
584
|
+
decision = self.repeat(caller, idempotency_key, now if now is not None else _fs.now_iso(), limit_holds=limit_holds,
|
|
585
|
+
approval_granted=approval_granted, capability=capability, arguments=arguments)
|
|
586
|
+
if decision["decision"] == "conflict":
|
|
587
|
+
raise InteropError("idempotency-conflict: key %s names a job of another intent; send the same capability and input, or a new key." % idempotency_key)
|
|
588
|
+
if decision["decision"] == "same":
|
|
589
|
+
raise InteropError("Key %s already names job %s; answer with that job (JobStore.repeat)." % (idempotency_key, decision["job"]["id"]))
|
|
590
|
+
job_id = "job_" + secrets.token_hex(12)
|
|
591
|
+
job: Dict[str, Any] = {"id": job_id, "status": "working"}
|
|
592
|
+
if poll_interval_ms:
|
|
593
|
+
job["pollIntervalMs"] = int(poll_interval_ms)
|
|
594
|
+
private: Dict[str, Any] = {"capability": capability, "input": arguments,
|
|
595
|
+
"traceparent": traceparent if parse_traceparent(traceparent) else None}
|
|
596
|
+
if delegation: # DEC-0039: who started it; only they read, cancel or answer it
|
|
597
|
+
private["delegation"] = {"principal": delegation.get("principal"), "caller": delegation.get("caller")}
|
|
598
|
+
self._save({"job": job, "private": private})
|
|
599
|
+
if idempotency_key is not None:
|
|
600
|
+
_fs.atomic_write(self._key_path(caller, idempotency_key), (json.dumps({"job": job_id, "intent": intent}) + "\n").encode(), 0o600)
|
|
601
|
+
return {"job": {"id": job_id, "status": "working"}}
|
|
585
602
|
|
|
586
603
|
def _key_path(self, caller: Optional[str], key: str) -> Path:
|
|
587
|
-
import hashlib
|
|
588
604
|
if not isinstance(key, str) or not key:
|
|
589
605
|
raise InteropError("An idempotency key is a non-empty string.")
|
|
606
|
+
if _SURROGATE.search(key) or (isinstance(caller, str) and _SURROGATE.search(caller)):
|
|
607
|
+
raise InteropError("An idempotency key and its caller are Unicode text; a lone surrogate is refused.")
|
|
590
608
|
name = hashlib.sha256(json.dumps([caller, key], separators=(",", ":"), ensure_ascii=False).encode("utf-8")).hexdigest() # as JSON.stringify writes it
|
|
591
609
|
directory = self.dir / "keys"
|
|
592
610
|
_fs.ensure_private_dir(directory)
|
|
593
611
|
return directory / ("%s.json" % name)
|
|
594
612
|
|
|
613
|
+
def _key_record(self, caller: Optional[str], key: str) -> Optional[Dict[str, Any]]:
|
|
614
|
+
try:
|
|
615
|
+
record = json.loads(self._key_path(caller, key).read_text(encoding="utf-8"))
|
|
616
|
+
except FileNotFoundError:
|
|
617
|
+
return None
|
|
618
|
+
except ValueError:
|
|
619
|
+
record = None
|
|
620
|
+
if not isinstance(record, dict):
|
|
621
|
+
raise InteropError("Key %s is unreadable on disk." % key)
|
|
622
|
+
return record
|
|
623
|
+
|
|
595
624
|
def repeat(self, caller: Optional[str], key: str, now: Any, limit_holds: Optional[bool] = None,
|
|
596
|
-
approval_granted: bool = False) -> Dict[str, Any]:
|
|
597
|
-
"""The answer to a key this caller sent before (DEC-0087): {"decision": "new"|"same"|"run", "job":
|
|
598
|
-
|
|
625
|
+
approval_granted: bool = False, *, capability: Optional[str] = None, arguments: Any = None) -> Dict[str, Any]:
|
|
626
|
+
"""The answer to a key this caller sent before (DEC-0087): {"decision": "new"|"same"|"run"|"conflict", "job"}. "same": answer with
|
|
627
|
+
that job, never run it again (outcome-unknown above all); "run": the intent runs again, once; "conflict": given `capability` and
|
|
628
|
+
its input `arguments`, the key was kept beside another intent (FAA-77). A 0.40.0 key file ({"job": id}) never answers "conflict"."""
|
|
629
|
+
record = self._key_record(caller, key)
|
|
630
|
+
if record is None:
|
|
631
|
+
return {"decision": "new", "job": None}
|
|
599
632
|
try:
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
except (FileNotFoundError, UnknownJob):
|
|
633
|
+
job = self.get(record["job"])
|
|
634
|
+
except UnknownJob:
|
|
603
635
|
return {"decision": "new", "job": None}
|
|
604
|
-
except (
|
|
636
|
+
except (KeyError, TypeError):
|
|
605
637
|
raise InteropError("Key %s is unreadable on disk." % key) from None
|
|
606
|
-
|
|
638
|
+
decision = repeated_key_decision(job, now, limit_holds, approval_granted)
|
|
639
|
+
if capability is not None:
|
|
640
|
+
intent = intent_digest(capability, arguments)
|
|
641
|
+
if "intent" in record:
|
|
642
|
+
decision = "conflict" if record["intent"] != intent else decision
|
|
643
|
+
elif decision == "run": # a 0.40.0 key runs only the intent its job stored (an input with no JSON form is refused)
|
|
644
|
+
decision = "run" if intent_digest(*map(self.private(record["job"]).get, ("capability", "input"))) == intent else "same"
|
|
645
|
+
return {"decision": decision, "job": job}
|
|
607
646
|
|
|
608
647
|
def get(self, job_id: str) -> Dict[str, Any]:
|
|
609
648
|
return dict(self._load(job_id)["job"])
|
|
@@ -615,15 +654,16 @@ class JobStore:
|
|
|
615
654
|
return self._load(job_id)["private"].get("traceparent")
|
|
616
655
|
|
|
617
656
|
def _transition(self, job_id: str, status: str, **fields: Any) -> Dict[str, Any]:
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
657
|
+
with self._lock:
|
|
658
|
+
record = self._load(job_id)
|
|
659
|
+
job = record["job"]
|
|
660
|
+
if job["status"] in TERMINAL:
|
|
661
|
+
raise InteropError("Job %s is already %s; a terminal job does not change." % (job_id, job["status"]))
|
|
662
|
+
for key in ("inputRequests", "result", "error", "statusMessage"):
|
|
663
|
+
job.pop(key, None)
|
|
664
|
+
job["status"] = status
|
|
665
|
+
job.update({key: value for key, value in fields.items() if value is not None})
|
|
666
|
+
return self._save(record)
|
|
627
667
|
|
|
628
668
|
def working(self, job_id: str, message: Optional[str] = None) -> Dict[str, Any]:
|
|
629
669
|
return self._transition(job_id, "working", statusMessage=message)
|
|
@@ -641,15 +681,16 @@ class JobStore:
|
|
|
641
681
|
"""Apply inputResponses to an input_required job; unknown keys are ignored (MCP Tasks rule).
|
|
642
682
|
|
|
643
683
|
Returns the responses that matched; when any did, the job is working again."""
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
684
|
+
with self._lock:
|
|
685
|
+
job = self.get(job_id)
|
|
686
|
+
if job["status"] != "input_required":
|
|
687
|
+
return {}
|
|
688
|
+
matched = {key: value for key, value in (input_responses or {}).items()
|
|
689
|
+
if key in job.get("inputRequests", {}) and isinstance(value, dict)
|
|
690
|
+
and value.get("action") in ("accept", "decline", "cancel")}
|
|
691
|
+
if matched:
|
|
692
|
+
self.working(job_id)
|
|
693
|
+
return matched
|
|
653
694
|
|
|
654
695
|
def complete(self, job_id: str, envelope: Dict[str, Any]) -> Dict[str, Any]:
|
|
655
696
|
"""The envelope carries the job's trace, authoritative for the stored result (DEC-0017); one that names
|
|
@@ -675,13 +716,10 @@ class JobStore:
|
|
|
675
716
|
"""A job a limit paused and its provider will resume (DEC-0087): working, its statusMessage in the pause grammar,
|
|
676
717
|
and pollIntervalMs reaching the reset within its cap of one hour."""
|
|
677
718
|
message = pause_message(resets_at, text)
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
record["job"]["pollIntervalMs"] = int(min(wait, 3600000))
|
|
683
|
-
self._save(record)
|
|
684
|
-
return self._transition(job_id, "working", statusMessage=message)
|
|
719
|
+
wait = utc_time(resets_at) - utc_time(_fs.now_iso()) if resets_at is not None else 0
|
|
720
|
+
# One write, after the terminal check: a terminal job does not change, pollIntervalMs included (finding 11).
|
|
721
|
+
return self._transition(job_id, "working", statusMessage=message,
|
|
722
|
+
pollIntervalMs=int(min(wait, 3600000)) if wait > 0 else None)
|
|
685
723
|
|
|
686
724
|
def quota_exhausted(self, job_id: str, message: str, retryable: bool, resets_at: Optional[str] = None) -> Dict[str, Any]:
|
|
687
725
|
"""A job a limit stopped and its provider will not resume: failed, quota-exhausted, data {retryable, resetsAt?}."""
|
|
@@ -786,7 +824,8 @@ def mcp_header_problem(message: Any, headers: Any) -> Optional[str]:
|
|
|
786
824
|
return "Mcp-Method %s disagrees with the body's method %s" % (sent, method)
|
|
787
825
|
field = NAMED_METHODS.get(str(method))
|
|
788
826
|
if field:
|
|
789
|
-
|
|
827
|
+
params = message.get("params")
|
|
828
|
+
value = params.get(field) if isinstance(params, dict) else None
|
|
790
829
|
named = header("Mcp-Name")
|
|
791
830
|
if named is None and declared:
|
|
792
831
|
return "the body carries params.%s but the required Mcp-Name header is absent" % field
|
|
@@ -873,8 +912,11 @@ class McpToolServer:
|
|
|
873
912
|
if "id" not in message:
|
|
874
913
|
return None # a notification gets no response
|
|
875
914
|
rid = message["id"]
|
|
876
|
-
params = message.get("params")
|
|
877
|
-
meta = params.get("_meta")
|
|
915
|
+
params = {} if message.get("params") is None else message["params"]
|
|
916
|
+
meta = params.get("_meta") if isinstance(params, dict) else None
|
|
917
|
+
meta = {} if meta is None else meta
|
|
918
|
+
if not isinstance(params, dict) or not isinstance(meta, dict): # finding 17: answered, never a crash
|
|
919
|
+
return self._error(rid, -32602, "params and params._meta are JSON objects.")
|
|
878
920
|
span = child_traceparent(meta.get("traceparent"))
|
|
879
921
|
method = message["method"]
|
|
880
922
|
if method == "initialize":
|
|
@@ -893,7 +935,9 @@ class McpToolServer:
|
|
|
893
935
|
if method != "tools/call":
|
|
894
936
|
return self._error(rid, -32601, "Method %s is not served here." % method)
|
|
895
937
|
name = params.get("name")
|
|
896
|
-
arguments = params.get("arguments")
|
|
938
|
+
arguments = {} if params.get("arguments") is None else params["arguments"]
|
|
939
|
+
if not isinstance(name, str) or not isinstance(arguments, dict):
|
|
940
|
+
return self._error(rid, -32602, "A tool call names its tool as a string and passes its arguments as a JSON object.")
|
|
897
941
|
if name in ("fabric.job.get", "fabric.job.cancel") and self.jobs is not None:
|
|
898
942
|
verdict = None
|
|
899
943
|
if self._authorize is not None:
|
|
@@ -936,7 +980,10 @@ class McpToolServer:
|
|
|
936
980
|
if name == "fabric.job.cancel":
|
|
937
981
|
state = self.jobs.get(job_id)
|
|
938
982
|
if state["status"] not in TERMINAL:
|
|
939
|
-
|
|
983
|
+
try:
|
|
984
|
+
state = self.jobs.cancel(job_id)
|
|
985
|
+
except InteropError: # it ended meanwhile: answer as it ended
|
|
986
|
+
state = self.jobs.get(job_id)
|
|
940
987
|
return tool_result({"job": state}, traceparent=job_span)
|
|
941
988
|
responses = arguments.get("inputResponses")
|
|
942
989
|
if responses:
|