@nifrajs/agent 3.1.0 → 3.3.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/events.ts CHANGED
@@ -83,3 +83,128 @@ export function createAgentEvidenceStream(
83
83
  }
84
84
  return stream
85
85
  }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Resumable evidence log
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /** One turn's replayable evidence view - see {@link AgentEvidenceLog.replay}. */
92
+ export interface AgentEvidenceReplay {
93
+ /** Evidence already recorded with `seq` greater than the requested cursor, in order. */
94
+ readonly evidence: readonly AgentStepEvidence[]
95
+ /** Live continuation while the turn is still running; `undefined` once it has finished. */
96
+ readonly live: AgentEvidenceStream | undefined
97
+ /** Resolves with the value passed to {@link AgentEvidenceLog.finish} when the turn ends. */
98
+ readonly result: Promise<unknown>
99
+ }
100
+
101
+ /**
102
+ * Seam for resumable evidence streams. An HTTP seam records one turn's step evidence through
103
+ * `open`, stores the terminal wire frame through `finish`, and serves SSE reconnects through
104
+ * `replay` (`Last-Event-ID` maps to evidence `seq`). The stored result value is opaque to the log -
105
+ * each seam stores whatever it needs to re-emit its own terminal frames.
106
+ *
107
+ * {@link createMemoryAgentEvidenceLog} is the single-process dev/test reference; a durable,
108
+ * multi-process log is an adapter concern behind the same interface.
109
+ */
110
+ export interface AgentEvidenceLog {
111
+ /**
112
+ * Telemetry port recording one turn's evidence - combine it into `ports.telemetry` with
113
+ * `combineAgentTelemetry`. Opening a finished turn reopens it (a resume of a suspended run):
114
+ * evidence keeps appending and late replays await the new result.
115
+ */
116
+ open(turnId: string): AgentTelemetryPort
117
+ /** Mark the turn finished: live replays complete and `result` is stored for late reconnects. */
118
+ finish(turnId: string, result: unknown): void | Promise<void>
119
+ /** The turn's evidence after `afterSeq`, or `undefined` for an unknown turn. */
120
+ replay(
121
+ turnId: string,
122
+ afterSeq: number,
123
+ ): AgentEvidenceReplay | undefined | Promise<AgentEvidenceReplay | undefined>
124
+ }
125
+
126
+ export interface MemoryAgentEvidenceLogOptions {
127
+ /** Maximum retained turns; the oldest turn is evicted when a new one opens. Default 256. */
128
+ readonly maxTurns?: number
129
+ }
130
+
131
+ interface MemoryTurnRecord {
132
+ readonly evidence: AgentStepEvidence[]
133
+ finished: boolean
134
+ resultPromise: Promise<unknown>
135
+ resolveResult: (value: unknown) => void
136
+ readonly subscribers: Set<AgentEvidenceStream>
137
+ }
138
+
139
+ /**
140
+ * In-memory {@link AgentEvidenceLog} reference for local development and tests. Single-process by
141
+ * construction: replay only sees runs recorded by this instance. Retention is bounded by
142
+ * `maxTurns` with oldest-first eviction, so a reconnect to an evicted turn reports
143
+ * replay-unavailable rather than growing memory without bound.
144
+ */
145
+ export function createMemoryAgentEvidenceLog(
146
+ options: MemoryAgentEvidenceLogOptions = {},
147
+ ): AgentEvidenceLog {
148
+ const maxTurns = options.maxTurns ?? 256
149
+ if (!Number.isSafeInteger(maxTurns) || maxTurns < 1)
150
+ throw new RangeError("agent evidence log: maxTurns must be a positive safe integer")
151
+ const turns = new Map<string, MemoryTurnRecord>()
152
+
153
+ const deferred = (record: MemoryTurnRecord): void => {
154
+ record.resultPromise = new Promise<unknown>((resolve) => {
155
+ record.resolveResult = resolve
156
+ })
157
+ }
158
+
159
+ return {
160
+ open(turnId) {
161
+ let turn = turns.get(turnId)
162
+ if (turn === undefined) {
163
+ while (turns.size >= maxTurns) {
164
+ const oldest = turns.keys().next().value
165
+ if (oldest === undefined) break
166
+ turns.delete(oldest)
167
+ }
168
+ turn = {
169
+ evidence: [],
170
+ finished: false,
171
+ resultPromise: Promise.resolve(undefined),
172
+ resolveResult: () => {},
173
+ subscribers: new Set(),
174
+ }
175
+ deferred(turn)
176
+ turns.set(turnId, turn)
177
+ } else if (turn.finished) {
178
+ // A resume reopens the turn: the next finish() resolves a fresh result for new replays.
179
+ turn.finished = false
180
+ deferred(turn)
181
+ }
182
+ const opened = turn
183
+ return {
184
+ step(evidence) {
185
+ opened.evidence.push(evidence)
186
+ for (const subscriber of opened.subscribers) subscriber.step(evidence)
187
+ },
188
+ }
189
+ },
190
+ finish(turnId, result) {
191
+ const turn = turns.get(turnId)
192
+ if (turn === undefined || turn.finished) return
193
+ turn.finished = true
194
+ turn.resolveResult(result)
195
+ for (const subscriber of turn.subscribers) subscriber.complete()
196
+ turn.subscribers.clear()
197
+ },
198
+ replay(turnId, afterSeq) {
199
+ const turn = turns.get(turnId)
200
+ if (turn === undefined) return undefined
201
+ const evidence = turn.evidence.filter((item) => item.seq > afterSeq)
202
+ if (turn.finished) return { evidence, live: undefined, result: turn.resultPromise }
203
+ // Snapshot and subscribe in the same synchronous step, so no evidence item can land between
204
+ // the filtered history and the live stream.
205
+ const live = createAgentEvidenceStream()
206
+ turn.subscribers.add(live)
207
+ return { evidence, live, result: turn.resultPromise }
208
+ },
209
+ }
210
+ }
@@ -199,19 +199,21 @@ function spawnProcess(input: SpawnInput): Promise<LocalProcessResult> {
199
199
  cancelled = true
200
200
  terminate()
201
201
  }
202
- input.signal?.addEventListener("abort", cancel, { once: true })
202
+ const cleanup = (): void => {
203
+ clearTimeout(timer)
204
+ if (killTimer !== undefined) clearTimeout(killTimer)
205
+ input.signal?.removeEventListener("abort", cancel)
206
+ }
203
207
  child.once("error", () => {
204
208
  if (settled) return
205
209
  settled = true
206
- clearTimeout(timer)
207
- if (killTimer !== undefined) clearTimeout(killTimer)
210
+ cleanup()
208
211
  reject(new LocalProcessPolicyError("invalid_request"))
209
212
  })
210
213
  child.once("close", (exitCode, signal) => {
211
214
  if (settled) return
212
215
  settled = true
213
- clearTimeout(timer)
214
- if (killTimer !== undefined) clearTimeout(killTimer)
216
+ cleanup()
215
217
  resolve({
216
218
  ok: !timedOut && !cancelled && exitCode === 0,
217
219
  exitCode,
@@ -223,6 +225,8 @@ function spawnProcess(input: SpawnInput): Promise<LocalProcessResult> {
223
225
  limitations: input.limitations,
224
226
  })
225
227
  })
228
+ input.signal?.addEventListener("abort", cancel, { once: true })
229
+ if (input.signal?.aborted === true) cancel() // close the precheck/listener-registration race
226
230
  })
227
231
  }
228
232