@passioncode-ai/fabric-agent-adapter 0.61.0 → 0.63.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 +201 -0
- package/README.md +6 -3
- package/SECURITY.md +3 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +4 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +11 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +56 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/channel-agent.example.json +103 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +160 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +6 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +18 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +18 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +55 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +193 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +42 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +5 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/practices.md +46 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +16 -15
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +7 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +209 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +34 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +29 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +362 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +35 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +4 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +8 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"https://fabric.passioncode.ai/agent-contract/extensions/channel-agent/0.1": {
|
|
3
|
+
"standard": "channel-agent-standard/1",
|
|
4
|
+
"projects": [
|
|
5
|
+
{
|
|
6
|
+
"project": "project:example-shop",
|
|
7
|
+
"side": "example-side",
|
|
8
|
+
"lead_agent": "example-agent",
|
|
9
|
+
"adopted_at": "2026-11-10",
|
|
10
|
+
"taxonomy": {
|
|
11
|
+
"version": "2026-11-04",
|
|
12
|
+
"codes": [
|
|
13
|
+
{
|
|
14
|
+
"code": "orders.status",
|
|
15
|
+
"label": "Where is my order"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"code": "payments.refund",
|
|
19
|
+
"label": "A refund"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"tiers": [
|
|
24
|
+
{
|
|
25
|
+
"code": "premium",
|
|
26
|
+
"label": "Premium",
|
|
27
|
+
"rule": "open_orders > 10",
|
|
28
|
+
"escalate": "at_once"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"question_route": {
|
|
32
|
+
"capability": "operator.ask",
|
|
33
|
+
"target": "00000000-0000-4000-8000-000000000001",
|
|
34
|
+
"daily_cap": 10,
|
|
35
|
+
"days": "mon-fri",
|
|
36
|
+
"tz": "Europe/Warsaw",
|
|
37
|
+
"send_at": "09:00",
|
|
38
|
+
"expires": "P7D"
|
|
39
|
+
},
|
|
40
|
+
"gap_registry": {
|
|
41
|
+
"none": "not_yet",
|
|
42
|
+
"until": "2026-11-30"
|
|
43
|
+
},
|
|
44
|
+
"channels": [
|
|
45
|
+
{
|
|
46
|
+
"id": "mail",
|
|
47
|
+
"kind": "mail",
|
|
48
|
+
"replies_enabled": true,
|
|
49
|
+
"decision": null,
|
|
50
|
+
"final_limit": "P7D",
|
|
51
|
+
"first_reply_limit": "PT24H",
|
|
52
|
+
"holding_reply": true,
|
|
53
|
+
"clarifying_question": true,
|
|
54
|
+
"quiet_window": null,
|
|
55
|
+
"late_answer_window": null,
|
|
56
|
+
"items_seen_unit": "mails",
|
|
57
|
+
"texts": {
|
|
58
|
+
"holding": {
|
|
59
|
+
"en": "Thank you, we are looking into it and will answer here."
|
|
60
|
+
},
|
|
61
|
+
"final": {
|
|
62
|
+
"en": "Here is the answer to your question. Write again if anything is still unclear."
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
],
|
|
67
|
+
"sources": [
|
|
68
|
+
{
|
|
69
|
+
"id": "knowledge",
|
|
70
|
+
"owner": "example-agent",
|
|
71
|
+
"read": "file",
|
|
72
|
+
"cadence": "PT1H",
|
|
73
|
+
"max_age": "PT2H"
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
"declined_sources": [],
|
|
77
|
+
"handoff_queues": [
|
|
78
|
+
{
|
|
79
|
+
"id": "support-mailbox",
|
|
80
|
+
"owner": "example-team",
|
|
81
|
+
"limit": "P2D",
|
|
82
|
+
"answered_signal": "none",
|
|
83
|
+
"recommendation": "5d1f0a3c-8b2e-4c7a-9e6f-1a2b3c4d5e6f"
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"evals": {
|
|
87
|
+
"suite": "test/evals/support",
|
|
88
|
+
"judge_budget_usd": 0,
|
|
89
|
+
"replay_budget_usd": 0,
|
|
90
|
+
"spend_record": null
|
|
91
|
+
},
|
|
92
|
+
"tickets": {
|
|
93
|
+
"outbox": "state/tickets-outbox.jsonl",
|
|
94
|
+
"token_slot": "example-shop/prod/SIGNAL_TICKETS_TOKEN",
|
|
95
|
+
"pd_check": {
|
|
96
|
+
"none": "not_used"
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"recorded_decisions": []
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -24,8 +24,9 @@ For each message it:
|
|
|
24
24
|
What the agent does not do: send the reply anywhere (the service that calls it owns its channel), judge facts with a
|
|
25
25
|
model, or answer outside the knowledge file. The model's text is data: a citation it invents fails the check.
|
|
26
26
|
|
|
27
|
-
The kit modules
|
|
28
|
-
(`fabric_kit.py install <dir> --module fabric_openrouter
|
|
27
|
+
The kit modules sit beside this file in a service, installed as the README's "Install into a service" says
|
|
28
|
+
(`fabric_kit.py install <dir> --lang py --module fabric_openrouter --module fabric_usage --module fabric_paths
|
|
29
|
+
--module fabric_connections`); in this repository they are read from ../../scripts.
|
|
29
30
|
Standard library only. FAA-65 REQ-09.
|
|
30
31
|
"""
|
|
31
32
|
from __future__ import annotations
|
|
@@ -38,8 +39,9 @@ import re
|
|
|
38
39
|
import secrets
|
|
39
40
|
import sys
|
|
40
41
|
import threading
|
|
42
|
+
import time
|
|
41
43
|
from pathlib import Path
|
|
42
|
-
from typing import Any, Callable, Dict, List, Mapping, Optional
|
|
44
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple, Union
|
|
43
45
|
|
|
44
46
|
if str(Path(__file__).resolve().parent) not in sys.path: # the kit installed beside it, under `python3 -I` too
|
|
45
47
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
@@ -127,6 +129,25 @@ def words(text: str) -> set:
|
|
|
127
129
|
return {w for w in (m.lower() for m in _WORD.findall(text)) if len(w) > 1 and w not in STOPWORDS}
|
|
128
130
|
|
|
129
131
|
|
|
132
|
+
#: How deep the knowledge file may nest (the file, its entries list, an entry, a member): far past any real entry.
|
|
133
|
+
MAX_KNOWLEDGE_DEPTH = 32
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _depth(value: Any) -> int:
|
|
137
|
+
"""How deep a parsed JSON value nests, walked without recursion."""
|
|
138
|
+
deepest, stack = 0, [(value, 1)]
|
|
139
|
+
while stack:
|
|
140
|
+
item, level = stack.pop()
|
|
141
|
+
deepest = max(deepest, level)
|
|
142
|
+
if level > MAX_KNOWLEDGE_DEPTH:
|
|
143
|
+
return level
|
|
144
|
+
if isinstance(item, dict):
|
|
145
|
+
stack.extend((child, level + 1) for child in item.values())
|
|
146
|
+
elif isinstance(item, list):
|
|
147
|
+
stack.extend((child, level + 1) for child in item)
|
|
148
|
+
return deepest
|
|
149
|
+
|
|
150
|
+
|
|
130
151
|
class Knowledge:
|
|
131
152
|
"""Entries `{id, question, answer, tags?}` from a JSON file (`{"entries": [...]}`); ids are unique."""
|
|
132
153
|
|
|
@@ -137,14 +158,28 @@ class Knowledge:
|
|
|
137
158
|
raise RecipeError("every knowledge entry has an id of letters, digits, '.', '_', ':' or '-'")
|
|
138
159
|
if not isinstance(entry.get("answer"), str) or not entry["answer"].strip():
|
|
139
160
|
raise RecipeError("knowledge entry %s has no answer" % entry["id"])
|
|
161
|
+
if "question" in entry and not isinstance(entry["question"], str):
|
|
162
|
+
raise RecipeError("knowledge entry %s has a question that is not text" % entry["id"])
|
|
163
|
+
tags = entry.get("tags", [])
|
|
164
|
+
if not isinstance(tags, list) or not all(isinstance(tag, str) for tag in tags):
|
|
165
|
+
raise RecipeError("knowledge entry %s has tags that are not a list of text" % entry["id"])
|
|
140
166
|
if entry["id"] in self.entries:
|
|
141
167
|
raise RecipeError("knowledge entry %s appears twice" % entry["id"])
|
|
142
168
|
self.entries[entry["id"]] = dict(entry)
|
|
143
169
|
|
|
144
170
|
@classmethod
|
|
145
171
|
def load(cls, path: Path) -> "Knowledge":
|
|
146
|
-
|
|
147
|
-
|
|
172
|
+
try:
|
|
173
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
174
|
+
except RecursionError:
|
|
175
|
+
raise RecipeError("the knowledge file is nested too deep to read") from None
|
|
176
|
+
except ValueError: # not JSON at all: refused like every other wrong shape, its content never quoted
|
|
177
|
+
raise RecipeError("the knowledge file %s is not JSON" % Path(path).name) from None
|
|
178
|
+
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
|
|
179
|
+
raise RecipeError("the knowledge file is an object with an `entries` list") # never an empty set by mistake
|
|
180
|
+
if _depth(data) > MAX_KNOWLEDGE_DEPTH: # the same verdict on every Python, whatever its parser's own limit
|
|
181
|
+
raise RecipeError("the knowledge file is nested too deep to read")
|
|
182
|
+
return cls(data["entries"])
|
|
148
183
|
|
|
149
184
|
def lookup(self, question: str, limit: int = 3) -> List[Dict[str, Any]]:
|
|
150
185
|
"""The entries sharing the most words with the question (its words against each entry's question, tags and
|
|
@@ -160,6 +195,52 @@ class Knowledge:
|
|
|
160
195
|
return [entry for _, _, entry in sorted(scored)[:limit]]
|
|
161
196
|
|
|
162
197
|
|
|
198
|
+
class KnowledgeSource:
|
|
199
|
+
"""The knowledge file as a declared source (the channel-agent standard's 7.1 and 7.4, FAA-197): read again when the
|
|
200
|
+
file changes, the last good version kept when a read fails (`degraded` until a good one), and `stale` past
|
|
201
|
+
`max_age_s`. The age is the last good version's: from its file's modification time, so a file nobody rebuilt ages
|
|
202
|
+
however often it is looked at, and a restart does not make old content new. The calling service shows `state()`
|
|
203
|
+
and opens one alert when it is stale."""
|
|
204
|
+
|
|
205
|
+
def __init__(self, path: Path, *, max_age_s: int, clock: Callable[[], float] = time.time):
|
|
206
|
+
if isinstance(max_age_s, bool) or not isinstance(max_age_s, int) or max_age_s < 1:
|
|
207
|
+
raise RecipeError("max_age_s is a whole number of seconds, at least 1")
|
|
208
|
+
self.path, self.max_age_s, self._clock = Path(path), max_age_s, clock
|
|
209
|
+
self._knowledge: Optional[Knowledge] = None
|
|
210
|
+
self._good_mtime = 0.0
|
|
211
|
+
self._seen: Optional[Tuple[int, int, int, int]] = None
|
|
212
|
+
self._degraded = False
|
|
213
|
+
|
|
214
|
+
def current(self) -> Knowledge:
|
|
215
|
+
try: # the time, the size, the inode and the status-change time, which no copy or `touch -r` can set back: a
|
|
216
|
+
# rewrite in one coarse tick, a copy that kept the time, or (on POSIX with fine timestamps) one in place
|
|
217
|
+
# with the same size still shows; on Windows st_ctime is the creation time and catches no such rewrite
|
|
218
|
+
stat = self.path.stat()
|
|
219
|
+
seen: Optional[Tuple[int, int, int, int]] = (stat.st_mtime_ns, stat.st_size, stat.st_ino, stat.st_ctime_ns)
|
|
220
|
+
except OSError:
|
|
221
|
+
seen = None
|
|
222
|
+
if self._knowledge is not None and seen is not None and seen == self._seen:
|
|
223
|
+
return self._knowledge # unchanged since this version was read (or failed to read)
|
|
224
|
+
try:
|
|
225
|
+
knowledge = Knowledge.load(self.path)
|
|
226
|
+
except (OSError, ValueError, RecursionError, RecipeError) as err: # RecursionError: JSON nested too deep
|
|
227
|
+
if self._knowledge is None:
|
|
228
|
+
if isinstance(err, RecipeError): # read, and refused: its message names the entry (ids are safe text)
|
|
229
|
+
raise
|
|
230
|
+
raise RecipeError("the knowledge file %s could not be read (%s)" % (self.path.name,
|
|
231
|
+
err.__class__.__name__))
|
|
232
|
+
self._seen, self._degraded = seen, True
|
|
233
|
+
else:
|
|
234
|
+
self._knowledge, self._seen, self._degraded = knowledge, seen, False
|
|
235
|
+
self._good_mtime = seen[0] / 1e9 if seen is not None else self._clock()
|
|
236
|
+
return self._knowledge
|
|
237
|
+
|
|
238
|
+
def state(self) -> Dict[str, Any]:
|
|
239
|
+
age = max(0, int(self._clock() - self._good_mtime)) if self._knowledge is not None else None
|
|
240
|
+
return {"id": "knowledge", "age_s": age, "max_age_s": self.max_age_s,
|
|
241
|
+
"stale": age is None or age > self.max_age_s, "degraded": self._degraded}
|
|
242
|
+
|
|
243
|
+
|
|
163
244
|
def _currency(unit: str) -> str:
|
|
164
245
|
unit = unit.lower()
|
|
165
246
|
return "rub" if unit.startswith("руб") else _CURRENCY.get(unit, unit)
|
|
@@ -240,6 +321,22 @@ SYSTEM = (
|
|
|
240
321
|
)
|
|
241
322
|
|
|
242
323
|
|
|
324
|
+
#: The channel-agent standard's 4.4 (FAA-197): a draft the checks refused is rewritten once with the refusal's reason.
|
|
325
|
+
REPAIR_NOTE = ("Your reply was refused by the checks: {reasons}. Write it again from the same entries only, keeping "
|
|
326
|
+
"every rule above, or answer exactly HANDOFF.")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def repairable(decision: Any) -> bool:
|
|
330
|
+
"""A hand-off the code checks made, with the draft they refused: no-knowledge, model-unsure and model:* are not."""
|
|
331
|
+
if not isinstance(decision, Mapping) or decision.get("action", "handoff") != "handoff" or decision.get("fallback"):
|
|
332
|
+
return False # a fallback already had its one repair
|
|
333
|
+
reasons = decision.get("reasons")
|
|
334
|
+
draft = decision.get("draft")
|
|
335
|
+
return (isinstance(draft, str) and bool(draft.strip()) and isinstance(reasons, list) and bool(reasons)
|
|
336
|
+
and all(isinstance(r, str) and r != "no-knowledge" and r != "model-unsure" and not r.startswith("model:")
|
|
337
|
+
for r in reasons))
|
|
338
|
+
|
|
339
|
+
|
|
243
340
|
def entry_text(entry: Mapping[str, Any]) -> str:
|
|
244
341
|
return "\n".join([str(entry.get("question", "")), str(entry["answer"]), " ".join(map(str, entry.get("tags", [])))])
|
|
245
342
|
|
|
@@ -356,7 +453,7 @@ def prune(state_dir: Path, keep_days: int, *, now: Optional[datetime] = None) ->
|
|
|
356
453
|
|
|
357
454
|
|
|
358
455
|
class SupportAgent:
|
|
359
|
-
def __init__(self, *, knowledge: Knowledge, client: Any, model: str, fallback: str, state_dir: Path,
|
|
456
|
+
def __init__(self, *, knowledge: Union[Knowledge, "KnowledgeSource"], client: Any, model: str, fallback: str, state_dir: Path,
|
|
360
457
|
max_tokens: int = 600, max_chars: int = 1200, lookup_limit: int = 3,
|
|
361
458
|
on_handoff: Optional[Callable[[Dict[str, Any]], None]] = None):
|
|
362
459
|
self.knowledge, self.client = knowledge, client
|
|
@@ -371,6 +468,22 @@ class SupportAgent:
|
|
|
371
468
|
self._local = threading.local()
|
|
372
469
|
self._log = DecisionLog(self.state_dir)
|
|
373
470
|
|
|
471
|
+
def connections(self) -> List[Dict[str, Any]]:
|
|
472
|
+
"""FAA-199: what the agent uses, for `connections_extension` in the well-known document of the service that runs
|
|
473
|
+
it (DEC-0096); that service adds its own hand-off route and channels."""
|
|
474
|
+
# A model id that is not one (empty, missing, with a space) is left out, so a misconfigured fallback degrades the
|
|
475
|
+
# replies, never the well-known document (0.62.0 seam reading 1).
|
|
476
|
+
named = [("model", self.model, "drafts replies from the knowledge file"),
|
|
477
|
+
("fallback", self.fallback, "drafts when the first model fails")]
|
|
478
|
+
import fabric_connections # the service that declares them installs it (README); the agent runs without it
|
|
479
|
+
kept = []
|
|
480
|
+
for ident, target, purpose in named: # by the connections rule itself, never a second copy of it (seam reading 2)
|
|
481
|
+
item = {"id": ident, "kind": "model", "target": target, "purpose": purpose}
|
|
482
|
+
probe = {"extensions": {fabric_connections.CONNECTIONS_EXTENSION_KEY: {"connections": [dict(item, state="unknown")]}}}
|
|
483
|
+
if not fabric_connections.connections_rules(probe):
|
|
484
|
+
kept.append(item)
|
|
485
|
+
return kept
|
|
486
|
+
|
|
374
487
|
@property
|
|
375
488
|
def decisions_file(self) -> Path:
|
|
376
489
|
return self._log.path
|
|
@@ -378,7 +491,8 @@ class SupportAgent:
|
|
|
378
491
|
def _outbox(self, decision: Dict[str, Any]) -> None:
|
|
379
492
|
# The outbox keeps the customer's own text until it is pruned: see `prune` and the README's retention note.
|
|
380
493
|
fabric_paths.ensure_private_dir(self.state_dir)
|
|
381
|
-
|
|
494
|
+
kept = {k: v for k, v in decision.items() if k != "draft"} # a person reads the question and the reasons
|
|
495
|
+
line = json.dumps(kept, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
382
496
|
_append_line(self.state_dir / "handoffs.jsonl", line.encode("utf-8"))
|
|
383
497
|
|
|
384
498
|
def prune(self, keep_days: int, *, now: Optional[datetime] = None) -> Dict[str, int]:
|
|
@@ -388,12 +502,22 @@ class SupportAgent:
|
|
|
388
502
|
self._log = DecisionLog(self.state_dir)
|
|
389
503
|
return removed
|
|
390
504
|
|
|
391
|
-
def answer(self, message_id: str, text: str, *, task_run_id: Optional[str] = None
|
|
392
|
-
|
|
505
|
+
def answer(self, message_id: str, text: str, *, task_run_id: Optional[str] = None,
|
|
506
|
+
repair: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
|
|
507
|
+
"""The decision for one message: `{messageId, action: reply|handoff, reply?, citations, reasons, costUsd}`; a
|
|
508
|
+
hand-off the checks made also carries the refused `draft`. `repair=` that decision (under a new message id)
|
|
509
|
+
rewrites the draft once with the refusal's reasons: a pass is a reply with `repaired: true`, and a repair that
|
|
510
|
+
does not end in a reply (a second refusal, a model `HANDOFF`, no knowledge on the retry; not a `model:*`
|
|
511
|
+
outage) a hand-off with `fallback: true` (the channel-agent standard's 4.4 and 13.2, FAA-197)."""
|
|
393
512
|
if not isinstance(message_id, str) or not _ID.fullmatch(message_id):
|
|
394
513
|
raise RecipeError("message id: letters, digits, '.', '_', ':' or '-', up to 128")
|
|
395
514
|
if not isinstance(text, str) or not text.strip():
|
|
396
515
|
raise RecipeError("the message has no text")
|
|
516
|
+
if repair is not None and not repairable(repair):
|
|
517
|
+
raise RecipeError("repair= takes a hand-off the checks made, with its draft and reasons: the first call's "
|
|
518
|
+
"decision or the on_handoff copy (a repeat returns no draft, and a fallback had its repair)")
|
|
519
|
+
if repair is not None and repair.get("messageId") == message_id:
|
|
520
|
+
raise RecipeError("repair= runs under a new message id: the refused one returns its stored decision")
|
|
397
521
|
if getattr(self._local, "in_handoff", False):
|
|
398
522
|
raise ReentryError("answer() was called from on_handoff: queue the hand-off and call answer() again after "
|
|
399
523
|
"this call returns")
|
|
@@ -402,7 +526,7 @@ class SupportAgent:
|
|
|
402
526
|
done = self._log.get(message_id)
|
|
403
527
|
if done is not None:
|
|
404
528
|
return dict(done, repeated=True)
|
|
405
|
-
decision = self._decide(message_id, text, task_run_id)
|
|
529
|
+
decision = self._decide(message_id, text, task_run_id, repair)
|
|
406
530
|
at = _now()
|
|
407
531
|
if decision["action"] == "handoff": # before the decision is saved: a crash repeats a handoff, never loses one
|
|
408
532
|
self._local.in_handoff = True
|
|
@@ -410,27 +534,38 @@ class SupportAgent:
|
|
|
410
534
|
self.on_handoff(dict(decision, question=text, at=_stamp(at)))
|
|
411
535
|
finally:
|
|
412
536
|
self._local.in_handoff = False
|
|
413
|
-
self._log.append(decision, at)
|
|
537
|
+
self._log.append({k: v for k, v in decision.items() if k != "draft"}, at) # the draft is never stored
|
|
414
538
|
return dict(decision, repeated=False)
|
|
415
539
|
|
|
416
|
-
def _decide(self, message_id: str, text: str, task_run_id: Optional[str]
|
|
540
|
+
def _decide(self, message_id: str, text: str, task_run_id: Optional[str],
|
|
541
|
+
repair: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
|
|
417
542
|
base = {"messageId": message_id, "citations": [], "costUsd": None}
|
|
418
|
-
|
|
543
|
+
knowledge = self.knowledge.current() if isinstance(self.knowledge, KnowledgeSource) else self.knowledge
|
|
544
|
+
entries = knowledge.lookup(text, self.lookup_limit)
|
|
419
545
|
if not entries:
|
|
420
|
-
|
|
546
|
+
gone = dict(base, action="handoff", reasons=["no-knowledge"])
|
|
547
|
+
return dict(gone, fallback=True) if repair is not None else gone
|
|
548
|
+
messages = build_messages(text, entries)
|
|
549
|
+
if repair is not None:
|
|
550
|
+
messages += [{"role": "assistant", "content": repair["draft"]},
|
|
551
|
+
{"role": "user", "content": REPAIR_NOTE.format(reasons=", ".join(repair["reasons"]))}]
|
|
421
552
|
try:
|
|
422
|
-
got = self.client.chat(
|
|
553
|
+
got = self.client.chat(messages, model=self.model, fallback=self.fallback,
|
|
423
554
|
max_tokens=self.max_tokens, temperature=0, task_run_id=task_run_id)
|
|
424
555
|
except fabric_openrouter.OpenRouterError as err:
|
|
425
556
|
return dict(base, action="handoff", reasons=["model:" + err.kind])
|
|
426
557
|
base["costUsd"] = got["receipt"].get("costUsd")
|
|
427
558
|
if got["content"].strip() == "HANDOFF":
|
|
428
|
-
|
|
559
|
+
unsure = dict(base, action="handoff", reasons=["model-unsure"])
|
|
560
|
+
return dict(unsure, fallback=True) if repair is not None else unsure # the repair was spent
|
|
429
561
|
checked = check_reply(got["content"], text, [e["id"] for e in entries], max_chars=self.max_chars,
|
|
430
562
|
sources={e["id"]: entry_text(e) for e in entries})
|
|
431
563
|
if not checked["ok"]:
|
|
432
|
-
|
|
433
|
-
|
|
564
|
+
refused = dict(base, action="handoff", reasons=checked["problems"], citations=checked["citations"],
|
|
565
|
+
draft=got["content"])
|
|
566
|
+
return dict(refused, fallback=True) if repair is not None else refused
|
|
567
|
+
replied = dict(base, action="reply", reply=checked["reply"], citations=checked["citations"], reasons=[])
|
|
568
|
+
return dict(replied, repaired=True) if repair is not None else replied
|
|
434
569
|
|
|
435
570
|
|
|
436
571
|
def from_config(config_path: Path, *, on_handoff: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
@@ -450,7 +585,13 @@ def from_config(config_path: Path, *, on_handoff: Optional[Callable[[Dict[str, A
|
|
|
450
585
|
ledger=fabric_usage.JsonlUsageLedger(state / "usage.jsonl"), prices=prices,
|
|
451
586
|
key_env=config.get("keyEnv", "OPENROUTER_API_KEY"),
|
|
452
587
|
project_id=config.get("projectId"), **overrides)
|
|
453
|
-
|
|
588
|
+
knowledge: Union[Knowledge, KnowledgeSource]
|
|
589
|
+
if config.get("knowledgeMaxAgeSeconds") is not None: # the file as a source with an age (FAA-197)
|
|
590
|
+
knowledge = KnowledgeSource(home / config["knowledge"], max_age_s=config["knowledgeMaxAgeSeconds"])
|
|
591
|
+
knowledge.current() # read once, now: a bad file fails here as a RecipeError
|
|
592
|
+
else:
|
|
593
|
+
knowledge = Knowledge.load(home / config["knowledge"])
|
|
594
|
+
return SupportAgent(knowledge=knowledge, client=client, model=config["model"],
|
|
454
595
|
fallback=config["fallback"], state_dir=state, max_tokens=int(config.get("maxTokens", 600)),
|
|
455
596
|
max_chars=int(config.get("maxChars", 1200)), on_handoff=on_handoff)
|
|
456
597
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md
CHANGED
|
@@ -45,8 +45,10 @@ const answer = await request('GET', 'https://api.example/v1/items', { headers, d
|
|
|
45
45
|
|
|
46
46
|
Every answer comes back as a `Response`, whatever its status; `HttpError` means no usable answer came, with `kind`
|
|
47
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`); a URL that is not http or https keeps only its scheme (`htps:<redacted
|
|
49
|
-
|
|
48
|
+
host, port and path (`redact_url`); a URL that is not http or https keeps only its scheme (`htps:<redacted>`, and `ssh:<redacted>`
|
|
49
|
+
for `ssh://h:99999/r` too), and an http, https, ws, wss or ftp URL that does not parse (Node reads it with `new URL`;
|
|
50
|
+
Python reads its own special-scheme form and prints what it finds, never the user information), or a value with no
|
|
51
|
+
scheme, is `<unparseable url>`. The path is not redacted (Node writes it as `new URL`
|
|
50
52
|
normalises it, Python as given), so a key never belongs in it. Python's `check_url` refuses a host
|
|
51
53
|
with a backslash, space, control character (C0, DEL or C1) or `%`, a URL with a space or ASCII control character before its
|
|
52
54
|
first `#` (the default transport sends a URL up to there, as Node's does; a transport you pass receives the URL as given), or a character outside ASCII in its path or query (ASCII whitespace at its ends is trimmed, as urllib and `new URL` trim it; a Unicode space ending the part before the first `#` is refused, where Node keeps it percent-encoded), a bracketed host that is not an IPv6 address with no zone, and a port that is not
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md
CHANGED
|
@@ -133,8 +133,8 @@ part before the first `#` is refused, as `check_url` refuses it, because Node ke
|
|
|
133
133
|
trim it and read the keys when it ends the URL); a leading space is refused as not a URL), a host holding `%`, a backslash, a Unicode space or a control character (urllib
|
|
134
134
|
would decode or dial it as another host), a bracketed host that is no IPv6 address, a port that is not ASCII digits or is above
|
|
135
135
|
65535, no host, and an empty userinfo. The Node kit accepts several of these as `new URL` reads them, among them a `%`
|
|
136
|
-
host decoded, a backslash as `/`, a tab or line break removed
|
|
137
|
-
|
|
136
|
+
host decoded, a backslash as `/`, a tab or line break removed and an extra slash before the host skipped. A user
|
|
137
|
+
information as written is refused by both kits, empty or holding a backslash (since 0.62.0). The
|
|
138
138
|
Python kit verifies Ed25519 itself (RFC 8032, standard library only, strict as OpenSSL: `S` below the group order,
|
|
139
139
|
canonical points, no small-order key); it follows no redirect when it reads the key set; the Node kit uses `node:crypto` and refuses small-order keys
|
|
140
140
|
itself (`isSmallOrderKey`), because Node before 26 lets OpenSSL accept a signature under one.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md
CHANGED
|
@@ -202,6 +202,8 @@ const { dirs, instance, lock } = await holdContextInstance('example-agent', 'org
|
|
|
202
202
|
`~/Library/Application Support/<id>.<instance>`, `~/Library/Logs/<id>.<instance>`, `~/Library/Caches/<id>.<instance>`),
|
|
203
203
|
never inside the personal tree. `launchd_uninstall(..., purge=True, service_id=..., instance=...)` /
|
|
204
204
|
`launchdUninstall(..., {purge, serviceId, instance})` purge only that instance's folders.
|
|
205
|
+
- **Its descriptor names its repository.** A new instance is a new install, so its descriptor passes
|
|
206
|
+
`source.repository` as the personal one does (FAA-198, since 0.62.0); without it the write is refused.
|
|
205
207
|
- **What it checks, before anything is created.** `context_instance` / `contextInstance` is pure and answers
|
|
206
208
|
`{context, kind, org?, instance, key, dirs, descriptorPath, grant?}`, never the record. An organization's instance
|
|
207
209
|
needs the estate record revision this machine applied (verified with `verify_estate_record`; the helper verifies no
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -86,7 +86,8 @@ that document until `service.id` matches (at most 40 s) before it calls the serv
|
|
|
86
86
|
|
|
87
87
|
1. Build or unpack the release into `~/.local/share/<id>/releases/<version>-<sha12>/`
|
|
88
88
|
(a venv or `node_modules` inside it). Never edit a release after it is written.
|
|
89
|
-
2. `write_descriptor` (port and id claims are checked here
|
|
89
|
+
2. `write_descriptor` (port and id claims are checked here, and a new descriptor without `source.repository` is
|
|
90
|
+
refused; a rewrite of an installed one warns, FAA-198).
|
|
90
91
|
3. `launchd_install` (Node: `await launchdInstall(...)`, the same steps): write + `plutil -lint`, read `launchctl print-disabled gui/<uid>`,
|
|
91
92
|
`enable` **only when no override is recorded** (a first install), `bootout` and wait
|
|
92
93
|
until `launchctl print gui/<uid>/<label>` fails, `bootstrap` with up to five retries,
|
|
@@ -150,7 +151,8 @@ never infers supervision from its parent process.
|
|
|
150
151
|
|
|
151
152
|
**An app** a person installs and starts has no supervisor (contract DEC-0069). Its descriptor says
|
|
152
153
|
`"lifecycle": {"manager": "person", "app": "<the installed bundle or executable>"}`, `installedBy {by: "person", role,
|
|
153
|
-
tool}
|
|
154
|
+
tool}`, `source.repository` (the app's own repository; its first write is refused without it, FAA-198) and no `origin`:
|
|
155
|
+
it says the app is installed, never that it runs. The installer writes it; where there is none
|
|
154
156
|
(a macOS app dragged into Applications), the app writes it on a start when `app_descriptor_action` /
|
|
155
157
|
`appDescriptorAction` answers `write` (no descriptor yet, another version in `installedBy.tool`, or another path than
|
|
156
158
|
`lifecycle.app`), and never from an App Translocation path. Its live origin and process id go in its own liveness file,
|
|
@@ -15,10 +15,12 @@ Order matters: each step leaves the service answering.
|
|
|
15
15
|
delete it with the operator's consent.
|
|
16
16
|
6. **Regenerate the plist** from `launchd_plist`, pointing at a release; delete stray
|
|
17
17
|
`.plist.bak` files from `~/Library/LaunchAgents` after moving them elsewhere.
|
|
18
|
-
7. **Write the descriptor** from the installer
|
|
19
|
-
moving the newcomer, never the established service.
|
|
20
|
-
8. **
|
|
21
|
-
|
|
18
|
+
7. **Write the descriptor** from the installer, with `source.repository` (a new descriptor without it is refused);
|
|
19
|
+
resolve any port claim it refuses by moving the newcomer, never the established service.
|
|
20
|
+
8. **Declare what it uses** with `connections_extension` / `connectionsExtension` in the well-known document; a
|
|
21
|
+
service that uses nothing outside itself declares an empty list (`connections_extension([])`), which reads `PASS`.
|
|
22
|
+
9. **Re-run the probe** until nothing `FAIL`s and `descriptor.source-repository` and `well-known.connections` read
|
|
23
|
+
`PASS` (a `NOT_RUN` is not a pass); record the table in the service's verification document.
|
|
22
24
|
|
|
23
25
|
## Gaps that existing services usually have
|
|
24
26
|
|
|
@@ -141,8 +141,8 @@ key, the partner and the organization contexts, and `placement_trust` / `placeme
|
|
|
141
141
|
one of those contexts and to no other (FAC-SEM-122, [the estate record reference](estate-record.md)). The partner's own
|
|
142
142
|
agents call the company's agents with the partner innermost in the delegation chain ([delegation](delegation.md)).
|
|
143
143
|
|
|
144
|
-
Still to come, each waiting on its owner: a partner's own space in a catalog and publishing to it (FAA-44,
|
|
145
|
-
PA-03).
|
|
144
|
+
Still to come, each waiting on its owner: a partner's own space in a catalog and publishing to it (FAA-44, the organization
|
|
145
|
+
edition's PA-03).
|
|
146
146
|
|
|
147
147
|
## What this version does not do
|
|
148
148
|
|
|
@@ -55,7 +55,8 @@ File `<id>.<instance>.json`, mode 0600 (on Windows a protected ACL), atomic.
|
|
|
55
55
|
| `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task), `node` (Project Observatory's node supervises it as its child, with no supervisor file, DEC-0061), `person` (an app a person installs and starts, then `app`, the installed app, DEC-0069) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032), `label` and `plist` beside any but `launchd` (DEC-0057), `app` beside any but `person`. Both kits and the probe accept all six; both kits write the unit and the task (`systemd_unit`, `task_xml` / `systemdUnit`, `taskXml`). An app's descriptor says it is installed, never that it runs: the probe checks `app` is still there (`descriptor.app-installed`) and reads no well-known document; a reader that finds the app gone treats the descriptor as stale (`app_descriptor_stale` / `appDescriptorStale`), and an app that writes its own descriptor rewrites it only when `app_descriptor_action` / `appDescriptorAction` says `write` |
|
|
56
56
|
| `paths` | `data` (required), `logs[]`, optional `config`, `cache` |
|
|
57
57
|
| `commands` | only `doctor` and `update`, argument arrays whose first item is an absolute or `~/` path (Windows: also `C:\…`, `C:/…` or `~\…`, never a network share; DEC-0032) |
|
|
58
|
-
| `source.repository
|
|
58
|
+
| `source.repository` | the repository of the service's code (`https://github.com/owner/name`), which links the agent to its project. Optional in the schema; the kit's `write_descriptor` / `writeDescriptor` refuse a new descriptor without it since 0.62.0 (`source_refusal` / `sourceRefusal` says why, FAA-198), and rewriting one already installed without it warns (`NoSourceRepositoryWarning`; Node `process.emitWarning` with code `FABRIC_NO_SOURCE_REPOSITORY`), so an installed agent's update keeps working. The probe names its absence (`descriptor.source-repository`, `NOT_RUN`) |
|
|
59
|
+
| `fabricManifest` | optional |
|
|
59
60
|
| `installedAt`, `installedBy` | when, and who installed it: the installer and its version as a string, or `{by, role?, tool}` (DEC-0061): `by` `person` (with `role` `operator` or `administrator`), `node` or `organization`; `role` for a person only; `tool` the installer and its version. It names a role, never a person. An app's descriptor uses the object form with `by: "person"`. `register_remote` / `registerRemote` and the sample write the object form |
|
|
60
61
|
| `runner` | optional `{kind?, credentialKind}` (DEC-0061): `credentialKind` `subscription`, `key`, `platform-metered` or `none`: a kind, never a credential (`FAC-SEM-086` refuses a credential-shaped value here or in `installedBy`); a subscription names its vendor's runner (`claude-code`, `kimi-code`, `codex`) |
|
|
61
62
|
| `peer` | optional `{endpoint, custody, residency, maxDataClass}` (DEC-0061): the service is offered to others. `endpoint` `https://<dns-name>[:<port>][/<path>]`, never a reserved name (`FAC-SEM-085`); `custody` `organization-managed`, `personal` or `processor-hosted` (an organization's cloud a processor runs under a processing agreement, DEC-0099); `residency` 1–32 ISO 3166-1 alpha-2 codes, `EU` admitted, user-assigned codes refused; `maxDataClass` `public` < `internal` < `confidential` < `restricted`. An offer needs `runner` and never runs on a subscription. A host sends a request from anyone but the operator only when `peer_refusal` / `peerRefusal` answers null (`FAC-SEM-088`); usage rows may take `route: "platform-metered"` |
|
|
@@ -127,7 +128,8 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
|
|
|
127
128
|
`connectionsExtension(declared, {degraded, states})`: `state` comes from the service's own checks (`states`, by
|
|
128
129
|
`id`), else `degraded` when `degraded[]` names it, else `unknown`; a declaration a host would hide is refused with
|
|
129
130
|
`ServiceError`, and `build_well_known` refuses one passed in by hand. `connections_view` / `connectionsView` read
|
|
130
|
-
a peer's list as Fabric Dashboards does, and `check_service.py` reports `well-known.connections
|
|
131
|
+
a peer's list as Fabric Dashboards does, and `check_service.py` reports `well-known.connections` (`NOT_RUN` when
|
|
132
|
+
the document has no connections declaration, FAA-199, and `PASS` for an empty list; the reference service declares its template and its demo source). Build it each
|
|
131
133
|
time the well-known document is built, from the same `degraded` list:
|
|
132
134
|
|
|
133
135
|
```python
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md
CHANGED
|
@@ -42,8 +42,18 @@ same objects (well-known document, events feed, login code) at the same paths.
|
|
|
42
42
|
- `commands` may carry `doctor` (a local executable); never `update`.
|
|
43
43
|
- `source.repository` names the repository of the service's code, so Fabric Dashboards can tell code apart from a store
|
|
44
44
|
that versions data. Pass it as `registerRemote({ ..., source: { repository } })` / `register_remote(..., source=...)`
|
|
45
|
-
(since 0.49.3
|
|
46
|
-
|
|
45
|
+
(since 0.49.3; a new registration without it is refused since 0.62.0, before the token file is written, and a
|
|
46
|
+
rotation that passes none keeps the installed one, FAA-198); the
|
|
47
|
+
kit checks it as the schema's URI (RFC 3986 characters, a host and a valid port when it names an
|
|
48
|
+
authority) and refuses a `user:password@`, a user outside a URL's authority (an scp form), and a value shaped like a
|
|
49
|
+
credential in its user, query or fragment or as a path segment; a path segment in lower case, or of letters with no
|
|
50
|
+
digit, is the repository's name unless a key's shape starts it or follows a `.`, `_` or `-` in it (since 0.62.0, so
|
|
51
|
+
`task-pipeline-dashboard` and `Task-Pipeline-Dashboard` are accepted, a name that starts with `sk-` and 16 more
|
|
52
|
+
characters is not, in any case, nor `%73k-` and the rest; a host is read for a key's shape anywhere in it, inside a word too, so
|
|
53
|
+
`task-pipeline-dashboard.example.com` is refused, FAA-200 (32)). A path and a user information are read as written and with
|
|
54
|
+
their ASCII escapes decoded, and a password as written and as the URL standard reads it (`https://h\u:pw@x/r` is
|
|
55
|
+
refused). A checkout's origin in scp form (`git@host:owner/name.git`) is refused: write it `https://host/owner/name`,
|
|
56
|
+
the repository's own page with no path below it, no query and no fragment, which the estate's readers join.
|
|
47
57
|
- `installedBy` is the installer's name and version as a string (`"example-installer 1.2.0"`), or
|
|
48
58
|
`{by, role?, tool}` where `by` is `person`, `node` or `organization` and `role` (`operator`, `administrator`) is a
|
|
49
59
|
person's only. There is no `agent` kind: an agent that registers a service writes the string form, or
|
|
@@ -111,5 +121,8 @@ The login rules keep every `Set-Cookie` of the answer and judge the session cook
|
|
|
111
121
|
|
|
112
122
|
Rotate the token by setting the new value on the platform and re-running `registerRemote` on the
|
|
113
123
|
operator's computer with it; the old token stops working the moment the platform restarts with the
|
|
114
|
-
new value, and so does every session begun under it when `LoginCodes` has `token=`.
|
|
124
|
+
new value, and so does every session begun under it when `LoginCodes` has `token=`. The re-run keeps the
|
|
125
|
+
installed `source` when it passes none (one the kit refuses, edited by hand, is dropped with the warning); pass
|
|
126
|
+
`runner`, `peer`, `summary`, `doctor` and `installedBy` / `installed_by` again, since a re-run writes only what it is
|
|
127
|
+
given (without `installedBy` it writes the default person form). Rotate the
|
|
115
128
|
session key on the platform to sign every operator out.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -64,6 +64,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
64
64
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
65
65
|
import fabric_service as fs # noqa: E402
|
|
66
66
|
import fabric_connections as fcn # noqa: E402
|
|
67
|
+
from check_service_rules import shown_repository # noqa: E402 - a repository in evidence carries no userinfo or query
|
|
67
68
|
import fabric_interop as fi # noqa: E402
|
|
68
69
|
|
|
69
70
|
Result = Dict[str, str]
|
|
@@ -194,6 +195,18 @@ class Probe:
|
|
|
194
195
|
else:
|
|
195
196
|
unique = "port %d is unique" % self.port
|
|
196
197
|
self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or unique)
|
|
198
|
+
# FAA-198: the repository that links the agent to its project; a new install is refused without it, an installed one
|
|
199
|
+
# is named here (NOT_RUN is no pass) until its owner adds it.
|
|
200
|
+
# A source descriptor.valid refuses is no pass either, and the evidence never carries a URL's userinfo or query,
|
|
201
|
+
# where a password rides (0.62.0 unit reading 2).
|
|
202
|
+
if not fs.names_repository(self.d):
|
|
203
|
+
self.add("descriptor.source-repository", "NOT_RUN", "the descriptor %s" % fs.NO_SOURCE_REPOSITORY)
|
|
204
|
+
# what the source adds, read in the member rules, which a missing required key never cuts short (unit reading 16)
|
|
205
|
+
elif added := sorted(set(fs.descriptor_member_problems(self.d)) - set(fs.descriptor_member_problems(
|
|
206
|
+
{k: v for k, v in self.d.items() if k != "source"}))): # its own problem (a member name it names is the descriptor's own, FAA-200 (39))
|
|
207
|
+
self.add("descriptor.source-repository", "NOT_RUN", "source is refused: %s" % added[0])
|
|
208
|
+
else:
|
|
209
|
+
self.add("descriptor.source-repository", "PASS", "names %s" % shown_repository(self.d["source"]["repository"]))
|
|
197
210
|
if (self.d.get("lifecycle") or {}).get("manager") == "person":
|
|
198
211
|
# DEC-0069: installed, never running — a reader checks the app is still there; liveness is the app's own file.
|
|
199
212
|
present = fs.app_present(self.d)
|
|
@@ -275,6 +288,9 @@ class Probe:
|
|
|
275
288
|
self.add("well-known.connections", "FAIL" if view["reported"] else "PASS",
|
|
276
289
|
"; ".join(f["message"] for f in fcn.connections_rules(wk)[:5]) or
|
|
277
290
|
"%d connection(s), %d named in degraded[]" % (len(view["connections"]), failing))
|
|
291
|
+
else: # FAA-199: named, never a pass, so the connection map is not left empty unseen
|
|
292
|
+
self.add("well-known.connections", "NOT_RUN", "declares no %s: a host cannot draw what the service uses "
|
|
293
|
+
"(connections_extension / connectionsExtension)" % fcn.CONNECTIONS_EXTENSION_KEY)
|
|
278
294
|
# DEC-0121, FAC-SEM-142: every button's agent twin, declared; no declaration is NOT_RUN (D10).
|
|
279
295
|
self.add("well-known.actions", *well_known_actions_verdict(wk))
|
|
280
296
|
# DEC-0122: where the public Agent Card is; no member is NOT_RUN.
|
|
@@ -809,9 +825,9 @@ class Probe:
|
|
|
809
825
|
if not source:
|
|
810
826
|
self.add(rule, "NOT_RUN", "data is inside repository %s and the descriptor names no source.repository to tell code from data" % top)
|
|
811
827
|
elif remote.returncode == 0 and same_repository(remote.stdout.strip(), source):
|
|
812
|
-
self.add(rule, "FAIL", "data lives in the service's own code checkout %s (%s)" % (top, source))
|
|
828
|
+
self.add(rule, "FAIL", "data lives in the service's own code checkout %s (%s)" % (top, shown_repository(source)))
|
|
813
829
|
else:
|
|
814
|
-
self.add(rule, "PASS", "data is in repository %s, which is not the service's code (%s)" % (top, remote.stdout.strip() or "no remote"))
|
|
830
|
+
self.add(rule, "PASS", "data is in repository %s, which is not the service's code (%s)" % (top, shown_repository(remote.stdout.strip()) or "no remote"))
|
|
815
831
|
|
|
816
832
|
def lock_rule(self, data: Path) -> None:
|
|
817
833
|
lock = data / "service.lock"
|