@percepteye/agent-flywheel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/wire.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The flywheel/1 wire types, and the rules that keep a report honest.
3
+ *
4
+ * Two ends of ONE contract: `agent_flywheel/contract.py` is the
5
+ * other, and `schema/flywheel-1.json` ($id
6
+ * https://schemas.percepteye.ai/flywheel/1.json) is the arbiter. A test in
7
+ * this package asserts CONTRACT_VERSION equals that schema's
8
+ * `x-contract-version`, because two copies of a contract with no mechanism
9
+ * keeping them in sync is not a contract.
10
+ */
11
+ export const CONTRACT_VERSION = "flywheel/1";
12
+
13
+ /** Runtime mirrors of the production-turn limits published by the schema. */
14
+ export const PRODUCTION_IDENTIFIER_MAX_CHARS = 255;
15
+ export const PRODUCTION_TURN_BATCH_MAX_ITEMS = 64;
16
+
17
+ const PRODUCTION_IDENTIFIER_RE = /^(?!\s)(?!.*\s$)[^\u0000-\u001F\u007F-\u009F]+$/u;
18
+ const PRODUCTION_TURN_IDENTIFIER_RE = /^(?!\.{1,2}$)(?!\s)(?!.*\s$)(?!.*[\\/])[^\u0000-\u001F\u007F-\u009F]+$/u;
19
+
20
+ function withinProductionIdentifierLimit(value) {
21
+ // Python's len() and JSON Schema's maxLength count Unicode code points,
22
+ // whereas JavaScript's String.length counts UTF-16 code units.
23
+ return [...value].length <= PRODUCTION_IDENTIFIER_MAX_CHARS;
24
+ }
25
+
26
+ /** Return an exact opaque production correlation id, or null. Never rewrite. */
27
+ export function productionIdentifier(value) {
28
+ return typeof value === "string"
29
+ && value.length > 0
30
+ && withinProductionIdentifierLimit(value)
31
+ && PRODUCTION_IDENTIFIER_RE.test(value)
32
+ ? value
33
+ : null;
34
+ }
35
+
36
+ /** Return an exact production id that is also one safe path segment. */
37
+ export function productionTurnIdentifier(value) {
38
+ return typeof value === "string"
39
+ && value.length > 0
40
+ && withinProductionIdentifierLimit(value)
41
+ && PRODUCTION_TURN_IDENTIFIER_RE.test(value)
42
+ ? value
43
+ : null;
44
+ }
45
+
46
+ /** Agent ids share the turn-id grammar because they also enter URL paths. */
47
+ export const productionAgentIdentifier = productionTurnIdentifier;
48
+
49
+ /**
50
+ * Every key a report may carry. A CLOSED set, mirroring
51
+ * `contract.py:292 WIRE_KEYS`.
52
+ *
53
+ * The closedness is the point: anything derived from the inference wire or
54
+ * from the rollout record is absent BY CONSTRUCTION, so an agent cannot
55
+ * assert a field it has no way to know. The server validates independently,
56
+ * because a client-side check binds only honest clients.
57
+ */
58
+ export const WIRE_KEYS = Object.freeze([
59
+ "final_text", "tool_calls", "tool_calls_omitted_count", "artifacts",
60
+ "events", "timing", "llm_call_count", "reward", "success",
61
+ "agent_fingerprint", "exclusion",
62
+ ]);
63
+
64
+ const WIRE_KEY_SET = new Set(WIRE_KEYS);
65
+
66
+ export class ContractError extends Error {
67
+ constructor(message) {
68
+ super(message);
69
+ this.name = "ContractError";
70
+ }
71
+ }
72
+
73
+ /**
74
+ * One rollout, as the control plane handed it to us.
75
+ *
76
+ * Field names are the SERVER's, not ours -- renaming them here would put a
77
+ * translation layer between two things that must agree exactly.
78
+ */
79
+ export function rolloutRequestFromWire(raw) {
80
+ if (!raw || typeof raw !== "object") {
81
+ throw new ContractError("rollout payload is not an object");
82
+ }
83
+ const rolloutId = raw.rollout_id ?? raw.rolloutId;
84
+ if (!rolloutId) throw new ContractError("rollout payload has no rollout_id");
85
+ const leaseExpiresTs = raw.lease_expires_ts ?? raw.leaseExpiresTs;
86
+ return {
87
+ rolloutId: String(rolloutId),
88
+ turnInput: String(raw.turn_input ?? ""),
89
+ llmUrl: raw.llm_url ?? null,
90
+ apiKey: raw.api_key ?? null,
91
+ artifactsDir: raw.artifacts_dir ?? null,
92
+ deadlineTs: Number(raw.deadline_ts ?? 0) || 0,
93
+ leaseToken: raw.lease_token ?? null,
94
+ leaseExpiresTs: leaseExpiresTs !== null && leaseExpiresTs !== undefined
95
+ && Number.isFinite(Number(leaseExpiresTs))
96
+ ? Number(leaseExpiresTs)
97
+ : null,
98
+ metadata: raw.metadata && typeof raw.metadata === "object"
99
+ && !Array.isArray(raw.metadata)
100
+ ? raw.metadata
101
+ : {},
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Serialize a report, enforcing the two distinctions the Python end depends on.
107
+ *
108
+ * `toolCalls` defaults to `null`, NEVER `[]`. The distinction is load-bearing:
109
+ * `null` means "not reported", `[]` means "I observed zero tool calls", which
110
+ * is a positive assertion. An agent that reports nothing must not accidentally
111
+ * assert zero, so the default is the honest one (`contract.py:300-310`).
112
+ */
113
+ export function rolloutOutputToWire(output) {
114
+ if (!output || typeof output !== "object") {
115
+ throw new ContractError("report is not an object");
116
+ }
117
+ if (typeof output.finalText !== "string") {
118
+ // `final_text` is the ONE required field of RolloutOutput. Sending a
119
+ // report without it is a report about nothing.
120
+ throw new ContractError("final_text is required and must be a string");
121
+ }
122
+ const wire = { final_text: output.finalText };
123
+
124
+ if (output.toolCalls !== undefined && output.toolCalls !== null) {
125
+ if (!Array.isArray(output.toolCalls)) {
126
+ throw new ContractError("tool_calls must be an array or null");
127
+ }
128
+ wire.tool_calls = output.toolCalls.map(scrubToolCall);
129
+ }
130
+ // ALWAYS SENT, even at zero. Non-zero forbids the training grade, so a
131
+ // reader must be able to tell "none omitted" from "the field was missing".
132
+ wire.tool_calls_omitted_count = Number(output.toolCallsOmittedCount ?? 0) || 0;
133
+
134
+ if (output.llmCallCount !== undefined && output.llmCallCount !== null) {
135
+ if (!Number.isInteger(output.llmCallCount) || output.llmCallCount < 0) {
136
+ throw new ContractError(
137
+ "llm_call_count must be a nonnegative integer or null",
138
+ );
139
+ }
140
+ wire.llm_call_count = output.llmCallCount;
141
+ }
142
+
143
+ for (const [key, value] of [
144
+ ["artifacts", output.artifacts], ["events", output.events],
145
+ ["timing", output.timing],
146
+ ["reward", output.reward], ["success", output.success],
147
+ ["agent_fingerprint", output.agentFingerprint],
148
+ ["exclusion", output.exclusion],
149
+ ]) {
150
+ if (value !== undefined && value !== null) wire[key] = value;
151
+ }
152
+
153
+ for (const key of Object.keys(wire)) {
154
+ if (!WIRE_KEY_SET.has(key)) {
155
+ throw new ContractError(`report carries a key outside the contract: ${key}`);
156
+ }
157
+ }
158
+ return wire;
159
+ }
160
+
161
+ /**
162
+ * An `unknown` outcome must carry NO status and NO body.
163
+ *
164
+ * `contract.py:189-193` scrubs both so a scorer cannot recover a verdict from
165
+ * the envelope of a call whose result we never saw. Leaving a status_code on
166
+ * an `unknown` would let a reward function read success out of a field that
167
+ * only describes the transport.
168
+ */
169
+ function scrubToolCall(call) {
170
+ const out = { ...call };
171
+ if (out.outcome === "unknown") {
172
+ out.status_code = null;
173
+ delete out.body;
174
+ delete out.response;
175
+ }
176
+ if ("environment" in out) {
177
+ // `contract.py:253-258` refuses this outright: the environment is the
178
+ // control plane's fact about the rollout, not the agent's to assert.
179
+ throw new ContractError("tool call may not carry an 'environment' field");
180
+ }
181
+ return out;
182
+ }