@passioncode-ai/fabric-agent-adapter 0.39.0 → 0.40.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 +126 -1
- package/README.md +25 -17
- package/bin/fabric-agent-adapter.js +6 -6
- 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 +13 -13
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +56 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
- 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 +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +12 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +11 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +6 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +8 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +26 -26
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +10 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +13 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -209
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +3 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2521
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +16 -181
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +22 -44
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3430
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +33 -33
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +27 -27
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +31 -31
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +9 -9
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +8 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A support agent on the service kit: it answers one inbound question from a knowledge file, or hands it to a person.
|
|
3
|
+
|
|
4
|
+
support_agent.py answer --config config.json --message-id m-1 --text "How do I reset my password?"
|
|
5
|
+
|
|
6
|
+
For each message it:
|
|
7
|
+
|
|
8
|
+
1. returns the decision it already made for that message id (idempotent: a repeat costs nothing and never sends a
|
|
9
|
+
second reply);
|
|
10
|
+
2. finds the knowledge entries the question shares words with; none found is a handoff (`no-knowledge`);
|
|
11
|
+
3. drafts a reply through fabric_openrouter (its own key, the spend ceiling, a usage receipt per call, a fallback of
|
|
12
|
+
another vendor); no answer, the ceiling or a refused key is a handoff (`model:<kind>`);
|
|
13
|
+
4. accepts the draft only when code proves it: it cites at least one entry, every cited entry was given to it, it
|
|
14
|
+
carries no em dash (U+2014) and no dash substitute, it is written in the question's script, and it fits the length
|
|
15
|
+
limit. Anything else is a handoff, with the failed checks named;
|
|
16
|
+
5. records the decision (0600, atomic) and, for a handoff, appends it to the outbox the operator's channel reads.
|
|
17
|
+
|
|
18
|
+
What the agent does not do: send the reply anywhere (the service that calls it owns its channel), judge facts with a
|
|
19
|
+
model, or answer outside the knowledge file. The model's text is data: a citation it invents fails the check.
|
|
20
|
+
|
|
21
|
+
The kit modules (fabric_openrouter and its closure) sit beside this file in a service
|
|
22
|
+
(`fabric_kit.py install <dir> --module fabric_openrouter`); in this repository they are read from ../../scripts.
|
|
23
|
+
Standard library only. FAA-65 REQ-09.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
import threading
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional
|
|
35
|
+
|
|
36
|
+
if str(Path(__file__).resolve().parent) not in sys.path: # the kit installed beside it, under `python3 -I` too
|
|
37
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
38
|
+
try:
|
|
39
|
+
import fabric_openrouter
|
|
40
|
+
except ImportError: # in the adapter's repository: the kit is two folders up
|
|
41
|
+
sys.path.append(str(Path(__file__).resolve().parents[2] / "scripts"))
|
|
42
|
+
import fabric_openrouter
|
|
43
|
+
import fabric_paths
|
|
44
|
+
import fabric_usage
|
|
45
|
+
|
|
46
|
+
EM_DASH = "\u2014"
|
|
47
|
+
_DASHES = re.compile(r"[\u2012\u2014\u2015\u2e3a\u2e3b\ufe58]|--|(?<=[^\W\d_])[ \u00a0][-\u2013]\s(?=[^\W\d_])")
|
|
48
|
+
_CITATION = re.compile(r"\[KB:([A-Za-z0-9][A-Za-z0-9._-]{0,63})\]")
|
|
49
|
+
_WORD = re.compile(r"\w+", re.UNICODE)
|
|
50
|
+
_URL = re.compile(r"https?://\S+")
|
|
51
|
+
_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
|
|
52
|
+
SCRIPTS = (
|
|
53
|
+
("latin", ((0x41, 0x5A), (0x61, 0x7A), (0xC0, 0x24F), (0x1E00, 0x1EFF))),
|
|
54
|
+
("cyrillic", ((0x400, 0x52F),)),
|
|
55
|
+
("greek", ((0x370, 0x3FF),)),
|
|
56
|
+
("hebrew", ((0x590, 0x5FF),)),
|
|
57
|
+
("arabic", ((0x600, 0x6FF), (0x750, 0x77F))),
|
|
58
|
+
("devanagari", ((0x900, 0x97F),)),
|
|
59
|
+
("thai", ((0xE00, 0xE7F),)),
|
|
60
|
+
("cjk", ((0x3040, 0x30FF), (0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xAC00, 0xD7AF))),
|
|
61
|
+
)
|
|
62
|
+
STOPWORDS = frozenset("a an and are as at be by can do does for from how i in is it my of on or the to what when "
|
|
63
|
+
"where which who why will with you your и в во на не что как я мой моя мне с по для из к "
|
|
64
|
+
"у о а но ли же это".split())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RecipeError(Exception):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def script_of(text: str) -> Optional[str]:
|
|
72
|
+
"""The script most of a text's letters are written in (citations and URLs left out), or None without letters."""
|
|
73
|
+
counts: Dict[str, int] = {}
|
|
74
|
+
for ch in _URL.sub(" ", _CITATION.sub(" ", text)):
|
|
75
|
+
code = ord(ch)
|
|
76
|
+
for name, ranges in SCRIPTS:
|
|
77
|
+
if any(lo <= code <= hi for lo, hi in ranges):
|
|
78
|
+
counts[name] = counts.get(name, 0) + 1
|
|
79
|
+
break
|
|
80
|
+
return max(counts, key=lambda k: (counts[k], k)) if counts else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def words(text: str) -> set:
|
|
84
|
+
return {w for w in (m.lower() for m in _WORD.findall(text)) if len(w) > 1 and w not in STOPWORDS}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Knowledge:
|
|
88
|
+
"""Entries `{id, question, answer, tags?}` from a JSON file (`{"entries": [...]}`); ids are unique."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, entries: List[Mapping[str, Any]]):
|
|
91
|
+
self.entries: Dict[str, Dict[str, Any]] = {}
|
|
92
|
+
for entry in entries:
|
|
93
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("id"), str) or not _ID.fullmatch(entry["id"]):
|
|
94
|
+
raise RecipeError("every knowledge entry has an id of letters, digits, '.', '_', ':' or '-'")
|
|
95
|
+
if not isinstance(entry.get("answer"), str) or not entry["answer"].strip():
|
|
96
|
+
raise RecipeError("knowledge entry %s has no answer" % entry["id"])
|
|
97
|
+
if entry["id"] in self.entries:
|
|
98
|
+
raise RecipeError("knowledge entry %s appears twice" % entry["id"])
|
|
99
|
+
self.entries[entry["id"]] = dict(entry)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def load(cls, path: Path) -> "Knowledge":
|
|
103
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
104
|
+
return cls(data.get("entries", []) if isinstance(data, dict) else [])
|
|
105
|
+
|
|
106
|
+
def lookup(self, question: str, limit: int = 3) -> List[Dict[str, Any]]:
|
|
107
|
+
"""The entries sharing the most words with the question (its words against each entry's question, tags and
|
|
108
|
+
answer), best first, ties by id; entries sharing none are left out."""
|
|
109
|
+
asked = words(question)
|
|
110
|
+
scored = []
|
|
111
|
+
for entry in self.entries.values():
|
|
112
|
+
text = " ".join([str(entry.get("question", "")), " ".join(map(str, entry.get("tags", []))),
|
|
113
|
+
str(entry["answer"])])
|
|
114
|
+
score = len(asked & words(text))
|
|
115
|
+
if score:
|
|
116
|
+
scored.append((-score, entry["id"], entry))
|
|
117
|
+
return [entry for _, _, entry in sorted(scored)[:limit]]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_reply(draft: str, question: str, given: List[str], *, max_chars: int) -> Dict[str, Any]:
|
|
121
|
+
"""What code can prove about a draft: `{ok, problems, citations, reply}`; `reply` is the text without markers."""
|
|
122
|
+
problems = []
|
|
123
|
+
cited = list(dict.fromkeys(_CITATION.findall(draft)))
|
|
124
|
+
if not cited:
|
|
125
|
+
problems.append("no-citation")
|
|
126
|
+
unknown = [c for c in cited if c not in given]
|
|
127
|
+
if unknown:
|
|
128
|
+
problems.append("unknown-citation:" + ",".join(unknown))
|
|
129
|
+
if _DASHES.search(draft):
|
|
130
|
+
problems.append("dash")
|
|
131
|
+
reply = re.sub(r"[ \t]+", " ", _CITATION.sub("", draft)).replace(" .", ".").replace(" ,", ",").strip()
|
|
132
|
+
if not reply:
|
|
133
|
+
problems.append("empty")
|
|
134
|
+
elif len(reply) > max_chars:
|
|
135
|
+
problems.append("too-long")
|
|
136
|
+
wanted, written = script_of(question), script_of(reply)
|
|
137
|
+
if wanted and written and wanted != written:
|
|
138
|
+
problems.append("script:%s-not-%s" % (written, wanted))
|
|
139
|
+
return {"ok": not problems, "problems": problems, "citations": cited, "reply": reply}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
SYSTEM = (
|
|
143
|
+
"You are a support agent. Answer the customer's question using only the knowledge entries given below. "
|
|
144
|
+
"Cite every entry you rely on as [KB:<id>] right after the sentence it supports. If the entries do not answer "
|
|
145
|
+
"the question, reply exactly: HANDOFF. Write in the language of the question. Write plainly and briefly. "
|
|
146
|
+
"Never use an em dash or any dash between clauses: use a full stop, a comma, a colon or parentheses."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_messages(question: str, entries: List[Dict[str, Any]]) -> List[Dict[str, str]]:
|
|
151
|
+
kb = "\n\n".join("[KB:%s]\nQ: %s\nA: %s" % (e["id"], e.get("question", ""), e["answer"]) for e in entries)
|
|
152
|
+
return [{"role": "system", "content": SYSTEM + "\n\nKnowledge entries:\n\n" + kb},
|
|
153
|
+
{"role": "user", "content": question}]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class SupportAgent:
|
|
157
|
+
def __init__(self, *, knowledge: Knowledge, client: Any, model: str, fallback: str, state_dir: Path,
|
|
158
|
+
max_tokens: int = 600, max_chars: int = 1200, lookup_limit: int = 3,
|
|
159
|
+
on_handoff: Optional[Callable[[Dict[str, Any]], None]] = None):
|
|
160
|
+
self.knowledge, self.client = knowledge, client
|
|
161
|
+
self.model, self.fallback = model, fallback
|
|
162
|
+
self.state_dir = Path(state_dir)
|
|
163
|
+
self.max_tokens, self.max_chars, self.lookup_limit = max_tokens, max_chars, lookup_limit
|
|
164
|
+
self.on_handoff = on_handoff or self._outbox
|
|
165
|
+
self._lock = threading.Lock()
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def decisions_file(self) -> Path:
|
|
169
|
+
return self.state_dir / "decisions.json"
|
|
170
|
+
|
|
171
|
+
def _decisions(self) -> Dict[str, Any]:
|
|
172
|
+
try:
|
|
173
|
+
return json.loads(self.decisions_file.read_text(encoding="utf-8"))
|
|
174
|
+
except FileNotFoundError:
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
def _outbox(self, decision: Dict[str, Any]) -> None:
|
|
178
|
+
fabric_paths.ensure_private_dir(self.state_dir)
|
|
179
|
+
line = json.dumps(decision, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
180
|
+
fd = os.open(str(self.state_dir / "handoffs.jsonl"), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
181
|
+
try:
|
|
182
|
+
os.write(fd, line.encode("utf-8"))
|
|
183
|
+
finally:
|
|
184
|
+
os.close(fd)
|
|
185
|
+
|
|
186
|
+
def answer(self, message_id: str, text: str, *, task_run_id: Optional[str] = None) -> Dict[str, Any]:
|
|
187
|
+
"""The decision for one message: `{messageId, action: reply|handoff, reply?, citations, reasons, costUsd}`."""
|
|
188
|
+
if not isinstance(message_id, str) or not _ID.fullmatch(message_id):
|
|
189
|
+
raise RecipeError("message id: letters, digits, '.', '_', ':' or '-', up to 128")
|
|
190
|
+
if not isinstance(text, str) or not text.strip():
|
|
191
|
+
raise RecipeError("the message has no text")
|
|
192
|
+
text = re.sub("[\ud800-\udfff]", "\ufffd", text) # a lone surrogate (bytes a terminal could not decode) is kept, not fatal
|
|
193
|
+
with self._lock: # one decision per message id, in this process; the service's instance lock covers others
|
|
194
|
+
done = self._decisions().get(message_id)
|
|
195
|
+
if done is not None:
|
|
196
|
+
return dict(done, repeated=True)
|
|
197
|
+
decision = self._decide(message_id, text, task_run_id)
|
|
198
|
+
if decision["action"] == "handoff": # before the decision is saved: a crash repeats a handoff, never loses one
|
|
199
|
+
self.on_handoff(dict(decision, question=text))
|
|
200
|
+
decisions = self._decisions()
|
|
201
|
+
decisions[message_id] = decision
|
|
202
|
+
fabric_paths.ensure_private_dir(self.state_dir)
|
|
203
|
+
fabric_paths.atomic_write(self.decisions_file,
|
|
204
|
+
json.dumps(decisions, ensure_ascii=False, indent=1).encode("utf-8"))
|
|
205
|
+
return dict(decision, repeated=False)
|
|
206
|
+
|
|
207
|
+
def _decide(self, message_id: str, text: str, task_run_id: Optional[str]) -> Dict[str, Any]:
|
|
208
|
+
base = {"messageId": message_id, "citations": [], "costUsd": None}
|
|
209
|
+
entries = self.knowledge.lookup(text, self.lookup_limit)
|
|
210
|
+
if not entries:
|
|
211
|
+
return dict(base, action="handoff", reasons=["no-knowledge"])
|
|
212
|
+
try:
|
|
213
|
+
got = self.client.chat(build_messages(text, entries), model=self.model, fallback=self.fallback,
|
|
214
|
+
max_tokens=self.max_tokens, temperature=0, task_run_id=task_run_id)
|
|
215
|
+
except fabric_openrouter.OpenRouterError as err:
|
|
216
|
+
return dict(base, action="handoff", reasons=["model:" + err.kind])
|
|
217
|
+
base["costUsd"] = got["receipt"].get("costUsd")
|
|
218
|
+
if got["content"].strip() == "HANDOFF":
|
|
219
|
+
return dict(base, action="handoff", reasons=["model-unsure"])
|
|
220
|
+
checked = check_reply(got["content"], text, [e["id"] for e in entries], max_chars=self.max_chars)
|
|
221
|
+
if not checked["ok"]:
|
|
222
|
+
return dict(base, action="handoff", reasons=checked["problems"], citations=checked["citations"])
|
|
223
|
+
return dict(base, action="reply", reply=checked["reply"], citations=checked["citations"], reasons=[])
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def from_config(config_path: Path, **overrides: Any) -> SupportAgent:
|
|
227
|
+
"""An agent from a config file (see config.example.json): paths are relative to the config's folder."""
|
|
228
|
+
config = json.loads(Path(config_path).read_text(encoding="utf-8"))
|
|
229
|
+
home = Path(config_path).resolve().parent
|
|
230
|
+
state = (home / config.get("stateDir", "state")).resolve()
|
|
231
|
+
prices = config.get("prices") or {}
|
|
232
|
+
if config.get("catalog"):
|
|
233
|
+
prices = dict(fabric_openrouter.prices_from_catalog(json.loads((home / config["catalog"]).read_text("utf-8"))),
|
|
234
|
+
**prices)
|
|
235
|
+
client = fabric_openrouter.Client(ceiling_usd=float(config["ceilingUsd"]), window=config.get("window", "day"),
|
|
236
|
+
ledger=fabric_usage.JsonlUsageLedger(state / "usage.jsonl"), prices=prices,
|
|
237
|
+
key_env=config.get("keyEnv", "OPENROUTER_API_KEY"),
|
|
238
|
+
project_id=config.get("projectId"), **overrides)
|
|
239
|
+
return SupportAgent(knowledge=Knowledge.load(home / config["knowledge"]), client=client, model=config["model"],
|
|
240
|
+
fallback=config["fallback"], state_dir=state, max_tokens=int(config.get("maxTokens", 600)),
|
|
241
|
+
max_chars=int(config.get("maxChars", 1200)))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
245
|
+
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
246
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
247
|
+
ask = sub.add_parser("answer", help="decide one message and print the decision as JSON")
|
|
248
|
+
ask.add_argument("--config", required=True, type=Path)
|
|
249
|
+
ask.add_argument("--message-id", required=True)
|
|
250
|
+
ask.add_argument("--text", help="the question; read from stdin when absent")
|
|
251
|
+
args = parser.parse_args(argv)
|
|
252
|
+
try:
|
|
253
|
+
agent = from_config(args.config)
|
|
254
|
+
text = args.text if args.text is not None else sys.stdin.read()
|
|
255
|
+
decision = agent.answer(args.message_id, text)
|
|
256
|
+
except (RecipeError, fabric_openrouter.OpenRouterError, OSError, ValueError, KeyError) as err:
|
|
257
|
+
print(json.dumps({"error": type(err).__name__, "message": str(err)}), file=sys.stderr)
|
|
258
|
+
return 1
|
|
259
|
+
print(json.dumps(decision, ensure_ascii=False, indent=1))
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
sys.exit(main())
|
|
@@ -63,12 +63,12 @@ One tick every 15 s ± 5 s (`step()` answers the wait):
|
|
|
63
63
|
|
|
64
64
|
| Step | Board call | Checkpoint written first |
|
|
65
65
|
|---|---|---|
|
|
66
|
-
| hold the slot | `com.enroll` (again at about 20 s while idle: the holder's enroll renews its 60 s lease) |
|
|
66
|
+
| hold the slot | `com.enroll` (again at about 20 s while idle: the holder's enroll renews its 60 s lease) | none |
|
|
67
67
|
| take a request | `com.claim`, the same key while nothing is claimable | `claimed` |
|
|
68
|
-
| read it | `com.get`: the digest must equal the claim's, and the request must name this Project and capability |
|
|
68
|
+
| read it | `com.get`: the digest must equal the claim's, and the request must name this Project and capability | none |
|
|
69
69
|
| accept | `com.accept` with the claim's exact fence (request, generation, attempt, digest, revision) | `accepting`, then `accepted` |
|
|
70
70
|
| act | `com.effect_begin`, only when the handler calls `ctx.begin_effect()` | `effect-beginning`, then `in-progress` |
|
|
71
|
-
| while working | `com.renew` at about 20 s; `com.progress` for `ctx.progress(note)` |
|
|
71
|
+
| while working | `com.renew` at about 20 s; `com.progress` for `ctx.progress(note)` | none |
|
|
72
72
|
| answer | `com.reply` (fenced) when the outcome carries `reply` | `replying` |
|
|
73
73
|
| settle | `com.complete {state, observed, summary?, evidence?}` | `completing`, then the entry is dropped |
|
|
74
74
|
|
|
@@ -82,12 +82,12 @@ own monotonic clock and never reasons from `leaseExpiresAt`.
|
|
|
82
82
|
`admit(request)` is required: it is the service's own input gate. It answers `None` to admit or one sentence saying
|
|
83
83
|
why not; a refusal accepts the request and completes it `failed_known` with that sentence, so the requester learns it
|
|
84
84
|
and the request does not bounce. A redacted body is refused the same way. The kit never hands a body to a model, a
|
|
85
|
-
shell or a tool; what the handler does with an admitted body
|
|
86
|
-
spend sees it
|
|
85
|
+
shell or a tool; what the handler does with an admitted body (and the checks it needs before a model, a tool or a
|
|
86
|
+
spend sees it) is the service's own responsibility. A message never carries an approval.
|
|
87
87
|
|
|
88
88
|
## Effects, observation, evidence and the reply
|
|
89
89
|
|
|
90
|
-
- Call `ctx.begin_effect(summary)
|
|
90
|
+
- Call `ctx.begin_effect(summary)`, and let it return, before the handler spends, writes outside the service or calls
|
|
91
91
|
anything that acts. If it raises `Stopped`, act no further.
|
|
92
92
|
- After an effect began, the outcome must be observed (`observed: true`): the kit never sends an unobserved result; the
|
|
93
93
|
request stays unknown and is reconciled later.
|
|
@@ -130,7 +130,7 @@ contract's matrix, held to its vectors (`test/fixtures/contract/comms-hold-vecto
|
|
|
130
130
|
| `com.enroll`, `com.claim`, `com.responder_replace` | the slot pauses: asked again after 15 s, then 30 s, doubling to 15 minutes; one `board.slot.held` event per capability, and `board.slot.released` when an enroll succeeds again |
|
|
131
131
|
| `com.accept` (the hold came after the claim) | nothing was done: the entry is dropped, the claim lapses and the request waits on the board (`board.request.waiting`); the slot pauses |
|
|
132
132
|
| `com.effect_begin` | the effect has not begun: the handler waits (same idempotency key, the pause doubling) while the tick keeps renewing the lease, then begins once the hold is lifted; `ctx.stopped` ends the wait |
|
|
133
|
-
| accepted work at restart under a hold | kept as `lapsed`, then taken back with `com.responder_replace` and a transfer once the hold is lifted
|
|
133
|
+
| accepted work at restart under a hold | kept as `lapsed`, then taken back with `com.responder_replace` and a transfer once the hold is lifted, never claimed back |
|
|
134
134
|
|
|
135
135
|
A hold is not an outage: no `board.down`, no notify. Work already accepted keeps running and completes.
|
|
136
136
|
|
|
@@ -143,7 +143,7 @@ whose responder was gone. Read it as the owner's attestation, not the responder'
|
|
|
143
143
|
A board session (`fabric.board.join`) is served the respond scope only: `com.status`, `com.list`, `com.get`,
|
|
144
144
|
`com.reply` (the fenced form), `com.enroll`, `com.claim`, `com.renew`, `com.accept`, `com.progress`,
|
|
145
145
|
`com.effect_begin`, `com.complete`, `com.reconcile`, `com.responder_replace`. `BoardSession.call` refuses anything else
|
|
146
|
-
locally (`not-in-scope`)
|
|
146
|
+
locally (`not-in-scope`): never `com.submit`, `com.cancel`, `com.read_ack` or `com.directory`.
|
|
147
147
|
|
|
148
148
|
The idempotency epoch comes from `com.status`. A Fabric built before its board-session fix tells a board session none:
|
|
149
149
|
the kit fails closed (`no-epoch`, `board.blocked`) instead of guessing one.
|
|
@@ -152,13 +152,13 @@ the kit fails closed (`no-epoch`, `board.blocked`) instead of guessing one.
|
|
|
152
152
|
|
|
153
153
|
The directory belongs to a Project session (a coding agent's, or a launched one). Before looking a Project up in a
|
|
154
154
|
`com.directory` page, `find_project(page, project_id, capability=None)` / `findProject` checks the page: closed shapes
|
|
155
|
-
(no count, holder or generation) and FAC-SEM-107
|
|
155
|
+
(no count, holder or generation) and FAC-SEM-107: each Project once, each capability once per Project. Project ids and
|
|
156
156
|
capability names compare **exactly as written** (DEC-0077; they are not URIs, so not by normal form). A page that fails
|
|
157
157
|
raises `directory-invalid` rather than answering; an unlisted Project may still be addressed by an id already held.
|
|
158
158
|
|
|
159
159
|
## Events, the probe and the live smoke
|
|
160
160
|
|
|
161
|
-
Events go to `emit(kind, level, text, notify=…, data=…)`
|
|
161
|
+
Events go to `emit(kind, level, text, notify=…, data=…)` (`JsonlEventLog.append` fits) with `data {schema, value}`
|
|
162
162
|
checked by FAC-SEM-101 (no credential in data): `board.request.completed`, `board.request.refused`,
|
|
163
163
|
`board.request.skipped`, `board.request.reconciled`, `board.request.held`, `board.attempt.lost`,
|
|
164
164
|
`board.reconcile.needed`, `board.slot.contested`, `board.access.needed`, `board.blocked`, `board.down`, each
|
|
@@ -167,10 +167,10 @@ notifying episode said once.
|
|
|
167
167
|
`check_service.py <id> [--board-kit <the service's fabric_board.py or fabric-board.mjs>]` rule **board.responder**:
|
|
168
168
|
`NOT_RUN` when the well-known `protocols` names no `fabric-project-comms` revision; `FAIL` for a revision this kit does
|
|
169
169
|
not read or a fixture conversation that goes wrong; `PASS` when the kit's `self_check()` / `selfCheck()` passes (the
|
|
170
|
-
whole conversation, a fenced accept, a session without an epoch
|
|
170
|
+
whole conversation, a fenced accept, a session without an epoch; no Fabric touched).
|
|
171
171
|
|
|
172
172
|
The live smoke runs only against a disposable Fabric (built app and its own stack), never an operator's estate: Fabric's
|
|
173
|
-
COM-03 packet (`docs/evidence/plans/2026-10-04-project-communications.md`) has the exact commands
|
|
173
|
+
COM-03 packet (`docs/evidence/plans/2026-10-04-project-communications.md`) has the exact commands: register
|
|
174
174
|
`fabric-mcp` for Claude Code and Codex, the operator allows `session.enroll` and `board.respond`
|
|
175
175
|
(`board:<example-agent project>/<capability>`), Claude submits a request, the responder enrolls, claims, accepts and
|
|
176
176
|
completes, then is killed mid-claim and replaced. Without that Fabric, report it `NOT_RUN`.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Connectors: outbound HTTP and OpenRouter
|
|
2
|
+
|
|
3
|
+
Two kit modules a service uses to reach the outside: `fabric_http` for any HTTP API, `fabric_openrouter` for models.
|
|
4
|
+
Both have Node twins (`fabric-http.mjs`, `fabric-openrouter.mjs`) that run the same cases
|
|
5
|
+
(`test/fixtures/kit-http-cases.json`, `test/fixtures/kit-openrouter-cases.json`). Install them with
|
|
6
|
+
`fabric_kit.py install <dir> --module fabric_openrouter` ([kit modules](kit-modules.md)). The project board has its
|
|
7
|
+
own connector ([board](board.md)). Standard library only; tests inject the transport and never open the network.
|
|
8
|
+
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
- [fabric_http](#fabric_http)
|
|
12
|
+
- [fabric_openrouter](#fabric_openrouter)
|
|
13
|
+
- [Writing a connector for another API](#writing-a-connector-for-another-api)
|
|
14
|
+
|
|
15
|
+
## fabric_http
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import fabric_http
|
|
19
|
+
answer = fabric_http.request("GET", "https://api.example/v1/items", headers={"Authorization": "Bearer " + token},
|
|
20
|
+
deadline_s=20, retries=2)
|
|
21
|
+
answer.status, answer.headers["content-type"], answer.json()
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import { request } from './fabric-http.mjs';
|
|
26
|
+
const answer = await request('GET', 'https://api.example/v1/items', { headers, deadlineS: 20, retries: 2 });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Rule | Default | Why |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| One deadline covers every attempt and every wait | `deadline_s` 30 | a caller's own deadline is one number, not one per retry |
|
|
32
|
+
| Retried: 408, 425, 429, 500, 502, 503, 504 and connection errors | `retries` 2 (0 to 10) | transient failures |
|
|
33
|
+
| Only when repeating is safe: `GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`, or an `Idempotency-Key` header; any other request only on 429 | | a second `POST` after a timeout may do the work twice |
|
|
34
|
+
| Wait before retry n: `min(max_backoff_s, backoff_base_s * 2**n) * (0.5 + random())` | 0.5 s base, 8 s cap | jitter spreads retries of many clients |
|
|
35
|
+
| `Retry-After` (seconds, or an IMF-fixdate) replaces the wait; over `max_retry_after_s` the answer stands | 30 s | the server knows when it recovers |
|
|
36
|
+
| A wait that would pass the deadline is not taken: the last answer or error stands | | |
|
|
37
|
+
| The body is read to one byte past `max_bytes`; over it is an error | 8 MiB | a runaway answer never fills memory |
|
|
38
|
+
| Redirects are answered, never followed | | a redirect must not carry the `Authorization` header to another host |
|
|
39
|
+
| Plain `http://` is refused unless the host is loopback or `insecure=True`; credentials in a URL are refused | | a token never crosses a network in clear, nor sits in a URL |
|
|
40
|
+
| Header names must be tokens; a value with a line break or NUL is refused | | header injection |
|
|
41
|
+
| A JSON body is written as `JSON.stringify` writes it: a lone surrogate escaped, NaN and Infinity refused | | both twins send the same bytes |
|
|
42
|
+
| Whatever the transport throws is a connection error, in both languages; a timeout is a timeout | | |
|
|
43
|
+
| The deadline is held across the whole response: a watchdog shuts the socket when it passes, so trickled headers or a trickled chunk cannot hold the caller | | |
|
|
44
|
+
| `Response.json()` reads as `JSON.parse` does: a byte order mark skipped, `NaN` and `Infinity` refused | | |
|
|
45
|
+
|
|
46
|
+
Every answer comes back as a `Response`, whatever its status; `HttpError` means no usable answer came, with `kind`
|
|
47
|
+
`connection`, `timeout`, `too-large` or `refused`, the status when there was one, and the URL redacted to scheme,
|
|
48
|
+
host, port and path (`redact_url`). An error never carries a request header or body.
|
|
49
|
+
|
|
50
|
+
## fabric_openrouter
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import fabric_openrouter, fabric_usage
|
|
54
|
+
client = fabric_openrouter.Client(ceiling_usd=2.0, window="day",
|
|
55
|
+
ledger=fabric_usage.JsonlUsageLedger(state / "usage.jsonl"),
|
|
56
|
+
prices=fabric_openrouter.prices_from_catalog(catalog))
|
|
57
|
+
answer = client.chat(messages, model="deepseek/deepseek-v4.1-flash", fallback="qwen/qwen3.8-flash", max_tokens=800)
|
|
58
|
+
answer["content"], answer["receipt"]["costUsd"], answer["attempts"]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
const client = new Client({ ceilingUsd: 2, ledger: new JsonlUsageLedger(file), prices: pricesFromCatalog(catalog) });
|
|
63
|
+
const answer = await client.chat(messages, { model, fallback, maxTokens: 800 });
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Rule | What happens |
|
|
67
|
+
|---|---|
|
|
68
|
+
| The key | read from the environment variable `key_env` names (`OPENROUTER_API_KEY`) at each call; sent only in `Authorization`; missing is `OpenRouterError` kind `config` before anything is sent |
|
|
69
|
+
| The ceiling, before every attempt | the window's spend (ledger receipts in the UTC `day` or `month`) plus calls in flight plus this attempt's worst case must stay within `ceiling_usd`; otherwise kind `ceiling`, nothing sent. Retries and the fallback are checked the same way |
|
|
70
|
+
| The worst case | the input at one token per two UTF-8 bytes of the messages and every param (tools, response_format) as compact JSON (`prompt_tokens_bound`), `max_tokens` of output, at the model's price, plus a per-request price. `models`, `route`, `plugins`, `web_search_options` and `n` are refused (they change what is billed), and so is `max_completion_tokens` (the client sets the output budget) |
|
|
71
|
+
| A model without a price | never called (kind `unpriced`): its spend could not be bounded. Prices come from `GET https://openrouter.ai/api/v1/models` through `prices_from_catalog`; a price is a plain decimal string or a number, and anything else (blank, hex, a list) is no price |
|
|
72
|
+
| A receipt without a cost in the window | an unmeasured attempt, or an answer that came with no usage, counted at the worst case of the attempt that wrote it while this client runs; one written before it started counts as `unmeasured_call_usd`, by default the current attempt's worst case |
|
|
73
|
+
| Receipts | every status-200 answer records one, and so does an attempt that may have been billed without an answer to read (a timeout, a cut or refused connection, a 200 over the size cap or not JSON: no tokens, `costUsd: null`, `tokensMeasured: false`; over-counting a refused connection is the safe side): the served model, the provider's tokens (cache reads and writes kept apart), `costUsd` from `usage.cost`, else from the price list (`costBasis: price-list`), else `null` with `tokensMeasured: false`; `route: key`, the client's `project_id` and the call's `task_run_id`. A request the provider answered with a 4xx or 5xx records nothing: it was not billed |
|
|
74
|
+
| What is an answer | `finish_reason` `stop` or `tool_calls` with content or tool calls; `error` is retried on the same model; `length` (the budget ran out, reasoning included), `content_filter` and empty content go to the fallback |
|
|
75
|
+
| Retries | 408, 425, 429, 500, 502, 503, 504 and connection errors on the same model, `retries` times (default 1), `Retry-After` honoured, within one deadline (`deadline_s` 120) for the whole call |
|
|
76
|
+
| Stop at once | 401, 402 and 403 (kinds `auth`, `payment`): a key, credit or permission problem is the same on every model |
|
|
77
|
+
| The fallback | required, of another vendor (the part before `/` differs) |
|
|
78
|
+
| Content | text only: a string, null, or parts that are all `{"type": "text"}`; an image, audio or file part is refused (`config`), because what it is billed cannot be bounded from its bytes |
|
|
79
|
+
| What the ceiling does not cover | the `provider` routing param can send a call to a provider priced above the catalog; two `Client` objects sharing one ledger each hold only their own calls in flight, so give an agent one client per key |
|
|
80
|
+
| Attribution | off unless an `attribution` setting is given (FAC-SEM-098, `fabric_usage.attribution_headers`) |
|
|
81
|
+
| Request body | `model`, `messages`, `max_tokens`, `usage: {include: true}` and your params (temperature, reasoning, response_format, tools); `stream`, `model`, `messages`, `max_tokens` and `usage` as params are refused |
|
|
82
|
+
|
|
83
|
+
`OpenRouterError.attempts` lists every attempt as `{model, status, outcome}` (`answered`, `retry`, `error`,
|
|
84
|
+
`length`, `filtered`, `empty`, `refused`, `bad-answer`, `auth`, `payment`); nothing in it holds the key or the
|
|
85
|
+
prompt. `totals(receipts)` gives calls, priced spend and unpriced calls for a dashboard line. Choosing the models
|
|
86
|
+
themselves: creating-fabric-agents, `references/models-and-providers.md`.
|
|
87
|
+
|
|
88
|
+
## Writing a connector for another API
|
|
89
|
+
|
|
90
|
+
Build it on `fabric_http.request`: one module per API, the key read from a named environment variable at call time,
|
|
91
|
+
the deadline and retries passed through, the provider's own answer mapped into your types, and a receipt through
|
|
92
|
+
`fabric_usage.make_usage_receipt` for every billed call. Put it in your service, not in the kit, unless several
|
|
93
|
+
services need it; a kit module also needs its Node twin and shared cases ([adding a module](kit-modules.md#adding-or-changing-a-module)).
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# The dashboard kit
|
|
1
|
+
# The dashboard kit: `fabric-dashboard/0.1`
|
|
2
2
|
|
|
3
3
|
Fabric Dashboards shows a service's page in a pane beside its sidebar and the agent console, and the
|
|
4
4
|
operator drags the console wider whenever they need it. The page never knows how much room it will
|
|
@@ -97,12 +97,12 @@ service. Load it after the stylesheet, with ids only:
|
|
|
97
97
|
scroll. The mark and the word *Fabric*, the mark alone below 480 px; the accessible name is always *Open Fabric*;
|
|
98
98
|
`--ink` on `--panel` with an `--edge` boundary, the pairs the themes are measured on.
|
|
99
99
|
- **Links (FL-1…FL-6):** built with the kit's port of the contract's `buildFabricLink`; a value outside the grammar
|
|
100
|
-
|
|
100
|
+
(or one shaped like a credential) means no button, never a bad link. A press opens a view in Fabric and starts
|
|
101
101
|
nothing (FB-8).
|
|
102
102
|
- **The host (FB-6):** when the host's `fabric_button` cookie says `owner=host`, the page hides its own button.
|
|
103
103
|
- **Fabric absent (FB-5):** when the cookie says `fabric=absent`, or `data-fabric="absent"`, or when the page is still
|
|
104
|
-
visible and focused 1.5 s after a link was followed, the menu says
|
|
105
|
-
passioncode.ai/fabric
|
|
104
|
+
visible and focused 1.5 s after a link was followed, the menu says that Fabric is not installed and names
|
|
105
|
+
passioncode.ai/fabric to get it. Nothing downloads, nothing retries. A local service's server decides `data-fabric` with
|
|
106
106
|
`fabric_button_presence()` / `fabricButtonPresence()`: `present` while Fabric's own descriptor
|
|
107
107
|
(`services/fabric.default.json`, Fabric ADR-0139) is registered and, for an app's descriptor (`manager: "person"`,
|
|
108
108
|
DEC-0069), its app is still installed; `absent` otherwise. A press only follows a link, so the button never reads
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md
CHANGED
|
@@ -4,10 +4,10 @@ The dashboard is where the operator decides; it must stay truthful when parts of
|
|
|
4
4
|
service are not.
|
|
5
5
|
|
|
6
6
|
1. **Same origin, static assets.** HTML shell plus hashed `app.js`/`app.css` from a
|
|
7
|
-
whitelist; no inline script; a strict CSP. Nothing from a CDN
|
|
7
|
+
whitelist; no inline script; a strict CSP. Nothing from a CDN: the page must work
|
|
8
8
|
offline on a plane.
|
|
9
9
|
2. **State first, then detail.** The top of every page answers "is it working, and
|
|
10
|
-
does it need me?"
|
|
10
|
+
does it need me?": the same `status`, `degraded` and attention tiles the
|
|
11
11
|
well-known document publishes, so the page and Fabric Dashboards never disagree.
|
|
12
12
|
3. **Calm refresh.** Poll the page's own API every 5 s while visible, back off when
|
|
13
13
|
hidden, and pause while a field is focused, a form has unsaved input or a dialog is
|
|
@@ -16,7 +16,7 @@ service are not.
|
|
|
16
16
|
4. **Partial failure stays partial.** Load panels independently (`Promise.allSettled`);
|
|
17
17
|
a failed panel shows its own one-sentence error and a retry, and the rest render.
|
|
18
18
|
5. **Every error is a sentence and an action.** "Store listing push failed: App Store
|
|
19
|
-
Connect rejected the key. Settings → Keys"
|
|
19
|
+
Connect rejected the key. Settings → Keys", never a status code, a stack trace or a
|
|
20
20
|
blank area. Show when data was last fresh.
|
|
21
21
|
6. **Server down is a state, not a crash.** When the API stops answering, keep the
|
|
22
22
|
last data greyed with "Service not answering since 17:02", and recover by itself
|
|
@@ -30,4 +30,4 @@ service are not.
|
|
|
30
30
|
9. **Links into the page are stable.** Event `link`s point at hash routes that survive
|
|
31
31
|
a reload (`/dashboard#/approvals/77`) so a notification opens the exact item.
|
|
32
32
|
10. **No second browser tab required.** With Fabric Dashboards installed, the page is
|
|
33
|
-
shown inside it; do not open windows or tabs on your own
|
|
33
|
+
shown inside it; do not open windows or tabs on your own; use in-page dialogs.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Delegation
|
|
1
|
+
# Delegation: checking who acts for whom (DEC-0039)
|
|
2
2
|
|
|
3
3
|
A service that other agents of the estate call checks each call offline with the kit's verifier: the estate hub
|
|
4
4
|
signs a delegation token naming the person (`sub`), the actor chain (`act`), this service (`aud`), the
|
|
@@ -22,7 +22,7 @@ contract's own verifier does.
|
|
|
22
22
|
## Declaring it
|
|
23
23
|
|
|
24
24
|
In the well-known document: `surfaces.mcp.auth: "delegation"` (only delegation tokens) and
|
|
25
|
-
`auth.delegation: {issuer, jwks}
|
|
25
|
+
`auth.delegation: {issuer, jwks}`: the `iss` you trust (`urn:fabric:estate:<id>`) and where the key set is read.
|
|
26
26
|
The descriptor's token stays the host's: it is never a caller's credential on `/mcp` (DL-12). The probe checks
|
|
27
27
|
the declaration (`auth.delegation-declared`) and that `/mcp` refuses the descriptor token.
|
|
28
28
|
|
|
@@ -34,7 +34,7 @@ keys = fd.JwksCache(well_known["auth"]["delegation"]["jwks"]) # https,
|
|
|
34
34
|
gate = fd.delegation_gate(audience="example-agent", jwks=keys, issuer=well_known["auth"]["delegation"]["issuer"], # required
|
|
35
35
|
effects={"example.write": "draft", "example.publish": "publish"}) # each tool's manifest effect
|
|
36
36
|
server = fi.McpToolServer("example-agent", VERSION, authorize=gate) # pass the request headers to handle()
|
|
37
|
-
# in a handler: ctx.delegation["principal"], ["caller"], ["chain"]
|
|
37
|
+
# in a handler: ctx.delegation["principal"], ["caller"], ["chain"]; record the chain with the work
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
```js
|
|
@@ -50,7 +50,7 @@ if (!verdict.ok) return refusalToolResult(verdict);
|
|
|
50
50
|
DL-25 places each caller in a tier by its origin: **T0** a loopback caller under the owner's account that the
|
|
51
51
|
service's own local floor (the descriptor token, the Host guard) has authenticated; **T1** an enrolled machine of
|
|
52
52
|
the same owner; **T2** everyone else, and any call relayed onto loopback from off the machine (a tunnel, a proxy, the
|
|
53
|
-
hub forwarding a T2 caller). DL-26: a T0 or T1 caller is never required to present a token
|
|
53
|
+
hub forwarding a T2 caller). DL-26: a T0 or T1 caller is never required to present a token; pass the tier,
|
|
54
54
|
`gate(tool, headers, tier="T0")` or `server.handle(message, headers, tier="T0")`, and the call passes with no
|
|
55
55
|
delegation. A token such a caller presents is still verified and refused when invalid. A T2 caller takes the whole
|
|
56
56
|
profile. Money limits and human gates bind at every tier.
|
|
@@ -66,8 +66,10 @@ job records who started it.
|
|
|
66
66
|
**A partner in the chain** (DEC-0090). A token a partner's agent presents for a customer organization carries the
|
|
67
67
|
partner as the innermost actor: `{partner: "org:<id>"}`, exactly that member, under at least one agent. It counts
|
|
68
68
|
toward the depth limit and ends the chain; outermost, with an `act` beneath, or as `personal` it is
|
|
69
|
-
`malformed-chain`. A node with a `sub` beside `partner` is an agent,
|
|
70
|
-
|
|
69
|
+
`malformed-chain`. A node with a `sub` beside `partner` is checked as an agent, yet `ctx.delegation["chain"]` lists it
|
|
70
|
+
by its `org:` id, as the contract's `chainOf` does; the schema refuses such a node, so a correct issuer never mints one
|
|
71
|
+
(raised with the contract, FAA-64). Job ownership and idempotency keys follow `(principal, caller)` and do not include
|
|
72
|
+
the partner (FAA-64). `chain_depth(act)` /
|
|
71
73
|
`chainDepth(act)` answer the contract's depth, infinity for a chain that is not one. Minting a partner's token (its
|
|
72
74
|
grant, FAC-SEM-124) is the issuer's, not the kit's.
|
|
73
75
|
|
|
@@ -76,19 +78,19 @@ grant, FAC-SEM-124) is the issuer's, not the kit's.
|
|
|
76
78
|
An action with an external effect may need the operator's approval (DL-17, DL-18). The order is fixed: the token,
|
|
77
79
|
then the grant check where there is one, then `verify_approval` / `verifyApproval`, which consumes the approval's
|
|
78
80
|
`jti` last, then the action. Without an approval, answer `approval-required` with
|
|
79
|
-
`action_digest(action)` / `actionDigest(action)`
|
|
81
|
+
`action_digest(action)` / `actionDigest(action)` (the SHA-256 of the action's RFC 8785 form, `jcs`) so the caller
|
|
80
82
|
can ask the hub. When the meter denies a grant check, answer `refusal_from_denial(denial)` /
|
|
81
83
|
`refusalFromDenial(denial)`: the meter's code unchanged, its window and level in `error.data` (DL-21 step 6). Keep consumed ids in `FileJtiStore` (0600, survives a restart); `MemoryJtiStore` is for tests
|
|
82
84
|
and `UNSAFE_NO_JTI_STORE` exists only by name. No store at all, or a store file that cannot be read, is
|
|
83
|
-
`unknown-state
|
|
85
|
+
`unknown-state`, never a pass.
|
|
84
86
|
|
|
85
87
|
## What a refusal looks like
|
|
86
88
|
|
|
87
|
-
DL-19: an MCP tool answers `refusal_tool_result(r)` / `refusalToolResult(r)
|
|
89
|
+
DL-19: an MCP tool answers `refusal_tool_result(r)` / `refusalToolResult(r)`: `isError: true` and
|
|
88
90
|
`structuredContent: {error: {code, message, data: {detail}}}`; an HTTP route answers `refusal_http(r)` /
|
|
89
91
|
`refusalHttp(r)` with the code's status (401 for `unauthenticated`, `token-expired`, `principal-unproven`, with
|
|
90
92
|
`WWW-Authenticate: Bearer error="invalid_token"`; 503 for `issuer-unavailable` and `unknown-state`; 403 for the
|
|
91
|
-
rest). A published key the service cannot use
|
|
93
|
+
rest). A published key the service cannot use (of small order, for another use, or published twice) is the
|
|
92
94
|
issuer's fault, `issuer-unavailable` (DEC-0045); a gate without a trusted issuer refuses everything as
|
|
93
95
|
`no-trusted-issuer`. `detail` is a short machine word; the token, the approval and the keys never appear in a result or a log.
|
|
94
96
|
|
|
@@ -120,4 +122,4 @@ current verified revision as `estate=` / `{ estate }`, as a value or as a functi
|
|
|
120
122
|
## What the kit does not do
|
|
121
123
|
|
|
122
124
|
It does not mint tokens, check attenuation against a parent (the issuer's rule, DL-5), check a grant or a spend
|
|
123
|
-
window (the meter's, DL-21), or decide which actions need an approval
|
|
125
|
+
window (the meter's, DL-21), or decide which actions need an approval; the service decides that.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
# The estate record
|
|
1
|
+
# The estate record: what the operator's machines are, signed by their passkey (DEC-0055)
|
|
2
2
|
|
|
3
3
|
Fabric Agent Contract DEC-0055, `docs/specification/estate-record.md`, schema `schemas/estate-record.schema.json`,
|
|
4
|
-
rules `FAC-SEM-072…076`, with contexts (DEC-0082, `docs/specification/contexts.md`, `FAC-SEM-110`, `FAC-SEM-111`)
|
|
4
|
+
rules `FAC-SEM-072…076`, with contexts (DEC-0082, `docs/specification/contexts.md`, `FAC-SEM-110`, `FAC-SEM-111`) and a
|
|
5
|
+
partner's scoped publisher key (DEC-0090, `docs/specification/partner-channel.md`, `FAC-SEM-122`).
|
|
5
6
|
One revision of one person's estate lists:
|
|
6
7
|
- the passkeys that sign it;
|
|
7
8
|
- the machines, with their device keys and a certificate of each machine's hub key;
|
|
@@ -78,13 +79,13 @@ gate = fd.delegation_gate(audience=..., jwks=keys, issuer=..., effects=..., esta
|
|
|
78
79
|
estate_revocations=lambda: delta_entries)
|
|
79
80
|
```
|
|
80
81
|
|
|
81
|
-
- **The token's fault**
|
|
82
|
-
key or machine
|
|
82
|
+
- **The token's fault** (no such certification, another key under that `kid`, a `kid` on two machines, a revoked hub
|
|
83
|
+
key or machine) is `unauthenticated` (for an approval, `approval-invalid`) with detail `kid-not-certified`.
|
|
83
84
|
- **The trust source's fault** is `issuer-unavailable`: `estate-unreadable` (a source that throws or answers nothing
|
|
84
85
|
readable, a revision without a number or digest, a window of zero or over 30 days, dates read strictly, `machines` or
|
|
85
86
|
`revocations` not arrays), `estate-outside-window`, `estate-rolled-back` (lower than the applied revision) and
|
|
86
87
|
`estate-forked` (the applied number with another digest). A caller cannot cure these by minting again.
|
|
87
|
-
- An unreadable clock
|
|
88
|
+
- An unreadable clock (not a finite number of seconds a `Date` can hold) is refused `unknown-state` `no-clock`
|
|
88
89
|
before row 1, in every verifier.
|
|
89
90
|
- Only an absent `estate` skips the check. In the Node kit `null` is an unreadable source; in Python, where `None` is
|
|
90
91
|
the absent value, pass a function returning `None` for a source that has nothing.
|
|
@@ -117,6 +118,11 @@ of `[A-Za-z0-9][A-Za-z0-9_-]{0,127}`. Absent is `personal`; `null` is not absent
|
|
|
117
118
|
names the keys that are a partner's (`kid`, `partner: org:<id>`, `contexts`); a placement under such a key is in one
|
|
118
119
|
of the organization contexts listed, never `personal` or another organization. A key is scoped once, and only a key
|
|
119
120
|
`publisherKeys` pins. A key no scope names keeps DEC-0051's meaning, trusted in any context.
|
|
121
|
+
- **Update a copied kit before a revision carries `publisherScopes`.** A service runs the copy of the kit it was built
|
|
122
|
+
with; a copy older than 0.39.0 does not read `publisherScopes`, so it trusts a partner's key in every context and
|
|
123
|
+
finds no rule broken. A service that applies the estate record updates its copy first:
|
|
124
|
+
`fabric_kit.py install <dir> --module fabric_estate` (or `--lang node --module fabric-estate`). Since 0.40.0 the
|
|
125
|
+
estate reader imports the kit's core modules, so the one file alone no longer loads ([kit modules](kit-modules.md)).
|
|
120
126
|
|
|
121
127
|
Every rule is total on hostile JSON, as the contract's are: an `instancePath` names an item by its index as written (a
|
|
122
128
|
non-object before it does not shift it), and a value whose own `toString` member is data is shown as its JSON. Both kits
|