@passioncode-ai/fabric-agent-adapter 0.4.3 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -2
- package/LICENSE +2 -3
- package/README.md +36 -7
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +20 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +57 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +15 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +267 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +19 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +77 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +177 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +215 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +470 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +118 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +9 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,71 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1 - 2026-09-30
|
|
4
|
+
|
|
5
|
+
Aligns the kits, the probe and the provider writer with the contract owner's rulings,
|
|
6
|
+
Fabric Agent Contract DEC-0017. **Contract pin** moves from `a22dea3` to `9cd778e`.
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
|
|
10
|
+
- **A job's result is the full result envelope** (OQ-0002): `result_envelope` /
|
|
11
|
+
`resultEnvelope` now build the contract's `result.schema.json` shape — `id`,
|
|
12
|
+
`contractVersion`, `outcome`, `artifacts`, `createdAt`, `producer` beside the four
|
|
13
|
+
collections, `output` and `usage` — and refuse `succeeded` with unverified claims
|
|
14
|
+
(FAC-SEM-001). `outcome` and `producer` are new required arguments.
|
|
15
|
+
- **The envelope carries its trace** (OQ-0003): `trace: {traceparent}`, authoritative for
|
|
16
|
+
a stored result. `JobStore.complete` fills it from the job's span and refuses an envelope
|
|
17
|
+
that names another span; `fabric.job.get` answers with the envelope's traceparent in
|
|
18
|
+
`_meta`, so the two always agree (FAC-SEM-022).
|
|
19
|
+
- **A job-backed tool serves `oneOf[result envelope, job handle]`** (OQ-0006):
|
|
20
|
+
`tool_for_capability` / `toolForCapability` wrap the output schema with
|
|
21
|
+
`job_tool_output_schema` / `jobToolOutputSchema` for a job capability, so
|
|
22
|
+
`structuredContent` always conforms; the manifest keeps the pure schema. The probe's
|
|
23
|
+
`interop.tools-match` expects the union for a job capability.
|
|
24
|
+
- **Provider entries name their provider by URI** (OQ-0001): `fabric_provider.py` requires
|
|
25
|
+
`providerId` (`--provider-id`), refuses an entry whose manifest does not resolve or whose
|
|
26
|
+
`provider.id` differs, and `validate` checks that the file is named `<id>.json`.
|
|
27
|
+
|
|
28
|
+
## 0.5.0 - 2026-09-30
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`fabric-interop/0.1` in the kits** (Fabric Agent Contract DEC-0016).
|
|
33
|
+
`building-fabric-services/scripts/fabric_interop.py` and its Node twin
|
|
34
|
+
`fabric-interop.mjs`: W3C trace context (`child_traceparent`, `trace_ids`), a
|
|
35
|
+
capability served as the MCP tool of its name with annotations from its effect,
|
|
36
|
+
durable jobs (`JobStore`: ids survive a restart, terminal states never change,
|
|
37
|
+
`unknown-job` for an unknown id), the result envelope with `usage`, and elicitations
|
|
38
|
+
(a titled single-select in form mode, URL mode for secrets; form mode refuses a
|
|
39
|
+
secret field). Python also ships `McpToolServer`, a minimal `tools/list` /
|
|
40
|
+
`tools/call` dispatcher with `fabric.job.get` and `fabric.job.cancel` built in.
|
|
41
|
+
- Events carry `traceId` and `spanId` as a pair (`make_event`, `makeEvent`).
|
|
42
|
+
- The sample service serves MCP at `/mcp`: `sample.echo`, and `sample.draft`, a job
|
|
43
|
+
that stops for a title choice and completes with a traced result. `register` writes
|
|
44
|
+
its manifest and schemas and points the descriptor's `fabricManifest` at it.
|
|
45
|
+
- The probe `check_service.py` gains seven `interop.*` rules: manifest link (G-07),
|
|
46
|
+
capability list, tools equal to the manifest (FAC-SEM-017), job tools, unknown job,
|
|
47
|
+
trace propagation and event trace pairs. It calls only `tools/list` and
|
|
48
|
+
`fabric.job.get` for a made-up id.
|
|
49
|
+
- `adapting-projects-to-fabric/scripts/fabric_provider.py`: writes, validates and
|
|
50
|
+
removes `fabric-provider/0.1` entries for agents that are not services — argv only,
|
|
51
|
+
env values as `secret-ref:` references, never the id of an existing service.
|
|
52
|
+
- `adapt_project.py scaffold --job` writes the capability's interop block; an `mcp`
|
|
53
|
+
capability now requires the tool of its own name instead of `tool:replace-me`, and
|
|
54
|
+
`check` refuses a malformed interop block.
|
|
55
|
+
- Skills teach it: `building-fabric-services` gains `references/interop.md`,
|
|
56
|
+
`adapting-projects-to-fabric` gains `references/provider-entry.md` and step 4b, and
|
|
57
|
+
`creating-fabric-agents` starts new agents on it. Trigger and scenario evals added
|
|
58
|
+
first.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- **Contract pin** moves from `a5a2709` to `a22dea3` (fabric-agent-contract `main`,
|
|
63
|
+
AR-1: interop, provider entries, runner catalogue, pipelines). The provider profiles
|
|
64
|
+
and every existing schema field are unchanged; the contract added schemas and
|
|
65
|
+
optional fields only (DEC-0016). **One pin**: `fabric-contract.lock.json`, and
|
|
66
|
+
`test/validate.py` fails any live file that names another contract revision (G-11).
|
|
67
|
+
- The README has a newcomer path that starts the sample service and probes it.
|
|
68
|
+
|
|
3
69
|
## 0.4.3 - 2026-09-29
|
|
4
70
|
|
|
5
71
|
### Changed
|
|
@@ -7,8 +73,7 @@
|
|
|
7
73
|
- **License.** The adapter is now source-available under
|
|
8
74
|
`PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0`, with a
|
|
9
75
|
commercial license on request (contact@passioncode.ai). Versions up to and including
|
|
10
|
-
v0.4.2, and npm
|
|
11
|
-
available under it. The manifests, the three skills' `license:` and the README say the
|
|
76
|
+
v0.4.2, on GitHub and on npm, were released under MIT and remain available under it. The manifests, the three skills' `license:` and the README say the
|
|
12
77
|
same; contributions come in under `CLA.md`, which the new PR template asks for.
|
|
13
78
|
`test/validate.py` fails a tree that slips back to MIT.
|
|
14
79
|
- Author and marketplace owner are PassionCode.ai (`https://passioncode.ai/`).
|
package/LICENSE
CHANGED
|
@@ -15,9 +15,8 @@ this software or its code into a product or service you provide to others,
|
|
|
15
15
|
needs a separate commercial license: contact@passioncode.ai.
|
|
16
16
|
This summary is not part of either license; the texts below govern.
|
|
17
17
|
|
|
18
|
-
Versions up to and including v0.4.2 of this repository
|
|
19
|
-
|
|
20
|
-
under MIT.
|
|
18
|
+
Versions up to and including v0.4.2 of this repository, and of its npm package, were
|
|
19
|
+
released under the MIT License; those releases remain available under MIT.
|
|
21
20
|
|
|
22
21
|
Required Notice: Copyright (c) 2026 Siarhei Sheleh (https://passioncode.ai)
|
|
23
22
|
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ without moving the Project onto one proprietary agent runtime.
|
|
|
17
17
|
[](https://github.com/passioncode-ai/fabric-agent-adapter/actions/workflows/validate.yml)
|
|
18
18
|
[](LICENSE)
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Portable Agent Skills that make any agent Fabric-compatible, for the
|
|
21
21
|
[Fabric Agent Contract](https://github.com/passioncode-ai/fabric-agent-contract):
|
|
22
22
|
`adapting-projects-to-fabric`, `creating-fabric-agents` and
|
|
23
23
|
`building-fabric-services`.
|
|
@@ -32,6 +32,14 @@ complete `scripts/sample_service.py`, and `scripts/check_service.py`, a live pro
|
|
|
32
32
|
python3 plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py example-agent
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
Services and agents that other agents call follow `fabric-interop/0.1`: each capability
|
|
36
|
+
is the MCP tool of its name, long work is a job with a stable id (`fabric.job.get`,
|
|
37
|
+
`fabric.job.cancel`), a question for a person is an elicitation, and every call carries
|
|
38
|
+
one W3C trace. The kits ship the helpers (`scripts/fabric_interop.py`,
|
|
39
|
+
`scripts/fabric-interop.mjs`), the sample service serves a job end to end, and the probe
|
|
40
|
+
checks the interop rules. An agent that is not a service is announced with a provider
|
|
41
|
+
entry: `adapting-projects-to-fabric/scripts/fabric_provider.py`.
|
|
42
|
+
|
|
35
43
|
For provider bundles the adapting skill helps an agent author:
|
|
36
44
|
|
|
37
45
|
- inspect the project's stable integration surfaces without executing it;
|
|
@@ -46,7 +54,7 @@ yet ship the host registry/runtime needed to connect and authorize a live provid
|
|
|
46
54
|
|
|
47
55
|
## Install
|
|
48
56
|
|
|
49
|
-
The repository
|
|
57
|
+
The repository and the npm package are public; neither path needs an account.
|
|
50
58
|
|
|
51
59
|
npm — no GitHub access required:
|
|
52
60
|
|
|
@@ -110,6 +118,23 @@ python3 "$SKILL_DIR/scripts/adapt_project.py" check /path/to/project \
|
|
|
110
118
|
Scaffolding is non-destructive by default. It writes only the paths declared in the
|
|
111
119
|
[delivery brief](docs/evidence/specs/2026-08-27-brief.md) and refuses collisions.
|
|
112
120
|
|
|
121
|
+
## Verify one service (newcomer path)
|
|
122
|
+
|
|
123
|
+
After installing (above), with Python 3.9+ and no other dependency:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
KIT=plugins/fabric-agent-adapter/skills/building-fabric-services/scripts # or ~/.agents/skills/building-fabric-services/scripts
|
|
127
|
+
DATA=$(mktemp -d); SERVICES=$(mktemp -d)
|
|
128
|
+
python3 "$KIT/sample_service.py" serve --port 47190 --data-dir "$DATA" &
|
|
129
|
+
python3 "$KIT/sample_service.py" register --port 47190 --data-dir "$DATA" --services-dir "$SERVICES"
|
|
130
|
+
python3 "$KIT/check_service.py" sample --services-dir "$SERVICES" # exit 0: no FAIL
|
|
131
|
+
kill %1
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The probe prints one line per rule — `PASS`, `FAIL` or `NOT_RUN` with its evidence — and
|
|
135
|
+
exits 1 on any `FAIL`. Run it against your own service by its id once its installer has
|
|
136
|
+
written the descriptor.
|
|
137
|
+
|
|
113
138
|
## Validate this repository
|
|
114
139
|
|
|
115
140
|
```bash
|
|
@@ -125,17 +150,21 @@ checks copies installed elsewhere, such as `~/.agents/skills/*/SKILL.md`.
|
|
|
125
150
|
|
|
126
151
|
## Contract pin
|
|
127
152
|
|
|
153
|
+
The one pin is [`fabric-contract.lock.json`](fabric-contract.lock.json):
|
|
154
|
+
|
|
128
155
|
- version: `0.1.0`
|
|
129
|
-
- commit: `
|
|
156
|
+
- commit: `9cd778eb6f14b977f9a5b4930826f62dc95c5619`
|
|
130
157
|
|
|
131
|
-
|
|
132
|
-
|
|
158
|
+
Every other mention of the contract revision — this section, the skills' metadata, the
|
|
159
|
+
skill card, `adapt_project.py` — must equal it; `python3 test/validate.py` fails a tree
|
|
160
|
+
where one does not. The contract repository is normative. Updating the pin requires a
|
|
161
|
+
new adapter release, fixture review, and a complete validation run.
|
|
133
162
|
|
|
134
163
|
## License
|
|
135
164
|
|
|
136
165
|
Source-available under PolyForm Noncommercial or Internal Use; commercial license on
|
|
137
166
|
request (contact@passioncode.ai). SPDX:
|
|
138
167
|
`PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0` — see
|
|
139
|
-
[LICENSE](LICENSE). Versions up to and including v0.4.2 (
|
|
140
|
-
under the MIT License and remain available under it. Contributions are accepted under
|
|
168
|
+
[LICENSE](LICENSE). Versions up to and including v0.4.2 (on GitHub and on npm) were
|
|
169
|
+
released under the MIT License and remain available under it. Contributions are accepted under
|
|
141
170
|
[CLA.md](CLA.md) ([CONTRIBUTING.md](CONTRIBUTING.md)).
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
-
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test test
|
|
6
|
+
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
|
|
7
7
|
},
|
|
8
8
|
"bin": {
|
|
9
9
|
"fabric-agent-adapter": "bin/fabric-agent-adapter.js"
|
|
@@ -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.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.5.1",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,9 +5,9 @@ license: PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned private fabric-agent-contract checkout. 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.5.1"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "9cd778eb6f14b977f9a5b4930826f62dc95c5619"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Adapting projects to Fabric
|
|
@@ -70,7 +70,7 @@ Use exactly:
|
|
|
70
70
|
|
|
71
71
|
- contract version `0.1.0`;
|
|
72
72
|
- repository `https://github.com/passioncode-ai/fabric-agent-contract`;
|
|
73
|
-
- commit `
|
|
73
|
+
- commit `9cd778eb6f14b977f9a5b4930826f62dc95c5619`.
|
|
74
74
|
|
|
75
75
|
Read the pinned contract's guide
|
|
76
76
|
`docs/guides/connecting-compatible-agents.md`, the selected profile specification, and
|
|
@@ -93,7 +93,10 @@ python3 <skill-dir>/scripts/adapt_project.py scaffold <project-root> \
|
|
|
93
93
|
--schema-base https://agents.example/fabric
|
|
94
94
|
```
|
|
95
95
|
|
|
96
|
-
Valid profiles are `mcp`, `a2a`, and `local-runner`.
|
|
96
|
+
Valid profiles are `mcp`, `a2a`, and `local-runner`. For `mcp` the capability is served
|
|
97
|
+
as the MCP tool of its own name (`requiredFeatures: ["tool:<capability>"]`, contract
|
|
98
|
+
`fabric-interop/0.1`); add `--job` when its work may outlive one request, which writes
|
|
99
|
+
`"job": true` in the capability's interop block. The helper creates only the locked
|
|
97
100
|
target paths. It refuses any collision. Do not use `--force` unless the user explicitly
|
|
98
101
|
authorizes replacement after the exact conflicting files and diff are shown.
|
|
99
102
|
|
|
@@ -108,6 +111,11 @@ Preserve the chosen ownership boundary:
|
|
|
108
111
|
- A2A maps the provider's remote task, progress, artifact, cancellation, and terminal
|
|
109
112
|
states; Fabric does not take over its internal loop.
|
|
110
113
|
- MCP exposes bounded tools/resources/prompts while Fabric owns planning and retries.
|
|
114
|
+
The served tool's name, input and output schemas equal the manifest's (FAC-SEM-017);
|
|
115
|
+
long work returns a job handle and serves `fabric.job.get` / `fabric.job.cancel`; a
|
|
116
|
+
question for a person is a form-mode choice, a secret goes through URL mode; every call
|
|
117
|
+
runs as a child span of `_meta.traceparent`. The kit for all of it is
|
|
118
|
+
`building-fabric-services`'s `scripts/fabric_interop.py`.
|
|
111
119
|
- Local runner maps typed input, executable identity, result location, cancellation,
|
|
112
120
|
heartbeat, and partial results without relying on ambient accounts.
|
|
113
121
|
|
|
@@ -115,6 +123,14 @@ Keep model selection and provider-internal reasoning outside the Fabric contract
|
|
|
115
123
|
typed outcomes, evidence, artifacts, and protocol-visible state—not chain-of-thought.
|
|
116
124
|
Treat all provider output as untrusted until schemas and semantic assertions pass.
|
|
117
125
|
|
|
126
|
+
### 4b. Announce an agent that is not a service
|
|
127
|
+
|
|
128
|
+
An agent Fabric reaches as a CLI or a stdio MCP server gets a provider entry, written by
|
|
129
|
+
its installer with [`scripts/fabric_provider.py`](scripts/fabric_provider.py) and removed
|
|
130
|
+
by its uninstaller — argv arrays only, env values as `secret-ref:` references, never the
|
|
131
|
+
id of an existing service, and a `providerId` equal to the manifest's `provider.id`. Load [provider entries](references/provider-entry.md). An
|
|
132
|
+
agent that runs as a service uses a descriptor instead (`building-fabric-services`).
|
|
133
|
+
|
|
118
134
|
### 5. Make probes safe and meaningful
|
|
119
135
|
|
|
120
136
|
For each capability, create at least one bounded fixture that:
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Provider entries: agents that are not services
|
|
2
|
+
|
|
3
|
+
Normative source: the Fabric Agent Contract's `docs/specification/provider.md`
|
|
4
|
+
(`fabric-provider/0.1`, DEC-0016, rulings DEC-0017) at the commit this plugin pins.
|
|
5
|
+
|
|
6
|
+
An agent Fabric reaches as a CLI or a stdio MCP server — not a long-running service —
|
|
7
|
+
is announced by one entry, `providers/<id>.json`, in the same root as the services
|
|
8
|
+
directory:
|
|
9
|
+
|
|
10
|
+
| Platform | Directory |
|
|
11
|
+
|---|---|
|
|
12
|
+
| macOS | `~/Library/Application Support/ai.passioncode.fabric/providers/` |
|
|
13
|
+
| Linux | `${XDG_DATA_HOME:-~/.local/share}/passioncode-fabric/providers/` |
|
|
14
|
+
| any | `FABRIC_PROVIDERS_DIR` when set |
|
|
15
|
+
|
|
16
|
+
A service uses a descriptor instead (the `building-fabric-services` skill). One id is a
|
|
17
|
+
service or a provider, never both.
|
|
18
|
+
|
|
19
|
+
## The writer
|
|
20
|
+
|
|
21
|
+
`scripts/fabric_provider.py` is the installer's tool; it writes atomically, mode 0600,
|
|
22
|
+
and refuses what the contract refuses.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
python3 scripts/fabric_provider.py write --id example-agent --provider-id https://agents.example/providers/example-agent \
|
|
26
|
+
--name "Example Agent" \
|
|
27
|
+
--manifest ~/.local/share/example-agent/fabric-agent.json --installed-by "example-agent 1.0.0" \
|
|
28
|
+
--env EXAMPLE_API_KEY=secret-ref:example-agent/EXAMPLE_API_KEY \
|
|
29
|
+
--stdio ~/.local/bin/example-agent mcp
|
|
30
|
+
python3 scripts/fabric_provider.py write ... --url http://127.0.0.1:47201/mcp
|
|
31
|
+
python3 scripts/fabric_provider.py validate ~/Library/Application\ Support/ai.passioncode.fabric/providers/example-agent.json
|
|
32
|
+
python3 scripts/fabric_provider.py remove example-agent # the uninstaller
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`--stdio` takes the rest of the line as the argument array; there is no shell string.
|
|
36
|
+
|
|
37
|
+
It refuses, with one sentence that never quotes a value:
|
|
38
|
+
|
|
39
|
+
- a manifest that does not resolve, or whose `provider.id` is not the entry's
|
|
40
|
+
`providerId` — the two URIs are compared as URIs (FAC-SEM-014);
|
|
41
|
+
- an id that is already a service in `services/` (FAC-SEM-013);
|
|
42
|
+
- an env value that is not `secret-ref:<name>`, or a reference that has the shape of a
|
|
43
|
+
credential itself — a token prefix, a private key, a JWT (FAC-SEM-015: a value is
|
|
44
|
+
checked by its form; name patterns apply to names);
|
|
45
|
+
- a URL other than `http://127.0.0.1:<port>/mcp`, a manifest path that is not an
|
|
46
|
+
absolute or `~/` path to `fabric-agent.json`, a shell-string command, unknown fields.
|
|
47
|
+
|
|
48
|
+
`validate` also checks that the file is named `<id>.json` (FAC-SEM-014, slug half).
|
|
49
|
+
From Python: `write_provider_entry(entry)`, `remove_provider_entry(id)`,
|
|
50
|
+
`validate_provider_entry(entry)`, `manifest_problems(entry)`, `providers_dir()`.
|
|
51
|
+
|
|
52
|
+
## What the entry does not do
|
|
53
|
+
|
|
54
|
+
It grants nothing: Fabric lists the agent, and admission (probes) and a project binding
|
|
55
|
+
still decide what it may do. The manifest it points at is the authority for the
|
|
56
|
+
agent's capabilities. Equality is between like things (DEC-0017): the slug `id` with the
|
|
57
|
+
file name, the URI `providerId` with the manifest's URI `provider.id`.
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py
CHANGED
|
@@ -16,7 +16,8 @@ from urllib.parse import urlparse
|
|
|
16
16
|
|
|
17
17
|
CONTRACT_VERSION = "0.1.0"
|
|
18
18
|
CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
|
|
19
|
-
CONTRACT_COMMIT = "
|
|
19
|
+
CONTRACT_COMMIT = "9cd778eb6f14b977f9a5b4930826f62dc95c5619"
|
|
20
|
+
INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
20
21
|
MCP_REVISION = "2026-07-28"
|
|
21
22
|
A2A_VERSION = "1.0"
|
|
22
23
|
LOCAL_VERSION = "fabric-local-runner/0.1"
|
|
@@ -167,7 +168,8 @@ def _profile(args: argparse.Namespace, schema_base: str) -> Dict[str, Any]:
|
|
|
167
168
|
"kind": "mcp",
|
|
168
169
|
"protocolRevision": MCP_REVISION,
|
|
169
170
|
"connection": connection,
|
|
170
|
-
|
|
171
|
+
# fabric-interop/0.1 C3.1: the capability is served as the MCP tool of its own name.
|
|
172
|
+
"requiredFeatures": ["tool:%s" % args.capability_name],
|
|
171
173
|
"probes": [probe],
|
|
172
174
|
}
|
|
173
175
|
if profile == "a2a":
|
|
@@ -215,6 +217,8 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
|
|
|
215
217
|
_require_absolute_uri(args.agent_card_url, "Agent Card URL")
|
|
216
218
|
if urlparse(args.agent_card_url).scheme != "https":
|
|
217
219
|
raise AdaptationError("Agent Card URL must use HTTPS")
|
|
220
|
+
if getattr(args, "job", False) and args.profile != "mcp":
|
|
221
|
+
raise AdaptationError("--job marks an MCP capability as a job (fabric-interop/0.1); %s has no job handle" % args.profile)
|
|
218
222
|
for runtime_arg in args.runtime_arg:
|
|
219
223
|
lowered = runtime_arg.lower().replace("_", "").replace("-", "")
|
|
220
224
|
if any(term in lowered for term in ("password", "apikey", "accesstoken", "clientsecret")):
|
|
@@ -249,6 +253,8 @@ def _generated_files(args: argparse.Namespace) -> Dict[str, str]:
|
|
|
249
253
|
}
|
|
250
254
|
],
|
|
251
255
|
}
|
|
256
|
+
if getattr(args, "job", False):
|
|
257
|
+
manifest["capabilities"][0]["extensions"] = {INTEROP_KEY: {"job": True}}
|
|
252
258
|
lock = {
|
|
253
259
|
"contract": "fabric-agent-contract",
|
|
254
260
|
"version": CONTRACT_VERSION,
|
|
@@ -431,6 +437,12 @@ def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
|
|
|
431
437
|
errors.append("capability[%d] must pin %s=%s" % (index, expected[0], expected[1]))
|
|
432
438
|
if not profile.get("probes"):
|
|
433
439
|
errors.append("capability[%d] must declare probes" % index)
|
|
440
|
+
block = (capability.get("extensions") or {}).get(INTEROP_KEY)
|
|
441
|
+
if block is not None:
|
|
442
|
+
if not isinstance(block, dict) or set(block) - {"job"} or not isinstance(block.get("job", False), bool):
|
|
443
|
+
errors.append("capability[%d] interop block must be {\"job\": true|false}" % index)
|
|
444
|
+
elif kind != "mcp":
|
|
445
|
+
errors.append("capability[%d] interop block is for an mcp capability, not %s" % (index, kind))
|
|
434
446
|
for field in ("inputSchema", "outputSchema"):
|
|
435
447
|
value = capability.get(field)
|
|
436
448
|
try:
|
|
@@ -503,6 +515,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
503
515
|
scaffold.add_argument("--runner-kind", default="replace-me")
|
|
504
516
|
scaffold.add_argument("--executable-ref", default="urn:executable:replace-me")
|
|
505
517
|
scaffold.add_argument("--runtime-arg", action="append", default=[])
|
|
518
|
+
scaffold.add_argument("--job", action="store_true", help="the capability's work may outlive one request (fabric-interop/0.1 job)")
|
|
506
519
|
scaffold.add_argument("--force", action="store_true")
|
|
507
520
|
scaffold.add_argument("--json", action="store_true")
|
|
508
521
|
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Write and remove fabric-provider/0.1 entries: agents that are not services.
|
|
3
|
+
|
|
4
|
+
An agent reached as a CLI or a stdio MCP server announces itself with one entry in
|
|
5
|
+
the providers directory, beside the services directory. Only its installer writes the
|
|
6
|
+
entry and only its uninstaller removes it; the entry grants no Project access.
|
|
7
|
+
Standard library only; it does not import the service kit, so this skill stands alone.
|
|
8
|
+
|
|
9
|
+
fabric_provider.py write --id ID --provider-id URI --name NAME --manifest PATH --installed-by TEXT
|
|
10
|
+
(--url http://127.0.0.1:PORT/mcp | --stdio EXECUTABLE [ARG ...])
|
|
11
|
+
[--env NAME=secret-ref:REF ...] [--summary TEXT] [--repository URL]
|
|
12
|
+
fabric_provider.py remove ID
|
|
13
|
+
fabric_provider.py validate PATH
|
|
14
|
+
|
|
15
|
+
Normative source: fabric-agent-contract docs/specification/provider.md (DEC-0016, rulings DEC-0017).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# #region provider-writer — docs: plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md#the-writer
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import datetime as dt
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
from typing import Any, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
PROTOCOL = "fabric-provider/0.1"
|
|
33
|
+
_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|
34
|
+
_ENV_NAME = re.compile(r"^[A-Z_][A-Z0-9_]*$")
|
|
35
|
+
_SECRET_REF = re.compile(r"^secret-ref:([A-Za-z0-9][A-Za-z0-9._/:@-]{0,255})$")
|
|
36
|
+
_LOCAL_PATH = re.compile(r"^(~/|/)[^\x00]*$")
|
|
37
|
+
_URL = re.compile(r"^http://127\.0\.0\.1:([0-9]{1,5})/mcp$")
|
|
38
|
+
_URI = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:[^\s]+$")
|
|
39
|
+
FIELDS = {"protocol", "id", "providerId", "name", "summary", "manifest", "run", "source", "installedAt", "installedBy", "extensions"}
|
|
40
|
+
# FAC-SEM-015 reads a credential by its SHAPE: a reference named after a key is fine.
|
|
41
|
+
CREDENTIAL_SHAPES = [re.compile(p) for p in (
|
|
42
|
+
r"^(sk|pk|rk)[-_](live|test|proj|or|ant)?[-_]?[A-Za-z0-9_-]{16,}",
|
|
43
|
+
r"^(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}",
|
|
44
|
+
r"^github_pat_[A-Za-z0-9_]{20,}",
|
|
45
|
+
r"^xox[abprs]-[A-Za-z0-9-]{10,}",
|
|
46
|
+
r"^(AKIA|ASIA)[A-Z0-9]{16}$",
|
|
47
|
+
r"^lin_api_[A-Za-z0-9]{20,}",
|
|
48
|
+
r"^AIza[0-9A-Za-z_-]{30,}",
|
|
49
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
|
|
50
|
+
r"^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$",
|
|
51
|
+
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])[A-Za-z0-9+=_-]{32,}$",
|
|
52
|
+
)]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ProviderError(Exception):
|
|
56
|
+
"""An entry was refused; the message is one readable sentence and never quotes a value."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def services_dir() -> Path:
|
|
60
|
+
override = os.environ.get("FABRIC_SERVICES_DIR")
|
|
61
|
+
if override:
|
|
62
|
+
return Path(override).expanduser()
|
|
63
|
+
home = Path.home()
|
|
64
|
+
if sys.platform == "darwin":
|
|
65
|
+
return home / "Library/Application Support/ai.passioncode.fabric/services"
|
|
66
|
+
return Path(os.environ.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric/services"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def providers_dir() -> Path:
|
|
70
|
+
"""FABRIC_PROVIDERS_DIR, else `providers/` in the same root as `services/`."""
|
|
71
|
+
override = os.environ.get("FABRIC_PROVIDERS_DIR")
|
|
72
|
+
if override:
|
|
73
|
+
return Path(override).expanduser()
|
|
74
|
+
return services_dir().parent / "providers"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def looks_like_credential(value: str) -> bool:
|
|
78
|
+
return any(shape.search(value) for shape in CREDENTIAL_SHAPES)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def validate_provider_entry(entry: Any) -> List[str]:
|
|
82
|
+
"""Mirrors provider.schema.json and FAC-SEM-015 (the schema stays normative)."""
|
|
83
|
+
if not isinstance(entry, dict):
|
|
84
|
+
return ["an entry is a JSON object"]
|
|
85
|
+
problems: List[str] = []
|
|
86
|
+
for key in ("protocol", "id", "providerId", "name", "manifest", "run", "installedAt", "installedBy"):
|
|
87
|
+
if key not in entry:
|
|
88
|
+
problems.append("missing %s" % key)
|
|
89
|
+
for key in sorted(set(entry) - FIELDS):
|
|
90
|
+
problems.append("unknown field %s" % key)
|
|
91
|
+
if problems:
|
|
92
|
+
return problems
|
|
93
|
+
if entry["protocol"] != PROTOCOL:
|
|
94
|
+
problems.append("protocol must be %s" % PROTOCOL)
|
|
95
|
+
if not _ID.match(str(entry["id"])):
|
|
96
|
+
problems.append("id must match %s" % _ID.pattern)
|
|
97
|
+
if not _URI.match(str(entry["providerId"])):
|
|
98
|
+
problems.append("providerId is the absolute URI of the manifest's provider.id")
|
|
99
|
+
if not isinstance(entry["name"], str) or not 1 <= len(entry["name"]) <= 80:
|
|
100
|
+
problems.append("name is 1 to 80 characters")
|
|
101
|
+
if "summary" in entry and (not isinstance(entry["summary"], str) or len(entry["summary"]) > 200):
|
|
102
|
+
problems.append("summary is at most 200 characters")
|
|
103
|
+
manifest = str(entry["manifest"])
|
|
104
|
+
if not _LOCAL_PATH.match(manifest) or not re.search(r"(^|/)fabric-agent\.json$", manifest):
|
|
105
|
+
problems.append("manifest is an absolute or ~/ path to fabric-agent.json")
|
|
106
|
+
problems.extend(_run_problems(entry["run"]))
|
|
107
|
+
return problems
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run_problems(run: Any) -> List[str]:
|
|
111
|
+
mcp = run.get("mcp") if isinstance(run, dict) and set(run) == {"mcp"} else None
|
|
112
|
+
if not isinstance(mcp, dict) or len(mcp) != 1 or not set(mcp) <= {"stdio", "url"}:
|
|
113
|
+
return ["run is {mcp: {stdio: ...}} or {mcp: {url: ...}}, exactly one"]
|
|
114
|
+
if "url" in mcp:
|
|
115
|
+
match = _URL.match(str(mcp["url"]))
|
|
116
|
+
return [] if match and 1 <= int(match.group(1)) <= 65535 else ["run.mcp.url is http://127.0.0.1:<port>/mcp"]
|
|
117
|
+
stdio = mcp["stdio"]
|
|
118
|
+
problems: List[str] = []
|
|
119
|
+
if not isinstance(stdio, dict) or not set(stdio) <= {"command", "env"} or "command" not in stdio:
|
|
120
|
+
return ["run.mcp.stdio is {command, env?}"]
|
|
121
|
+
command = stdio["command"]
|
|
122
|
+
if not isinstance(command, list) or not 1 <= len(command) <= 32 or not all(isinstance(a, str) and a for a in command):
|
|
123
|
+
problems.append("run.mcp.stdio.command is an argument array, never a shell string")
|
|
124
|
+
env = stdio.get("env", {})
|
|
125
|
+
if not isinstance(env, dict):
|
|
126
|
+
return problems + ["run.mcp.stdio.env is an object"]
|
|
127
|
+
for name, value in env.items():
|
|
128
|
+
if not _ENV_NAME.match(name):
|
|
129
|
+
problems.append("env name %s is not an environment variable name" % name)
|
|
130
|
+
reference = _SECRET_REF.match(value) if isinstance(value, str) else None
|
|
131
|
+
if not reference or looks_like_credential(reference.group(1)):
|
|
132
|
+
problems.append("env %s must be a secret reference (secret-ref:<name>), never a secret value (FAC-SEM-015)" % name)
|
|
133
|
+
return problems
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def manifest_problems(entry: Dict[str, Any]) -> List[str]:
|
|
137
|
+
"""FAC-SEM-014 (DEC-0017), URI half: the manifest resolves and its provider.id equals providerId."""
|
|
138
|
+
try:
|
|
139
|
+
manifest = json.loads(Path(os.path.expanduser(str(entry["manifest"]))).read_text(encoding="utf-8"))
|
|
140
|
+
except (OSError, ValueError):
|
|
141
|
+
return ["the manifest %s does not resolve (FAC-SEM-014)" % entry["manifest"]]
|
|
142
|
+
provider = manifest.get("provider") if isinstance(manifest, dict) else None
|
|
143
|
+
if not isinstance(provider, dict) or not isinstance(manifest.get("capabilities"), list):
|
|
144
|
+
return ["%s is not a provider manifest (FAC-SEM-014)" % entry["manifest"]]
|
|
145
|
+
if provider.get("id") != entry["providerId"]:
|
|
146
|
+
return ["providerId %s is not the manifest's provider.id %s (FAC-SEM-014)" % (entry["providerId"], provider.get("id"))]
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _atomic_write(path: Path, data: bytes) -> None:
|
|
151
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
os.chmod(path.parent, 0o700)
|
|
153
|
+
fd, tmp = tempfile.mkstemp(prefix="." + path.name + ".", dir=str(path.parent))
|
|
154
|
+
try:
|
|
155
|
+
with os.fdopen(fd, "wb") as handle:
|
|
156
|
+
handle.write(data)
|
|
157
|
+
handle.flush()
|
|
158
|
+
os.fsync(handle.fileno())
|
|
159
|
+
os.chmod(tmp, 0o600)
|
|
160
|
+
os.replace(tmp, path)
|
|
161
|
+
except BaseException:
|
|
162
|
+
try:
|
|
163
|
+
os.unlink(tmp)
|
|
164
|
+
except FileNotFoundError:
|
|
165
|
+
pass
|
|
166
|
+
raise
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _service_ids(directory: Path) -> Dict[str, str]:
|
|
170
|
+
ids: Dict[str, str] = {}
|
|
171
|
+
if directory.is_dir():
|
|
172
|
+
for path in sorted(directory.glob("*.json")):
|
|
173
|
+
try:
|
|
174
|
+
ids.setdefault(str(json.loads(path.read_text(encoding="utf-8")).get("id")), path.name)
|
|
175
|
+
except (OSError, ValueError, AttributeError):
|
|
176
|
+
continue
|
|
177
|
+
return ids
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def write_provider_entry(entry: Dict[str, Any], directory: Optional[Path] = None, services: Optional[Path] = None) -> Path:
|
|
181
|
+
"""Installer-only. Refuses an invalid entry and an id that is already a service (FAC-SEM-013)."""
|
|
182
|
+
problems = validate_provider_entry(entry)
|
|
183
|
+
if not problems:
|
|
184
|
+
problems = manifest_problems(entry)
|
|
185
|
+
if problems:
|
|
186
|
+
raise ProviderError("Provider entry is invalid: %s." % "; ".join(problems))
|
|
187
|
+
clash = _service_ids(services or services_dir()).get(entry["id"])
|
|
188
|
+
if clash:
|
|
189
|
+
raise ProviderError("%s is already a service (%s); an id is a service or a provider, never both (FAC-SEM-013)." % (entry["id"], clash))
|
|
190
|
+
target = (directory or providers_dir()) / ("%s.json" % entry["id"])
|
|
191
|
+
_atomic_write(target, (json.dumps(entry, indent=2, ensure_ascii=False) + "\n").encode())
|
|
192
|
+
return target
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def remove_provider_entry(provider_id: str, directory: Optional[Path] = None) -> bool:
|
|
196
|
+
"""Uninstaller-only. Returns whether there was an entry to remove."""
|
|
197
|
+
if not _ID.match(provider_id):
|
|
198
|
+
raise ProviderError("Provider id %s does not match %s." % (provider_id, _ID.pattern))
|
|
199
|
+
try:
|
|
200
|
+
((directory or providers_dir()) / ("%s.json" % provider_id)).unlink()
|
|
201
|
+
return True
|
|
202
|
+
except FileNotFoundError:
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _now() -> str:
|
|
207
|
+
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
211
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
212
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
213
|
+
write = sub.add_parser("write")
|
|
214
|
+
write.add_argument("--id", required=True)
|
|
215
|
+
write.add_argument("--provider-id", required=True, help="the manifest's provider.id (a URI)")
|
|
216
|
+
write.add_argument("--name", required=True)
|
|
217
|
+
write.add_argument("--summary")
|
|
218
|
+
write.add_argument("--manifest", required=True)
|
|
219
|
+
write.add_argument("--installed-by", required=True)
|
|
220
|
+
write.add_argument("--repository")
|
|
221
|
+
write.add_argument("--env", action="append", default=[], help="NAME=secret-ref:REF (a reference, never a value)")
|
|
222
|
+
transport = write.add_mutually_exclusive_group(required=True)
|
|
223
|
+
transport.add_argument("--url")
|
|
224
|
+
transport.add_argument("--stdio", nargs=argparse.REMAINDER, help="the executable and its arguments, last on the line")
|
|
225
|
+
remove = sub.add_parser("remove")
|
|
226
|
+
remove.add_argument("id")
|
|
227
|
+
check = sub.add_parser("validate")
|
|
228
|
+
check.add_argument("path", type=Path)
|
|
229
|
+
args = parser.parse_args(argv)
|
|
230
|
+
try:
|
|
231
|
+
if args.command == "remove":
|
|
232
|
+
print("removed" if remove_provider_entry(args.id) else "no entry for %s" % args.id)
|
|
233
|
+
return 0
|
|
234
|
+
if args.command == "validate":
|
|
235
|
+
entry = json.loads(args.path.read_text(encoding="utf-8"))
|
|
236
|
+
problems = validate_provider_entry(entry)
|
|
237
|
+
if isinstance(entry, dict) and entry.get("id") != args.path.stem:
|
|
238
|
+
problems.append("the file name %s is not <id>.json for %s (FAC-SEM-014)" % (args.path.name, entry.get("id")))
|
|
239
|
+
if not problems:
|
|
240
|
+
problems = manifest_problems(entry)
|
|
241
|
+
print("; ".join(problems) if problems else "valid")
|
|
242
|
+
return 1 if problems else 0
|
|
243
|
+
env: Dict[str, str] = {}
|
|
244
|
+
for pair in args.env:
|
|
245
|
+
name, sep, value = pair.partition("=")
|
|
246
|
+
if not sep:
|
|
247
|
+
raise ProviderError("--env takes NAME=secret-ref:REF.")
|
|
248
|
+
env[name] = value
|
|
249
|
+
run: Dict[str, Any] = {"mcp": {"url": args.url}} if args.url else {"mcp": {"stdio": {"command": args.stdio}}}
|
|
250
|
+
if env and args.stdio:
|
|
251
|
+
run["mcp"]["stdio"]["env"] = env
|
|
252
|
+
entry: Dict[str, Any] = {"protocol": PROTOCOL, "id": args.id, "providerId": args.provider_id, "name": args.name, "manifest": args.manifest,
|
|
253
|
+
"run": run, "installedAt": _now(), "installedBy": args.installed_by}
|
|
254
|
+
if args.summary:
|
|
255
|
+
entry["summary"] = args.summary
|
|
256
|
+
if args.repository:
|
|
257
|
+
entry["source"] = {"repository": args.repository}
|
|
258
|
+
print(write_provider_entry(entry))
|
|
259
|
+
return 0
|
|
260
|
+
except (ProviderError, OSError, ValueError) as exc:
|
|
261
|
+
print(str(exc), file=sys.stderr)
|
|
262
|
+
return 1
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
raise SystemExit(main())
|
|
267
|
+
# #endregion provider-writer
|