@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +122 -3
  2. package/README.md +6 -3
  3. package/SECURITY.md +4 -3
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +37 -12
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +20 -9
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +5 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +26 -5
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -2
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +21 -7
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +6 -2
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +15 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +55 -19
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +15 -2
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +27 -6
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +31 -7
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +18 -16
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +9 -5
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +15 -6
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +93 -93
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +37 -12
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +365 -53
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
  105. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  106. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -0,0 +1,115 @@
1
+ // The code checks of the support-agent recipe for a Node agent (FAA-264 item 10): checkReply, the gate the
2
+ // communication template runs on every final text, and what it reads (unsupported, scriptOf). The twin of
3
+ // support_agent.py's check_reply; both answer the cases in test/fixtures/support-check-reply-cases.json the same way.
4
+ // Python's Unicode classes are written out (\w as [\p{L}\p{N}_], \d as \p{Nd}, \b as lookarounds), since a JavaScript
5
+ // \w, \d and \b are ASCII. Node 20+, no dependency.
6
+ const W = '\\p{L}\\p{N}_';
7
+ const LETTER = '\\p{L}\\p{Nl}\\p{No}'; // Python's [^\W\d_]: a word character that is no decimal digit and no underscore
8
+ const DASHES = new RegExp(`[\\u2012\\u2014\\u2015\\u2e3a\\u2e3b\\ufe58]|--|(?<=[${LETTER}])[ \\u00a0][-\\u2013]\\s(?=[${LETTER}])`, 'u');
9
+ const CITATION = /\[KB:([A-Za-z0-9][A-Za-z0-9._-]{0,63})\]/g;
10
+ const URL_RUN = /https?:\/\/\S+/gu;
11
+ const CCTLD = ('ac ad ae af ag ai al am ao aq ar as at au aw ax az ba bb bd be bf bg bh bi bj bm bn bo br bs bt bw by bz ca '
12
+ + 'cc cd cf cg ch ci ck cl cm cn co cr cu cv cw cx cy cz de dj dk dm do dz ec ee eg er es et eu fi fj fk fm fo '
13
+ + 'fr ga gd ge gf gg gh gi gl gm gn gp gq gr gs gt gu gw gy hk hm hn hr ht hu id ie il im in io iq ir is it je '
14
+ + 'jm jo jp ke kg kh ki km kn kp kr kw ky kz la lb lc li lk lr ls lt lu lv ly ma mc md me mg mh mk ml mm mn mo '
15
+ + 'mp mq mr ms mt mu mv mw mx my mz na nc ne nf ng ni nl no np nr nu nz om pa pe pf pg ph pk pl pm pn pr ps pt '
16
+ + 'pw py qa re ro rs ru rw sa sb sc sd se sg sh si sk sl sm sn so sr ss st su sv sx sy sz tc td tf tg th tj tk '
17
+ + 'tl tm tn to tr tt tv tw tz ua ug uk us uy uz va vc ve vg vi vn vu wf ws ye yt za zm zw');
18
+ const GTLD = ('com net org info biz edu gov mil int app dev xyz online site shop store tech cloud email link live news blog '
19
+ + 'page help support pro club top vip one world today space website mobi name travel agency company services '
20
+ + 'solutions digital network media studio design money finance bank pay refund claim click download win '
21
+ + 'рф рус укр бел срб');
22
+ const TLDS = [...new Set(`${CCTLD} ${GTLD}`.split(' '))].sort((a, b) => b.length - a.length || (a < b ? -1 : a > b ? 1 : 0)).join('|');
23
+ const STOP = '[^\\s<>()"\'\\[\\]]';
24
+ const LINK = new RegExp(`(?:(?<![${W}])https?://|(?<![${W}@.-])www\\.)${STOP}+`
25
+ + `|(?<![${W}@.\\-/])(?:[\\p{L}\\p{N}](?:[${W}-]{0,61}[\\p{L}\\p{N}])?\\.)+(?:${TLDS})(?![${W}-])(?::\\p{Nd}{1,5})?(?:[/?#]${STOP}*)?`, 'giu');
26
+ const EMAIL = new RegExp(`[${W}.+-]+@[${W}-]+(?:\\.[${W}-]+)+`, 'gu');
27
+ const PHONE = new RegExp(`(?<![${W}+])\\+?\\p{Nd}(?:[ ().-]{0,2}\\p{Nd}){6,}`, 'gu');
28
+ const CURRENCY = { $: 'usd', '€': 'eur', '£': 'gbp', '¥': 'jpy', '₽': 'rub', '₹': 'inr', dollar: 'usd', dollars: 'usd', euro: 'eur', euros: 'eur', pound: 'gbp', pounds: 'gbp' };
29
+ const CODES = 'usd|eur|gbp|jpy|rub|inr|chf|cad|aud|cny|uah|pln|try';
30
+ const UNIT = `[$\\u20ac\\u00a3\\u00a5\\u20bd\\u20b9]|(?<![${W}])(?:${CODES}|dollars?|euros?|pounds?)(?![${W}])|руб[${W}]*`;
31
+ const NUM = '\\p{Nd}+(?:[.,]\\p{Nd}+)*';
32
+ const AMOUNT = new RegExp(`(${UNIT})\\s?(${NUM})|(${NUM})\\s?(${UNIT})`, 'giu');
33
+ const NUMBER = new RegExp(NUM, 'gu');
34
+ const TRAILING = ".,;:!?'\")]";
35
+ export const SCRIPTS = Object.freeze([
36
+ ['latin', [[0x41, 0x5A], [0x61, 0x7A], [0xC0, 0x24F], [0x1E00, 0x1EFF]]],
37
+ ['cyrillic', [[0x400, 0x52F]]],
38
+ ['greek', [[0x370, 0x3FF]]],
39
+ ['hebrew', [[0x590, 0x5FF]]],
40
+ ['arabic', [[0x600, 0x6FF], [0x750, 0x77F]]],
41
+ ['devanagari', [[0x900, 0x97F]]],
42
+ ['thai', [[0xE00, 0xE7F]]],
43
+ ['cjk', [[0x3040, 0x30FF], [0x3400, 0x4DBF], [0x4E00, 0x9FFF], [0xAC00, 0xD7AF]]],
44
+ ]);
45
+
46
+ /** The script most of a text's letters are written in (citations and URLs left out), or null without letters. */
47
+ export function scriptOf(text) {
48
+ const counts = new Map();
49
+ for (const ch of text.replace(CITATION, ' ').replace(URL_RUN, ' ')) {
50
+ const code = ch.codePointAt(0);
51
+ const hit = SCRIPTS.find(([, ranges]) => ranges.some(([lo, hi]) => lo <= code && code <= hi));
52
+ if (hit) counts.set(hit[0], (counts.get(hit[0]) ?? 0) + 1);
53
+ }
54
+ let best = null;
55
+ for (const [name, n] of counts) if (best === null || n > counts.get(best) || (n === counts.get(best) && name > best)) best = name;
56
+ return best;
57
+ }
58
+
59
+ const currency = (unit) => {
60
+ const lowered = unit.toLowerCase();
61
+ return lowered.startsWith('руб') ? 'rub' : (CURRENCY[lowered] ?? lowered);
62
+ };
63
+
64
+ function linkKey(link) {
65
+ let key = link.replace(/^https?:\/\//i, '');
66
+ while (key && (TRAILING + '/').includes(key[key.length - 1])) key = key.slice(0, -1);
67
+ const cut = key.search(/[/?#]/);
68
+ return cut < 0 ? key.toLowerCase() : key.slice(0, cut).toLowerCase() + key.slice(cut);
69
+ }
70
+
71
+ const rstrip = (text, chars) => { let end = text.length; while (end && chars.includes(text[end - 1])) end -= 1; return text.slice(0, end); };
72
+
73
+ function claims(text) {
74
+ const amounts = new Set();
75
+ for (const m of text.matchAll(AMOUNT)) amounts.add(JSON.stringify([currency(m[1] ?? m[4]), m[2] ?? m[3]]));
76
+ return {
77
+ url: new Set([...text.matchAll(LINK)].map((m) => linkKey(m[0]))),
78
+ email: new Set([...text.matchAll(EMAIL)].map((m) => rstrip(m[0], TRAILING).toLowerCase())),
79
+ phone: new Set([...text.matchAll(PHONE)].map((m) => m[0].replace(/\P{Nd}/gu, ''))),
80
+ amount: amounts,
81
+ number: new Set([...text.matchAll(NUMBER)].map((m) => m[0])),
82
+ };
83
+ }
84
+
85
+ /** unsupported:<kind> for each kind of claim in the reply that the source text does not carry. */
86
+ export function unsupported(reply, source) {
87
+ const said = claims(reply);
88
+ const known = claims(source);
89
+ const problems = [];
90
+ for (const kind of ['url', 'email', 'phone', 'amount', 'number']) {
91
+ const missing = kind === 'phone'
92
+ ? [...said[kind]].filter((c) => ![...known[kind]].some((k) => k.endsWith(c)))
93
+ : [...said[kind]].filter((c) => !known[kind].has(c));
94
+ if (missing.length) problems.push(`unsupported:${kind}`);
95
+ }
96
+ return problems;
97
+ }
98
+
99
+ /** What code can prove about a draft: {ok, problems, citations, reply}; the twin of support_agent.check_reply. */
100
+ export function checkReply(draft, question, given, { maxChars, sources = {} } = {}) {
101
+ const problems = [];
102
+ const cited = [...new Set([...draft.matchAll(CITATION)].map((m) => m[1]))];
103
+ if (!cited.length) problems.push('no-citation');
104
+ const unknown = cited.filter((c) => !given.includes(c));
105
+ if (unknown.length) problems.push(`unknown-citation:${unknown.join(',')}`);
106
+ if (DASHES.test(draft)) problems.push('dash');
107
+ const reply = draft.replace(CITATION, '').replace(/[ \t]+/g, ' ').replaceAll(' .', '.').replaceAll(' ,', ',').trim();
108
+ if (!reply) problems.push('empty');
109
+ else if ([...reply].length > maxChars) problems.push('too-long');
110
+ problems.push(...unsupported(reply, cited.filter((c) => given.includes(c)).map((c) => (Object.hasOwn(sources, c) ? sources[c] : '')).join('\n')));
111
+ const wanted = scriptOf(question);
112
+ const written = scriptOf(reply);
113
+ if (wanted && written && wanted !== written) problems.push(`script:${written}-not-${wanted}`);
114
+ return { ok: problems.length === 0, problems, citations: cited, reply };
115
+ }
@@ -58,7 +58,8 @@ _DASHES = re.compile(r"[\u2012\u2014\u2015\u2e3a\u2e3b\ufe58]|--|(?<=[^\W\d_])[
58
58
  _CITATION = re.compile(r"\[KB:([A-Za-z0-9][A-Za-z0-9._-]{0,63})\]")
59
59
  _WORD = re.compile(r"\w+", re.UNICODE)
60
60
  _URL = re.compile(r"https?://\S+")
61
- _ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
61
+ _ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}") # a message id
62
+ _ENTRY_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}") # a knowledge id: exactly what _CITATION can name
62
63
  # What a reply may carry only when the entries it cites carry it (D6): a link, an address, a phone number, an amount,
63
64
  # a number. The customer's message never counts: an instruction hidden in it is exactly what this refuses.
64
65
  # A bare host is a link only when its last label is a top-level domain people write in prose: every two-letter
@@ -154,8 +155,10 @@ class Knowledge:
154
155
  def __init__(self, entries: List[Mapping[str, Any]]):
155
156
  self.entries: Dict[str, Dict[str, Any]] = {}
156
157
  for entry in entries:
157
- if not isinstance(entry, dict) or not isinstance(entry.get("id"), str) or not _ID.fullmatch(entry["id"]):
158
- raise RecipeError("every knowledge entry has an id of letters, digits, '.', '_', ':' or '-'")
158
+ if not isinstance(entry, dict) or not isinstance(entry.get("id"), str) or not _ENTRY_ID.fullmatch(entry["id"]):
159
+ # the citation's own grammar (FAA-264): an id with a colon or over 64 characters could never be cited
160
+ raise RecipeError("every knowledge entry has an id a citation can name: letters, digits, '.', '_' or "
161
+ "'-', up to 64 (map one that has a colon)")
159
162
  if not isinstance(entry.get("answer"), str) or not entry["answer"].strip():
160
163
  raise RecipeError("knowledge entry %s has no answer" % entry["id"])
161
164
  if "question" in entry and not isinstance(entry["question"], str):
@@ -452,10 +455,52 @@ def prune(state_dir: Path, keep_days: int, *, now: Optional[datetime] = None) ->
452
455
  return removed
453
456
 
454
457
 
458
+ def check_policy(policy: Mapping[str, Any]) -> Dict[str, List[str]]:
459
+ """Categories only a person handles (refunds, account recovery, legal), each a list of words or phrases; a message
460
+ that holds one is routed to a person before the lookup and the model, so a refund is never a knowledge gap."""
461
+ checked: Dict[str, List[str]] = {}
462
+ for category, phrases in dict(policy).items():
463
+ if not isinstance(category, str) or not _ENTRY_ID.fullmatch(category):
464
+ raise RecipeError("a policy category is a short id (letters, digits, '.', '_' or '-')")
465
+ if not isinstance(phrases, list) or not phrases or not all(isinstance(p, str) and p.strip() for p in phrases):
466
+ raise RecipeError("policy %s lists the words that route a message to a person" % category)
467
+ checked[category] = [p.strip().lower() for p in phrases]
468
+ return checked
469
+
470
+
471
+ def policy_route(policy: Mapping[str, List[str]], text: str) -> Optional[str]:
472
+ """The first category whose phrase the message holds (whole words, any case), or None."""
473
+ lowered = " %s " % " ".join(_WORD.findall(text.lower()))
474
+ for category, phrases in policy.items():
475
+ if any(" %s " % " ".join(_WORD.findall(p)) in lowered for p in phrases):
476
+ return category
477
+ return None
478
+
479
+
480
+ _REDACT = (
481
+ (re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+"), "[email]"),
482
+ (re.compile(r"(?<!\w)(?:\d[ -]?){13,19}(?!\w)"), "[card]"),
483
+ (re.compile(r"(?<![\w+])\+?\d[\d ()-]{6,}\d(?!\w)"), "[number]"),
484
+ (re.compile(r"[A-Za-z0-9+/=_-]{32,}"), "[secret]"),
485
+ )
486
+
487
+
488
+ def redact(text: str) -> str:
489
+ """The customer's text as the outbox keeps it: an address, a card or phone number, and a long key-shaped run
490
+ replaced by a marker, so a person reading the queue never reads a code or a key the customer pasted."""
491
+ for pattern, marker in _REDACT:
492
+ text = pattern.sub(marker, text)
493
+ return text
494
+
495
+
455
496
  class SupportAgent:
456
497
  def __init__(self, *, knowledge: Union[Knowledge, "KnowledgeSource"], client: Any, model: str, fallback: str, state_dir: Path,
457
498
  max_tokens: int = 600, max_chars: int = 1200, lookup_limit: int = 3,
458
- on_handoff: Optional[Callable[[Dict[str, Any]], None]] = None):
499
+ on_handoff: Optional[Callable[[Dict[str, Any]], None]] = None,
500
+ policy: Optional[Mapping[str, Any]] = None):
501
+ if fabric_openrouter.vendor(model) and fabric_openrouter.vendor(model) == fabric_openrouter.vendor(fallback):
502
+ raise RecipeError("the fallback is a model of another vendor than %s (refused at load, FAA-116)" % model)
503
+ self.policy = check_policy(policy or {})
459
504
  self.knowledge, self.client = knowledge, client
460
505
  self.model, self.fallback = model, fallback
461
506
  self.state_dir = Path(state_dir)
@@ -492,6 +537,8 @@ class SupportAgent:
492
537
  # The outbox keeps the customer's own text until it is pruned: see `prune` and the README's retention note.
493
538
  fabric_paths.ensure_private_dir(self.state_dir)
494
539
  kept = {k: v for k, v in decision.items() if k != "draft"} # a person reads the question and the reasons
540
+ if isinstance(kept.get("question"), str):
541
+ kept["question"] = redact(kept["question"]) # what a person reads carries no code, key, card or address
495
542
  line = json.dumps(kept, ensure_ascii=False, separators=(",", ":")) + "\n"
496
543
  _append_line(self.state_dir / "handoffs.jsonl", line.encode("utf-8"))
497
544
 
@@ -509,6 +556,9 @@ class SupportAgent:
509
556
  rewrites the draft once with the refusal's reasons: a pass is a reply with `repaired: true`, and a repair that
510
557
  does not end in a reply (a second refusal, a model `HANDOFF`, no knowledge on the retry; not a `model:*`
511
558
  outage) a hand-off with `fallback: true` (the channel-agent standard's 4.4 and 13.2, FAA-197)."""
559
+ if getattr(self._local, "in_handoff", False): # first, so a bad input from on_handoff is a ReentryError too
560
+ raise ReentryError("answer() was called from on_handoff: queue the hand-off and call answer() again after "
561
+ "this call returns")
512
562
  if not isinstance(message_id, str) or not _ID.fullmatch(message_id):
513
563
  raise RecipeError("message id: letters, digits, '.', '_', ':' or '-', up to 128")
514
564
  if not isinstance(text, str) or not text.strip():
@@ -518,9 +568,6 @@ class SupportAgent:
518
568
  "decision or the on_handoff copy (a repeat returns no draft, and a fallback had its repair)")
519
569
  if repair is not None and repair.get("messageId") == message_id:
520
570
  raise RecipeError("repair= runs under a new message id: the refused one returns its stored decision")
521
- if getattr(self._local, "in_handoff", False):
522
- raise ReentryError("answer() was called from on_handoff: queue the hand-off and call answer() again after "
523
- "this call returns")
524
571
  text = re.sub("[\ud800-\udfff]", "\ufffd", text) # a lone surrogate (bytes a terminal could not decode) is kept, not fatal
525
572
  with self._lock: # one decision per message id, in this process; the service's instance lock covers others
526
573
  done = self._log.get(message_id)
@@ -540,6 +587,9 @@ class SupportAgent:
540
587
  def _decide(self, message_id: str, text: str, task_run_id: Optional[str],
541
588
  repair: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]:
542
589
  base = {"messageId": message_id, "citations": [], "costUsd": None}
590
+ routed = policy_route(self.policy, text)
591
+ if routed is not None: # a person's category goes to a person before any lookup or model call (FAA-116)
592
+ return dict(base, action="handoff", reasons=["policy:" + routed])
543
593
  knowledge = self.knowledge.current() if isinstance(self.knowledge, KnowledgeSource) else self.knowledge
544
594
  entries = knowledge.lookup(text, self.lookup_limit)
545
595
  if not entries:
@@ -571,9 +621,10 @@ class SupportAgent:
571
621
  def from_config(config_path: Path, *, on_handoff: Optional[Callable[[Dict[str, Any]], None]] = None,
572
622
  **overrides: Any) -> SupportAgent:
573
623
  """An agent from a config file (see config.example.json): paths are relative to the config's folder. `on_handoff`
574
- takes each hand-off in place of the outbox. Other keywords go to the model client (`transport`, `deadline_s`,
575
- `retries`), except those the config already sets (`ceiling_usd`, `window`, `ledger`, `prices`, `key_env`,
576
- `project_id`)."""
624
+ takes each hand-off in place of the outbox. Other keywords go to the model client as they are: the eleven the
625
+ config does not set (`base_url`, `attribution`, `unmeasured_call_usd`, `deadline_s`, `retries`, `environ`,
626
+ `transport`, `sleep`, `clock`, `now`, `rand`); the six it always sets (`ceiling_usd`, `window`, `ledger`, `prices`,
627
+ `key_env`, `project_id`) are not keywords here. `policy` in the config routes a person's categories first."""
577
628
  config = json.loads(Path(config_path).read_text(encoding="utf-8"))
578
629
  home = Path(config_path).resolve().parent
579
630
  state = (home / config.get("stateDir", "state")).resolve()
@@ -593,7 +644,8 @@ def from_config(config_path: Path, *, on_handoff: Optional[Callable[[Dict[str, A
593
644
  knowledge = Knowledge.load(home / config["knowledge"])
594
645
  return SupportAgent(knowledge=knowledge, client=client, model=config["model"],
595
646
  fallback=config["fallback"], state_dir=state, max_tokens=int(config.get("maxTokens", 600)),
596
- max_chars=int(config.get("maxChars", 1200)), on_handoff=on_handoff)
647
+ max_chars=int(config.get("maxChars", 1200)), on_handoff=on_handoff,
648
+ policy=config.get("policy"))
597
649
 
598
650
 
599
651
  def main(argv: Optional[List[str]] = None) -> int:
@@ -0,0 +1,98 @@
1
+ # A2A: serving a card and the binding, calling a peer
2
+
3
+ A service that other agents reach over the open A2A protocol (version 1.0, the contract's A2A peer profile) serves a
4
+ public Agent Card, answers A2A's JSON-RPC binding, and may call other A2A agents. The kit's module is
5
+ `fabric_a2a_server.py` with its Node twin `fabric-a2a-server.mjs` (FAA-10); `fabric_a2a.py` reads the card and the
6
+ well-known member (DEC-0122), and `check_service_a2a.py` holds the probe's A2A rules.
7
+
8
+ ## The card
9
+
10
+ ```python
11
+ from fabric_a2a import a2a_surface
12
+ from fabric_a2a_server import agent_card
13
+
14
+ card = agent_card(name="Example Agent", description="Drafts notes.", version="0.2.0",
15
+ url="http://127.0.0.1:47000/a2a/v1", capabilities=[{"name": "notes.draft", "description": "Drafts a note."}],
16
+ security_schemes={"own": {"apiKeySecurityScheme": {"location": "header", "name": "X-Example-Key"}}})
17
+ # serve card at GET /.well-known/agent-card.json, without the service token; declare it:
18
+ well_known["surfaces"]["a2a"] = a2a_surface()
19
+ ```
20
+
21
+ - Each capability is one `AgentSkill`; its id is the capability's name, the id the provider manifest's A2A profile
22
+ lists in `skillIds`.
23
+ - One interface: the JSON-RPC binding at `url`, `protocolVersion` 1.0. On a local placement the URL is on the service's
24
+ loopback origin; on a remote one it is https.
25
+ - `agent_card` runs `agent_card_findings` before it returns, so a card that names a member about the running process,
26
+ holds a value shaped like a credential, names the descriptor's token header as its scheme, or marks a Fabric
27
+ extension required is never served.
28
+ - **The interface never takes the descriptor's token** (DEC-0122 item 3). It has its own scheme (an API key of its own,
29
+ OAuth, or none on a loopback interface trusted under HA-3), and the service's `authorize` turns the request's headers
30
+ into a caller.
31
+
32
+ ## The binding
33
+
34
+ ```python
35
+ from fabric_a2a_server import A2ABinding, agent_message, text_of
36
+
37
+ def handler(start): # a new message
38
+ if text_of(start.message) == "ping":
39
+ return agent_message("pong") # a reply with no task
40
+ start.start("notes.draft") # or a task: a job in the interop job store
41
+ run_in_background(start.task_id) # the service moves the job: working, request_input, complete, fail
42
+
43
+ binding = A2ABinding(jobs, handler, skills=["notes.draft"], authorize=lambda headers: keys.get(headers.get("x-example-key")))
44
+ status, body = binding.handle(request_body_bytes, request_headers) # POST /a2a/v1
45
+ ```
46
+
47
+ | A2A 1.0 | The kit |
48
+ |---|---|
49
+ | `SendMessage` | the handler replies (`{message}`) or starts a job (`{task}`); a message with `taskId` answers an input-required task |
50
+ | `SendStreamingMessage` | `binding.stream(params, caller, rid=...)`: server-sent events, the task first, then a `statusUpdate` at each change, ending at a terminal or input-required state |
51
+ | `GetTask` | the job as a Task |
52
+ | `CancelTask` | `JobStore.cancel`; an ended task answers -32002 |
53
+ | task id | the job id; only the caller that started it reads, answers or cancels it (any other gets -32001) |
54
+ | `TASK_STATE_WORKING`, `_INPUT_REQUIRED`, `_COMPLETED`, `_FAILED`, `_CANCELED` | job `working`, `input_required`, `completed`, `failed`, `cancelled` |
55
+ | an input-required question | the job's elicitation requests in the status message's data part; the answer is a data part `{inputResponses}` as `fabric.job.get` takes it, or plain text for a question of one field |
56
+ | the result | the job's result envelope as the artifact `result`, one data part |
57
+ | a failure | the status message names the job's error message and its code in a data part |
58
+
59
+ JSON-RPC codes follow A2A 1.0 section 5.4: -32001 task not found, -32002 not cancelable, -32004 unsupported, -32009 an
60
+ `A2A-Version` header that is not 1.0; -32700, -32600, -32601 and -32602 are JSON-RPC's own. A caller `authorize`
61
+ refuses gets HTTP 401; a body over 1 MiB gets 413.
62
+
63
+ ## Calling a peer
64
+
65
+ ```python
66
+ from fabric_a2a_server import A2AClient
67
+
68
+ client = A2AClient(["https://peer.example"], credential_env="EXAMPLE_PEER_KEY")
69
+ peer = client.discover("https://peer.example") # {card, url}
70
+ answer = client.call(peer["url"], "Draft a note about Monday.")
71
+ client.get(peer["url"], answer["task"]["id"]); client.cancel(peer["url"], answer["task"]["id"])
72
+ ```
73
+
74
+ - **Only the operator's origins.** The client reaches an origin in its list, and the interface the card names must be
75
+ on one too.
76
+ - **The key from the environment.** `credential_env` names a variable that `use_secret.py run --vault-only <project>
77
+ <NAME> -- <command>` fills; the client never reads a key file beside the agent, and an error never carries the key.
78
+ - **Bounded.** Each call has a deadline (30 s) and a size cap (1 MiB) through `fabric_http`, follows no redirect and
79
+ never retries a `SendMessage`. A plain `http://` URL is refused unless it is loopback (and then its listener must be
80
+ this account's) or the client is built with `insecure=True`.
81
+
82
+ ## The probe
83
+
84
+ `check_service_a2a.py <origin> [--remote] [--key-env NAME] [--probe-text TEXT]` reads `surfaces.a2a`, the card,
85
+ the interface and the binding's refusals (`a2a.surface`, `a2a.card`, `a2a.interface`, `a2a.task-not-found`, `a2a.cancel-not-found`,
86
+ `a2a.method-not-found`); `a2a.task-states` runs only with a message the service names as safe to send, since any
87
+ message may start real work. An interface that answers 401 without `--key-env` makes the binding rows NOT_RUN.
88
+
89
+ It is a command of its own, not a part of `check_service.py`: that probe's `a2a.card` reads the public card with no
90
+ Authorization header and never calls the card's interfaces (D5), while this one calls the interface with the
91
+ interface's own key. Run both for a service that serves A2A.
92
+
93
+ ## Not here yet
94
+
95
+ - The interop test against a live Hermes A2A endpoint (`a2a_discover`, `a2a_call`): it waits for a Hermes A2A endpoint
96
+ reachable on this machine (FAA-10).
97
+ - The sample service serving both MCP and A2A.
98
+ - The REST and gRPC bindings, push notifications and `ListTasks`.
@@ -48,6 +48,16 @@ repository-relative path at a commit, an eval report). A decision without a numb
48
48
  | building-fabric-services | at each release | contribute the release's measured wins as success cases and its new technical decisions |
49
49
  | working-with-fabric | the retro after work | a lesson with a measured effect is also a success case: contribute it beside the lesson it records |
50
50
 
51
+ ## A template and the communication prompt
52
+
53
+ For an agent that talks with people on a channel, the database's template and creating-fabric-agents' communication
54
+ agent prompt (`references/communication-agent-template.md`) are read together, never as two templates (the database's
55
+ own rule). Where they disagree, the template wins, in every part and not only the first-release ones: it is the merged
56
+ practice of the agents of its kind. A part the prompt carries and the template lacks (a first-release part, a check, a
57
+ pipeline step) is built too, unless the template rules it out, and goes back to the database as feedback on that
58
+ template (`agents-db.contribute`); the completion report names each such part. Without a template for the kind, the
59
+ prompt is the recipe.
60
+
51
61
  ## Recording the template
52
62
 
53
63
  An agent built from a template declares it: `provenance.template` with its id and version, so a later template
@@ -121,7 +121,7 @@ body (contract project-comms.md, DEC-0125 MD-3). The kit cannot tell his persona
121
121
  any other id as written. The mandate is a block fenced ` ```json `, the opening and the closing fence each at the start
122
122
  of its own line, with no indent (lowercase; an indented fence, as inside a list item, is not read either; ` ````json `, ` ```JSON ` and `~~~` are not read), whose value has `kind: "mandate/1"`; any other JSON
123
123
  block, a decision block included, is not read, with no warning. A fence quoted inside another fenced block is still
124
- read: quote an example in prose, not in a fence. Two `mandate/1` blocks, whoever their chains end in, or one whose text holds `mandate/1` and is
124
+ read: quote an example in prose, not in a fence. Two `mandate/1` blocks, whoever their chains end in, or one whose text holds `mandate/1` (its slash plain or JSON-escaped, `\/` or `\u002f`, FAA-196 (13)) and is
125
125
  not JSON (`NaN` included), read as none (a broken block without `mandate/1` is skipped, and a mandate beside it
126
126
  is still read), and the responder emits `board.request.mandate_unread` (a warning) with the
127
127
  request id and its `reason`, `mandate-blocks` or `mandate-unreadable`: the block holds his words, which reach no event, log or
@@ -131,6 +131,10 @@ body (contract project-comms.md, DEC-0125 MD-3). The kit cannot tell his persona
131
131
  than 4300 digits on every Python (the kit refuses it there, as `int_max_str_digits` does from 3.11 and
132
132
  the 3.8.14, 3.9.14 and 3.10.7 security releases; a limit configured
133
133
  lower, `PYTHONINTMAXSTRDIGITS` on a Python that has one, refuses an integer longer than it).
134
+ - A request transferred to a new session runs again from a fresh `com.get`. Its mandate is read only when the message
135
+ read back carries the digest the claim took and names this request, capability and Project, as the first claim
136
+ checks; otherwise it is worked without one, and `board.request.mandate_unread` names `digest-mismatch` or
137
+ `not-addressed` (FAA-196 (1)).
134
138
  - The handler gets `ctx.mandate` (the one block without `source`, or None) and `ctx.mandate_covers(call)` (Node
135
139
  `ctx.mandateCovers(call)`, `call` being `{capability, action?, effect, spendUsd?, project?}`: pass `project` when the work
136
140
  acts in one, since a call naming none is not limited by the mandate's `projects`; both are read failing closed, as
@@ -145,7 +149,12 @@ body (contract project-comms.md, DEC-0125 MD-3). The kit cannot tell his persona
145
149
  spend; all five are read up front, whatever the scope limits, so a member that raises raises to the handler (in
146
150
  Python a `KeyError` reads as not named), and a Python mapping's default, a `defaultdict`'s
147
151
  included, is read as named, the `defaultdict` gaining the key): `mandate_covers` with `tier: "T1"`, `self` the service's `agent_id`, no `callerAgent` (the
148
- board names a Project's principal, not an agent id the chain can bind, so the chain's end alone binds it) and the
152
+ board names a Project's principal, not an agent id the chain can bind, so the chain's end alone binds it), unless the
153
+ service passes `identify_agent=` (Node `identifyAgent`, FAA-135): a function of the request's sender (`{project,
154
+ principal}`, as the board verified it) answering the agent id the service authenticates for it, or None; asked only
155
+ when a mandate travelled, its agent id must be the one before `agent_id` in the chain, and an id the registry
156
+ refuses, an answer that is not a string or a hook that raises fails closed (`mandate-not-for-this-call`), as
157
+ [interop](interop.md) reads `identify_agent`; and the
149
158
  responder's wall clock (`wall=`: seconds, `time.time` by default; Node milliseconds, `Date.now`; one that returns a value
150
159
  that is not a finite number answers `{"fault": "now-unreadable"}`; a clock that raises passes its error to the handler). It answers `{"ok": True, "recordAs": {by, via: "mandate", task,
151
160
  chain, caller}}` (`caller` the sender Project, as MD-3 records it; a sender that names no Project is never read), a
@@ -180,7 +189,7 @@ body (contract project-comms.md, DEC-0125 MD-3). The kit cannot tell his persona
180
189
  | `authority_changed`, `not_authorized` | stop claiming; one `board.access.needed` event (notify) saying what to ask for (except `not_authorized` on the move-back claim of a moved slot, read as another session's slot, [below](#when-the-operator-moves-a-slot-to-another-session-faa-155)) |
181
190
  | `fenced` on enroll or claim | another live holder: wait for it to lapse; never replace it on its own initiative. When this responder recorded holding the slot, it first reads `com.status`: a newer generation means the slot [moved](#when-the-operator-moves-a-slot-to-another-session-faa-155), and it is not taken again even after that holder lapses |
182
191
  | lost after an effect began | keep the entry as `unknown`; `observe(entry)` is asked to settle it with `com.reconcile` |
183
- | Fabric down, hub missing | bounded backoff (2 s doubling to 5 minutes, jittered), one `board.down` event, no spinning |
192
+ | Fabric down, hub missing | bounded backoff (2 s doubling to 5 minutes, jittered), one `board.down` event, no spinning; a board that flaps is said down once until it stays up `DOWN_QUIET_SECONDS` (300 s, FAA-187 (22)) |
184
193
  | one call without an answer while the lease holds | `board.down`, but the session and the running work stay; the call goes again, with the same key, before the lease lapses |
185
194
  | `revision_conflict` on `com.renew` or `com.progress` | they store nothing: the revision is read again (`com.get`) and the call sent once more; a second one loses the attempt |
186
195
  | `idempotency_conflict` | a key sent with other content is a defect, not an outage: `board.blocked` (notify) naming it, and the session is left |
@@ -311,10 +320,10 @@ held and not transferred). The responder keeps that move instead of undoing it:
311
320
  Fabric release through v0.3.19 (2026-11-11) offers the operator's move (the control is CT-174's in Fabric, its agent twin CT-181 in the contract, then CT-125),
312
321
  so today every move is another session taking the slot and take-back is the route back; the check below is ready
313
322
  for the move. While
314
- a slot is moved the responder reads `com.status` once a renewal (20 s) and, when the slot's generation is past the
323
+ a slot is moved the responder reads `com.status` once a renewal (20 s; one read a tick for every moved slot, FAA-194 (5)) and, when the slot's generation is past the
315
324
  recorded move, sends one `com.claim` under a fresh key, as PR-2's "Learning the slot is its own" says (FAA-165,
316
325
  0.60.0); the move itself drops the claim key of that capability, whose receipt the move made dead. A claim that
317
- lands is the slot held again: the record loses `moved`, the request it handed out is worked (with none, its own
326
+ lands is the slot held again (`board.slot.returned`, info, says so; FAA-194 (8)): the record loses `moved`, the request it handed out is worked (with none, its own
318
327
  enroll renews the lease at once), accepted work left `lapsed` comes back with a transfer, and a later move is said
319
328
  again. `lease_expired` (the move reached this session and its lease lapsed first) enrolls again. `held` asks again
320
329
  at the next renewal. `idempotency_window_expired` or `capacity_exceeded` asks again at the next renewal under a
@@ -323,7 +332,7 @@ held and not transferred). The responder keeps that move instead of undoing it:
323
332
  recorded as `moved` at that generation, so it is not asked about twice. A claim whose answer was lost (`unreachable`) is asked again under the same key at the next renewal, so a claim that
324
333
  landed is not stranded; until then it counts as at most a lease from that key's first send (a fresh key starts
325
334
  its own, and it counts only while more than the 10 s margin of that lease is left, then no more, as a lapsed slot), so a second lost answer keeps
326
- the session (finding 38). A replayed receipt from a generation older than `com.status` shows (a move away and back meanwhile) is
335
+ the session (as any call without an answer does while a lease holds: the failure table above; FAA-194 (34)). A replayed receipt from a generation older than `com.status` shows (a move away and back meanwhile) is
327
336
  dead: its key is dropped and a fresh key asks at the next renewal. A take-back drops the capability's claim key too, so a request a move-back claim handed out while the take-back raced
328
337
  it waits for its attempt's lease (60 s) before it is claimed again (a take-back that lands before the move-back check makes its claim key
329
338
  leaves that key to the claim loop, which works the request at once). Nothing is asked while the responder stops. It is never an enroll or a replace on a slot another session holds, and it waits
@@ -405,11 +414,13 @@ Python (`fabric_hub`): `fabric_root(platform, env)`, `locate_hub(root=None, aliv
405
414
  `request_board_access(agent_id, project_id, capabilities, reason)`, `poll_access(request_id, poll_secret,
406
415
  credential_file)`, `join_board(project_id, credential_file) -> BoardGrant`, `call_door(tool, args)`,
407
416
  `call_binding(tool, args, credential_file)`; `pid_alive(pid)` answers True, False, or None for another OS account's
408
- process (not verified); `Backoff(first=2, cap=300, jitter=0.2, floor=1)`; `HubError(code,
417
+ process (not verified); on Windows it asks OpenProcess for the access Node's `process.kill(pid, 0)` asks (libuv's
418
+ terminate, query and synchronize), so an elevated process of the same user reads None in both kits, as another
419
+ account's does, where limited query access alone would have read it as running (FAA-323); `Backoff(first=2, cap=300, jitter=0.2, floor=1)`; `HubError(code,
409
420
  message, retryable, data)`.
410
421
 
411
422
  Python (`fabric_board`): `Responder(*, hub, project_id, capabilities, credential_file, checkpoint_file, handler,
412
- admit, observe=None, emit=None, service, now, rand, spawn, session_factory, agent_id=None, mandate_from=None, wall=time.time)`
423
+ admit, observe=None, emit=None, service, now, rand, spawn, session_factory, agent_id=None, mandate_from=None, wall=time.time, identify_agent=None, links=None)`
413
424
  with `step()`, `run(stop, drain)`, `shutdown(drain)`, `take_back(capability)`; `WorkContext.progress(note)`,
414
425
  `.begin_effect(summary)`, `.stopped`, `.mandate`, `.mandate_covers(call)`; `PERSONAL_BOARD`; `BoardSession(grant)`;
415
426
  `Checkpoint(path)`; `comms_canonical(value)`, `comms_digest(operation, value)`, `receipt_problems(value, op)`,
@@ -420,4 +431,4 @@ effect_began)`, `self_check()`; `BoardError`, `Stopped`.
420
431
  Node: the same names in camelCase (`locateHub(root, {alive, estate, now})`, `new HubClient(root, {alive, timeout,
421
432
  estate, post})`, `new Responder({...})` with async `step()`, `run({signal, drain})`, `shutdown(drain)`,
422
433
  `takeBack(capability)`; `await ctx.progress(note)`, `await ctx.beginEffect(summary)`, `ctx.mandate`, `ctx.mandateCovers(call)`;
423
- the options `agentId`, `mandateFrom` and `wall` (milliseconds); `selfCheck()`). Both kits read and write the same checkpoint file.
434
+ the options `agentId`, `mandateFrom`, `wall` (milliseconds), `identifyAgent` and `links`; `selfCheck()`). Both kits read and write the same checkpoint file. `links` maps a notify kind to the page on the service that resolves it (`{"board.slot.moved": "/#board"}`, a path, as `make_event` takes one): a notice that notifies carries it as `link`, so the banner opens that page; give it only with an emit that takes `link` (`JsonlEventLog.append` does; FAA-187 (21)). A Node emit receives `{notify, subject, link?, data?}` as one object: spread it before its own `subject` or `link` if it sets its own, or the responder's wins (FAA-187 (27)).
@@ -37,7 +37,7 @@ const answer = await request('GET', 'https://api.example/v1/items', { headers, d
37
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
38
  | Redirects are answered, never followed | | a redirect must not carry the `Authorization` header to another host |
39
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
- | Plain `http://` to a loopback host goes on a connection whose server end is this OS account's process, told from the socket table before a byte is sent and never through a proxy from the environment (`fabric_peer_owner`); another account's listener, root's, or one whose owner cannot be told is kind `foreign-user`, nothing sent, never retried (on a restricted macOS table its reason says `table-restricted`: protocol.md, FAA-272). The Node twin sends such a request through `node:http` on a checked connection, not `fetch` | | DEC-0154 HA-2: loopback is one per computer, not one per OS account |
40
+ | Plain `http://` to a loopback host goes on a connection whose server end is this OS account's process, told from the socket table before a byte is sent and never through a proxy from the environment (`fabric_peer_owner`); another account's listener, root's, or one whose owner cannot be told is kind `foreign-user`, nothing sent, never retried (on a restricted macOS table its reason says `table-restricted`, which no retry clears, unless this account's own processes hold the accepted socket, DEC-0155 RT-2: protocol.md, FAA-272, FAA-292). The Node twin sends such a request through `node:http` on a checked connection, not `fetch` | | DEC-0154 HA-2: loopback is one per computer, not one per OS account |
41
41
  | Header names must be tokens; a value with a line break or NUL is refused | | header injection |
42
42
  | A JSON body is written as `JSON.stringify` writes it: a lone surrogate escaped, NaN and Infinity refused | | both twins send the same bytes |
43
43
  | Whatever the transport throws is a connection error, in both languages; a timeout is a timeout | | |
@@ -85,7 +85,7 @@ const answer = await client.chat(messages, { model, fallback, maxTokens: 800 });
85
85
  | The fallback | required, of another vendor (the part before `/` differs) |
86
86
  | 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 |
87
87
  | 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 |
88
- | The base URL | `base_url` (`baseUrl`), by default `https://openrouter.ai/api/v1`: checked by `check_url` when the client is built; ASCII whitespace and trailing `/` at its ends are trimmed (Node also trims every C0 control at the ends and removes a tab or line break anywhere, as `new URL` reads them); a base that is not a string is refused as `check_url` refuses it (`HttpError` kind `refused`, both twins); a query or a fragment is refused (kind `config`, both twins, before `check_url`). Python also refuses a space, a control or a non-ASCII character in the path (`check_url`'s `HttpError` kind `refused`); Node sends it percent-encoded, as `new URL` writes it (FAA-187 (18)) |
88
+ | The base URL | `base_url` (`baseUrl`), by default `https://openrouter.ai/api/v1`: checked by `check_url` when the client is built; ASCII whitespace and trailing `/` at its ends are trimmed (Node also trims every C0 control at the ends); a base that is not a string is refused as `check_url` refuses it (`HttpError` kind `refused`, both twins); a query or a fragment is refused (kind `config`, both twins, before `check_url`). One rule reads the base as both twins send it, before `check_url` (`base_url_problem`, `baseUrlProblem`, FAA-187 (18)): a backslash anywhere, a path holding anything but URL path characters and `%XX` escapes (a space, a control, a tab or line break inside, a character outside ASCII) or a dot segment, escaped or not, is kind `config` in both, where Python refused some and Node sent them percent-encoded or rewritten. A host outside ASCII is kept as urllib dials it, in IDNA, so attribution reads the host the call reaches in both twins (FAA-187 (19)) |
89
89
  | Attribution | off unless an `attribution` setting is given (FAC-SEM-098, `fabric_usage.attribution_headers`) |
90
90
  | 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 |
91
91
 
@@ -44,10 +44,12 @@ response = server.handle(json_rpc_message) # POST /mcp body in, JSON re
44
44
  ```
45
45
 
46
46
  - A handler returns the value for `structuredContent`; for a job it returns
47
- `ctx.start_job()`, and `on_job_started(job_id, ctx)` does or schedules the work.
47
+ `ctx.start_job()`, and `on_job_started(job_id, ctx)` does or schedules the work. An `InteropError` it raises (a
48
+ refused `approval_step`) fails the job just created with `tool-error` and answers the call as one (FAA-196 (28)).
48
49
  - `jobs.request_input(job_id, {"title_choice": fi.choice_request(...)}, "Two titles are ready.")`
49
50
  stops for a person. Fabric answers through `fabric.job.get` with `inputResponses`; the
50
- server calls `on_input(job_id, answers, ctx)` with the answers that matched.
51
+ server calls `on_input(job_id, answers, ctx)` with the answers that matched; an `InteropError` it raises is answered
52
+ as a `tool-error`, as a handler's is (FAA-196 (27)). `steps=` is a JSON object or none (FAA-196 (26)).
51
53
  - `jobs.complete(job_id, fi.result_envelope(outcome=..., producer=..., ...))`,
52
54
  `jobs.fail(job_id, code, message, data=None)`, `jobs.cancel(job_id)`; a limit or an unseen effect has its own calls
53
55
  ([Limits and uncertain effects](#limits-and-uncertain-effects)). A terminal job never changes
@@ -335,6 +337,16 @@ server.add_tool(approve_tool, approve_reply, action=reply_action, target="reply_
335
337
  `authorize` accepted a delegation token, the token's caller must be the agent before it in the chain
336
338
  (`mandate-not-for-this-call` otherwise). A T2 caller's mandate changes nothing (MD-5): it still gets
337
339
  `approval-required`.
340
+ - **A per-agent credential (FAA-135).** A service that authenticates its callers with its own per-agent credentials
341
+ (`surfaces.mcp.auth: own`) names the caller's agent id with `McpToolServer(identify_agent=)`, a function of
342
+ `(headers, tier)` read when no delegation token named a caller: that agent must then be the one before `agent_id` in
343
+ the chain, at the call, at a job's approval step and at an answer. `None` binds nothing, as before; an id the
344
+ registry refuses, an answer that is not a string, or a hook that raises binds `UNREADABLE_AGENT`, which no chain
345
+ holds, so every mandate is refused `mandate-not-for-this-call` (fail closed). `identify` (the identity recorded) and
346
+ `identify_agent` (the agent id a chain is bound with) are separate hooks: an identity is not always an agent id.
347
+ `identified_agent(value)` / `identifiedAgent(value)` reads a hook's answer that way; Node has no server class, so a
348
+ Node service passes `callerAgent: identifiedAgent(<its hook's answer>)` (or `UNREADABLE_AGENT` when its hook throws)
349
+ to `actionGate` and `jobStep` itself. Never a name the call carries.
338
350
  - A covered call is the operator's act: `ctx.record` is `{by: "operator", via: "mandate", task, chain, caller}` and
339
351
  `ctx.mandate` the mandate it took (its `instruction` stays in the audit record, never in events or a page). Outside
340
352
  it the answer is `operator-decision-required` with `data.detail` `outside-mandate: <action|capability|effect|spend|project>`
@@ -59,8 +59,10 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
59
59
  | L0 | `fabric_time` | strict RFC 3339, UTC days, formatting, day windows, the clock ([dates](#dates-and-times)) |
60
60
  | L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
61
61
  | L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
62
- | L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py`, vendored from commit `f86f0622` (1.1.0) and read against `2180235c` (1.2.0, whose Linux rule it carries), with the readings its header names (`1.2.0+kit.6`; Observatory's 1.3.x libproc proof is FAA-292), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) a caller may vouch with a pid it started, proved by `lsof -p` |
62
+ | L0 | `fabric_socket_proof` | this OS account's own TCP sockets, read from its own processes, and the two proofs they give on a restricted macOS table (DEC-0155 RT-2, FAA-292; FAA-272): `accepted` (the server end of a connection held by one of this account's processes) and `own_listener` (its listener on exactly 127.0.0.1); Python reads them through libproc (Project Observatory's `peer_owner` 1.3.1 reader, calibrated on a pair of its own) and lsof when libproc is unavailable, the Node twin through `lsof -nP -a -u <uid> -iTCP:<port> -sTCP:<state> -F pun`; `fabric_peer_owner` asks it when it sits beside it |
63
+ | L0 | `fabric_peer_owner` | which OS account owns each end of a loopback connection, from the computer's socket table (DEC-0154 HA-2, FAA-226): `listener`, `server_end`, `peer`, `CheckedHTTPConnection`; the Python file is Project Observatory's `peer_owner.py`, vendored from commit `f86f0622` (1.1.0) and read against `2180235c` (1.2.0, whose Linux rule it carries) and `a69c6fee` (1.3.1), with the readings its header names (`1.3.1+kit.7`), the Node twin reads netstat, `/proc` and the owner SID where Python reads `sysctl`, `/proc` and `GetExtendedTcpTable`; on a restricted macOS table (FAA-267) `fabric_socket_proof`, when beside it, proves a server end or listener this account's processes hold (RT-2), and after it a caller may vouch with a pid it started, proved by `lsof -p` (RT-3) |
63
64
  | L0 | `fabric_tailnet` | who calls an agent behind `tailscale serve` and what that person may open (FAA-297): `Tailscale-User-Login` only on the agent's own serve listener from a loopback peer, groups from a generated `tailnet-perimeter/1` file, a personal SHA-256-stored key for MCP no wider than the groups; `authorize`, `load_perimeter`, `load_keys`, `hash_key`, refusals 401/403 with a code ([surfaces-and-auth.md](surfaces-and-auth.md#behind-tailscale-serve)) |
65
+ | L0 | `fabric_tailnet_identity` | a person's identity at every door (dashboard, MCP, the service's own) of a service published through `tailscale serve` (FAA-324): `Tailscale-User-Login` believed only when the service listens on a loopback address and the peer is loopback, groups and per-door rules from the organization's `tailnet-groups/1` file (schema `tailnet-groups.schema.json`, shipped beside it as data), read again on change and closing every door while unreadable or invalid; `TailnetIdentity`, `decide`, `load_groups`, `send_refusal`, every refusal 403 with a code and a sentence ([surfaces-and-auth.md](surfaces-and-auth.md#a-groups-file-at-every-door)) |
64
66
  | L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
65
67
  | L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
66
68
  | L1 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
@@ -87,6 +89,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
87
89
  | L3 | `fabric_auth` | the token, the network guard, login codes |
88
90
  | L3 | `fabric_events` | events, paging, the JSONL event log (DEC-0029) |
89
91
  | L3 | `fabric_logs` | rotating logs, the stop signal and the drain (LC-12) |
92
+ | L3 | `fabric_ops_digest` | the operations digests: `ops.digest.hourly@1` and `ops.digest.daily@1` folded from the service's own counts, FAC-SEM-166 and FAC-SEM-167, the OD-9 declaration (DEC-0153, FAA-206); its reference is [ops-digest.md](ops-digest.md) |
90
93
  | L3 | `fabric_supervisors` | launchd (a LaunchAgent, or a LaunchDaemon in the system domain, FAA-278), systemd user units, Task Scheduler (DEC-0032, LC-14) |
91
94
  | L3 | `fabric_service` | the facade: every name 0.39.1 exported, re-exported from the modules above |
92
95
  | L3 | `fabric_profiles` | runner and model profiles ([profiles](profiles.md)) |
@@ -102,6 +105,7 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
102
105
  | L4 | `fabric_board` | the facade of the board connector ([board](board.md)) |
103
106
  | L4 | `fabric_http` | outbound HTTP: deadline, bounded retries, `Retry-After`, size cap ([connectors](connectors.md)) |
104
107
  | L4 | `fabric_openrouter` | OpenRouter chat: own key, spend ceiling, receipts, fallback ([connectors](connectors.md)) |
108
+ | L4 | `fabric_a2a_server` | A2A 1.0 serving and calling: the Agent Card from capabilities, the JSON-RPC binding over the interop job store, a bounded client with the operator's origin allowlist (FAA-10); its reference is [a2a.md](a2a.md) |
105
109
 
106
110
  Beside the kit, not in `kit.json`: the probe (`check_service.py`, `check_service_rules.py`,
107
111
  `check_service_channel_agent.py`, `check_service_channel_agent_schema.py` and the
@@ -106,11 +106,14 @@ systemd and Task Scheduler do not poll yet, FAA-253).
106
106
  then poll the well-known document until it answers with this `id.instance` on a listener of this OS account
107
107
  (DEC-0154 HA-2: another account's listener, or one whose owner cannot be told, is `foreign-user` and never a
108
108
  successful install; the error names it). Where the socket table hides other processes (macOS 27.2 under an
109
- ad-hoc-signed interpreter), the installer vouches with its own job's pid from `launchctl print`, for a listen on
110
- exactly `127.0.0.1` alone (DEC-0155 RT-3; a wildcard bind is never vouched for since FAA-312); a listener it
111
- cannot prove reads `table-restricted` and the error names the way round it (FAA-267). The pid is the job's own, so a
112
- `ProgramArguments` wrapper that starts the server as a child (`sh -c` without `exec`, `uv run`, `npm start`) cannot
113
- vouch for it: on such a host start the server with `exec` or run the installer from Apple's `/usr/bin/python3`.
109
+ ad-hoc-signed interpreter), the job's accepted socket in one of this account's processes proves it first (DEC-0155
110
+ RT-2, `fabric_socket_proof`, FAA-292), then the installer vouches with its own job's pid from `launchctl print`,
111
+ for a listen on exactly `127.0.0.1` alone (RT-3; a wildcard bind is never vouched for since FAA-312); a listener
112
+ neither proves reads `table-restricted` (beside HA-2's `foreign-user`, which the pinned contract's definition
113
+ covers) and the error names the way round it (FAA-267). The vouch takes the job's own pid, so a `ProgramArguments`
114
+ wrapper that starts the server as a child (`sh -c` without `exec`, `uv run`, `npm start`) cannot vouch for it,
115
+ though RT-2 still proves its child's accepted socket: on such a host start the server with `exec`, or run the
116
+ installer from Apple's `/usr/bin/python3`.
114
117
  A LaunchDaemon reads its answer differently: *The system domain*, below.
115
118
  4. Upgrade = new release, rewrite the plist with the new path, `launchd_install`
116
119
  again, then `prune_releases(releases_dir, current=<new release>)`: the running release