@nifrajs/agent-app 3.3.0 → 4.0.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/client.ts CHANGED
@@ -40,6 +40,7 @@ export const AGENT_APP_FEATURES = [
40
40
  "inbox",
41
41
  "reload",
42
42
  "resume",
43
+ "run-studio",
43
44
  "workflows",
44
45
  ] as const
45
46
 
@@ -188,7 +189,12 @@ export class AgentAppClient {
188
189
  ...(options?.signal ? { signal: options.signal } : {}),
189
190
  })
190
191
  for await (const event of stream) {
191
- for (const view of buffer.offer(toEventView(event))) yield view
192
+ for (const view of buffer.offer(toEventView(event))) {
193
+ // The host numbers events per session, so the next turn's buffer starts after this one.
194
+ if (this.currentSession !== undefined && view.seq > this.currentSession.lastSeq)
195
+ this.currentSession = Object.freeze({ ...this.currentSession, lastSeq: view.seq })
196
+ yield view
197
+ }
192
198
  }
193
199
  }
194
200
 
package/src/index.ts CHANGED
@@ -48,6 +48,11 @@ export {
48
48
  OrderedEventBuffer,
49
49
  type RegistryCapabilityView,
50
50
  type RepairRequiredView,
51
+ type ReviewView,
52
+ type ReviewViewCheck,
53
+ type ReviewViewEvidence,
54
+ type ReviewViewFinding,
55
+ type ReviewViewStatus,
51
56
  type RunStudioNodeState,
52
57
  type RunStudioNodeView,
53
58
  type RunStudioView,
@@ -66,6 +71,7 @@ export {
66
71
  toFaultInjectionViews,
67
72
  toHandoffView,
68
73
  toRegistryCapabilityView,
74
+ toReviewView,
69
75
  toRunStudioView,
70
76
  toRunView,
71
77
  toSessionView,
package/src/transport.ts CHANGED
@@ -8,8 +8,14 @@
8
8
  * is never copied into a result, an error, or a log line.
9
9
  */
10
10
 
11
- import type { AgentEvent } from "@nifrajs/agent-protocol"
12
- import { isAgentEvent } from "@nifrajs/agent-protocol"
11
+ import {
12
+ type AgentEvent,
13
+ assertTransportByteLimit,
14
+ isAgentEvent,
15
+ MAX_TRANSPORT_BYTES,
16
+ parseTransportContentLength,
17
+ readBoundedTransportBytes,
18
+ } from "@nifrajs/agent-protocol"
13
19
 
14
20
  /** One RPC-style call. `params` is JSON-serializable; `signal` cancels the in-flight request. */
15
21
  export interface AgentTransportRequest {
@@ -41,6 +47,8 @@ export interface HttpAgentTransportOptions {
41
47
  readonly authorize?: AuthProvider
42
48
  /** Injectable fetch for tests and non-DOM hosts. Defaults to the ambient `fetch`. */
43
49
  readonly fetch?: typeof fetch
50
+ /** Maximum response bytes retained by command and SSE reads. Defaults to 8 MiB. */
51
+ readonly maxResponseBytes?: number
44
52
  }
45
53
 
46
54
  /** Thrown only for transport-level faults (network, malformed body). Carries no credential. */
@@ -59,6 +67,7 @@ export class HttpAgentTransport implements AgentTransport {
59
67
  private readonly url: string
60
68
  private readonly authorize: AuthProvider | undefined
61
69
  private readonly fetchImpl: typeof fetch
70
+ private readonly maxResponseBytes: number
62
71
 
63
72
  constructor(options: HttpAgentTransportOptions) {
64
73
  if (typeof options.endpoint !== "string" || options.endpoint.length === 0)
@@ -68,6 +77,8 @@ export class HttpAgentTransport implements AgentTransport {
68
77
  const bound = options.fetch ?? globalThis.fetch
69
78
  if (typeof bound !== "function") throw new TypeError("agent transport: no fetch available")
70
79
  this.fetchImpl = bound.bind(globalThis)
80
+ this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES
81
+ assertResponseLimit(this.maxResponseBytes)
71
82
  }
72
83
 
73
84
  private async headers(accept: string): Promise<Headers> {
@@ -90,7 +101,7 @@ export class HttpAgentTransport implements AgentTransport {
90
101
  } catch (error) {
91
102
  throw new AgentTransportError(request.method, describe(error))
92
103
  }
93
- const text = await response.text()
104
+ const text = await readResponseText(response, request.method, this.maxResponseBytes)
94
105
  if (!response.ok)
95
106
  return { ok: false, status: response.status, error: text || response.statusText }
96
107
  if (text.length === 0) return { ok: true, status: response.status, value: undefined as T }
@@ -114,11 +125,11 @@ export class HttpAgentTransport implements AgentTransport {
114
125
  throw new AgentTransportError(request.method, describe(error))
115
126
  }
116
127
  if (!response.ok) {
117
- const text = await response.text()
128
+ const text = await readResponseText(response, request.method, this.maxResponseBytes)
118
129
  throw new AgentTransportError(request.method, text || `status ${response.status}`)
119
130
  }
120
131
  if (response.body === null) return
121
- yield* parseEventStream(response.body, request.method)
132
+ yield* parseEventStream(response.body, request.method, this.maxResponseBytes)
122
133
  }
123
134
  }
124
135
 
@@ -126,34 +137,76 @@ export class HttpAgentTransport implements AgentTransport {
126
137
  export async function* parseEventStream(
127
138
  body: ReadableStream<Uint8Array>,
128
139
  method: string,
140
+ maxFrameBytes = DEFAULT_MAX_RESPONSE_BYTES,
129
141
  ): AsyncIterable<AgentEvent> {
142
+ assertResponseLimit(maxFrameBytes)
130
143
  const reader = body.getReader()
131
144
  const decoder = new TextDecoder()
145
+ const encoder = new TextEncoder()
132
146
  let buffer = ""
147
+ let reachedEof = false
133
148
  try {
134
149
  for (;;) {
135
150
  const chunk = await reader.read()
136
- if (chunk.done) break
151
+ if (chunk.done) {
152
+ reachedEof = true
153
+ buffer += decoder.decode()
154
+ let boundary = sseBoundary(buffer)
155
+ while (boundary !== undefined) {
156
+ const frame = buffer.slice(0, boundary.index)
157
+ buffer = buffer.slice(boundary.end)
158
+ if (encoder.encode(frame).byteLength > maxFrameBytes)
159
+ throw new AgentTransportError(method, "SSE frame exceeds the configured response limit")
160
+ const event = frameToEvent(frame)
161
+ if (event !== undefined) yield event
162
+ boundary = sseBoundary(buffer)
163
+ }
164
+ if (encoder.encode(buffer).byteLength > maxFrameBytes)
165
+ throw new AgentTransportError(method, "SSE frame exceeds the configured response limit")
166
+ const event = frameToEvent(buffer)
167
+ if (event !== undefined) yield event
168
+ break
169
+ }
137
170
  buffer += decoder.decode(chunk.value, { stream: true })
138
- let boundary = buffer.indexOf("\n\n")
139
- while (boundary !== -1) {
140
- const frame = buffer.slice(0, boundary)
141
- buffer = buffer.slice(boundary + 2)
171
+ let boundary = sseBoundary(buffer)
172
+ while (boundary !== undefined) {
173
+ const frame = buffer.slice(0, boundary.index)
174
+ buffer = buffer.slice(boundary.end)
175
+ if (encoder.encode(frame).byteLength > maxFrameBytes)
176
+ throw new AgentTransportError(method, "SSE frame exceeds the configured response limit")
142
177
  const event = frameToEvent(frame)
143
178
  if (event !== undefined) yield event
144
- boundary = buffer.indexOf("\n\n")
179
+ boundary = sseBoundary(buffer)
180
+ }
181
+ if (encoder.encode(buffer).byteLength > maxFrameBytes) {
182
+ try {
183
+ await reader.cancel()
184
+ } catch {
185
+ // Preserve the bounded transport error if the source has already closed.
186
+ }
187
+ throw new AgentTransportError(method, "SSE frame exceeds the configured response limit")
145
188
  }
146
189
  }
147
190
  } catch (error) {
191
+ if (error instanceof AgentTransportError) throw error
148
192
  throw new AgentTransportError(method, describe(error))
149
193
  } finally {
194
+ if (!reachedEof) {
195
+ // Releasing a reader lock does not stop the response producer. Cancel the body when a
196
+ // consumer breaks early so sockets, server-side work, and buffered chunks can be reclaimed.
197
+ try {
198
+ await reader.cancel()
199
+ } catch {
200
+ // Preserve the original transport/read error, or the consumer's early-return semantics.
201
+ }
202
+ }
150
203
  reader.releaseLock()
151
204
  }
152
205
  }
153
206
 
154
207
  function frameToEvent(frame: string): AgentEvent | undefined {
155
208
  const data = frame
156
- .split("\n")
209
+ .split(/\r\n|\n|\r/)
157
210
  .filter((line) => line.startsWith("data:"))
158
211
  .map((line) => line.slice(5).replace(/^ /, ""))
159
212
  .join("\n")
@@ -167,6 +220,66 @@ function frameToEvent(frame: string): AgentEvent | undefined {
167
220
  return isAgentEvent(value) ? value : undefined
168
221
  }
169
222
 
223
+ function sseBoundary(value: string): { readonly index: number; readonly end: number } | undefined {
224
+ for (let index = 0; index < value.length; index++) {
225
+ const firstLength = lineEndingLength(value, index)
226
+ if (firstLength === undefined) return undefined
227
+ if (firstLength === 0) continue
228
+ const secondIndex = index + firstLength
229
+ const secondLength = lineEndingLength(value, secondIndex)
230
+ if (secondLength === undefined) return undefined
231
+ if (secondLength > 0) return { index, end: secondIndex + secondLength }
232
+ index = secondIndex
233
+ }
234
+ return undefined
235
+ }
236
+
237
+ function lineEndingLength(value: string, index: number): number | undefined {
238
+ const character = value[index]
239
+ if (character === "\n") return 1
240
+ if (character !== "\r") return 0
241
+ if (index + 1 >= value.length) return undefined
242
+ return value[index + 1] === "\n" ? 2 : 1
243
+ }
244
+
245
+ const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
246
+ const MAX_RESPONSE_BYTES = MAX_TRANSPORT_BYTES
247
+
248
+ function assertResponseLimit(value: number): void {
249
+ assertTransportByteLimit(value, {
250
+ minimum: 1_024,
251
+ maximum: MAX_RESPONSE_BYTES,
252
+ label: "agent transport: maxResponseBytes",
253
+ })
254
+ }
255
+
256
+ async function readResponseText(
257
+ response: Response,
258
+ method: string,
259
+ maxBytes: number,
260
+ ): Promise<string> {
261
+ const declared = parseTransportContentLength(response.headers.get("content-length"))
262
+ if (declared !== undefined && declared > maxBytes) {
263
+ try {
264
+ await response.body?.cancel()
265
+ } catch {
266
+ // The response is already over its cap; cancellation failure must not replace the useful error.
267
+ }
268
+ throw new AgentTransportError(method, "response body exceeds the configured limit")
269
+ }
270
+ if (response.body === null) return ""
271
+ const read = await readBoundedTransportBytes(response.body, maxBytes)
272
+ if (!read.ok) {
273
+ throw new AgentTransportError(
274
+ method,
275
+ read.reason === "too-large"
276
+ ? "response body exceeds the configured limit"
277
+ : describe(read.error),
278
+ )
279
+ }
280
+ return new TextDecoder().decode(read.bytes)
281
+ }
282
+
170
283
  function serialize(request: AgentTransportRequest): string {
171
284
  return JSON.stringify({
172
285
  method: request.method,