@passioncode-ai/fabric-agent-adapter 0.43.0 → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/package.json +1 -1
  3. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  4. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  5. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -2
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +7 -3
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +20 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +8 -5
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +9 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +186 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +6 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +264 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -254
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +100 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -89
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +2 -2
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +448 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +6 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +54 -369
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +390 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +20 -187
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +207 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +234 -19
  27. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  28. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  29. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  30. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +4 -3
  31. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +3 -1
@@ -0,0 +1,264 @@
1
+ // fabric-interop/0.1, the rule layer: the extension's names and limits, its errors, the trace context (C3.4), a usage
2
+ // object's reading (C3.2) and the job limits and uncertain effects (DEC-0087). Every function answers by a rule and
3
+ // touches no file; fabric-interop.mjs builds the tools, the envelope and the job store on it and re-exports every public
4
+ // name here. Part of the Node service kit (FAA-92, split from fabric-interop.mjs; layer L3); the twin of
5
+ // fabric_interop_rules.py. Node 20+, no dependency.
6
+ import crypto from 'node:crypto';
7
+ import { ServiceError } from './fabric-base.mjs';
8
+ import { jcs } from './fabric-delegation.mjs';
9
+ import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
10
+ import { isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
11
+
12
+ export const PROTOCOL = 'fabric-interop/0.1';
13
+ export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1';
14
+ export const MCP_REVISION = '2026-07-28';
15
+ export const TERMINAL = ['completed', 'failed', 'cancelled'];
16
+ export const CONTRACT_VERSION = '0.1.0';
17
+ export const OUTCOMES = ['succeeded', 'partial', 'failed', 'cancelled', 'blocked'];
18
+ export const ENVELOPE_REQUIRED = ['id', 'contractVersion', 'outcome', 'done', 'proof', 'scope', 'notVerified', 'artifacts', 'createdAt', 'producer', 'output', 'usage'];
19
+ // The job handle inline, as a job tool's outputSchema carries it (contract interop-job-handle.schema.json).
20
+ export const JOB_HANDLE_SCHEMA = {
21
+ type: 'object', required: ['job'], additionalProperties: false,
22
+ properties: { job: { type: 'object', required: ['id', 'status'], additionalProperties: false,
23
+ properties: { id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$' }, status: { const: 'working' } } } },
24
+ };
25
+
26
+ export const TRACEPARENT = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
27
+ export const JOB_ID = /^[A-Za-z0-9._:-]{1,128}$/;
28
+ export const KEY = /^[A-Za-z0-9_.-]{1,64}$/;
29
+ export const SECRET_WORDS = /pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|(access|auth|bearer|refresh|session)[\s_-]?token|^token$|credential/i;
30
+ export const USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'costUsd', 'wallMs'];
31
+
32
+ export class InteropError extends ServiceError {}
33
+ export class UnknownJob extends InteropError {
34
+ constructor(jobId) { super(`unknown-job: no job ${jobId} here.`); this.jobId = jobId; }
35
+ }
36
+
37
+ // --- C3.4 trace context ---------------------------------------------------------------
38
+ export function parseTraceparent(value) {
39
+ const m = typeof value === 'string' ? TRACEPARENT.exec(value) : null;
40
+ if (!m) return null;
41
+ const [, version, traceId, spanId, flags] = m;
42
+ if (version === 'ff' || /^0+$/.test(traceId) || /^0+$/.test(spanId)) return null;
43
+ return { version, traceId, spanId, flags };
44
+ }
45
+
46
+ export const spanIdNew = () => { for (;;) { const v = crypto.randomBytes(8).toString('hex'); if (!/^0+$/.test(v)) return v; } };
47
+
48
+ export function childTraceparent(parent) {
49
+ const p = parseTraceparent(parent);
50
+ return `00-${p ? p.traceId : crypto.randomBytes(16).toString('hex')}-${spanIdNew()}-${p ? p.flags : '01'}`;
51
+ }
52
+
53
+ export const traceIds = (traceparent) => {
54
+ const p = parseTraceparent(traceparent);
55
+ return p ? { traceId: p.traceId, spanId: p.spanId } : {};
56
+ };
57
+
58
+ export function checkUsage(usage, nulls = false) {
59
+ if (!usage || typeof usage !== 'object') throw new InteropError('usage must be an object.');
60
+ for (const key of ['inputTokens', 'outputTokens', 'wallMs']) if (!(key in usage)) throw new InteropError(`usage.${key} is required.`);
61
+ for (const [key, value] of Object.entries(usage)) {
62
+ if (nulls && key === 'partial') {
63
+ if (typeof value !== 'boolean') throw new InteropError('usage.partial must be a boolean.');
64
+ continue;
65
+ }
66
+ if (!USAGE_KEYS.includes(key)) throw new InteropError(`usage.${key} is not a usage field.`);
67
+ if (nulls && value === null) continue;
68
+ const ok = typeof value === 'number' && value >= 0 && (key === 'costUsd' || Number.isInteger(value));
69
+ if (!ok) throw new InteropError(`usage.${key} must be a non-negative ${key === 'costUsd' ? 'number' : 'integer'}.`);
70
+ }
71
+ if (nulls && usageFindings(usage, '').length) throw new InteropError('usage.partial is false while a number is null: an unknown number makes the usage partial (FAC-SEM-119).');
72
+ return { ...usage };
73
+ }
74
+ // --- C3.2 limits and uncertain effects (DEC-0087) ------------------------------------------
75
+ // #region job-limits-kit-node, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#limits-and-uncertain-effects
76
+ // The contract's src/job-limits.ts, answer for answer (test/fixtures/contract/job-limit-vectors.json), the twin of
77
+ // fabric_interop.py. Every function here answers and never throws on malformed input.
78
+
79
+ export const QUOTA_EXHAUSTED = 'quota-exhausted';
80
+ export const OUTCOME_UNKNOWN = 'outcome-unknown';
81
+ export const LIMIT_REACHED = 'limit-reached';
82
+ export const INTEROP_NULL_USAGE = 'fabric-interop/0.2';
83
+ /** The revisions this kit reads, named in a request's `_meta.protocols` (DEC-0067, DEC-0087). */
84
+ export const PROTOCOLS_READ = Object.freeze(['fabric-interop/0.1', INTEROP_NULL_USAGE]);
85
+ export const JOB_STATUSES = ['working', 'input_required', 'completed', 'failed', 'cancelled'];
86
+ export const UTC_INSTANT = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-5][0-9])(?:\.([0-9]{1,3}))?Z$/;
87
+ export const PAUSE = /^quota-exhausted(?: until ([^ \t\n\r]+?))?(?:: ([ \t\n\r]*[^ \t\n\r][\s\S]*))?$/;
88
+ export const ERROR_CODE = /^[a-z][a-z0-9-]{1,63}$/;
89
+ export const MAX_STATUS_MESSAGE = 500;
90
+ export const MAX_ERROR_MESSAGE = 2000;
91
+ export const codePoints = (value) => [...value].length;
92
+ export const own = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
93
+
94
+ /** A UTC instant as milliseconds, or NaN: the pattern and a day that exists, as the contract's strictTime reads it. */
95
+ export function utcTime(value) {
96
+ const m = isString(value) ? UTC_INSTANT.exec(value) : null;
97
+ if (!m) return Number.NaN;
98
+ const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
99
+ const date = new Date(0);
100
+ date.setUTCFullYear(year, month - 1, day);
101
+ if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59) return Number.NaN;
102
+ date.setUTCHours(hour, minute, second, Number((m[7] ?? '').padEnd(3, '0')));
103
+ return date.getTime();
104
+ }
105
+
106
+ export function quotaDataOk(data) {
107
+ const retryable = own(data, 'retryable');
108
+ if (!isObject(data) || typeof retryable !== 'boolean') return false;
109
+ if (Object.keys(data).some((name) => name !== 'retryable' && name !== 'resetsAt')) return false;
110
+ if (!Object.hasOwn(data, 'resetsAt')) return true;
111
+ return retryable && Number.isFinite(utcTime(own(data, 'resetsAt')));
112
+ }
113
+
114
+ export function jobOf(answer) {
115
+ const job = own(answer, 'job');
116
+ const id = own(job, 'id');
117
+ if (!isObject(job) || !isString(id) || id.length === 0 || !JOB_STATUSES.includes(own(job, 'status'))) return undefined;
118
+ return job;
119
+ }
120
+
121
+ export function readJobPause(answer) {
122
+ const job = jobOf(answer);
123
+ const message = own(job, 'statusMessage');
124
+ if (!job || own(job, 'status') !== 'working' || !isString(message) || !message.startsWith(QUOTA_EXHAUSTED)) return { paused: false };
125
+ const match = PAUSE.exec(message);
126
+ if (!match || codePoints(message) > MAX_STATUS_MESSAGE) return { paused: false, malformed: true };
127
+ const until = match[1];
128
+ if (until === undefined) return { paused: true };
129
+ return Number.isFinite(utcTime(until)) ? { paused: true, resetsAt: until } : { paused: false, malformed: true };
130
+ }
131
+
132
+ /** What a caller does with one fabric.job.get or fabric.job.cancel answer at `now`; never "submit anew". */
133
+ export function jobCallerAction(answer, now) {
134
+ const at = utcTime(now);
135
+ const job = jobOf(answer);
136
+ if (!Number.isFinite(at) || !job) return { action: 'invalid' };
137
+ const status = own(job, 'status');
138
+ if (status === 'working') {
139
+ const pause = readJobPause(answer);
140
+ if ('malformed' in pause) return { action: 'invalid' };
141
+ if (!pause.paused) return { action: 'poll' };
142
+ if (pause.resetsAt === undefined) return { action: 'wait' };
143
+ return utcTime(pause.resetsAt) > at ? { action: 'wait', notBefore: pause.resetsAt } : { action: 'poll' };
144
+ }
145
+ if (status === 'input_required') return { action: 'answer' };
146
+ if (status === 'completed') return { action: 'done' };
147
+ if (status === 'cancelled') return { action: 'cancelled' };
148
+ const error = own(job, 'error');
149
+ const code = own(error, 'code');
150
+ const message = own(error, 'message');
151
+ const codeOk = (isString(code) && ERROR_CODE.test(code)) || Number.isInteger(code);
152
+ const shapeOk = isObject(error) && Object.keys(error).every((name) => name === 'code' || name === 'message' || name === 'data');
153
+ if (!shapeOk || !codeOk || !isString(message) || message.length === 0 || codePoints(message) > MAX_ERROR_MESSAGE) return { action: 'invalid' };
154
+ if (code === OUTCOME_UNKNOWN) return { action: 'reconcile' };
155
+ if (code === LIMIT_REACHED) return { action: 'person' };
156
+ if (code !== QUOTA_EXHAUSTED) return { action: 'failed' };
157
+ const data = own(error, 'data');
158
+ if (!quotaDataOk(data)) return { action: 'invalid' };
159
+ if (!data.retryable) return { action: 'person' };
160
+ return data.resetsAt !== undefined && utcTime(data.resetsAt) > at ? { action: 'resend', notBefore: data.resetsAt } : { action: 'resend' };
161
+ }
162
+
163
+ /** The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 over its RFC 8785 form. */
164
+ export function intentIdempotencyKey(intent) {
165
+ const names = ['agentId', 'capability', 'input', 'request'];
166
+ if (!isObject(intent) || Object.keys(intent).some((name) => !names.includes(name))) return undefined;
167
+ const [agentId, capability, input, request] = names.map((name) => own(intent, name));
168
+ if (!isString(agentId) || agentId.length === 0 || !isString(capability) || capability.length === 0 || !isObject(input)) return undefined;
169
+ if (Object.hasOwn(intent, 'request') && (!isString(request) || request.length === 0 || codePoints(request) > 256)) return undefined;
170
+ let form;
171
+ try {
172
+ form = jcs(Object.hasOwn(intent, 'request') ? { agentId, capability, input, request } : { agentId, capability, input });
173
+ } catch {
174
+ return undefined;
175
+ }
176
+ const bytes = crypto.createHash('sha256').update(form, 'utf8').digest().subarray(0, 16);
177
+ bytes[6] = (bytes[6] & 0x0f) | 0x80;
178
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
179
+ const hex = bytes.toString('hex');
180
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
181
+ }
182
+
183
+ export function readsNullUsage(requestMeta) {
184
+ const protocols = own(requestMeta, 'protocols');
185
+ return Array.isArray(protocols) && protocols.includes(INTEROP_NULL_USAGE);
186
+ }
187
+
188
+ /** A request's `_meta` from this kit as a caller: its trace, and the interop revisions it reads (DEC-0087). */
189
+ export function requestMeta(traceparent) {
190
+ const meta = { protocols: [...PROTOCOLS_READ] };
191
+ if (parseTraceparent(traceparent)) meta.traceparent = traceparent;
192
+ return meta;
193
+ }
194
+
195
+ /** FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null. */
196
+ export function usageFindings(usage, at) {
197
+ if (!isObject(usage) || usage.partial !== false) return [];
198
+ const unknown = USAGE_KEYS.filter((name) => Object.hasOwn(usage, name) && usage[name] === null);
199
+ if (unknown.length === 0) return [];
200
+ return [{ code: 'FAC-SEM-119', instancePath: `${at}/partial`, message: `partial is false while ${unknown.join(', ')} is null: an unknown number makes the usage partial` }];
201
+ }
202
+
203
+ /** The usage a provider writes toward a caller: null and partial toward a 0.2 reader, the measured lower bound otherwise. */
204
+ export function usageToward(usage, meta) {
205
+ if (readsNullUsage(meta)) {
206
+ const checked = checkUsage(usage, true);
207
+ if (USAGE_KEYS.some((name) => checked[name] === null) && !('partial' in checked)) checked.partial = true;
208
+ return checked;
209
+ }
210
+ if (!isObject(usage)) throw new InteropError('usage must be an object.');
211
+ return checkUsage(Object.fromEntries(Object.entries(usage).filter(([key]) => key !== 'partial').map(([key, value]) => [key, value === null ? 0 : value])));
212
+ }
213
+
214
+ export function jobFindings(value) {
215
+ const findings = [];
216
+ if ('malformed' in readJobPause(value)) {
217
+ findings.push({ code: 'FAC-SEM-119', instancePath: '/job/statusMessage', message: "a working job's message that starts quota-exhausted reads `quota-exhausted`, `quota-exhausted until <UTC instant>`, then optionally `: <text>`, at most 500 characters" });
218
+ }
219
+ const error = own(own(value, 'job'), 'error');
220
+ const data = own(error, 'data');
221
+ if (own(error, 'code') === QUOTA_EXHAUSTED && isString(own(data, 'resetsAt')) && !Number.isFinite(utcTime(own(data, 'resetsAt')))) {
222
+ findings.push({ code: 'FAC-SEM-119', instancePath: '/job/error/data/resetsAt', message: 'resetsAt is a UTC instant on a day that exists' });
223
+ }
224
+ findings.push(...usageFindings(own(own(own(value, 'job'), 'result'), 'usage'), '/job/result/usage'));
225
+ return findings;
226
+ }
227
+
228
+ export const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
229
+
230
+ /** FAC-SEM-119 (kinds interop-job, usage and result) and FAC-SEM-120 (kind job-caller); undefined for another kind. */
231
+ export function jobLimitRules(kind, value) {
232
+ if (kind === 'interop-job') return jobFindings(value);
233
+ if (kind === 'usage') return usageFindings(value, '');
234
+ if (kind === 'result') return usageFindings(own(value, 'usage'), '/usage');
235
+ if (kind === 'job-caller') {
236
+ const expected = jobCallerAction(own(value, 'answer'), own(value, 'now'));
237
+ return sameJson(own(value, 'action'), expected) ? [] : [{ code: 'FAC-SEM-120', instancePath: '/action', message: `the caller answers ${JSON.stringify(expected)} as jobCallerAction does` }];
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ /** A paused job's statusMessage in the pause grammar; throws InteropError for one a reader would call malformed. */
243
+ export function pauseMessage({ resetsAt, text } = {}) {
244
+ const message = `${QUOTA_EXHAUSTED}${resetsAt === undefined ? '' : ` until ${resetsAt}`}${text === undefined ? '' : `: ${text}`}`;
245
+ if ('malformed' in readJobPause({ job: { id: 'x', status: 'working', statusMessage: message } })) {
246
+ throw new InteropError('A pause reads quota-exhausted [until <UTC instant, Z, at most 3 fraction digits>][: <text>], at most 500 characters.');
247
+ }
248
+ return message;
249
+ }
250
+
251
+ /** The provider's answer to a repeated key: 'run' (the intent runs again, once) or 'same' (answer with that job). */
252
+ export function repeatedKeyDecision(job, now, { limitHolds, approvalGranted = false } = {}) {
253
+ if (!isObject(job) || job.status !== 'failed') return 'same';
254
+ const error = own(job, 'error');
255
+ const code = own(error, 'code');
256
+ if (code === LIMIT_REACHED) return approvalGranted === true ? 'run' : 'same';
257
+ if (code !== QUOTA_EXHAUSTED) return 'same';
258
+ if (limitHolds !== undefined && limitHolds !== null) return limitHolds === false ? 'run' : 'same';
259
+ const data = own(error, 'data');
260
+ const at = utcTime(now);
261
+ if (!quotaDataOk(data) || !data.retryable || !Number.isFinite(at)) return 'same';
262
+ return data.resetsAt === undefined || utcTime(data.resetsAt) <= at ? 'run' : 'same';
263
+ }
264
+ // #endregion job-limits-kit-node
@@ -11,51 +11,37 @@ import path from 'node:path';
11
11
  import { ServiceError, atomicWrite, ensurePrivateDir, nowIso } from './fabric-service.mjs';
12
12
  import { jcs } from './fabric-delegation.mjs';
13
13
 
14
- export const PROTOCOL = 'fabric-interop/0.1';
15
- export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1';
16
- export const MCP_REVISION = '2026-07-28';
17
- export const TERMINAL = ['completed', 'failed', 'cancelled'];
18
- export const CONTRACT_VERSION = '0.1.0';
19
- const OUTCOMES = ['succeeded', 'partial', 'failed', 'cancelled', 'blocked'];
20
- const ENVELOPE_REQUIRED = ['id', 'contractVersion', 'outcome', 'done', 'proof', 'scope', 'notVerified', 'artifacts', 'createdAt', 'producer', 'output', 'usage'];
21
- // The job handle inline, as a job tool's outputSchema carries it (contract interop-job-handle.schema.json).
22
- export const JOB_HANDLE_SCHEMA = {
23
- type: 'object', required: ['job'], additionalProperties: false,
24
- properties: { job: { type: 'object', required: ['id', 'status'], additionalProperties: false,
25
- properties: { id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$' }, status: { const: 'working' } } } },
26
- };
27
-
28
- const TRACEPARENT = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
29
- const JOB_ID = /^[A-Za-z0-9._:-]{1,128}$/;
30
- const KEY = /^[A-Za-z0-9_.-]{1,64}$/;
31
- const SECRET_WORDS = /pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|(access|auth|bearer|refresh|session)[\s_-]?token|^token$|credential/i;
32
- const USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'costUsd', 'wallMs'];
33
-
34
- export class InteropError extends ServiceError {}
35
- export class UnknownJob extends InteropError {
36
- constructor(jobId) { super(`unknown-job: no job ${jobId} here.`); this.jobId = jobId; }
37
- }
38
-
39
- // --- C3.4 trace context ---------------------------------------------------------------
40
- export function parseTraceparent(value) {
41
- const m = typeof value === 'string' ? TRACEPARENT.exec(value) : null;
42
- if (!m) return null;
43
- const [, version, traceId, spanId, flags] = m;
44
- if (version === 'ff' || /^0+$/.test(traceId) || /^0+$/.test(spanId)) return null;
45
- return { version, traceId, spanId, flags };
46
- }
47
-
48
- const spanIdNew = () => { for (;;) { const v = crypto.randomBytes(8).toString('hex'); if (!/^0+$/.test(v)) return v; } };
49
-
50
- export function childTraceparent(parent) {
51
- const p = parseTraceparent(parent);
52
- return `00-${p ? p.traceId : crypto.randomBytes(16).toString('hex')}-${spanIdNew()}-${p ? p.flags : '01'}`;
53
- }
54
-
55
- export const traceIds = (traceparent) => {
56
- const p = parseTraceparent(traceparent);
57
- return p ? { traceId: p.traceId, spanId: p.spanId } : {};
58
- };
14
+ export {
15
+ PROTOCOL,
16
+ EXTENSION_KEY,
17
+ MCP_REVISION,
18
+ TERMINAL,
19
+ CONTRACT_VERSION,
20
+ JOB_HANDLE_SCHEMA,
21
+ InteropError,
22
+ UnknownJob,
23
+ parseTraceparent,
24
+ childTraceparent,
25
+ traceIds,
26
+ QUOTA_EXHAUSTED,
27
+ OUTCOME_UNKNOWN,
28
+ LIMIT_REACHED,
29
+ INTEROP_NULL_USAGE,
30
+ PROTOCOLS_READ,
31
+ utcTime,
32
+ quotaDataOk,
33
+ readJobPause,
34
+ jobCallerAction,
35
+ intentIdempotencyKey,
36
+ readsNullUsage,
37
+ requestMeta,
38
+ usageFindings,
39
+ usageToward,
40
+ jobLimitRules,
41
+ pauseMessage,
42
+ repeatedKeyDecision,
43
+ } from './fabric-interop-rules.mjs';
44
+ import { PROTOCOL, EXTENSION_KEY, MCP_REVISION, TERMINAL, CONTRACT_VERSION, JOB_HANDLE_SCHEMA, InteropError, UnknownJob, parseTraceparent, childTraceparent, traceIds, QUOTA_EXHAUSTED, OUTCOME_UNKNOWN, LIMIT_REACHED, INTEROP_NULL_USAGE, PROTOCOLS_READ, utcTime, quotaDataOk, readJobPause, jobCallerAction, intentIdempotencyKey, readsNullUsage, requestMeta, usageFindings, usageToward, jobLimitRules, pauseMessage, repeatedKeyDecision, OUTCOMES, ENVELOPE_REQUIRED, JOB_ID, KEY, SECRET_WORDS } from './fabric-interop-rules.mjs'; // the rule layer (FAA-92)
59
45
 
60
46
  // --- C3.1 capabilities as tools ----------------------------------------------------------
61
47
  export function expectedAnnotations(effect, idempotency) {
@@ -95,22 +81,6 @@ export const unknownJobResult = (jobId, traceparent) =>
95
81
  toolResult({ error: { code: 'unknown-job', message: `No job ${jobId} here.` } }, { isError: true, traceparent });
96
82
 
97
83
  // --- C3.2 the result envelope --------------------------------------------------------------
98
- function checkUsage(usage, nulls = false) {
99
- if (!usage || typeof usage !== 'object') throw new InteropError('usage must be an object.');
100
- for (const key of ['inputTokens', 'outputTokens', 'wallMs']) if (!(key in usage)) throw new InteropError(`usage.${key} is required.`);
101
- for (const [key, value] of Object.entries(usage)) {
102
- if (nulls && key === 'partial') {
103
- if (typeof value !== 'boolean') throw new InteropError('usage.partial must be a boolean.');
104
- continue;
105
- }
106
- if (!USAGE_KEYS.includes(key)) throw new InteropError(`usage.${key} is not a usage field.`);
107
- if (nulls && value === null) continue;
108
- const ok = typeof value === 'number' && value >= 0 && (key === 'costUsd' || Number.isInteger(value));
109
- if (!ok) throw new InteropError(`usage.${key} must be a non-negative ${key === 'costUsd' ? 'number' : 'integer'}.`);
110
- }
111
- if (nulls && usageFindings(usage, '').length) throw new InteropError('usage.partial is false while a number is null: an unknown number makes the usage partial (FAC-SEM-119).');
112
- return { ...usage };
113
- }
114
84
 
115
85
  // The full result envelope (contract result.schema.json, DEC-0017): the shape a synchronous call returns.
116
86
  export function resultEnvelope({ outcome, done, proof, scope, notVerified, output, usage, producer, artifacts = [], traceparent, id, createdAt, requestMeta }) {
@@ -129,199 +99,6 @@ export function resultEnvelope({ outcome, done, proof, scope, notVerified, outpu
129
99
  return envelope;
130
100
  }
131
101
 
132
- // --- C3.2 limits and uncertain effects (DEC-0087) ------------------------------------------
133
- // #region job-limits-kit-node, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#limits-and-uncertain-effects
134
- // The contract's src/job-limits.ts, answer for answer (test/fixtures/contract/job-limit-vectors.json), the twin of
135
- // fabric_interop.py. Every function here answers and never throws on malformed input.
136
-
137
- export const QUOTA_EXHAUSTED = 'quota-exhausted';
138
- export const OUTCOME_UNKNOWN = 'outcome-unknown';
139
- export const LIMIT_REACHED = 'limit-reached';
140
- export const INTEROP_NULL_USAGE = 'fabric-interop/0.2';
141
- /** The revisions this kit reads, named in a request's `_meta.protocols` (DEC-0067, DEC-0087). */
142
- export const PROTOCOLS_READ = Object.freeze(['fabric-interop/0.1', INTEROP_NULL_USAGE]);
143
- const JOB_STATUSES = ['working', 'input_required', 'completed', 'failed', 'cancelled'];
144
- const UTC_INSTANT = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-5][0-9])(?:\.([0-9]{1,3}))?Z$/;
145
- const PAUSE = /^quota-exhausted(?: until ([^ \t\n\r]+?))?(?:: ([ \t\n\r]*[^ \t\n\r][\s\S]*))?$/;
146
- const ERROR_CODE = /^[a-z][a-z0-9-]{1,63}$/;
147
- const MAX_STATUS_MESSAGE = 500;
148
- const MAX_ERROR_MESSAGE = 2000;
149
- const codePoints = (value) => [...value].length;
150
- import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
151
- import { isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
152
- const own = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
153
-
154
- /** A UTC instant as milliseconds, or NaN: the pattern and a day that exists, as the contract's strictTime reads it. */
155
- export function utcTime(value) {
156
- const m = isString(value) ? UTC_INSTANT.exec(value) : null;
157
- if (!m) return Number.NaN;
158
- const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
159
- const date = new Date(0);
160
- date.setUTCFullYear(year, month - 1, day);
161
- if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59) return Number.NaN;
162
- date.setUTCHours(hour, minute, second, Number((m[7] ?? '').padEnd(3, '0')));
163
- return date.getTime();
164
- }
165
-
166
- export function quotaDataOk(data) {
167
- const retryable = own(data, 'retryable');
168
- if (!isObject(data) || typeof retryable !== 'boolean') return false;
169
- if (Object.keys(data).some((name) => name !== 'retryable' && name !== 'resetsAt')) return false;
170
- if (!Object.hasOwn(data, 'resetsAt')) return true;
171
- return retryable && Number.isFinite(utcTime(own(data, 'resetsAt')));
172
- }
173
-
174
- function jobOf(answer) {
175
- const job = own(answer, 'job');
176
- const id = own(job, 'id');
177
- if (!isObject(job) || !isString(id) || id.length === 0 || !JOB_STATUSES.includes(own(job, 'status'))) return undefined;
178
- return job;
179
- }
180
-
181
- export function readJobPause(answer) {
182
- const job = jobOf(answer);
183
- const message = own(job, 'statusMessage');
184
- if (!job || own(job, 'status') !== 'working' || !isString(message) || !message.startsWith(QUOTA_EXHAUSTED)) return { paused: false };
185
- const match = PAUSE.exec(message);
186
- if (!match || codePoints(message) > MAX_STATUS_MESSAGE) return { paused: false, malformed: true };
187
- const until = match[1];
188
- if (until === undefined) return { paused: true };
189
- return Number.isFinite(utcTime(until)) ? { paused: true, resetsAt: until } : { paused: false, malformed: true };
190
- }
191
-
192
- /** What a caller does with one fabric.job.get or fabric.job.cancel answer at `now`; never "submit anew". */
193
- export function jobCallerAction(answer, now) {
194
- const at = utcTime(now);
195
- const job = jobOf(answer);
196
- if (!Number.isFinite(at) || !job) return { action: 'invalid' };
197
- const status = own(job, 'status');
198
- if (status === 'working') {
199
- const pause = readJobPause(answer);
200
- if ('malformed' in pause) return { action: 'invalid' };
201
- if (!pause.paused) return { action: 'poll' };
202
- if (pause.resetsAt === undefined) return { action: 'wait' };
203
- return utcTime(pause.resetsAt) > at ? { action: 'wait', notBefore: pause.resetsAt } : { action: 'poll' };
204
- }
205
- if (status === 'input_required') return { action: 'answer' };
206
- if (status === 'completed') return { action: 'done' };
207
- if (status === 'cancelled') return { action: 'cancelled' };
208
- const error = own(job, 'error');
209
- const code = own(error, 'code');
210
- const message = own(error, 'message');
211
- const codeOk = (isString(code) && ERROR_CODE.test(code)) || Number.isInteger(code);
212
- const shapeOk = isObject(error) && Object.keys(error).every((name) => name === 'code' || name === 'message' || name === 'data');
213
- if (!shapeOk || !codeOk || !isString(message) || message.length === 0 || codePoints(message) > MAX_ERROR_MESSAGE) return { action: 'invalid' };
214
- if (code === OUTCOME_UNKNOWN) return { action: 'reconcile' };
215
- if (code === LIMIT_REACHED) return { action: 'person' };
216
- if (code !== QUOTA_EXHAUSTED) return { action: 'failed' };
217
- const data = own(error, 'data');
218
- if (!quotaDataOk(data)) return { action: 'invalid' };
219
- if (!data.retryable) return { action: 'person' };
220
- return data.resetsAt !== undefined && utcTime(data.resetsAt) > at ? { action: 'resend', notBefore: data.resetsAt } : { action: 'resend' };
221
- }
222
-
223
- /** The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 over its RFC 8785 form. */
224
- export function intentIdempotencyKey(intent) {
225
- const names = ['agentId', 'capability', 'input', 'request'];
226
- if (!isObject(intent) || Object.keys(intent).some((name) => !names.includes(name))) return undefined;
227
- const [agentId, capability, input, request] = names.map((name) => own(intent, name));
228
- if (!isString(agentId) || agentId.length === 0 || !isString(capability) || capability.length === 0 || !isObject(input)) return undefined;
229
- if (Object.hasOwn(intent, 'request') && (!isString(request) || request.length === 0 || codePoints(request) > 256)) return undefined;
230
- let form;
231
- try {
232
- form = jcs(Object.hasOwn(intent, 'request') ? { agentId, capability, input, request } : { agentId, capability, input });
233
- } catch {
234
- return undefined;
235
- }
236
- const bytes = crypto.createHash('sha256').update(form, 'utf8').digest().subarray(0, 16);
237
- bytes[6] = (bytes[6] & 0x0f) | 0x80;
238
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
239
- const hex = bytes.toString('hex');
240
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
241
- }
242
-
243
- export function readsNullUsage(requestMeta) {
244
- const protocols = own(requestMeta, 'protocols');
245
- return Array.isArray(protocols) && protocols.includes(INTEROP_NULL_USAGE);
246
- }
247
-
248
- /** A request's `_meta` from this kit as a caller: its trace, and the interop revisions it reads (DEC-0087). */
249
- export function requestMeta(traceparent) {
250
- const meta = { protocols: [...PROTOCOLS_READ] };
251
- if (parseTraceparent(traceparent)) meta.traceparent = traceparent;
252
- return meta;
253
- }
254
-
255
- /** FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null. */
256
- export function usageFindings(usage, at) {
257
- if (!isObject(usage) || usage.partial !== false) return [];
258
- const unknown = USAGE_KEYS.filter((name) => Object.hasOwn(usage, name) && usage[name] === null);
259
- if (unknown.length === 0) return [];
260
- return [{ code: 'FAC-SEM-119', instancePath: `${at}/partial`, message: `partial is false while ${unknown.join(', ')} is null: an unknown number makes the usage partial` }];
261
- }
262
-
263
- /** The usage a provider writes toward a caller: null and partial toward a 0.2 reader, the measured lower bound otherwise. */
264
- export function usageToward(usage, meta) {
265
- if (readsNullUsage(meta)) {
266
- const checked = checkUsage(usage, true);
267
- if (USAGE_KEYS.some((name) => checked[name] === null) && !('partial' in checked)) checked.partial = true;
268
- return checked;
269
- }
270
- if (!isObject(usage)) throw new InteropError('usage must be an object.');
271
- return checkUsage(Object.fromEntries(Object.entries(usage).filter(([key]) => key !== 'partial').map(([key, value]) => [key, value === null ? 0 : value])));
272
- }
273
-
274
- function jobFindings(value) {
275
- const findings = [];
276
- if ('malformed' in readJobPause(value)) {
277
- findings.push({ code: 'FAC-SEM-119', instancePath: '/job/statusMessage', message: "a working job's message that starts quota-exhausted reads `quota-exhausted`, `quota-exhausted until <UTC instant>`, then optionally `: <text>`, at most 500 characters" });
278
- }
279
- const error = own(own(value, 'job'), 'error');
280
- const data = own(error, 'data');
281
- if (own(error, 'code') === QUOTA_EXHAUSTED && isString(own(data, 'resetsAt')) && !Number.isFinite(utcTime(own(data, 'resetsAt')))) {
282
- findings.push({ code: 'FAC-SEM-119', instancePath: '/job/error/data/resetsAt', message: 'resetsAt is a UTC instant on a day that exists' });
283
- }
284
- findings.push(...usageFindings(own(own(own(value, 'job'), 'result'), 'usage'), '/job/result/usage'));
285
- return findings;
286
- }
287
-
288
- const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
289
-
290
- /** FAC-SEM-119 (kinds interop-job, usage and result) and FAC-SEM-120 (kind job-caller); undefined for another kind. */
291
- export function jobLimitRules(kind, value) {
292
- if (kind === 'interop-job') return jobFindings(value);
293
- if (kind === 'usage') return usageFindings(value, '');
294
- if (kind === 'result') return usageFindings(own(value, 'usage'), '/usage');
295
- if (kind === 'job-caller') {
296
- const expected = jobCallerAction(own(value, 'answer'), own(value, 'now'));
297
- return sameJson(own(value, 'action'), expected) ? [] : [{ code: 'FAC-SEM-120', instancePath: '/action', message: `the caller answers ${JSON.stringify(expected)} as jobCallerAction does` }];
298
- }
299
- return undefined;
300
- }
301
-
302
- /** A paused job's statusMessage in the pause grammar; throws InteropError for one a reader would call malformed. */
303
- export function pauseMessage({ resetsAt, text } = {}) {
304
- const message = `${QUOTA_EXHAUSTED}${resetsAt === undefined ? '' : ` until ${resetsAt}`}${text === undefined ? '' : `: ${text}`}`;
305
- if ('malformed' in readJobPause({ job: { id: 'x', status: 'working', statusMessage: message } })) {
306
- throw new InteropError('A pause reads quota-exhausted [until <UTC instant, Z, at most 3 fraction digits>][: <text>], at most 500 characters.');
307
- }
308
- return message;
309
- }
310
-
311
- /** The provider's answer to a repeated key: 'run' (the intent runs again, once) or 'same' (answer with that job). */
312
- export function repeatedKeyDecision(job, now, { limitHolds, approvalGranted = false } = {}) {
313
- if (!isObject(job) || job.status !== 'failed') return 'same';
314
- const error = own(job, 'error');
315
- const code = own(error, 'code');
316
- if (code === LIMIT_REACHED) return approvalGranted === true ? 'run' : 'same';
317
- if (code !== QUOTA_EXHAUSTED) return 'same';
318
- if (limitHolds !== undefined && limitHolds !== null) return limitHolds === false ? 'run' : 'same';
319
- const data = own(error, 'data');
320
- const at = utcTime(now);
321
- if (!quotaDataOk(data) || !data.retryable || !Number.isFinite(at)) return 'same';
322
- return data.resetsAt === undefined || utcTime(data.resetsAt) <= at ? 'run' : 'same';
323
- }
324
- // #endregion job-limits-kit-node
325
102
 
326
103
  // --- C3.3 awaiting a choice ------------------------------------------------------------------
327
104
  export function formRequest(message, properties, required) {