@passioncode-ai/fabric-agent-adapter 0.4.2 → 0.5.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.
Files changed (21) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +150 -17
  3. package/README.md +44 -6
  4. package/SECURITY.md +2 -3
  5. package/bin/fabric-agent-adapter.js +1 -1
  6. package/package.json +5 -5
  7. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +4 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +25 -6
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +53 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +15 -2
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +242 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -7
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +77 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +174 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +188 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +424 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +14 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +116 -1
  21. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +22 -5
@@ -46,6 +46,8 @@ _INSTANCE = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
46
46
  _KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$")
47
47
  _ORIGIN = re.compile(r"^http://127\.0\.0\.1:([0-9]{3,5})$")
48
48
  _CODE = re.compile(r"^[A-Za-z0-9_-]{16,256}$")
49
+ _TRACE_ID = re.compile(r"^(?!0{32}$)[0-9a-f]{32}$")
50
+ _SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
49
51
 
50
52
 
51
53
  class ServiceError(Exception):
@@ -404,8 +406,12 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
404
406
  # --- events ------------------------------------------------------------------
405
407
 
406
408
  def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
407
- link: Optional[str] = None, notify: bool = False) -> Dict[str, Any]:
408
- """One activity event: one sentence a person reads, never a machine id."""
409
+ link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
410
+ span_id: Optional[str] = None) -> Dict[str, Any]:
411
+ """One activity event: one sentence a person reads, never a machine id.
412
+
413
+ An event about traced work carries its trace as a pair, `trace_id` and `span_id`
414
+ (fabric-interop/0.1 C3.4 c); fabric_interop.trace_ids(traceparent) gives both."""
409
415
  if level not in LEVELS:
410
416
  raise ServiceError("level must be one of %s." % ", ".join(LEVELS))
411
417
  if not _KIND.match(kind):
@@ -415,6 +421,10 @@ def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subj
415
421
  raise ServiceError("An event needs a sentence.")
416
422
  if link is not None and (not link.startswith("/") or link.startswith("//")):
417
423
  raise ServiceError("link must be a path on this service.")
424
+ if (trace_id is None) != (span_id is None):
425
+ raise ServiceError("An event carries traceId and spanId together, or neither.")
426
+ if trace_id is not None and not (_TRACE_ID.match(trace_id) and _SPAN_ID.match(str(span_id))):
427
+ raise ServiceError("traceId is 32 and spanId 16 lowercase hex characters, not all zeros.")
418
428
  event: Dict[str, Any] = {"id": str(event_id), "at": at, "kind": kind, "level": level, "text": text[:500]}
419
429
  if subject:
420
430
  event["subject"] = subject
@@ -422,6 +432,8 @@ def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subj
422
432
  event["link"] = link
423
433
  if notify:
424
434
  event["notify"] = True
435
+ if trace_id is not None:
436
+ event["traceId"], event["spanId"] = trace_id, span_id
425
437
  return event
426
438
 
427
439
 
@@ -3,6 +3,9 @@
3
3
 
4
4
  It is the worked example the skill points at, the target check_service.py tests
5
5
  itself against, and the fixture Fabric Dashboards runs its end-to-end tests on.
6
+ Over MCP (fabric-interop/0.1) it serves two capabilities: `sample.echo` answers at
7
+ once, and `sample.draft` is a job that stops for a titled choice before it completes.
8
+ `register` also writes the service's manifest, which names this descriptor.
6
9
 
7
10
  sample_service.py serve --port 47190 --data-dir DIR [--id sample] [--instance default]
8
11
  sample_service.py register --port 47190 --data-dir DIR [--services-dir DIR]
@@ -11,6 +14,7 @@ itself against, and the fixture Fabric Dashboards runs its end-to-end tests on.
11
14
  from __future__ import annotations
12
15
 
13
16
  import argparse
17
+ import hashlib
14
18
  import html
15
19
  import json
16
20
  import os
@@ -24,6 +28,7 @@ from urllib.parse import parse_qs, urlparse
24
28
 
25
29
  sys.path.insert(0, str(Path(__file__).resolve().parent))
26
30
  import fabric_service as fs # noqa: E402
31
+ import fabric_interop as fi # noqa: E402
27
32
 
28
33
  VERSION = "0.1.0"
29
34
  PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
@@ -32,6 +37,24 @@ PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
32
37
  </head><body><h1>{name}</h1><p>Status: {status}</p><ul>{rows}</ul></body></html>"""
33
38
 
34
39
 
40
+ def _schema(name: str, body: Dict[str, Any]) -> Dict[str, Any]:
41
+ return {"$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:fabric:schema:%s" % name, **body}
42
+
43
+
44
+ TEXT = {"type": "object", "additionalProperties": False, "properties": {"text": {"type": "string", "maxLength": 2000}}, "required": ["text"]}
45
+ CAPABILITIES = [
46
+ {"name": "sample.echo", "effect": "none", "idempotency": "required", "job": False,
47
+ "description": "Returns the text it was given.",
48
+ "input": _schema("sample.echo-input", TEXT), "output": _schema("sample.echo-output", TEXT)},
49
+ {"name": "sample.draft", "effect": "draft", "idempotency": "none", "job": True,
50
+ "description": "Drafts a short note on a topic; asks which title to use.",
51
+ "input": _schema("sample.draft-input", {"type": "object", "additionalProperties": False,
52
+ "properties": {"topic": {"type": "string", "minLength": 1, "maxLength": 200}}, "required": ["topic"]}),
53
+ "output": _schema("sample.draft-output", {"type": "object", "additionalProperties": False,
54
+ "properties": {"title": {"type": "string"}, "body": {"type": "string"}}, "required": ["title", "body"]})},
55
+ ]
56
+
57
+
35
58
  class Service:
36
59
  def __init__(self, args: argparse.Namespace):
37
60
  self.id = args.id
@@ -50,14 +73,57 @@ class Service:
50
73
  self.token = fs.ensure_token(self.token_file)
51
74
  self.log = fs.JsonlEventLog(self.data / "events.jsonl")
52
75
  self.codes = fs.LoginCodes(self.data / "auth")
76
+ self.jobs = fi.JobStore(self.data / "jobs")
77
+ self.mcp = fi.McpToolServer(self.id, VERSION, jobs=self.jobs, on_job_started=self.draft_started, on_input=self.draft_answered)
78
+ for cap in CAPABILITIES:
79
+ handler = self.echo if cap["name"] == "sample.echo" else self.draft
80
+ self.mcp.add_tool(fi.tool_for_capability(cap, cap["input"], cap["output"]), handler)
53
81
  self.log.append("service.started", "info", "%s started on build %s." % (self.name, self.build["commit"]))
54
82
 
83
+ # --- fabric-interop/0.1: the two capabilities ------------------------------------
84
+ def echo(self, args: Dict[str, Any], ctx: fi.CallContext) -> Dict[str, Any]:
85
+ if not isinstance(args.get("text"), str):
86
+ raise fi.InteropError("sample.echo needs text.")
87
+ return {"text": args["text"]}
88
+
89
+ def draft(self, args: Dict[str, Any], ctx: fi.CallContext) -> Dict[str, Any]:
90
+ if not isinstance(args.get("topic"), str) or not args["topic"].strip():
91
+ raise fi.InteropError("sample.draft needs a topic.")
92
+ return ctx.start_job(poll_interval_ms=1000)
93
+
94
+ def draft_started(self, job_id: str, ctx: fi.CallContext) -> None:
95
+ topic = str(ctx.arguments.get("topic"))
96
+ titles = [("plain", "About %s" % topic), ("question", "What is %s?" % topic)]
97
+ self.jobs.request_input(job_id, {"title_choice": fi.choice_request("Pick the title for the note.", "title", titles, "Title")},
98
+ "Two titles are ready.")
99
+ self.log.append("job.awaiting_choice", "notice", "A note about %s is waiting for you to pick its title." % topic,
100
+ notify=True, **fi.trace_ids(ctx.traceparent))
101
+
102
+ def draft_answered(self, job_id: str, answers: Dict[str, Any], ctx: fi.CallContext) -> None:
103
+ answer = answers.get("title_choice", {})
104
+ topic = str(ctx.arguments.get("topic"))
105
+ if answer.get("action") != "accept":
106
+ self.jobs.cancel(job_id)
107
+ self.log.append("job.cancelled", "info", "The note about %s was dropped: no title was chosen." % topic, **fi.trace_ids(ctx.traceparent))
108
+ return
109
+ choice = str((answer.get("content") or {}).get("title"))
110
+ title = "About %s" % topic if choice == "plain" else "What is %s?" % topic
111
+ body = "%s. This sample note was written by the sample service." % title
112
+ self.jobs.complete(job_id, fi.result_envelope(
113
+ done=[{"claimId": "NOTE", "statement": "A note titled %s was drafted." % title}], proof=[],
114
+ scope={"project": "urn:fabric:project:sample", "run": "urn:fabric:run:%s" % job_id, "node": "urn:fabric:node:draft",
115
+ "binding": {"id": "urn:fabric:binding:sample.draft", "revision": 1, "contentHash": "sha256:" + "0" * 64}, "writeScopes": []},
116
+ not_verified=[{"claim": "NOTE", "reason": "no checker has read the note"}],
117
+ output={"title": title, "body": body}, usage={"inputTokens": 0, "outputTokens": 0, "wallMs": 1}))
118
+ self.log.append("job.completed", "info", "The note %s is drafted." % title, **fi.trace_ids(ctx.traceparent))
119
+
55
120
  def well_known(self) -> Dict[str, Any]:
56
121
  return fs.build_well_known(
57
122
  service_id=self.id, instance=self.instance, name=self.name, version=VERSION, build=self.build,
58
123
  started_at=self.started_at, status="ready", degraded=self.degraded,
59
124
  summary=[{"label": "Events", "value": len(self.log.fetch(None, fs.EVENTS_MAX_LIMIT))}],
60
- surfaces={"dashboard": {"path": "/", "login": True}, "events": {"path": "/fabric/v1/events"}},
125
+ surfaces={"dashboard": {"path": "/", "login": True}, "events": {"path": "/fabric/v1/events"},
126
+ "mcp": {"path": "/mcp", "transport": "streamable-http", "capabilities": [c["name"] for c in CAPABILITIES]}},
61
127
  )
62
128
 
63
129
 
@@ -137,6 +203,16 @@ def make_handler(svc: Service):
137
203
  if not self._guard():
138
204
  return
139
205
  url = urlparse(self.path)
206
+ if url.path == "/mcp":
207
+ if not self._token_ok():
208
+ return
209
+ length = min(int(self.headers.get("Content-Length") or 0), 1048576)
210
+ try:
211
+ message = json.loads(self.rfile.read(length) or b"null")
212
+ except ValueError:
213
+ return self._send(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error."}})
214
+ response = svc.mcp.handle(message)
215
+ return self._send(202) if response is None else self._send(200, response)
140
216
  if url.path == "/fabric/v1/login-code":
141
217
  if not self._token_ok():
142
218
  return
@@ -180,8 +256,46 @@ def serve(args: argparse.Namespace) -> int:
180
256
  return 0
181
257
 
182
258
 
259
+ def write_manifest(args: argparse.Namespace, data: Path) -> Path:
260
+ """The provider manifest for this service, naming its descriptor (G-07), with its schemas beside it."""
261
+ schemas = data / "fabric" / "schemas"
262
+ for cap in CAPABILITIES:
263
+ for side in ("input", "output"):
264
+ fs.atomic_write(schemas / ("%s-%s.schema.json" % (cap["name"], side)),
265
+ (json.dumps(cap[side], indent=2) + "\n").encode(), 0o600)
266
+ origin = "http://127.0.0.1:%d" % args.port
267
+ capabilities = []
268
+ for cap in CAPABILITIES:
269
+ capabilities.append({
270
+ "id": "urn:fabric:capability:%s" % cap["name"], "name": cap["name"], "description": cap["description"],
271
+ "inputSchema": cap["input"]["$id"], "outputSchema": cap["output"]["$id"],
272
+ "effect": cap["effect"], "idempotency": cap["idempotency"], "dataClasses": ["public"],
273
+ "profile": {"kind": "mcp", "protocolRevision": fi.MCP_REVISION,
274
+ "connection": {"mode": "streamable-http", "url": origin + "/mcp"},
275
+ "requiredFeatures": ["tool:%s" % cap["name"]],
276
+ "probes": [{"id": "%s-shape" % cap["name"].replace(".", "-"), "inputFixture": "urn:fabric:fixture:%s" % cap["name"],
277
+ "outputSchema": cap["output"]["$id"], "timeoutMs": 5000, "sideEffectCeiling": "none",
278
+ "assertions": ["returns a value valid against its output schema"]}]},
279
+ "extensions": {fi.EXTENSION_KEY: {"job": cap["job"]}},
280
+ })
281
+ digest = hashlib.sha256(json.dumps(capabilities, sort_keys=True).encode()).hexdigest()
282
+ subject = "urn:fabric:provider:%s" % args.id
283
+ manifest = {
284
+ "contractVersion": "0.1.0",
285
+ "provider": {"id": subject, "revision": 1, "contentHash": "sha256:" + digest, "createdAt": fs.now_iso(),
286
+ "createdBy": "urn:fabric:adapter:sample-service", "name": args.name,
287
+ "identity": {"subject": subject, "method": "local-install"}, "supportedContractVersions": ["0.1.0"],
288
+ "extensions": {fs.EXTENSION_KEY: {"descriptor": "%s.%s" % (args.id, args.instance)}}},
289
+ "capabilities": capabilities,
290
+ }
291
+ path = data / "fabric-agent.json"
292
+ fs.atomic_write(path, (json.dumps(manifest, indent=2) + "\n").encode(), 0o600)
293
+ return path
294
+
295
+
183
296
  def register(args: argparse.Namespace) -> int:
184
297
  data = Path(args.data_dir)
298
+ manifest = write_manifest(args, data)
185
299
  descriptor = {
186
300
  "protocol": fs.PROTOCOL, "id": args.id, "instance": args.instance, "name": args.name,
187
301
  "summary": "Sample fabric-service/0.1 service.",
@@ -190,6 +304,7 @@ def register(args: argparse.Namespace) -> int:
190
304
  "lifecycle": {"manager": "launchd", "label": args.label, "plist": args.plist} if args.label
191
305
  else {"manager": "none"},
192
306
  "paths": {"data": str(data), "logs": []},
307
+ "fabricManifest": str(manifest),
193
308
  "installedAt": fs.now_iso(), "installedBy": "sample_service.py register",
194
309
  }
195
310
  try:
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: creating-fabric-agents
3
3
  description: Use when designing and building a NEW agent or provider that must be Fabric-compatible from its first commit — «создай агента, совместимого с фабрикой Passion Code», «новый агент под Fabric», "create a fabric-compatible agent", "build a new Fabric provider", "fabric-ready agent from scratch". Runs the intake grill (capability, named consumer, workflow-or-agent, MCP/A2A/local-runner profile, effect declarations), distils source projects into a knowledge pack whose recorded failures become planted eval fixtures, scaffolds the pinned contract bundle, and sets the two-clock eval expectation with the conformance report. NOT for adapting an existing project (use adapting-projects-to-fabric), building the Fabric host or orchestrator, or generic agent design where Fabric compatibility is not part of the request.
4
- license: MIT
4
+ 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. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
- author: passioncode-ai
8
- version: "0.4.2"
7
+ author: PassionCode.ai
8
+ version: "0.5.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "20a818e648a4c09a60df0126d11626922e8b9094"
10
+ contract-commit: "a22dea359ba04b8fe549abe81a5131552cb90eff"
11
11
  ---
12
12
 
13
13
  # Creating Fabric-compatible agents
@@ -30,6 +30,17 @@ Use this skill to design and build a new provider. Do not use it to:
30
30
  - claim admission: the deliverable is an admission-ready provider bundle, never a
31
31
  connected provider.
32
32
 
33
+ ## Handing off to neighbouring skills
34
+
35
+ - The agent's own loop — tool calls, routing and fallback, memory, sub-agents — is
36
+ designed with `agent-orchestrator` (agent-stack).
37
+ - Its eval suite beyond the step-2 observables — trajectories, judges, regression
38
+ fixtures from traces — is built with `agent-evals` (agent-stack).
39
+ - Packaging the agent as an Agent Skill or Claude Code plugin goes through `make-skill`.
40
+
41
+ When one of them is not installed, do that part by hand against its own documentation
42
+ and say in the report which skill was missing.
43
+
33
44
  ## Step 0 — the intake grill
34
45
 
35
46
  No file is created until every row has an answer. An answer of "later" fails the grill.
@@ -82,7 +93,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
82
93
  --schema-base <immutable-base-uri>
83
94
  ```
84
95
 
85
- Pin exactly contract `0.1.0` at commit `20a818e648a4c09a60df0126d11626922e8b9094` and
96
+ Pin exactly contract `0.1.0` at commit `a22dea359ba04b8fe549abe81a5131552cb90eff` and
86
97
  read the pinned guide before implementing protocol details. If this skill is installed
87
98
  without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
88
99
  contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
@@ -91,6 +102,12 @@ contract's `docs/guides/connecting-compatible-agents.md` and mark the structural
91
102
  The generated placeholders are deliberate blockers; a bundle still containing one is not
92
103
  ready for admission.
93
104
 
105
+ A new agent is called the `fabric-interop/0.1` way from its first version: add `--job`
106
+ for a capability whose work may outlive one request, serve each capability as the MCP
107
+ tool of its name, and carry the caller's trace (the `building-fabric-services` interop
108
+ reference). An agent that is not a service is announced with a provider entry
109
+ (`adapting-projects-to-fabric`, `scripts/fabric_provider.py`).
110
+
94
111
  ## Step 4 — implement against the observables
95
112
 
96
113
  If the agent keeps running on the operator's computer — it answers other agents, runs