@arnilo/prism-supervisor 0.0.5 → 0.0.6

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 CHANGED
@@ -1,6 +1,8 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.0.6] - 2026-07-19
4
+
5
+ - Fixed A2A streaming UTF-8 corruption across chunk boundaries with one fatal streaming decoder and incremental LF/CRLF/multiline SSE parsing; truncated or post-terminal streams fail without changing existing limits.
4
6
 
5
7
  ## [0.0.5] - 2026-07-16
6
8
 
package/README.md CHANGED
@@ -26,6 +26,6 @@ const supervisor = createSupervisor({
26
26
  console.log((await supervisor.delegate({ childId: "research", input: "Check sources" })).text);
27
27
  ```
28
28
 
29
- Also exports A2A 1.0 `createA2AAgentCard`, `signA2AAgentCard`, `verifyA2AAgentCard`, `createA2AHandler`, and `createA2AClient`. Only text parts and JSON-RPC `SendMessage`, `SendStreamingMessage`, and `GetExtendedAgentCard` are supported. Hosts own authentication, TLS, endpoint allow-lists, child credential resolution, and memory construction from package-derived resource/thread IDs.
29
+ Also exports A2A 1.0 `createA2AAgentCard`, `signA2AAgentCard`, `verifyA2AAgentCard`, `createA2AHandler`, and `createA2AClient`. Streaming uses one fatal UTF-8 decoder, accepts LF/CRLF/mixed SSE separators and multiline `data:`, rejects truncated/post-terminal frames, and retains existing finite byte/event/time limits. Only text parts and JSON-RPC `SendMessage`, `SendStreamingMessage`, and `GetExtendedAgentCard` are supported. Hosts own authentication, TLS, endpoint allow-lists, child credential resolution, and memory construction from package-derived resource/thread IDs.
30
30
 
31
31
  See [Supervisors](../../docs/supervisors.md) and [A2A interoperability](../../docs/a2a.md).
@@ -73,55 +73,33 @@ export function createA2AClient(options) {
73
73
  if (!response.ok || !response.body || !response.headers.get("content-type")?.startsWith("text/event-stream"))
74
74
  throw new A2AError("A2A stream request failed", response.status, "ERR_PRISM_A2A_REMOTE");
75
75
  reader = response.body.getReader();
76
- let buffered = "";
77
- let totalBytes = 0;
78
- let eventCount = 0;
79
76
  let terminal = false;
80
- while (true) {
81
- owned.signal.throwIfAborted();
82
- const next = await reader.read();
83
- if (next.done)
84
- break;
85
- totalBytes += next.value.byteLength;
86
- if (totalBytes > limits.maxStreamBytes)
87
- throw new A2AError("A2A stream exceeds max bytes", 507, "ERR_PRISM_A2A_STREAM_LIMIT");
88
- buffered += new TextDecoder().decode(next.value, { stream: true });
89
- while (buffered.includes("\n\n")) {
90
- const split = buffered.indexOf("\n\n");
91
- const frame = buffered.slice(0, split);
92
- buffered = buffered.slice(split + 2);
93
- if (new TextEncoder().encode(frame).byteLength > limits.maxEventBytes)
94
- throw new A2AError("A2A event exceeds max bytes", 507, "ERR_PRISM_A2A_STREAM_LIMIT");
95
- eventCount += 1;
96
- if (eventCount > limits.maxStreamEvents)
97
- throw new A2AError("A2A stream exceeds max events", 507, "ERR_PRISM_A2A_STREAM_LIMIT");
98
- const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
99
- if (!data)
100
- continue;
101
- let parsed;
102
- try {
103
- parsed = JSON.parse(data);
104
- }
105
- catch {
106
- throw new A2AError("Malformed A2A stream event", 502, "ERR_PRISM_A2A_REMOTE");
107
- }
108
- const rpc = parseRpcResponse(parsed, id);
109
- if (rpc.error)
110
- throw new A2AError(safeRemote(rpc.error.message, options), 502, "ERR_PRISM_A2A_REMOTE");
111
- const task = parseTaskResult(rpc.result);
112
- if (task.status.state === "TASK_STATE_FAILED" || task.status.state === "TASK_STATE_CANCELED")
113
- throw new A2AError("Remote A2A stream task failed", 502, "ERR_PRISM_A2A_REMOTE");
114
- if (task.status.state === "TASK_STATE_COMPLETED")
115
- terminal = true;
116
- for (const artifact of task.artifacts ?? [])
117
- for (const part of artifact.parts)
118
- yield options.redactor?.redact(part.text) ?? part.text;
77
+ for await (const data of readA2AStreamData(reader, limits, owned.signal)) {
78
+ if (terminal)
79
+ throw new A2AError("A2A stream continued after terminal task state", 502, "ERR_PRISM_A2A_REMOTE");
80
+ if (!data)
81
+ continue;
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(data);
119
85
  }
86
+ catch {
87
+ throw new A2AError("Malformed A2A stream event", 502, "ERR_PRISM_A2A_REMOTE");
88
+ }
89
+ const rpc = parseRpcResponse(parsed, id);
90
+ if (rpc.error)
91
+ throw new A2AError(safeRemote(rpc.error.message, options), 502, "ERR_PRISM_A2A_REMOTE");
92
+ const task = parseTaskResult(rpc.result);
93
+ if (task.status.state === "TASK_STATE_FAILED" || task.status.state === "TASK_STATE_CANCELED")
94
+ throw new A2AError("Remote A2A stream task failed", 502, "ERR_PRISM_A2A_REMOTE");
95
+ if (task.status.state === "TASK_STATE_COMPLETED")
96
+ terminal = true;
97
+ for (const artifact of task.artifacts ?? [])
98
+ for (const part of artifact.parts)
99
+ yield options.redactor?.redact(part.text) ?? part.text;
120
100
  }
121
101
  if (!terminal)
122
102
  throw new A2AError("A2A stream ended before terminal task state", 502, "ERR_PRISM_A2A_REMOTE");
123
- if (buffered.trim())
124
- throw new A2AError("Truncated A2A stream", 502, "ERR_PRISM_A2A_REMOTE");
125
103
  }
126
104
  finally {
127
105
  await reader?.cancel().catch(() => undefined);
@@ -142,6 +120,104 @@ export function createA2AClient(options) {
142
120
  }
143
121
  return { getCard, send, stream };
144
122
  }
123
+ async function* readA2AStreamData(reader, limits, signal) {
124
+ const decoder = new TextDecoder("utf-8", { fatal: true });
125
+ const lineParts = [];
126
+ let lineTail = "";
127
+ let lineBytes = 0;
128
+ let eventBytes = 0;
129
+ let eventCount = 0;
130
+ let eventHasLine = false;
131
+ let previousEndingBytes = 0;
132
+ let dataLines = [];
133
+ let totalBytes = 0;
134
+ const appendLine = (text) => {
135
+ lineBytes += Buffer.byteLength(text, "utf8");
136
+ const projectedEventBytes = eventBytes + (eventHasLine ? previousEndingBytes : 0) + lineBytes;
137
+ if (projectedEventBytes > limits.maxEventBytes + 1)
138
+ throw new A2AError("A2A event exceeds max bytes", 507, "ERR_PRISM_A2A_STREAM_LIMIT");
139
+ lineTail += text;
140
+ // ponytail: 4 KiB coalescing bounds one-byte chunk overhead; tune only if parser profiling requires it.
141
+ if (lineTail.length >= 4096) {
142
+ lineParts.push(lineTail);
143
+ lineTail = "";
144
+ }
145
+ };
146
+ const completeLine = (raw, endingBytes) => {
147
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
148
+ if (!line) {
149
+ eventCount += 1;
150
+ if (eventCount > limits.maxStreamEvents)
151
+ throw new A2AError("A2A stream exceeds max events", 507, "ERR_PRISM_A2A_STREAM_LIMIT");
152
+ const data = dataLines.join("\n");
153
+ eventBytes = 0;
154
+ eventHasLine = false;
155
+ previousEndingBytes = 0;
156
+ dataLines = [];
157
+ return data;
158
+ }
159
+ if (eventHasLine)
160
+ eventBytes += previousEndingBytes;
161
+ eventBytes += Buffer.byteLength(line, "utf8");
162
+ if (eventBytes > limits.maxEventBytes)
163
+ throw new A2AError("A2A event exceeds max bytes", 507, "ERR_PRISM_A2A_STREAM_LIMIT");
164
+ eventHasLine = true;
165
+ previousEndingBytes = endingBytes;
166
+ if (!line.startsWith(":")) {
167
+ const colon = line.indexOf(":");
168
+ const field = colon === -1 ? line : line.slice(0, colon);
169
+ const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
170
+ if (field === "data")
171
+ dataLines.push(value);
172
+ }
173
+ return undefined;
174
+ };
175
+ const feed = (text) => {
176
+ const completed = [];
177
+ let start = 0;
178
+ while (true) {
179
+ const newline = text.indexOf("\n", start);
180
+ if (newline === -1)
181
+ break;
182
+ appendLine(text.slice(start, newline));
183
+ const raw = `${lineParts.join("")}${lineTail}`;
184
+ const data = completeLine(raw, raw.endsWith("\r") ? 2 : 1);
185
+ if (data !== undefined)
186
+ completed.push(data);
187
+ lineParts.length = 0;
188
+ lineTail = "";
189
+ lineBytes = 0;
190
+ start = newline + 1;
191
+ }
192
+ appendLine(text.slice(start));
193
+ return completed;
194
+ };
195
+ try {
196
+ while (true) {
197
+ signal.throwIfAborted();
198
+ const next = await reader.read();
199
+ if (next.done)
200
+ break;
201
+ totalBytes += next.value.byteLength;
202
+ if (totalBytes > limits.maxStreamBytes)
203
+ throw new A2AError("A2A stream exceeds max bytes", 507, "ERR_PRISM_A2A_STREAM_LIMIT");
204
+ for (const data of feed(decoder.decode(next.value, { stream: true })))
205
+ yield data;
206
+ }
207
+ for (const data of feed(decoder.decode()))
208
+ yield data;
209
+ }
210
+ catch (error) {
211
+ if (signal.aborted)
212
+ throw signal.reason;
213
+ if (error instanceof A2AError)
214
+ throw error;
215
+ throw new A2AError("Malformed A2A UTF-8 stream", 502, "ERR_PRISM_A2A_REMOTE");
216
+ }
217
+ const tail = `${lineParts.join("")}${lineTail}`;
218
+ if (eventHasLine || tail.trim())
219
+ throw new A2AError("Truncated A2A stream", 502, "ERR_PRISM_A2A_REMOTE");
220
+ }
145
221
  function requestBody(id, method, input) {
146
222
  return { jsonrpc: "2.0", id, method, params: { message: { role: "user", messageId: `message-${id}`, parts: [{ text: input }] } } };
147
223
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arnilo/prism-supervisor",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Optional bounded local supervisor delegation and A2A 1.0 interoperability.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -25,7 +25,7 @@
25
25
  "pack:dry-run": "npm pack --dry-run"
26
26
  },
27
27
  "peerDependencies": {
28
- "@arnilo/prism": "0.0.5"
28
+ "@arnilo/prism": "0.0.6"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@arnilo/prism": "file:../.."