@intx/agent 0.1.2 → 0.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/LICENSE +176 -0
- package/README.md +80 -5
- package/dist/agent.d.ts +116 -0
- package/dist/agent.js +682 -0
- package/dist/canonicalize.d.ts +15 -0
- package/dist/canonicalize.js +160 -0
- package/dist/default-director.d.ts +24 -0
- package/dist/default-director.js +45 -0
- package/dist/definition.d.ts +139 -0
- package/dist/definition.js +40 -0
- package/dist/director-registry.d.ts +47 -0
- package/dist/director-registry.js +87 -0
- package/dist/director-types.d.ts +80 -0
- package/dist/director-types.js +13 -0
- package/dist/director.d.ts +70 -0
- package/dist/director.js +131 -0
- package/dist/env-validation.d.ts +59 -0
- package/dist/env-validation.js +180 -0
- package/dist/env.d.ts +160 -0
- package/dist/env.js +53 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +23 -0
- package/dist/internal-fixtures/mail.d.ts +39 -0
- package/dist/internal-fixtures/mail.js +86 -0
- package/dist/internal-fixtures/planner.d.ts +19 -0
- package/dist/internal-fixtures/planner.js +49 -0
- package/dist/lock.d.ts +16 -0
- package/dist/lock.js +47 -0
- package/dist/namespace.d.ts +12 -0
- package/dist/namespace.js +39 -0
- package/dist/send-queue.d.ts +25 -0
- package/dist/send-queue.js +147 -0
- package/dist/source.d.ts +43 -0
- package/dist/source.js +118 -0
- package/dist/stream.d.ts +16 -0
- package/dist/stream.js +115 -0
- package/dist/testing/audit-noop.d.ts +7 -0
- package/dist/testing/audit-noop.js +25 -0
- package/dist/testing/authorize-allow.d.ts +8 -0
- package/dist/testing/authorize-allow.js +19 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +17 -0
- package/dist/tool.d.ts +238 -0
- package/dist/tool.js +244 -0
- package/package.json +26 -7
- package/src/agent.test.ts +0 -46
- package/src/agent.ts +0 -494
- package/src/index.ts +0 -38
- package/src/lock.test.ts +0 -93
- package/src/lock.ts +0 -57
- package/src/send-queue.test.ts +0 -207
- package/src/send-queue.ts +0 -200
- package/src/source.test.ts +0 -171
- package/src/source.ts +0 -93
- package/src/stream.test.ts +0 -167
- package/src/stream.ts +0 -142
- package/src/tool.test.ts +0 -217
- package/src/tool.ts +0 -148
- package/tsconfig.json +0 -4
- package/tsconfig.tsbuildinfo +0 -1
package/dist/agent.js
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
// In-process agent runtime.
|
|
2
|
+
//
|
|
3
|
+
// `createAgent(def, env)` is the single entry point. The `def` is the
|
|
4
|
+
// portable, hashable `AgentDefinition` (id, system prompt, tool
|
|
5
|
+
// factories, director ref, inference preferences, capabilities, tags).
|
|
6
|
+
// The `env` is the runtime environment supplying the active inference
|
|
7
|
+
// source, the context store, the working directory, the audit sink,
|
|
8
|
+
// the authorize callback, and the director registry. The agent
|
|
9
|
+
// instantiates against those: it locks the context directory, walks
|
|
10
|
+
// each tool factory to build its tool runner, resolves the director
|
|
11
|
+
// against the registry, and wires the result into the reactor
|
|
12
|
+
// assembly. The reactor is wrapped exactly once.
|
|
13
|
+
//
|
|
14
|
+
// Composition:
|
|
15
|
+
// - `send()` enqueues into a FIFO `SendQueue` capped at
|
|
16
|
+
// `env.sendQueueMax`. Per-send `AbortSignal` removes queued items or
|
|
17
|
+
// rejects in-flight callers while letting the reactor cycle finish
|
|
18
|
+
// in the background.
|
|
19
|
+
// - `stream()` returns a bounded `StreamConsumer` iterator; consumers
|
|
20
|
+
// buffer independently and noisy backpressure poisons only the
|
|
21
|
+
// affected iterator.
|
|
22
|
+
// - `close()` aborts the reactor, drains the send queue with
|
|
23
|
+
// `AgentClosedError`, terminates every active stream iterator, waits
|
|
24
|
+
// up to `env.closeTimeoutMs` for the reactor's shutdown sequence to
|
|
25
|
+
// complete (audit flush, in-flight commits), and finally releases
|
|
26
|
+
// the singleton-per-`workdir` lock so another agent can open the
|
|
27
|
+
// same directory.
|
|
28
|
+
//
|
|
29
|
+
// `setSource` covers the whole source: id/provider/baseURL/apiKey/model
|
|
30
|
+
// plus the model-bound `defaults` and `capabilities`. Credentials and
|
|
31
|
+
// model rotate together via the shared source object the reactor reads
|
|
32
|
+
// lazily at the start of each inference call. The director never names
|
|
33
|
+
// a model -- `capabilities.infer(options?)` does not take one -- so the
|
|
34
|
+
// active source's model is the single source of truth and rotations
|
|
35
|
+
// take effect on the next inference call without any wrapper.
|
|
36
|
+
//
|
|
37
|
+
// Tool factories are bundle-shaped: each declares `(env) => ToolBundle`
|
|
38
|
+
// via `defineTool`. The agent invokes each factory once at construction,
|
|
39
|
+
// collects the bundles' definitions, and dispatches calls to the
|
|
40
|
+
// owning bundle's `run`. Bundle lifetimes (and any `dispose` step) are
|
|
41
|
+
// the caller's responsibility -- the env is the agent's dependency
|
|
42
|
+
// contract; the caller owns the lifetime of what it puts in env.
|
|
43
|
+
import { createReactorAssembly, } from "@intx/inference";
|
|
44
|
+
import { createDefaultDependencies } from "@intx/inference/providers";
|
|
45
|
+
import { getLogger } from "@intx/log";
|
|
46
|
+
import { createInboundMessage } from "@intx/mime";
|
|
47
|
+
import { validateDirectorConfig } from "./director.js";
|
|
48
|
+
import { validateEnv } from "./env-validation.js";
|
|
49
|
+
import { acquireContextDirLock } from "./lock.js";
|
|
50
|
+
import { createSourceRegistry } from "./source.js";
|
|
51
|
+
import { createSendQueue } from "./send-queue.js";
|
|
52
|
+
import { createStreamConsumer } from "./stream.js";
|
|
53
|
+
import { DuplicateToolError } from "./tool.js";
|
|
54
|
+
const logger = getLogger(["interchange", "agent"]);
|
|
55
|
+
// Synthetic recipient/sender used when `agent.send(content)` is
|
|
56
|
+
// called with a plain string. `agent.send` is the in-process API for
|
|
57
|
+
// driving an agent without a transport; the synthesized message is
|
|
58
|
+
// never sent over the wire, so the addresses are just shape-fillers
|
|
59
|
+
// for the reactor's MIME-derived event shape. The `from` field is
|
|
60
|
+
// override-able via `SendOptions.from` because callers occasionally
|
|
61
|
+
// want to stamp a meaningful sender for audit purposes. The `to`
|
|
62
|
+
// field is fixed because no in-tree call path makes a routing or
|
|
63
|
+
// audit decision on it: harness-wrapped agents do not surface
|
|
64
|
+
// `agent.send` (the `Harness` shape exposes only deliver/setSource/
|
|
65
|
+
// stream/close/blobReader), and standalone agents have no addressing
|
|
66
|
+
// substrate to begin with. Callers that need an addressable inbound
|
|
67
|
+
// message build the `InboundMessage` themselves and pass it to
|
|
68
|
+
// `agent.send(message)` directly, bypassing this synthesis path.
|
|
69
|
+
const DEFAULT_SEND_FROM = "user@local";
|
|
70
|
+
const DEFAULT_SEND_TO = "agent@local";
|
|
71
|
+
const DEFAULT_SEND_QUEUE_MAX = 16;
|
|
72
|
+
const DEFAULT_STREAM_BUFFER_MAX = 1024;
|
|
73
|
+
const DEFAULT_CLOSE_TIMEOUT_MS = 5000;
|
|
74
|
+
/**
|
|
75
|
+
* A `reactor.gate.blocked` event settled the active send but carried no
|
|
76
|
+
* `correlationId`, so the resulting suspension has no handle a caller
|
|
77
|
+
* could resume against. The reactor omits `correlationId` for gates
|
|
78
|
+
* parked without a correlation (e.g. a director suspend with no
|
|
79
|
+
* correlated external decision), and `send()` cannot hand back an
|
|
80
|
+
* unresumable outcome -- it surfaces this instead.
|
|
81
|
+
*/
|
|
82
|
+
export class GateSuspendedWithoutCorrelationError extends Error {
|
|
83
|
+
gateId;
|
|
84
|
+
constructor(gateId) {
|
|
85
|
+
super(`reactor suspended on gate ${gateId} without a correlationId; the send has no handle to resume against`);
|
|
86
|
+
this.name = "GateSuspendedWithoutCorrelationError";
|
|
87
|
+
this.gateId = gateId;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export class AgentClosedError extends Error {
|
|
91
|
+
constructor() {
|
|
92
|
+
super("agent is closed");
|
|
93
|
+
this.name = "AgentClosedError";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Walk each annotated tool factory, build the bundle, and produce a
|
|
98
|
+
* single `ToolRunner` that dispatches calls by tool name to the
|
|
99
|
+
* originating bundle. Throws on duplicate tool names across bundles.
|
|
100
|
+
*
|
|
101
|
+
* Bundle lifetimes (disposal) are the caller's responsibility per the
|
|
102
|
+
* `ToolBundle` contract once `createAgent` returns. While
|
|
103
|
+
* `createAgent` is still constructing -- whether the failure surfaces
|
|
104
|
+
* inside this function or later in `createAgent`'s body -- there is
|
|
105
|
+
* no caller to honor that contract, so the bundles list is exposed
|
|
106
|
+
* for the surrounding `try`/`finally` to dispose on failure.
|
|
107
|
+
*/
|
|
108
|
+
function resolveTools(def, env) {
|
|
109
|
+
const byName = new Map();
|
|
110
|
+
const definitions = [];
|
|
111
|
+
// Track constructed bundles so we can dispose them on a later
|
|
112
|
+
// factory's failure. Once `resolveTools` returns successfully the
|
|
113
|
+
// caller (createAgent) is the lifetime owner per the `ToolBundle`
|
|
114
|
+
// contract; until then the only reference is in this function.
|
|
115
|
+
const constructed = [];
|
|
116
|
+
try {
|
|
117
|
+
for (const factory of def.toolFactories) {
|
|
118
|
+
const bundle = factory(env);
|
|
119
|
+
constructed.push(bundle);
|
|
120
|
+
for (const definition of bundle.definitions) {
|
|
121
|
+
if (byName.has(definition.name)) {
|
|
122
|
+
throw new DuplicateToolError(definition.name);
|
|
123
|
+
}
|
|
124
|
+
byName.set(definition.name, bundle);
|
|
125
|
+
definitions.push(definition);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (cause) {
|
|
130
|
+
// Dispose every bundle we did successfully construct before
|
|
131
|
+
// re-raising. Without this, factories that allocate resources at
|
|
132
|
+
// construction time (mail bundles holding an IMAP session, posix
|
|
133
|
+
// bundles spawning an LSP server, etc.) leak when a later
|
|
134
|
+
// factory throws or a duplicate-name collision aborts the walk.
|
|
135
|
+
for (const bundle of constructed) {
|
|
136
|
+
if (bundle.dispose === undefined)
|
|
137
|
+
continue;
|
|
138
|
+
try {
|
|
139
|
+
// Swallow disposer errors so the original construction
|
|
140
|
+
// failure remains the one the caller sees; a noisy disposer
|
|
141
|
+
// running during rollback would mask the real problem.
|
|
142
|
+
//
|
|
143
|
+
// `void bundle.dispose()` would not be enough on its own: it
|
|
144
|
+
// discards the returned promise but leaves any rejection in
|
|
145
|
+
// flight, which the surrounding synchronous try/catch cannot
|
|
146
|
+
// observe and the runtime surfaces as an unhandled promise
|
|
147
|
+
// rejection. We attach a no-op `.catch` to absorb async
|
|
148
|
+
// rejections and let the throw below propagate immediately
|
|
149
|
+
// (the caller's lock is still held; awaiting rollback would
|
|
150
|
+
// delay the construction failure for no benefit).
|
|
151
|
+
const result = bundle.dispose();
|
|
152
|
+
if (result instanceof Promise) {
|
|
153
|
+
result.catch(() => {
|
|
154
|
+
// Swallow per the comment above.
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Synchronous throws from a non-async dispose that throws
|
|
160
|
+
// before returning a promise. Same intent as the async path:
|
|
161
|
+
// never let rollback noise mask the original failure.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
throw cause;
|
|
165
|
+
}
|
|
166
|
+
const runner = {
|
|
167
|
+
definitions: Object.freeze([...definitions]),
|
|
168
|
+
async run(call, signal) {
|
|
169
|
+
const bundle = byName.get(call.name);
|
|
170
|
+
if (bundle === undefined) {
|
|
171
|
+
return {
|
|
172
|
+
callId: call.id,
|
|
173
|
+
content: `unknown tool: ${call.name}`,
|
|
174
|
+
isError: true,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
return await bundle.run(call, signal);
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
return {
|
|
182
|
+
callId: call.id,
|
|
183
|
+
content: err instanceof Error ? err.message : String(err),
|
|
184
|
+
isError: true,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
return { definitions, runner, bundles: constructed };
|
|
190
|
+
}
|
|
191
|
+
function resolveDirector(def, env, toolDefinitions, compactorNames) {
|
|
192
|
+
const ref = def.director ?? env.directors.buildDefaultRef();
|
|
193
|
+
const factory = env.directors.resolve(ref);
|
|
194
|
+
// Re-validate ref.config against the factory's registered schema.
|
|
195
|
+
// `defineDirector.build(config)` validates at construction time, but
|
|
196
|
+
// `DirectorRef` is a public structural type -- nothing forces refs
|
|
197
|
+
// through `build`. A hand-constructed ref would otherwise reach the
|
|
198
|
+
// factory body with whatever shape the author wrote.
|
|
199
|
+
validateDirectorConfig(ref.config, factory.configSchema);
|
|
200
|
+
return factory(ref.config, env, {
|
|
201
|
+
systemPrompt: def.systemPrompt,
|
|
202
|
+
toolDefinitions,
|
|
203
|
+
compactorNames,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
export async function createAgent(def, env) {
|
|
207
|
+
validateEnv(def, env);
|
|
208
|
+
const lock = acquireContextDirLock(env.workdir);
|
|
209
|
+
// The construction below acquires several resources before the
|
|
210
|
+
// returned Agent's `close()` becomes reachable. Anything that
|
|
211
|
+
// throws between here and the final return leaks the lock and any
|
|
212
|
+
// tool-bundle resources unless we explicitly release them. Track
|
|
213
|
+
// the success path with a flag, release the lock in `finally` when
|
|
214
|
+
// we never reached the return, and dispose every successfully
|
|
215
|
+
// constructed tool bundle so post-`resolveTools` failures
|
|
216
|
+
// (resolveDirector throw, createReactorAssembly throw,
|
|
217
|
+
// createSourceRegistry throw, reactor.start throw) don't leak the
|
|
218
|
+
// bundles `resolveTools` built. The intra-`resolveTools` rollback
|
|
219
|
+
// disposes bundles that were constructed before the throwing
|
|
220
|
+
// factory; this outer rollback covers the rest.
|
|
221
|
+
let succeeded = false;
|
|
222
|
+
let bundlesForRollback = [];
|
|
223
|
+
try {
|
|
224
|
+
const resolvedTools = resolveTools(def, env);
|
|
225
|
+
bundlesForRollback = resolvedTools.bundles;
|
|
226
|
+
const sourceRegistry = createSourceRegistry({
|
|
227
|
+
sources: env.sources,
|
|
228
|
+
defaultSource: env.defaultSource,
|
|
229
|
+
});
|
|
230
|
+
// Capture the registered names as a frozen snapshot at construction
|
|
231
|
+
// so the director receives a stable list it can iterate. The
|
|
232
|
+
// reactor assembly retains the live `env.compactors` reference for
|
|
233
|
+
// `caps.compact` lookups, so a deployer that mutates the registry
|
|
234
|
+
// after `createAgent` returns would diverge this snapshot from the
|
|
235
|
+
// reactor's resolution. Treat `env.compactors` as immutable
|
|
236
|
+
// post-construction.
|
|
237
|
+
const compactorNames = Object.freeze(Object.keys(env.compactors ?? {}));
|
|
238
|
+
const director = resolveDirector(def, env, resolvedTools.definitions, compactorNames);
|
|
239
|
+
const contextStore = env.storage;
|
|
240
|
+
const auditStore = env.audit;
|
|
241
|
+
const authorize = env.authorize;
|
|
242
|
+
const deps = env.deps ?? createDefaultDependencies();
|
|
243
|
+
const sessionId = env.sessionId ?? crypto.randomUUID();
|
|
244
|
+
const streamBufferMax = env.streamBufferMax ?? DEFAULT_STREAM_BUFFER_MAX;
|
|
245
|
+
const streamConsumers = new Set();
|
|
246
|
+
// Pre-start buffer for events emitted between `reactor.start()` and
|
|
247
|
+
// the first `stream()` consumer attaching. Without this buffer
|
|
248
|
+
// those events fan out into an empty consumer set and are dropped
|
|
249
|
+
// silently: `reactor.start()` runs synchronously inside
|
|
250
|
+
// `createAgent`, before the caller has a chance to register a
|
|
251
|
+
// consumer, so a `reactor.start` event (or any other event the
|
|
252
|
+
// reactor emits during its synchronous startup window) would be
|
|
253
|
+
// lost. We buffer up to `streamBufferMax` events; when the first
|
|
254
|
+
// consumer attaches, the buffer is drained into it and discarded.
|
|
255
|
+
// Subsequent consumers see only events emitted after their own
|
|
256
|
+
// registration, matching the existing per-consumer fan-out
|
|
257
|
+
// semantics. Overflow during the pre-start window drops the
|
|
258
|
+
// oldest events with a log warning rather than throwing: aborting
|
|
259
|
+
// `reactor.start()` mid-startup leaves the agent in a worse state
|
|
260
|
+
// than missing observability for the very earliest events, and a
|
|
261
|
+
// startup that emits more than `streamBufferMax` events before
|
|
262
|
+
// any consumer registers is a pathology the caller can observe
|
|
263
|
+
// via the warning.
|
|
264
|
+
let preStartBuffer = [];
|
|
265
|
+
let preStartBufferOverflows = 0;
|
|
266
|
+
let activeCycle = null;
|
|
267
|
+
// sendQueue is built after the reactor (since its `start` callback
|
|
268
|
+
// delivers into the reactor), but handleEvent -- which is wired
|
|
269
|
+
// into the reactor's assembly -- needs to see sendQueue. Assigned
|
|
270
|
+
// exactly once after the reactor exists and before
|
|
271
|
+
// reactor.start(); no event can reach handleEvent before the
|
|
272
|
+
// queue is wired.
|
|
273
|
+
//
|
|
274
|
+
// The cycle is irreducible at the type level: `handleEvent`
|
|
275
|
+
// reads `sendQueue` from closure; `sendQueue.start` calls
|
|
276
|
+
// `reactor.deliver`; `reactor` is constructed with
|
|
277
|
+
// `onEvent: handleEvent`. Three references, each pointing at
|
|
278
|
+
// the next. `const` requires its initializer at declaration time,
|
|
279
|
+
// which forces the cycle to break at one of these edges --
|
|
280
|
+
// every break either threads an extra parameter through
|
|
281
|
+
// handleEvent (which the reactor's `onEvent` shape does not
|
|
282
|
+
// accept), wraps sendQueue behind a `{ value: SendQueue }` cell
|
|
283
|
+
// (which makes every send-site check for undefined that the
|
|
284
|
+
// construction order already guarantees absent), or splits
|
|
285
|
+
// handleEvent into a factory that takes sendQueue as input
|
|
286
|
+
// (which moves the same forward-declaration problem one level
|
|
287
|
+
// up). The `let` here is the smallest expression of the cycle
|
|
288
|
+
// the language allows; the comment block above is what makes
|
|
289
|
+
// the "assigned before any reachable read" invariant explicit.
|
|
290
|
+
// eslint-disable-next-line prefer-const -- forward declaration; const cannot express this ordering
|
|
291
|
+
let sendQueue;
|
|
292
|
+
// shutdownComplete resolves from the assembly's onShutdown hook
|
|
293
|
+
// (composed after audit flush by the assembly) or, as a fallback, from
|
|
294
|
+
// handleEvent observing the reactor's terminal `reactor.done` event.
|
|
295
|
+
// close() awaits this (with a timeout) before releasing the
|
|
296
|
+
// workdir lock so a subsequent createAgent on the same directory
|
|
297
|
+
// sees a quiesced store.
|
|
298
|
+
//
|
|
299
|
+
// Use Promise.withResolvers so `resolveShutdown` is bound to the
|
|
300
|
+
// promise's resolve function at the point of declaration rather
|
|
301
|
+
// than after the Promise constructor's synchronous executor runs;
|
|
302
|
+
// the previous pattern needed a no-op seed for a TDZ window that
|
|
303
|
+
// the language already closes synchronously.
|
|
304
|
+
const { promise: shutdownComplete, resolve: resolveShutdown,
|
|
305
|
+
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- Promise.withResolvers<void>() is the conventional shape for a fire-and-forget settled-signal; matches Promise<void> used elsewhere on this assembly
|
|
306
|
+
} = Promise.withResolvers();
|
|
307
|
+
// Error accumulation. inference.error and reactor.error events
|
|
308
|
+
// observed at the assembly's onEvent boundary accumulate here and
|
|
309
|
+
// flush at the assembly's afterCheckpoint and onShutdown lifecycle
|
|
310
|
+
// hooks. Audit recording is always wired now: env.audit is required.
|
|
311
|
+
//
|
|
312
|
+
// Serialization through `flushInProgress` + `pendingFollowUp`: if
|
|
313
|
+
// a flush is already running, all concurrent callers ride a single
|
|
314
|
+
// shared follow-up promise that fires exactly once after the
|
|
315
|
+
// current flush settles. This prevents the multi-caller race
|
|
316
|
+
// where N concurrent chained continuations each observe
|
|
317
|
+
// `flushInProgress === undefined` in the same microtask drain and
|
|
318
|
+
// start parallel `commitErrors(batch)` invocations on the same
|
|
319
|
+
// prefix -- which would double-commit and incorrectly splice the
|
|
320
|
+
// accumulator. The shared follow-up clears itself before invoking
|
|
321
|
+
// the next flush, so a fourth caller arriving after the follow-up
|
|
322
|
+
// begins still observes a clean state and starts its own flush.
|
|
323
|
+
const accumulatedErrors = [];
|
|
324
|
+
let errorSeq = 0;
|
|
325
|
+
let flushInProgress;
|
|
326
|
+
let pendingFollowUp;
|
|
327
|
+
function flushErrors() {
|
|
328
|
+
if (flushInProgress !== undefined) {
|
|
329
|
+
// If another caller already arranged a follow-up flush after
|
|
330
|
+
// the current one settles, ride that. Otherwise arrange one
|
|
331
|
+
// and let every later concurrent caller share it. Run the
|
|
332
|
+
// follow-up on both fulfilment and rejection: if the in-flight
|
|
333
|
+
// flush failed, the accumulator still holds its records and
|
|
334
|
+
// the next attempt should retry rather than observe the prior
|
|
335
|
+
// failure.
|
|
336
|
+
if (pendingFollowUp !== undefined)
|
|
337
|
+
return pendingFollowUp;
|
|
338
|
+
pendingFollowUp = flushInProgress.then(() => {
|
|
339
|
+
pendingFollowUp = undefined;
|
|
340
|
+
return flushErrors();
|
|
341
|
+
}, () => {
|
|
342
|
+
pendingFollowUp = undefined;
|
|
343
|
+
return flushErrors();
|
|
344
|
+
});
|
|
345
|
+
return pendingFollowUp;
|
|
346
|
+
}
|
|
347
|
+
if (accumulatedErrors.length === 0)
|
|
348
|
+
return Promise.resolve();
|
|
349
|
+
const count = accumulatedErrors.length;
|
|
350
|
+
const batch = accumulatedErrors.slice(0, count);
|
|
351
|
+
// Splice only after a successful commit. A throwing audit store
|
|
352
|
+
// must not lose the batch -- the next flush hook (a later
|
|
353
|
+
// afterCheckpoint or the onShutdown drain) retries the same
|
|
354
|
+
// records. Note this means that on a permanent audit-store
|
|
355
|
+
// failure, the accumulator grows unbounded; the assembly's
|
|
356
|
+
// expectation is that commitErrors failures are transient.
|
|
357
|
+
flushInProgress = (async () => {
|
|
358
|
+
try {
|
|
359
|
+
await auditStore.commitErrors(batch);
|
|
360
|
+
accumulatedErrors.splice(0, count);
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
flushInProgress = undefined;
|
|
364
|
+
}
|
|
365
|
+
})();
|
|
366
|
+
return flushInProgress;
|
|
367
|
+
}
|
|
368
|
+
function buildSyntheticTurn(text) {
|
|
369
|
+
return {
|
|
370
|
+
role: "assistant",
|
|
371
|
+
content: [{ type: "text", text }],
|
|
372
|
+
model: sourceRegistry.active.model,
|
|
373
|
+
timestamp: Date.now(),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function handleEvent(event) {
|
|
377
|
+
if (event.type === "inference.error") {
|
|
378
|
+
accumulatedErrors.push({
|
|
379
|
+
source: "inference",
|
|
380
|
+
category: event.data.error.category,
|
|
381
|
+
message: event.data.error.message,
|
|
382
|
+
fatal: false,
|
|
383
|
+
timestamp: new Date().toISOString(),
|
|
384
|
+
sessionId,
|
|
385
|
+
seq: errorSeq++,
|
|
386
|
+
...(event.data.error.statusCode !== undefined
|
|
387
|
+
? { statusCode: event.data.error.statusCode }
|
|
388
|
+
: {}),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
else if (event.type === "reactor.error") {
|
|
392
|
+
accumulatedErrors.push({
|
|
393
|
+
source: "reactor",
|
|
394
|
+
category: "reactor_error",
|
|
395
|
+
message: event.data.error,
|
|
396
|
+
fatal: event.data.fatal,
|
|
397
|
+
timestamp: new Date().toISOString(),
|
|
398
|
+
sessionId,
|
|
399
|
+
seq: errorSeq++,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
if (activeCycle !== null && event.type === "inference.done") {
|
|
403
|
+
activeCycle.lastAssistantTurn = event.data.turn;
|
|
404
|
+
}
|
|
405
|
+
if (activeCycle !== null) {
|
|
406
|
+
if (event.type === "connector.reply") {
|
|
407
|
+
const turn = activeCycle.lastAssistantTurn ??
|
|
408
|
+
buildSyntheticTurn(event.data.content);
|
|
409
|
+
activeCycle = null;
|
|
410
|
+
sendQueue.resolveActive({
|
|
411
|
+
type: "reply",
|
|
412
|
+
reply: event.data.content,
|
|
413
|
+
turn,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
else if (event.type === "reactor.gate.blocked") {
|
|
417
|
+
// The reactor parked on a gate before producing a reply. This
|
|
418
|
+
// is a terminal outcome for the active send: the cycle will not
|
|
419
|
+
// continue until the correlated external decision is delivered,
|
|
420
|
+
// and a parked cycle does not emit connector.reply or
|
|
421
|
+
// reactor.done, so leaving the send unsettled would hang the
|
|
422
|
+
// caller. Resolve with the suspended outcome so the caller can
|
|
423
|
+
// resume against the correlationId. A gate parked without a
|
|
424
|
+
// correlationId is unresumable -- surface it rather than hand
|
|
425
|
+
// back an outcome with no handle.
|
|
426
|
+
const { correlationId, approvalSnapshot } = event.data;
|
|
427
|
+
activeCycle = null;
|
|
428
|
+
if (correlationId === undefined) {
|
|
429
|
+
sendQueue.rejectActive(new GateSuspendedWithoutCorrelationError(event.data.gateId));
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
sendQueue.resolveActive({
|
|
433
|
+
type: "suspended",
|
|
434
|
+
correlationId,
|
|
435
|
+
...(approvalSnapshot !== undefined ? { approvalSnapshot } : {}),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
else if (event.type === "reactor.error" && event.data.fatal) {
|
|
440
|
+
// Only fatal reactor errors terminate the active send. Non-fatal
|
|
441
|
+
// errors (e.g. transient write/commit failures the reactor is
|
|
442
|
+
// recovering from) are surfaced via stream() but must not
|
|
443
|
+
// resolve send() -- the cycle is still running and may yet
|
|
444
|
+
// produce connector.reply or a fatal error.
|
|
445
|
+
activeCycle = null;
|
|
446
|
+
sendQueue.rejectActive(new Error(`reactor error: ${event.data.error}`));
|
|
447
|
+
}
|
|
448
|
+
else if (event.type === "reactor.done") {
|
|
449
|
+
activeCycle = null;
|
|
450
|
+
sendQueue.rejectActive(new AgentClosedError());
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
// reactor.done is the reactor's terminal event. Resolve
|
|
454
|
+
// shutdownComplete here in addition to the onShutdown hook so close()
|
|
455
|
+
// does not hang for the full closeTimeoutMs on paths where the hook
|
|
456
|
+
// never fires (e.g. the reactor's context-store load fails during
|
|
457
|
+
// start, or the composed onShutdown wrapper throws during audit
|
|
458
|
+
// flush). resolveShutdown is idempotent.
|
|
459
|
+
if (event.type === "reactor.done") {
|
|
460
|
+
resolveShutdown();
|
|
461
|
+
}
|
|
462
|
+
// Pre-start window: if no consumer has attached yet, buffer the
|
|
463
|
+
// event so the first consumer to attach picks it up. The buffer
|
|
464
|
+
// is discarded after the first drain; later consumers see only
|
|
465
|
+
// events emitted after their own registration. Overflow drops
|
|
466
|
+
// the oldest event with a log warning -- raising here would
|
|
467
|
+
// abort reactor startup, which is worse than missing
|
|
468
|
+
// observability for the earliest events.
|
|
469
|
+
if (preStartBuffer !== undefined && streamConsumers.size === 0) {
|
|
470
|
+
if (preStartBuffer.length >= streamBufferMax) {
|
|
471
|
+
preStartBuffer.shift();
|
|
472
|
+
preStartBufferOverflows += 1;
|
|
473
|
+
}
|
|
474
|
+
preStartBuffer.push(event);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// Iterate a snapshot so removing closed consumers mid-iteration is
|
|
478
|
+
// not just relying on Set's iteration tolerance.
|
|
479
|
+
for (const consumer of Array.from(streamConsumers)) {
|
|
480
|
+
consumer.push(event);
|
|
481
|
+
if (consumer.closed) {
|
|
482
|
+
streamConsumers.delete(consumer);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const { reactor, blobReader } = createReactorAssembly({
|
|
487
|
+
sessionId,
|
|
488
|
+
director,
|
|
489
|
+
source: sourceRegistry.active,
|
|
490
|
+
failOverToNextSource: () => sourceRegistry.failOverToNextSource(),
|
|
491
|
+
resetToPreferredSource: () => sourceRegistry.resetToPreferredSource(),
|
|
492
|
+
toolRunner: resolvedTools.runner,
|
|
493
|
+
contextStore,
|
|
494
|
+
onEvent: handleEvent,
|
|
495
|
+
auditStore,
|
|
496
|
+
authorize,
|
|
497
|
+
toolDefinitions: resolvedTools.definitions,
|
|
498
|
+
onShutdown: async () => {
|
|
499
|
+
try {
|
|
500
|
+
await flushErrors();
|
|
501
|
+
}
|
|
502
|
+
finally {
|
|
503
|
+
resolveShutdown();
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
afterCheckpoint: flushErrors,
|
|
507
|
+
...(env.sizeCapMaxChars !== undefined
|
|
508
|
+
? { sizeCapMaxChars: env.sizeCapMaxChars }
|
|
509
|
+
: {}),
|
|
510
|
+
deps,
|
|
511
|
+
...(env.compactors !== undefined ? { compactors: env.compactors } : {}),
|
|
512
|
+
});
|
|
513
|
+
sendQueue = createSendQueue({
|
|
514
|
+
maxDepth: env.sendQueueMax ?? DEFAULT_SEND_QUEUE_MAX,
|
|
515
|
+
start: (message) => {
|
|
516
|
+
activeCycle = { lastAssistantTurn: undefined };
|
|
517
|
+
reactor.deliver(message);
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
reactor.start();
|
|
521
|
+
let closed = false;
|
|
522
|
+
function ensureOpen() {
|
|
523
|
+
if (closed)
|
|
524
|
+
throw new AgentClosedError();
|
|
525
|
+
}
|
|
526
|
+
function buildInboundMessage(content, opts) {
|
|
527
|
+
if (typeof content !== "string")
|
|
528
|
+
return content;
|
|
529
|
+
// Conversation messages use `content` (a string); the mail-builder
|
|
530
|
+
// rejects passing `payload` for conversation types.
|
|
531
|
+
return createInboundMessage({
|
|
532
|
+
from: opts?.from ?? DEFAULT_SEND_FROM,
|
|
533
|
+
to: DEFAULT_SEND_TO,
|
|
534
|
+
content,
|
|
535
|
+
interchangeType: "conversation.message",
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
function send(content, opts) {
|
|
539
|
+
// Closed-agent errors come back as rejections so callers can handle
|
|
540
|
+
// them with `.catch()` instead of having to defensively wrap every
|
|
541
|
+
// `agent.send(...)` in a try/catch. `SendQueueFullError` from
|
|
542
|
+
// `sendQueue.enqueue` is left as a synchronous throw -- it signals a
|
|
543
|
+
// programmer error (the caller exceeded the configured queue cap)
|
|
544
|
+
// and per the design must fail loud.
|
|
545
|
+
if (closed)
|
|
546
|
+
return Promise.reject(new AgentClosedError());
|
|
547
|
+
const message = buildInboundMessage(content, opts);
|
|
548
|
+
return sendQueue.enqueue(message, opts?.signal);
|
|
549
|
+
}
|
|
550
|
+
function stream() {
|
|
551
|
+
ensureOpen();
|
|
552
|
+
const consumer = createStreamConsumer(streamBufferMax);
|
|
553
|
+
// Drain the pre-start buffer into the first consumer that
|
|
554
|
+
// attaches so events emitted between reactor.start() and the
|
|
555
|
+
// first stream() call are not lost. The buffer is discarded
|
|
556
|
+
// after the first drain -- later consumers see only events
|
|
557
|
+
// emitted after their own registration, matching the per-
|
|
558
|
+
// consumer semantics every other code path expects.
|
|
559
|
+
if (preStartBuffer !== undefined) {
|
|
560
|
+
if (preStartBufferOverflows > 0) {
|
|
561
|
+
logger.warn `pre-start event buffer overflowed by ${preStartBufferOverflows} event(s) before the first stream() consumer attached; oldest events were dropped`;
|
|
562
|
+
}
|
|
563
|
+
for (const event of preStartBuffer)
|
|
564
|
+
consumer.push(event);
|
|
565
|
+
preStartBuffer = undefined;
|
|
566
|
+
}
|
|
567
|
+
streamConsumers.add(consumer);
|
|
568
|
+
return consumer.iterator();
|
|
569
|
+
}
|
|
570
|
+
function deliver(message) {
|
|
571
|
+
ensureOpen();
|
|
572
|
+
reactor.deliver(message);
|
|
573
|
+
}
|
|
574
|
+
function setSource(source) {
|
|
575
|
+
ensureOpen();
|
|
576
|
+
sourceRegistry.setSource(source);
|
|
577
|
+
}
|
|
578
|
+
function setSources(sources, defaultSource) {
|
|
579
|
+
ensureOpen();
|
|
580
|
+
sourceRegistry.setSources(sources, defaultSource);
|
|
581
|
+
}
|
|
582
|
+
async function history() {
|
|
583
|
+
const loaded = await contextStore.load();
|
|
584
|
+
return loaded.turns;
|
|
585
|
+
}
|
|
586
|
+
async function checkpoints(limit) {
|
|
587
|
+
return contextStore.log(limit);
|
|
588
|
+
}
|
|
589
|
+
async function readAt(hash) {
|
|
590
|
+
return contextStore.readAt(hash);
|
|
591
|
+
}
|
|
592
|
+
async function close() {
|
|
593
|
+
if (closed)
|
|
594
|
+
return;
|
|
595
|
+
closed = true;
|
|
596
|
+
reactor.abort("user_disconnect");
|
|
597
|
+
sendQueue.drain(new AgentClosedError());
|
|
598
|
+
activeCycle = null;
|
|
599
|
+
for (const consumer of streamConsumers)
|
|
600
|
+
consumer.close();
|
|
601
|
+
streamConsumers.clear();
|
|
602
|
+
// Surface any pre-start buffer state the caller never observed.
|
|
603
|
+
// The buffer drains into the first `stream()` consumer at
|
|
604
|
+
// attachment time and logs its overflow count then. If no
|
|
605
|
+
// consumer ever attached (e.g. a `send()`-only caller that
|
|
606
|
+
// never subscribed to the event stream), the buffer and its
|
|
607
|
+
// overflow counter would silently disappear here without an
|
|
608
|
+
// operator signal. Log the overflow once at close time so a
|
|
609
|
+
// startup pathology that dropped reactor.start-window events
|
|
610
|
+
// is at least observable in the logs.
|
|
611
|
+
if (preStartBuffer !== undefined && preStartBufferOverflows > 0) {
|
|
612
|
+
logger.warn `pre-start event buffer overflowed by ${preStartBufferOverflows} event(s) and no stream() consumer ever attached to drain it; oldest events were dropped`;
|
|
613
|
+
}
|
|
614
|
+
preStartBuffer = undefined;
|
|
615
|
+
// Wait for the reactor's shutdown sequence (audit flush, in-flight
|
|
616
|
+
// commits) before releasing the lock so a subsequent createAgent on
|
|
617
|
+
// the same workdir does not race with background writers against
|
|
618
|
+
// the same .git directory. The timeout is a backstop: if the
|
|
619
|
+
// reactor's shutdown is genuinely stuck (e.g. a parked test fetch
|
|
620
|
+
// that never resolves) we release the lock anyway rather than
|
|
621
|
+
// deadlock the caller. `closeTimeoutMs: 0` disables the wait.
|
|
622
|
+
const timeoutMs = env.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
|
|
623
|
+
if (timeoutMs > 0) {
|
|
624
|
+
let timer;
|
|
625
|
+
const timeout = new Promise((resolve) => {
|
|
626
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
627
|
+
});
|
|
628
|
+
try {
|
|
629
|
+
await Promise.race([shutdownComplete, timeout]);
|
|
630
|
+
}
|
|
631
|
+
finally {
|
|
632
|
+
if (timer !== undefined)
|
|
633
|
+
clearTimeout(timer);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
lock.release();
|
|
637
|
+
}
|
|
638
|
+
const agent = {
|
|
639
|
+
send,
|
|
640
|
+
stream,
|
|
641
|
+
deliver,
|
|
642
|
+
close,
|
|
643
|
+
setSource,
|
|
644
|
+
setSources,
|
|
645
|
+
history,
|
|
646
|
+
checkpoints,
|
|
647
|
+
readAt,
|
|
648
|
+
blobReader,
|
|
649
|
+
};
|
|
650
|
+
succeeded = true;
|
|
651
|
+
return agent;
|
|
652
|
+
}
|
|
653
|
+
finally {
|
|
654
|
+
if (!succeeded) {
|
|
655
|
+
// Dispose every successfully constructed bundle. Mirror the
|
|
656
|
+
// intra-`resolveTools` rollback shape: swallow async rejections
|
|
657
|
+
// via a `.catch` (a bare `void promise.dispose()` would leave
|
|
658
|
+
// the rejection in flight and surface as an unhandled rejection
|
|
659
|
+
// on the event loop), swallow synchronous throws with the
|
|
660
|
+
// surrounding try/catch, and let the throw the caller actually
|
|
661
|
+
// raised propagate immediately rather than awaiting cleanup.
|
|
662
|
+
for (const bundle of bundlesForRollback) {
|
|
663
|
+
if (bundle.dispose === undefined)
|
|
664
|
+
continue;
|
|
665
|
+
try {
|
|
666
|
+
const result = bundle.dispose();
|
|
667
|
+
if (result instanceof Promise) {
|
|
668
|
+
result.catch(() => {
|
|
669
|
+
// Swallow per the comment above.
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
// Synchronous throws from a non-async dispose that throws
|
|
675
|
+
// before returning a promise. Same intent as the async
|
|
676
|
+
// path: never let rollback noise mask the original failure.
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
lock.release();
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|