@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/README.md +9 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.40.0 - 2026-10-21
|
|
4
|
+
|
|
5
|
+
The kits as production modules (FAA-65). Every name a kit module made public at 0.39.1 is still there; a service that
|
|
6
|
+
copies the kit now installs it with `fabric_kit.py install` (one file is no longer enough, see below).
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **A shared core, once per language** (layer L0 and L1): `fabric_jsvalue` / `fabric-jsvalue.mjs` (JavaScript's view
|
|
11
|
+
of JSON values: `show`, `js_to_number`, `same`, `json_equal`, `jcs` and the rest), `fabric_time` / `fabric-time.mjs`
|
|
12
|
+
(strict RFC 3339, UTC days, formatting, day windows, an injectable clock: the public dates API, on shared cases) and
|
|
13
|
+
`fabric_crypto` / `fabric-crypto.mjs` (Ed25519 and P-256 checks, small-order keys, base64url, sha256). They used to
|
|
14
|
+
live, twice, inside the estate and delegation readers; every reader now imports them.
|
|
15
|
+
- **The kit is a manifest-listed set, installable as one.** `kit.json` lists every module in dependency order, with the
|
|
16
|
+
kit modules it imports, its sha256 and the sha256 of every release since 0.8.0. `fabric_kit.py install <dir>`
|
|
17
|
+
copies a module's closure (data files included); `status <dir>` says `current`, `older` (with its release) or
|
|
18
|
+
`modified`, and names a module a copy imports but lacks. SKILL.md said the kit was "one self-contained file"; it was
|
|
19
|
+
not, and a one-file copy of `fabric_service.py` failed in `usage_findings`.
|
|
20
|
+
- **Connectors** (layer L4, both languages, shared cases): `fabric_http` (one deadline across attempts, bounded
|
|
21
|
+
retries with jittered backoff only when repeating is safe, `Retry-After` up to a cap, a response size cap, redirects
|
|
22
|
+
answered and never followed, plain http refused off loopback, redacted URLs in errors) and `fabric_openrouter` (the
|
|
23
|
+
agent's own key from a named environment variable, a spend ceiling checked before every attempt at the worst case
|
|
24
|
+
with calls in flight reserved, one usage receipt per answered call, empty, `length`, `error` and filtered answers
|
|
25
|
+
never returned as success, a fallback of another vendor required, 401/402/403 stop at once, attribution off by
|
|
26
|
+
default). References: `kit-modules.md`, `connectors.md`.
|
|
27
|
+
- **A support-agent recipe** (`building-fabric-services/recipes/support-agent/`): answers a question from a knowledge
|
|
28
|
+
file through `fabric_openrouter`, accepts a reply only when code proves it (citations exist and were given, no em
|
|
29
|
+
dash or substitute, the question's script, a length limit), otherwise hands it to a person with the failed checks
|
|
30
|
+
named; idempotent per message id. `test/validate.py` lets a recipe import the kit modules `kit.json` lists.
|
|
31
|
+
- **Layering held by tests**: a module imports only modules listed before it (a planted cycle is caught), none is over
|
|
32
|
+
1000 lines across every skill's scripts, the core is defined once, every module is in the module map, and the
|
|
33
|
+
0.39.1 public API of both facades is a snapshot test.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- `fabric_service` / `fabric-service.mjs` and `fabric_board` / `fabric-board.mjs` are facades over modules split by
|
|
38
|
+
responsibility (base, platform, paths, protocols, lock, usage, backups, descriptor, auth, events, logs, supervisors;
|
|
39
|
+
board shapes, session, fixture). The probe's rules moved to `check_service_rules.py`. In working-with-fabric,
|
|
40
|
+
`fabric_session.py` is a facade over `fabric_session_{env,mcp,mail,hosts,cli}.py`; the hooks, the worker and
|
|
41
|
+
`install-host-hooks` still run the facade.
|
|
42
|
+
- No import cycle is left: `fabric_estate` no longer imports `fabric_delegation`, in both languages.
|
|
43
|
+
- A Node service that imported a module-private helper of `fabric-service.mjs` or `fabric-board.mjs` (not exported at
|
|
44
|
+
0.39.1, so outside the snapshot) imports it from the module that holds it now.
|
|
45
|
+
- The probe runs the board responder's self-check in a clean Python (`-E -s`, its own working directory), so it reads
|
|
46
|
+
the kit it names and not a copy already imported.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **Found by the release review (three blind readings) and fixed before release:** the OpenRouter ceiling counted
|
|
51
|
+
only the messages, so a large `tools` list could pass it (the worst case now bounds the messages and every param;
|
|
52
|
+
`models`, `route`, `plugins` and `web_search_options` are refused); an attempt that may have been billed without an
|
|
53
|
+
answer to read (a timeout, a 200 over the size cap or not JSON) left no receipt (it now records one with no tokens
|
|
54
|
+
and no cost, counted at the worst case); the Python transport could overrun its deadline on a slowly trickled body
|
|
55
|
+
(it reads in chunks against one deadline); the twins differed on a lone surrogate, NaN in a body, a transport bug and
|
|
56
|
+
`http://127.1` (both now behave the same, on shared cases); `fabric_service.py` could not be loaded by its path from
|
|
57
|
+
another folder (every kit module now puts its own folder on `sys.path`, tested from `/`); `fabric_kit.py status`
|
|
58
|
+
judged an older copy by the current release's imports and named only direct ones (it now reads each copy's own
|
|
59
|
+
imports and follows the closure); an unknown `--module` is a usage error (exit 2); the facade keeps `_number`,
|
|
60
|
+
`_DAY_MS` and `_utc_date_ms`; `install-host-hooks` requires the session helper's new modules; the recipe hands off a
|
|
61
|
+
message holding bytes a terminal could not decode instead of exiting, and its dash check also catches U+2012 and
|
|
62
|
+
U+2015.
|
|
63
|
+
- **Found by the second review round and fixed:** a connection cut after a call may have been billed left no receipt
|
|
64
|
+
(every connection error now records an unmeasured one); `max_completion_tokens` and `n` could raise the output past
|
|
65
|
+
the bound (refused); an unmeasured call was counted at the current call's worst case (now at its own, while the
|
|
66
|
+
client runs); Node read a blank or hex price as a number (both twins now read a plain decimal or a number only);
|
|
67
|
+
Python's deadline started after the headers (it now covers them); `fabric_kit.py status` raised on a copy it could
|
|
68
|
+
not parse (it reports it `modified`) and now names a missing module's data files; `fabric_http` writes a JSON body
|
|
69
|
+
byte for byte as `JSON.stringify` (numbers and key order too, through the core's `json_stringify`); loopback edge
|
|
70
|
+
forms agree in both twins; the recipe's dash check covers U+2E3A, U+2E3B, U+FE58 and no-break spaces, and no longer
|
|
71
|
+
flags `1 - 2`.
|
|
72
|
+
- **Found by the third review round and fixed:** an answer with no usage was counted at the next call's worst case
|
|
73
|
+
(now at its own); image, audio and file parts could be sent past the bound (only text content is sent); Python
|
|
74
|
+
raised outside `OpenRouterError` and kept no receipt on a 400-digit count or cost or JSON nested too deep (both read
|
|
75
|
+
as unmeasured now); Python's deadline could be held by trickled headers (a watchdog shuts the socket at the
|
|
76
|
+
deadline); `Response.json()` reads a byte order mark and refuses `NaN` as `JSON.parse` does; a `Retry-After` year
|
|
77
|
+
before 1000 is no date in either twin.
|
|
78
|
+
- **A usage receipt's project id is checked the same way in both kits.** `fabric_service.py` defined its URI pattern
|
|
79
|
+
twice and the second won: Python accepted `project_id="urn:"` where Node refused it. Now one pattern per module, and
|
|
80
|
+
both kits refuse it (a test in each).
|
|
81
|
+
|
|
3
82
|
## 0.39.1 - 2026-10-21
|
|
4
83
|
|
|
5
84
|
Fixes from two rounds of blind review of 0.39.0 (FAA-64).
|
package/README.md
CHANGED
|
@@ -54,9 +54,15 @@ that is always alive, runs as one copy, keeps its state through a reinstall and
|
|
|
54
54
|
in Fabric Dashboards by itself: the `fabric-service/0.1` extension. It also makes an **online** agent
|
|
55
55
|
or dashboard (an `https` origin on a platform) a Fabric service (the remote placement, DEC-0019): the same
|
|
56
56
|
four routes behind the token, a `__Host-` session cookie, and one descriptor on the operator's computer;
|
|
57
|
-
`scripts/sample-remote-service.mjs` is the complete example. It ships Python and
|
|
58
|
-
Node
|
|
59
|
-
|
|
57
|
+
`scripts/sample-remote-service.mjs` is the complete example. It ships the service kit in Python and
|
|
58
|
+
Node: layered modules listed in `scripts/kit.json` behind the `fabric_service` / `fabric-service.mjs` facades,
|
|
59
|
+
installed into a service with `scripts/fabric_kit.py install` and checked with `fabric_kit.py status`
|
|
60
|
+
([kit modules](plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md), including
|
|
61
|
+
the dates API); connectors for outbound HTTP and OpenRouter (`fabric_http`, `fabric_openrouter`: one deadline, safe
|
|
62
|
+
retries, the agent's own key, a spend ceiling before every attempt, usage receipts, a fallback model;
|
|
63
|
+
[connectors](plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md)); a ready
|
|
64
|
+
[support-agent recipe](plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md);
|
|
65
|
+
a complete `scripts/sample_service.py`; and `scripts/check_service.py`, a live probe. A service that answers
|
|
60
66
|
requests other Projects post to its Project on Fabric's project board uses the board responder
|
|
61
67
|
(`scripts/fabric_hub.py` and `scripts/fabric_board.py`, Node `fabric-hub.mjs` and `fabric-board.mjs`): the
|
|
62
68
|
`board.respond` grant, enroll, claim, accept the exact digest, complete, a checkpoint before every step, and restarts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "fabric-agent-adapter",
|
|
4
4
|
"displayName": "Fabric Agent Adapter",
|
|
5
5
|
"description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.40.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.40.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "c55f2d5a11c9a0197e958e87212fca7ae1154769"
|
|
11
11
|
---
|
|
@@ -18,7 +18,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
18
18
|
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
19
19
|
metadata:
|
|
20
20
|
author: PassionCode.ai
|
|
21
|
-
version: "0.
|
|
21
|
+
version: "0.40.0"
|
|
22
22
|
contract-version: "0.1.0"
|
|
23
23
|
extension: "fabric-service/0.1"
|
|
24
24
|
extension-commit: "c55f2d5a11c9a0197e958e87212fca7ae1154769"
|
|
@@ -144,13 +144,24 @@ The request body is data behind the service's own input gate. Read [the board re
|
|
|
144
144
|
|
|
145
145
|
## Step 3: build it with the kit
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
Install the kit into the service with its manifest: `python3 scripts/fabric_kit.py install
|
|
148
|
+
<service-dir> --module fabric_service --module fabric_interop` (Python: the service and its MCP
|
|
149
|
+
surface, with every module they need; add `--module fabric_board` for a board responder, as
|
|
150
|
+
the sample has) or `--lang node` with the same names in `-`;
|
|
151
|
+
`fabric_kit.py status <service-dir>` says per file whether a copy is current, older or edited,
|
|
152
|
+
and what is missing. The kit is layered modules behind the `fabric_service` /
|
|
153
|
+
`fabric-service.mjs` facades, which keep every 0.39 import working; read
|
|
154
|
+
[the kit modules reference](references/kit-modules.md) for the map, the dates API and
|
|
155
|
+
installing more (`--module fabric_openrouter`). Both languages share file formats, and
|
|
150
156
|
their locks exclude each other. The complete worked example is
|
|
151
157
|
[`scripts/sample_service.py`](scripts/sample_service.py); read it before writing a
|
|
152
158
|
handler.
|
|
153
159
|
|
|
160
|
+
Calling out: [the connectors reference](references/connectors.md) (`fabric_http` with one
|
|
161
|
+
deadline, safe retries and a size cap; `fabric_openrouter` with the agent's own key, a
|
|
162
|
+
spend ceiling before every attempt, receipts and a fallback). A ready support agent on the
|
|
163
|
+
kit: [`recipes/support-agent/`](recipes/support-agent/README.md).
|
|
164
|
+
|
|
154
165
|
Startup order is the part that goes wrong; keep it exactly:
|
|
155
166
|
|
|
156
167
|
```python
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Recipe: a support agent
|
|
2
|
+
|
|
3
|
+
A service answers one inbound question from its own knowledge file, or hands it to a person. Built only on the service
|
|
4
|
+
kit: `fabric_openrouter` for the model, `fabric_usage` for receipts, `fabric_paths` for private, atomic state.
|
|
5
|
+
Tests: `test/test_support_agent_recipe.py` (a fake model; no network, no key).
|
|
6
|
+
|
|
7
|
+
## What it does with one message
|
|
8
|
+
|
|
9
|
+
| Step | Rule | When it fails |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| Repeat | the decision already made for this message id is returned, with no model call | |
|
|
12
|
+
| Lookup | entries sharing words with the question (its question, tags and answer), best three | handoff `no-knowledge` |
|
|
13
|
+
| Draft | `fabric_openrouter.Client.chat`: own key, spend ceiling before every attempt, a usage receipt per call, a fallback of another vendor, temperature 0 | handoff `model:<kind>` (`ceiling`, `failed`, `auth`, `payment`, `config`, `unpriced`) |
|
|
14
|
+
| Model unsure | the model may answer exactly `HANDOFF` | handoff `model-unsure` |
|
|
15
|
+
| Checks, proven by code | at least one `[KB:<id>]` citation; every cited id was given to the model; no em dash (U+2014), `--` or a spaced hyphen or en dash between words; the reply's script is the question's; at most `maxChars` | handoff, each failed check named (`no-citation`, `unknown-citation:<ids>`, `dash`, `script:<got>-not-<wanted>`, `too-long`, `empty`) |
|
|
16
|
+
| Record | the decision, 0600, written atomically; a handoff goes to the outbox first, so a crash repeats a handoff and never loses one | |
|
|
17
|
+
|
|
18
|
+
A reply comes back without its citation markers, with `citations` beside it. The agent never sends the reply: the
|
|
19
|
+
service that calls it owns its channel (mail, a chat, a store review) and its response time.
|
|
20
|
+
|
|
21
|
+
## Install into a service
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
python3 <adapter>/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py \
|
|
25
|
+
install <service>/support --lang py --module fabric_openrouter --module fabric_usage --module fabric_paths
|
|
26
|
+
cp <adapter>/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py <service>/support/
|
|
27
|
+
python3 <adapter>/.../fabric_kit.py status <service>/support # current, older or modified, per file
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Configure
|
|
31
|
+
|
|
32
|
+
`config.example.json`, paths relative to the config's folder:
|
|
33
|
+
|
|
34
|
+
| Key | Meaning |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `knowledge` | the knowledge file: `{"entries": [{"id", "question", "answer", "tags"}]}`, ids unique |
|
|
37
|
+
| `stateDir` | decisions, the handoff outbox and the usage ledger (`usage.jsonl`) |
|
|
38
|
+
| `model`, `fallback` | two models of different vendors; a lane that reads what customers write runs on an open-vendor model (DEC-0083) |
|
|
39
|
+
| `keyEnv` | the environment variable that holds the agent's own OpenRouter key; never a value in the file |
|
|
40
|
+
| `ceilingUsd`, `window` | the spend ceiling per UTC `day` or `month` |
|
|
41
|
+
| `prices` or `catalog` | USD per token per model. The example's prices are placeholders, not OpenRouter's: take them from `GET https://openrouter.ai/api/v1/models` (a saved catalog file named in `catalog` is read with `prices_from_catalog`). A model without a price is never called |
|
|
42
|
+
| `maxTokens`, `maxChars` | the output budget per call and the reply's length limit |
|
|
43
|
+
|
|
44
|
+
## Run
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
OPENROUTER_API_KEY=... python3 support_agent.py answer --config config.json --message-id m-1 --text "How do I reset my password?"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The key comes from the environment the service manager gives the agent, never typed on a command line. The decision
|
|
51
|
+
prints as JSON: `{"messageId", "action": "reply"|"handoff", "reply"?, "citations", "reasons", "costUsd", "repeated"}`.
|
|
52
|
+
Exit 1 on a broken config or knowledge file. A missing key is not a crash: the message is handed off with
|
|
53
|
+
`model:config`.
|
|
54
|
+
|
|
55
|
+
From a service's capability handler, build the agent once with `support_agent.from_config(path)` and call
|
|
56
|
+
`agent.answer(message_id, text, task_run_id=...)`; pass `on_handoff=` to `SupportAgent` to route handoffs to the
|
|
57
|
+
operator's channel instead of the outbox file.
|
|
58
|
+
|
|
59
|
+
## Before you call it finished
|
|
60
|
+
|
|
61
|
+
- Replace the example knowledge with the product's own, and run real past questions through it ([practices](../../../creating-fabric-agents/references/practices.md#judges-and-evals), *Judges
|
|
62
|
+
and evals*): count handoffs and read a sample of replies.
|
|
63
|
+
- Show spend: `fabric_usage.usage_report` over the ledger, or `fabric_openrouter.totals`.
|
|
64
|
+
- One decision per message id holds in one process; a service runs one instance per context under its instance lock.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"knowledge": "knowledge.example.json",
|
|
3
|
+
"stateDir": "state",
|
|
4
|
+
"model": "deepseek/deepseek-v4.1-flash",
|
|
5
|
+
"fallback": "qwen/qwen3.8-flash",
|
|
6
|
+
"keyEnv": "OPENROUTER_API_KEY",
|
|
7
|
+
"ceilingUsd": 1.0,
|
|
8
|
+
"window": "day",
|
|
9
|
+
"maxTokens": 600,
|
|
10
|
+
"maxChars": 1200,
|
|
11
|
+
"prices": {
|
|
12
|
+
"deepseek/deepseek-v4.1-flash": {"prompt": 0.0000002, "completion": 0.0000008},
|
|
13
|
+
"qwen/qwen3.8-flash": {"prompt": 0.0000001, "completion": 0.0000004}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"entries": [
|
|
3
|
+
{"id": "password-reset", "question": "How do I reset my password?",
|
|
4
|
+
"answer": "Open Settings, then Account, then Reset password. A link arrives by email within five minutes.",
|
|
5
|
+
"tags": ["password", "login", "account"]},
|
|
6
|
+
{"id": "refund-window", "question": "Can I get a refund?",
|
|
7
|
+
"answer": "Purchases can be refunded within 14 days through the store you bought from.",
|
|
8
|
+
"tags": ["refund", "money", "purchase"]},
|
|
9
|
+
{"id": "password-reset-ru", "question": "Как сбросить пароль?",
|
|
10
|
+
"answer": "Откройте Настройки, затем Аккаунт, затем Сбросить пароль. Ссылка придёт на почту в течение пяти минут.",
|
|
11
|
+
"tags": ["пароль", "вход", "аккаунт"]}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A support agent on the service kit: it answers one inbound question from a knowledge file, or hands it to a person.
|
|
3
|
+
|
|
4
|
+
support_agent.py answer --config config.json --message-id m-1 --text "How do I reset my password?"
|
|
5
|
+
|
|
6
|
+
For each message it:
|
|
7
|
+
|
|
8
|
+
1. returns the decision it already made for that message id (idempotent: a repeat costs nothing and never sends a
|
|
9
|
+
second reply);
|
|
10
|
+
2. finds the knowledge entries the question shares words with; none found is a handoff (`no-knowledge`);
|
|
11
|
+
3. drafts a reply through fabric_openrouter (its own key, the spend ceiling, a usage receipt per call, a fallback of
|
|
12
|
+
another vendor); no answer, the ceiling or a refused key is a handoff (`model:<kind>`);
|
|
13
|
+
4. accepts the draft only when code proves it: it cites at least one entry, every cited entry was given to it, it
|
|
14
|
+
carries no em dash (U+2014) and no dash substitute, it is written in the question's script, and it fits the length
|
|
15
|
+
limit. Anything else is a handoff, with the failed checks named;
|
|
16
|
+
5. records the decision (0600, atomic) and, for a handoff, appends it to the outbox the operator's channel reads.
|
|
17
|
+
|
|
18
|
+
What the agent does not do: send the reply anywhere (the service that calls it owns its channel), judge facts with a
|
|
19
|
+
model, or answer outside the knowledge file. The model's text is data: a citation it invents fails the check.
|
|
20
|
+
|
|
21
|
+
The kit modules (fabric_openrouter and its closure) sit beside this file in a service
|
|
22
|
+
(`fabric_kit.py install <dir> --module fabric_openrouter`); in this repository they are read from ../../scripts.
|
|
23
|
+
Standard library only. FAA-65 REQ-09.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
import threading
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional
|
|
35
|
+
|
|
36
|
+
if str(Path(__file__).resolve().parent) not in sys.path: # the kit installed beside it, under `python3 -I` too
|
|
37
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
38
|
+
try:
|
|
39
|
+
import fabric_openrouter
|
|
40
|
+
except ImportError: # in the adapter's repository: the kit is two folders up
|
|
41
|
+
sys.path.append(str(Path(__file__).resolve().parents[2] / "scripts"))
|
|
42
|
+
import fabric_openrouter
|
|
43
|
+
import fabric_paths
|
|
44
|
+
import fabric_usage
|
|
45
|
+
|
|
46
|
+
EM_DASH = "\u2014"
|
|
47
|
+
_DASHES = re.compile(r"[\u2012\u2014\u2015\u2e3a\u2e3b\ufe58]|--|(?<=[^\W\d_])[ \u00a0][-\u2013]\s(?=[^\W\d_])")
|
|
48
|
+
_CITATION = re.compile(r"\[KB:([A-Za-z0-9][A-Za-z0-9._-]{0,63})\]")
|
|
49
|
+
_WORD = re.compile(r"\w+", re.UNICODE)
|
|
50
|
+
_URL = re.compile(r"https?://\S+")
|
|
51
|
+
_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
|
|
52
|
+
SCRIPTS = (
|
|
53
|
+
("latin", ((0x41, 0x5A), (0x61, 0x7A), (0xC0, 0x24F), (0x1E00, 0x1EFF))),
|
|
54
|
+
("cyrillic", ((0x400, 0x52F),)),
|
|
55
|
+
("greek", ((0x370, 0x3FF),)),
|
|
56
|
+
("hebrew", ((0x590, 0x5FF),)),
|
|
57
|
+
("arabic", ((0x600, 0x6FF), (0x750, 0x77F))),
|
|
58
|
+
("devanagari", ((0x900, 0x97F),)),
|
|
59
|
+
("thai", ((0xE00, 0xE7F),)),
|
|
60
|
+
("cjk", ((0x3040, 0x30FF), (0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xAC00, 0xD7AF))),
|
|
61
|
+
)
|
|
62
|
+
STOPWORDS = frozenset("a an and are as at be by can do does for from how i in is it my of on or the to what when "
|
|
63
|
+
"where which who why will with you your и в во на не что как я мой моя мне с по для из к "
|
|
64
|
+
"у о а но ли же это".split())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RecipeError(Exception):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def script_of(text: str) -> Optional[str]:
|
|
72
|
+
"""The script most of a text's letters are written in (citations and URLs left out), or None without letters."""
|
|
73
|
+
counts: Dict[str, int] = {}
|
|
74
|
+
for ch in _URL.sub(" ", _CITATION.sub(" ", text)):
|
|
75
|
+
code = ord(ch)
|
|
76
|
+
for name, ranges in SCRIPTS:
|
|
77
|
+
if any(lo <= code <= hi for lo, hi in ranges):
|
|
78
|
+
counts[name] = counts.get(name, 0) + 1
|
|
79
|
+
break
|
|
80
|
+
return max(counts, key=lambda k: (counts[k], k)) if counts else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def words(text: str) -> set:
|
|
84
|
+
return {w for w in (m.lower() for m in _WORD.findall(text)) if len(w) > 1 and w not in STOPWORDS}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Knowledge:
|
|
88
|
+
"""Entries `{id, question, answer, tags?}` from a JSON file (`{"entries": [...]}`); ids are unique."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, entries: List[Mapping[str, Any]]):
|
|
91
|
+
self.entries: Dict[str, Dict[str, Any]] = {}
|
|
92
|
+
for entry in entries:
|
|
93
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("id"), str) or not _ID.fullmatch(entry["id"]):
|
|
94
|
+
raise RecipeError("every knowledge entry has an id of letters, digits, '.', '_', ':' or '-'")
|
|
95
|
+
if not isinstance(entry.get("answer"), str) or not entry["answer"].strip():
|
|
96
|
+
raise RecipeError("knowledge entry %s has no answer" % entry["id"])
|
|
97
|
+
if entry["id"] in self.entries:
|
|
98
|
+
raise RecipeError("knowledge entry %s appears twice" % entry["id"])
|
|
99
|
+
self.entries[entry["id"]] = dict(entry)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def load(cls, path: Path) -> "Knowledge":
|
|
103
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
104
|
+
return cls(data.get("entries", []) if isinstance(data, dict) else [])
|
|
105
|
+
|
|
106
|
+
def lookup(self, question: str, limit: int = 3) -> List[Dict[str, Any]]:
|
|
107
|
+
"""The entries sharing the most words with the question (its words against each entry's question, tags and
|
|
108
|
+
answer), best first, ties by id; entries sharing none are left out."""
|
|
109
|
+
asked = words(question)
|
|
110
|
+
scored = []
|
|
111
|
+
for entry in self.entries.values():
|
|
112
|
+
text = " ".join([str(entry.get("question", "")), " ".join(map(str, entry.get("tags", []))),
|
|
113
|
+
str(entry["answer"])])
|
|
114
|
+
score = len(asked & words(text))
|
|
115
|
+
if score:
|
|
116
|
+
scored.append((-score, entry["id"], entry))
|
|
117
|
+
return [entry for _, _, entry in sorted(scored)[:limit]]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def check_reply(draft: str, question: str, given: List[str], *, max_chars: int) -> Dict[str, Any]:
|
|
121
|
+
"""What code can prove about a draft: `{ok, problems, citations, reply}`; `reply` is the text without markers."""
|
|
122
|
+
problems = []
|
|
123
|
+
cited = list(dict.fromkeys(_CITATION.findall(draft)))
|
|
124
|
+
if not cited:
|
|
125
|
+
problems.append("no-citation")
|
|
126
|
+
unknown = [c for c in cited if c not in given]
|
|
127
|
+
if unknown:
|
|
128
|
+
problems.append("unknown-citation:" + ",".join(unknown))
|
|
129
|
+
if _DASHES.search(draft):
|
|
130
|
+
problems.append("dash")
|
|
131
|
+
reply = re.sub(r"[ \t]+", " ", _CITATION.sub("", draft)).replace(" .", ".").replace(" ,", ",").strip()
|
|
132
|
+
if not reply:
|
|
133
|
+
problems.append("empty")
|
|
134
|
+
elif len(reply) > max_chars:
|
|
135
|
+
problems.append("too-long")
|
|
136
|
+
wanted, written = script_of(question), script_of(reply)
|
|
137
|
+
if wanted and written and wanted != written:
|
|
138
|
+
problems.append("script:%s-not-%s" % (written, wanted))
|
|
139
|
+
return {"ok": not problems, "problems": problems, "citations": cited, "reply": reply}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
SYSTEM = (
|
|
143
|
+
"You are a support agent. Answer the customer's question using only the knowledge entries given below. "
|
|
144
|
+
"Cite every entry you rely on as [KB:<id>] right after the sentence it supports. If the entries do not answer "
|
|
145
|
+
"the question, reply exactly: HANDOFF. Write in the language of the question. Write plainly and briefly. "
|
|
146
|
+
"Never use an em dash or any dash between clauses: use a full stop, a comma, a colon or parentheses."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def build_messages(question: str, entries: List[Dict[str, Any]]) -> List[Dict[str, str]]:
|
|
151
|
+
kb = "\n\n".join("[KB:%s]\nQ: %s\nA: %s" % (e["id"], e.get("question", ""), e["answer"]) for e in entries)
|
|
152
|
+
return [{"role": "system", "content": SYSTEM + "\n\nKnowledge entries:\n\n" + kb},
|
|
153
|
+
{"role": "user", "content": question}]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class SupportAgent:
|
|
157
|
+
def __init__(self, *, knowledge: Knowledge, client: Any, model: str, fallback: str, state_dir: Path,
|
|
158
|
+
max_tokens: int = 600, max_chars: int = 1200, lookup_limit: int = 3,
|
|
159
|
+
on_handoff: Optional[Callable[[Dict[str, Any]], None]] = None):
|
|
160
|
+
self.knowledge, self.client = knowledge, client
|
|
161
|
+
self.model, self.fallback = model, fallback
|
|
162
|
+
self.state_dir = Path(state_dir)
|
|
163
|
+
self.max_tokens, self.max_chars, self.lookup_limit = max_tokens, max_chars, lookup_limit
|
|
164
|
+
self.on_handoff = on_handoff or self._outbox
|
|
165
|
+
self._lock = threading.Lock()
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def decisions_file(self) -> Path:
|
|
169
|
+
return self.state_dir / "decisions.json"
|
|
170
|
+
|
|
171
|
+
def _decisions(self) -> Dict[str, Any]:
|
|
172
|
+
try:
|
|
173
|
+
return json.loads(self.decisions_file.read_text(encoding="utf-8"))
|
|
174
|
+
except FileNotFoundError:
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
def _outbox(self, decision: Dict[str, Any]) -> None:
|
|
178
|
+
fabric_paths.ensure_private_dir(self.state_dir)
|
|
179
|
+
line = json.dumps(decision, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
180
|
+
fd = os.open(str(self.state_dir / "handoffs.jsonl"), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
181
|
+
try:
|
|
182
|
+
os.write(fd, line.encode("utf-8"))
|
|
183
|
+
finally:
|
|
184
|
+
os.close(fd)
|
|
185
|
+
|
|
186
|
+
def answer(self, message_id: str, text: str, *, task_run_id: Optional[str] = None) -> Dict[str, Any]:
|
|
187
|
+
"""The decision for one message: `{messageId, action: reply|handoff, reply?, citations, reasons, costUsd}`."""
|
|
188
|
+
if not isinstance(message_id, str) or not _ID.fullmatch(message_id):
|
|
189
|
+
raise RecipeError("message id: letters, digits, '.', '_', ':' or '-', up to 128")
|
|
190
|
+
if not isinstance(text, str) or not text.strip():
|
|
191
|
+
raise RecipeError("the message has no text")
|
|
192
|
+
text = re.sub("[\ud800-\udfff]", "\ufffd", text) # a lone surrogate (bytes a terminal could not decode) is kept, not fatal
|
|
193
|
+
with self._lock: # one decision per message id, in this process; the service's instance lock covers others
|
|
194
|
+
done = self._decisions().get(message_id)
|
|
195
|
+
if done is not None:
|
|
196
|
+
return dict(done, repeated=True)
|
|
197
|
+
decision = self._decide(message_id, text, task_run_id)
|
|
198
|
+
if decision["action"] == "handoff": # before the decision is saved: a crash repeats a handoff, never loses one
|
|
199
|
+
self.on_handoff(dict(decision, question=text))
|
|
200
|
+
decisions = self._decisions()
|
|
201
|
+
decisions[message_id] = decision
|
|
202
|
+
fabric_paths.ensure_private_dir(self.state_dir)
|
|
203
|
+
fabric_paths.atomic_write(self.decisions_file,
|
|
204
|
+
json.dumps(decisions, ensure_ascii=False, indent=1).encode("utf-8"))
|
|
205
|
+
return dict(decision, repeated=False)
|
|
206
|
+
|
|
207
|
+
def _decide(self, message_id: str, text: str, task_run_id: Optional[str]) -> Dict[str, Any]:
|
|
208
|
+
base = {"messageId": message_id, "citations": [], "costUsd": None}
|
|
209
|
+
entries = self.knowledge.lookup(text, self.lookup_limit)
|
|
210
|
+
if not entries:
|
|
211
|
+
return dict(base, action="handoff", reasons=["no-knowledge"])
|
|
212
|
+
try:
|
|
213
|
+
got = self.client.chat(build_messages(text, entries), model=self.model, fallback=self.fallback,
|
|
214
|
+
max_tokens=self.max_tokens, temperature=0, task_run_id=task_run_id)
|
|
215
|
+
except fabric_openrouter.OpenRouterError as err:
|
|
216
|
+
return dict(base, action="handoff", reasons=["model:" + err.kind])
|
|
217
|
+
base["costUsd"] = got["receipt"].get("costUsd")
|
|
218
|
+
if got["content"].strip() == "HANDOFF":
|
|
219
|
+
return dict(base, action="handoff", reasons=["model-unsure"])
|
|
220
|
+
checked = check_reply(got["content"], text, [e["id"] for e in entries], max_chars=self.max_chars)
|
|
221
|
+
if not checked["ok"]:
|
|
222
|
+
return dict(base, action="handoff", reasons=checked["problems"], citations=checked["citations"])
|
|
223
|
+
return dict(base, action="reply", reply=checked["reply"], citations=checked["citations"], reasons=[])
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def from_config(config_path: Path, **overrides: Any) -> SupportAgent:
|
|
227
|
+
"""An agent from a config file (see config.example.json): paths are relative to the config's folder."""
|
|
228
|
+
config = json.loads(Path(config_path).read_text(encoding="utf-8"))
|
|
229
|
+
home = Path(config_path).resolve().parent
|
|
230
|
+
state = (home / config.get("stateDir", "state")).resolve()
|
|
231
|
+
prices = config.get("prices") or {}
|
|
232
|
+
if config.get("catalog"):
|
|
233
|
+
prices = dict(fabric_openrouter.prices_from_catalog(json.loads((home / config["catalog"]).read_text("utf-8"))),
|
|
234
|
+
**prices)
|
|
235
|
+
client = fabric_openrouter.Client(ceiling_usd=float(config["ceilingUsd"]), window=config.get("window", "day"),
|
|
236
|
+
ledger=fabric_usage.JsonlUsageLedger(state / "usage.jsonl"), prices=prices,
|
|
237
|
+
key_env=config.get("keyEnv", "OPENROUTER_API_KEY"),
|
|
238
|
+
project_id=config.get("projectId"), **overrides)
|
|
239
|
+
return SupportAgent(knowledge=Knowledge.load(home / config["knowledge"]), client=client, model=config["model"],
|
|
240
|
+
fallback=config["fallback"], state_dir=state, max_tokens=int(config.get("maxTokens", 600)),
|
|
241
|
+
max_chars=int(config.get("maxChars", 1200)))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
245
|
+
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
246
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
247
|
+
ask = sub.add_parser("answer", help="decide one message and print the decision as JSON")
|
|
248
|
+
ask.add_argument("--config", required=True, type=Path)
|
|
249
|
+
ask.add_argument("--message-id", required=True)
|
|
250
|
+
ask.add_argument("--text", help="the question; read from stdin when absent")
|
|
251
|
+
args = parser.parse_args(argv)
|
|
252
|
+
try:
|
|
253
|
+
agent = from_config(args.config)
|
|
254
|
+
text = args.text if args.text is not None else sys.stdin.read()
|
|
255
|
+
decision = agent.answer(args.message_id, text)
|
|
256
|
+
except (RecipeError, fabric_openrouter.OpenRouterError, OSError, ValueError, KeyError) as err:
|
|
257
|
+
print(json.dumps({"error": type(err).__name__, "message": str(err)}), file=sys.stderr)
|
|
258
|
+
return 1
|
|
259
|
+
print(json.dumps(decision, ensure_ascii=False, indent=1))
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
sys.exit(main())
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Connectors: outbound HTTP and OpenRouter
|
|
2
|
+
|
|
3
|
+
Two kit modules a service uses to reach the outside: `fabric_http` for any HTTP API, `fabric_openrouter` for models.
|
|
4
|
+
Both have Node twins (`fabric-http.mjs`, `fabric-openrouter.mjs`) that run the same cases
|
|
5
|
+
(`test/fixtures/kit-http-cases.json`, `test/fixtures/kit-openrouter-cases.json`). Install them with
|
|
6
|
+
`fabric_kit.py install <dir> --module fabric_openrouter` ([kit modules](kit-modules.md)). The project board has its
|
|
7
|
+
own connector ([board](board.md)). Standard library only; tests inject the transport and never open the network.
|
|
8
|
+
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
- [fabric_http](#fabric_http)
|
|
12
|
+
- [fabric_openrouter](#fabric_openrouter)
|
|
13
|
+
- [Writing a connector for another API](#writing-a-connector-for-another-api)
|
|
14
|
+
|
|
15
|
+
## fabric_http
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import fabric_http
|
|
19
|
+
answer = fabric_http.request("GET", "https://api.example/v1/items", headers={"Authorization": "Bearer " + token},
|
|
20
|
+
deadline_s=20, retries=2)
|
|
21
|
+
answer.status, answer.headers["content-type"], answer.json()
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import { request } from './fabric-http.mjs';
|
|
26
|
+
const answer = await request('GET', 'https://api.example/v1/items', { headers, deadlineS: 20, retries: 2 });
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Rule | Default | Why |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| One deadline covers every attempt and every wait | `deadline_s` 30 | a caller's own deadline is one number, not one per retry |
|
|
32
|
+
| Retried: 408, 425, 429, 500, 502, 503, 504 and connection errors | `retries` 2 (0 to 10) | transient failures |
|
|
33
|
+
| Only when repeating is safe: `GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`, or an `Idempotency-Key` header; any other request only on 429 | | a second `POST` after a timeout may do the work twice |
|
|
34
|
+
| Wait before retry n: `min(max_backoff_s, backoff_base_s * 2**n) * (0.5 + random())` | 0.5 s base, 8 s cap | jitter spreads retries of many clients |
|
|
35
|
+
| `Retry-After` (seconds, or an IMF-fixdate) replaces the wait; over `max_retry_after_s` the answer stands | 30 s | the server knows when it recovers |
|
|
36
|
+
| A wait that would pass the deadline is not taken: the last answer or error stands | | |
|
|
37
|
+
| The body is read to one byte past `max_bytes`; over it is an error | 8 MiB | a runaway answer never fills memory |
|
|
38
|
+
| Redirects are answered, never followed | | a redirect must not carry the `Authorization` header to another host |
|
|
39
|
+
| Plain `http://` is refused unless the host is loopback or `insecure=True`; credentials in a URL are refused | | a token never crosses a network in clear, nor sits in a URL |
|
|
40
|
+
| Header names must be tokens; a value with a line break or NUL is refused | | header injection |
|
|
41
|
+
| A JSON body is written as `JSON.stringify` writes it: a lone surrogate escaped, NaN and Infinity refused | | both twins send the same bytes |
|
|
42
|
+
| Whatever the transport throws is a connection error, in both languages; a timeout is a timeout | | |
|
|
43
|
+
| The deadline is held across the whole response: a watchdog shuts the socket when it passes, so trickled headers or a trickled chunk cannot hold the caller | | |
|
|
44
|
+
| `Response.json()` reads as `JSON.parse` does: a byte order mark skipped, `NaN` and `Infinity` refused | | |
|
|
45
|
+
|
|
46
|
+
Every answer comes back as a `Response`, whatever its status; `HttpError` means no usable answer came, with `kind`
|
|
47
|
+
`connection`, `timeout`, `too-large` or `refused`, the status when there was one, and the URL redacted to scheme,
|
|
48
|
+
host, port and path (`redact_url`). An error never carries a request header or body.
|
|
49
|
+
|
|
50
|
+
## fabric_openrouter
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import fabric_openrouter, fabric_usage
|
|
54
|
+
client = fabric_openrouter.Client(ceiling_usd=2.0, window="day",
|
|
55
|
+
ledger=fabric_usage.JsonlUsageLedger(state / "usage.jsonl"),
|
|
56
|
+
prices=fabric_openrouter.prices_from_catalog(catalog))
|
|
57
|
+
answer = client.chat(messages, model="deepseek/deepseek-v4.1-flash", fallback="qwen/qwen3.8-flash", max_tokens=800)
|
|
58
|
+
answer["content"], answer["receipt"]["costUsd"], answer["attempts"]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
const client = new Client({ ceilingUsd: 2, ledger: new JsonlUsageLedger(file), prices: pricesFromCatalog(catalog) });
|
|
63
|
+
const answer = await client.chat(messages, { model, fallback, maxTokens: 800 });
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Rule | What happens |
|
|
67
|
+
|---|---|
|
|
68
|
+
| The key | read from the environment variable `key_env` names (`OPENROUTER_API_KEY`) at each call; sent only in `Authorization`; missing is `OpenRouterError` kind `config` before anything is sent |
|
|
69
|
+
| The ceiling, before every attempt | the window's spend (ledger receipts in the UTC `day` or `month`) plus calls in flight plus this attempt's worst case must stay within `ceiling_usd`; otherwise kind `ceiling`, nothing sent. Retries and the fallback are checked the same way |
|
|
70
|
+
| The worst case | the input at one token per two UTF-8 bytes of the messages and every param (tools, response_format) as compact JSON (`prompt_tokens_bound`), `max_tokens` of output, at the model's price, plus a per-request price. `models`, `route`, `plugins`, `web_search_options` and `n` are refused (they change what is billed), and so is `max_completion_tokens` (the client sets the output budget) |
|
|
71
|
+
| A model without a price | never called (kind `unpriced`): its spend could not be bounded. Prices come from `GET https://openrouter.ai/api/v1/models` through `prices_from_catalog`; a price is a plain decimal string or a number, and anything else (blank, hex, a list) is no price |
|
|
72
|
+
| A receipt without a cost in the window | an unmeasured attempt, or an answer that came with no usage, counted at the worst case of the attempt that wrote it while this client runs; one written before it started counts as `unmeasured_call_usd`, by default the current attempt's worst case |
|
|
73
|
+
| Receipts | every status-200 answer records one, and so does an attempt that may have been billed without an answer to read (a timeout, a cut or refused connection, a 200 over the size cap or not JSON: no tokens, `costUsd: null`, `tokensMeasured: false`; over-counting a refused connection is the safe side): the served model, the provider's tokens (cache reads and writes kept apart), `costUsd` from `usage.cost`, else from the price list (`costBasis: price-list`), else `null` with `tokensMeasured: false`; `route: key`, the client's `project_id` and the call's `task_run_id`. A request the provider answered with a 4xx or 5xx records nothing: it was not billed |
|
|
74
|
+
| What is an answer | `finish_reason` `stop` or `tool_calls` with content or tool calls; `error` is retried on the same model; `length` (the budget ran out, reasoning included), `content_filter` and empty content go to the fallback |
|
|
75
|
+
| Retries | 408, 425, 429, 500, 502, 503, 504 and connection errors on the same model, `retries` times (default 1), `Retry-After` honoured, within one deadline (`deadline_s` 120) for the whole call |
|
|
76
|
+
| Stop at once | 401, 402 and 403 (kinds `auth`, `payment`): a key, credit or permission problem is the same on every model |
|
|
77
|
+
| The fallback | required, of another vendor (the part before `/` differs) |
|
|
78
|
+
| Content | text only: a string, null, or parts that are all `{"type": "text"}`; an image, audio or file part is refused (`config`), because what it is billed cannot be bounded from its bytes |
|
|
79
|
+
| What the ceiling does not cover | the `provider` routing param can send a call to a provider priced above the catalog; two `Client` objects sharing one ledger each hold only their own calls in flight, so give an agent one client per key |
|
|
80
|
+
| Attribution | off unless an `attribution` setting is given (FAC-SEM-098, `fabric_usage.attribution_headers`) |
|
|
81
|
+
| Request body | `model`, `messages`, `max_tokens`, `usage: {include: true}` and your params (temperature, reasoning, response_format, tools); `stream`, `model`, `messages`, `max_tokens` and `usage` as params are refused |
|
|
82
|
+
|
|
83
|
+
`OpenRouterError.attempts` lists every attempt as `{model, status, outcome}` (`answered`, `retry`, `error`,
|
|
84
|
+
`length`, `filtered`, `empty`, `refused`, `bad-answer`, `auth`, `payment`); nothing in it holds the key or the
|
|
85
|
+
prompt. `totals(receipts)` gives calls, priced spend and unpriced calls for a dashboard line. Choosing the models
|
|
86
|
+
themselves: creating-fabric-agents, `references/models-and-providers.md`.
|
|
87
|
+
|
|
88
|
+
## Writing a connector for another API
|
|
89
|
+
|
|
90
|
+
Build it on `fabric_http.request`: one module per API, the key read from a named environment variable at call time,
|
|
91
|
+
the deadline and retries passed through, the provider's own answer mapped into your types, and a receipt through
|
|
92
|
+
`fabric_usage.make_usage_receipt` for every billed call. Put it in your service, not in the kit, unless several
|
|
93
|
+
services need it; a kit module also needs its Node twin and shared cases ([adding a module](kit-modules.md#adding-or-changing-a-module)).
|