@intx/harness 0.1.2 → 0.2.2

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.
@@ -0,0 +1,182 @@
1
+ import { type Agent, type AgentDefinition, type AnnotatedToolFactory, type BaseEnv, type ToolBundle } from "@intx/agent";
2
+ import type { BlobReader, ConnectorThreadState, ContextStore, InboundMessage, InferenceSource, MessageTransport } from "@intx/types/runtime";
3
+ import { createConnectorRouter } from "./connector-router.js";
4
+ /**
5
+ * Env extension the composition layer requires beyond `BaseEnv`. Tools
6
+ * shipped by this package declare the matching `requires` so
7
+ * `validateEnv` can blame either at the env entry point.
8
+ *
9
+ * `onReplySendFailed` is invoked when the reply drain catches a failure
10
+ * from `connectorRouter.composeReply` or `transport.send` for an
11
+ * outbound `connector.reply`. The reply is dropped and the router
12
+ * state is not advanced; the callback is the only programmatic surface
13
+ * a caller has to observe the loss. Production deployments that need
14
+ * retry semantics layer them on top of this callback.
15
+ *
16
+ * The callback may be synchronous or async; the reply drain awaits its
17
+ * resolution so an async callback's rejection is observed (and logged)
18
+ * rather than surfacing as an unhandled promise rejection.
19
+ *
20
+ * `onReplyDrainTerminated` is invoked when the reply drain's `for await`
21
+ * loop exits abnormally -- the only documented case is a
22
+ * `StreamBackpressureError` thrown by the agent's event stream when the
23
+ * drain's per-consumer buffer overruns `streamBufferMax`. After this
24
+ * fires the harness is no longer forwarding `connector.reply` events to
25
+ * the transport: in-process `agent.send()` callers still resolve, but
26
+ * outbound replies are silently dropped until `close()`. Production
27
+ * deployments that need to alert on this failure mode subscribe via
28
+ * this callback; the harness only emits a `logger.warn` otherwise. The
29
+ * callback may be synchronous or async and is awaited the same way
30
+ * `onReplySendFailed` is, so an async rejection is observed (and
31
+ * logged) rather than escaping as an unhandled rejection.
32
+ */
33
+ export interface MailEnv extends BaseEnv {
34
+ transport: MessageTransport;
35
+ address: string;
36
+ onConnectorStateChanged?: (state: ConnectorThreadState | null) => void;
37
+ onReplySendFailed?: (cause: unknown) => void | Promise<void>;
38
+ onReplyDrainTerminated?: (cause: unknown) => void | Promise<void>;
39
+ }
40
+ /**
41
+ * Narrowed public surface returned by `createHarness`. `close` is the
42
+ * only direct surface; everything else is a pass-through to the
43
+ * underlying agent. `stream` is exposed so observability consumers can
44
+ * subscribe to the reactor's event stream without having to grab the
45
+ * agent reference.
46
+ */
47
+ export interface Harness {
48
+ close(): Promise<void>;
49
+ deliver(message: InboundMessage): void;
50
+ setSource(source: InferenceSource): void;
51
+ setSources(sources: InferenceSource[], defaultSource: string): void;
52
+ stream: Agent["stream"];
53
+ readonly blobReader: BlobReader;
54
+ }
55
+ /**
56
+ * Mail-tool factory shape. The `createMailTools` constructor in
57
+ * `@intx/tools-mail` builds a runner from a transport-bearing
58
+ * capability set; the harness wraps that into a single `defineTool`
59
+ * bundle whose `requires` names the env keys the wrapper touches.
60
+ *
61
+ * Callers (e.g. the sidecar) supply the wrapper as a tool factory on
62
+ * their `AgentDefinition`. `createHarness` does not synthesize it
63
+ * internally -- the caller is the layer that knows which mail-tool
64
+ * implementation to use.
65
+ */
66
+ export type MailToolWrapper = (transport: MessageTransport) => Omit<ToolBundle, "dispose">;
67
+ /**
68
+ * Invoke the caller-supplied `onReplySendFailed` callback and absorb any
69
+ * failure it raises. Extracted from the reply drain so the await-the-
70
+ * callback contract is testable in isolation: a bare invocation would
71
+ * compile (TypeScript admits `async () => void` as satisfying a `void`-
72
+ * returning signature) but would let an async callback's rejection
73
+ * escape as an unhandled promise rejection. Awaiting protects against
74
+ * that; the helper exists so the protection is asserted by a test
75
+ * rather than implied by inspection of the drain.
76
+ *
77
+ * Exported only for the regression test in this package; no external
78
+ * consumer should call it.
79
+ *
80
+ * The export-and-mark-internal shape is the codebase's convention
81
+ * for helpers that exist to make a production-code contract
82
+ * testable in isolation. A separate `@intx/harness/testing`
83
+ * entry-point was considered and rejected: the helper is one
84
+ * try/catch wrapper around the production callback, tightly
85
+ * coupled to the `MailEnv` callback type defined adjacent to it.
86
+ * Moving it would either duplicate the production code in a test
87
+ * module (defeating the point) or require a parallel entry-point
88
+ * whose only export is a single function -- bundler ceremony for a
89
+ * boundary TypeScript cannot enforce anyway, since deep imports
90
+ * (`@intx/harness/src/harness`) reach the same module regardless
91
+ * of what `index.ts` re-exports. The docstring convention is
92
+ * load-bearing here: the marker is the contract.
93
+ *
94
+ * `invokeReplyDrainTerminated` (below) follows the same shape for
95
+ * the same reason.
96
+ */
97
+ export declare function invokeReplySendFailed(callback: NonNullable<MailEnv["onReplySendFailed"]>, cause: unknown): Promise<void>;
98
+ /**
99
+ * Invoke the caller-supplied `onReplyDrainTerminated` callback and
100
+ * absorb any failure it raises. Mirrors `invokeReplySendFailed`:
101
+ * extracted from the reply drain so the await-the-callback contract
102
+ * is testable in isolation, exported only for the regression test in
103
+ * this package.
104
+ */
105
+ export declare function invokeReplyDrainTerminated(callback: NonNullable<MailEnv["onReplyDrainTerminated"]>, cause: unknown): Promise<void>;
106
+ /**
107
+ * Build the `load` / `writeMetadata` overrides the harness layers onto
108
+ * `env.storage`. Extracted from `createHarness` so the dirty-bit gating
109
+ * on `load()` is directly testable -- the production path constructs
110
+ * the overrides inline with the same arguments.
111
+ *
112
+ * The `isInMemoryStateAuthoritative` callback is read on every `load`
113
+ * invocation. The harness sets the bit from the router's
114
+ * `onStateChanged` callback so the gate flips on the same tick a
115
+ * commit produces its first state change; subsequent loads (whether
116
+ * driven by reactor recovery, mid-cycle, or anywhere else) leave the
117
+ * router's in-memory snapshot intact rather than blanking it with the
118
+ * pre-commit disk value.
119
+ *
120
+ * Exported for the regression test in this package; no external
121
+ * consumer should call it. Same shape and rationale as
122
+ * `invokeReplySendFailed` and `invokeReplyDrainTerminated` above --
123
+ * the helper is tightly coupled to the dirty-bit gating semantics
124
+ * that live in this module, and a separate testing entry-point
125
+ * would buy bundler ceremony for a boundary TypeScript cannot
126
+ * enforce. The docstring "internal" marker is the contract.
127
+ */
128
+ export declare function createWrappedStorageOverrides(baseStorage: ContextStore, connectorRouter: ReturnType<typeof createConnectorRouter>, isInMemoryStateAuthoritative: () => boolean): Pick<ContextStore, "load" | "writeMetadata">;
129
+ /**
130
+ * Construct an `AnnotatedToolFactory` for a mail-tool bundle. The
131
+ * factory binds `transport` from env at construction time and produces
132
+ * a bundle whose lifetime is tied to the agent. Disposal of the
133
+ * underlying mail tools is the caller's responsibility (the env is the
134
+ * agent's dependency contract; the caller owns what it puts in env);
135
+ * the agent itself does not call bundle disposers (see the
136
+ * `ToolBundle` contract in `@intx/agent`). Callers that need to
137
+ * dispose mail tools on shutdown retain a reference to the underlying
138
+ * `MailToolWrapper`'s output and invoke its `dispose` directly --
139
+ * routing disposal through the bundle the agent receives would still
140
+ * not fire since the agent never holds it.
141
+ *
142
+ * The `requires: ["transport", "address"]` declaration captures the
143
+ * env-key surface of the entire mail composition path -- the factory
144
+ * body reads `transport`, and `createHarness` (which the caller pairs
145
+ * this factory with) reads `env.address` to label rejected-message
146
+ * log records identifying which agent's router refused the message.
147
+ * No routing decision keys off `env.address` -- the connector router
148
+ * routes on per-message thread state, not on the agent's own
149
+ * address -- so the field is observability-only. It still belongs in
150
+ * `requires` because the harness's log record assumes the field is
151
+ * populated; declaring it here lets the agent's `validateEnv` blame a
152
+ * missing `address` at construction time rather than letting the
153
+ * watch loop discover it under operational load. Callers that hand-
154
+ * build a `defineTool` factory for a different mail-tool runner must
155
+ * remember to surface `address` on their own `requires` if their
156
+ * `createHarness` consumes it -- the agent has no way to deduce
157
+ * composition-layer env requirements from a factory body that does
158
+ * not itself read the field.
159
+ *
160
+ * The `requires` set is fixed at the two keys above by design; this
161
+ * helper is not the extension point for mail-tool runners that need
162
+ * additional env keys. A mail tool that wants to read (say) a tenant
163
+ * identifier from env should drop down to `defineTool` directly,
164
+ * declare its own `requires` with the full surface, and call the
165
+ * underlying mail-tool constructor inside that factory. Folding an
166
+ * additional `requires` parameter into `defineMailTools` would push
167
+ * the "what does the harness need vs. what does the tool runner
168
+ * need" partition onto the caller, which is exactly the partition
169
+ * this helper exists to hide.
170
+ */
171
+ export declare function defineMailTools(wrapper: MailToolWrapper): AnnotatedToolFactory<MailEnv>;
172
+ /**
173
+ * Construct a composition-layer agent: the underlying agent wrapped
174
+ * with connector-state-aware storage, transport subscription, INBOX
175
+ * watch, and connector-reply forwarding.
176
+ *
177
+ * The reactor is wrapped exactly once -- inside `createAgent`.
178
+ * `createHarness` augments env.storage with connector-state load/save
179
+ * and subscribes to the agent's event stream to intercept
180
+ * `connector.reply` events for outbound transport sends.
181
+ */
182
+ export declare function createHarness<EnvReq extends MailEnv>(def: AgentDefinition<EnvReq>, env: EnvReq): Promise<Harness>;
@@ -0,0 +1,439 @@
1
+ // @intx/harness composition layer.
2
+ //
3
+ // The harness imports `@intx/agent` and composes a mail-transport
4
+ // surface on top of `createAgent(def, env)`. The reactor is wrapped
5
+ // exactly once -- inside the agent harness in `@intx/agent`. This
6
+ // module owns transport subscription, the connector router and its
7
+ // state persistence, the INBOX watch loop, and the outbound side of
8
+ // `connector.reply` events.
9
+ //
10
+ // What this module does *not* own: reactor wrapping, audit accumulation
11
+ // or flushing, source-registry hot-swap. Those live in `@intx/agent`
12
+ // and are reached via `agent.deliver`, `agent.setSource`, and
13
+ // `agent.stream()` respectively.
14
+ import { createAgent, defineTool, } from "@intx/agent";
15
+ import { getLogger } from "@intx/log";
16
+ import { createConnectorRouter } from "./connector-router.js";
17
+ const logger = getLogger(["interchange", "harness"]);
18
+ /**
19
+ * Invoke the caller-supplied `onReplySendFailed` callback and absorb any
20
+ * failure it raises. Extracted from the reply drain so the await-the-
21
+ * callback contract is testable in isolation: a bare invocation would
22
+ * compile (TypeScript admits `async () => void` as satisfying a `void`-
23
+ * returning signature) but would let an async callback's rejection
24
+ * escape as an unhandled promise rejection. Awaiting protects against
25
+ * that; the helper exists so the protection is asserted by a test
26
+ * rather than implied by inspection of the drain.
27
+ *
28
+ * Exported only for the regression test in this package; no external
29
+ * consumer should call it.
30
+ *
31
+ * The export-and-mark-internal shape is the codebase's convention
32
+ * for helpers that exist to make a production-code contract
33
+ * testable in isolation. A separate `@intx/harness/testing`
34
+ * entry-point was considered and rejected: the helper is one
35
+ * try/catch wrapper around the production callback, tightly
36
+ * coupled to the `MailEnv` callback type defined adjacent to it.
37
+ * Moving it would either duplicate the production code in a test
38
+ * module (defeating the point) or require a parallel entry-point
39
+ * whose only export is a single function -- bundler ceremony for a
40
+ * boundary TypeScript cannot enforce anyway, since deep imports
41
+ * (`@intx/harness/src/harness`) reach the same module regardless
42
+ * of what `index.ts` re-exports. The docstring convention is
43
+ * load-bearing here: the marker is the contract.
44
+ *
45
+ * `invokeReplyDrainTerminated` (below) follows the same shape for
46
+ * the same reason.
47
+ */
48
+ export async function invokeReplySendFailed(callback, cause) {
49
+ try {
50
+ await callback(cause);
51
+ }
52
+ catch (callbackError) {
53
+ logger.error `onReplySendFailed callback threw: ${callbackError}`;
54
+ }
55
+ }
56
+ /**
57
+ * Invoke the caller-supplied `onReplyDrainTerminated` callback and
58
+ * absorb any failure it raises. Mirrors `invokeReplySendFailed`:
59
+ * extracted from the reply drain so the await-the-callback contract
60
+ * is testable in isolation, exported only for the regression test in
61
+ * this package.
62
+ */
63
+ export async function invokeReplyDrainTerminated(callback, cause) {
64
+ try {
65
+ await callback(cause);
66
+ }
67
+ catch (callbackError) {
68
+ logger.error `onReplyDrainTerminated callback threw: ${callbackError}`;
69
+ }
70
+ }
71
+ /**
72
+ * Build the `load` / `writeMetadata` overrides the harness layers onto
73
+ * `env.storage`. Extracted from `createHarness` so the dirty-bit gating
74
+ * on `load()` is directly testable -- the production path constructs
75
+ * the overrides inline with the same arguments.
76
+ *
77
+ * The `isInMemoryStateAuthoritative` callback is read on every `load`
78
+ * invocation. The harness sets the bit from the router's
79
+ * `onStateChanged` callback so the gate flips on the same tick a
80
+ * commit produces its first state change; subsequent loads (whether
81
+ * driven by reactor recovery, mid-cycle, or anywhere else) leave the
82
+ * router's in-memory snapshot intact rather than blanking it with the
83
+ * pre-commit disk value.
84
+ *
85
+ * Exported for the regression test in this package; no external
86
+ * consumer should call it. Same shape and rationale as
87
+ * `invokeReplySendFailed` and `invokeReplyDrainTerminated` above --
88
+ * the helper is tightly coupled to the dirty-bit gating semantics
89
+ * that live in this module, and a separate testing entry-point
90
+ * would buy bundler ceremony for a boundary TypeScript cannot
91
+ * enforce. The docstring "internal" marker is the contract.
92
+ */
93
+ export function createWrappedStorageOverrides(baseStorage, connectorRouter, isInMemoryStateAuthoritative) {
94
+ return {
95
+ async load(signal) {
96
+ const loaded = await baseStorage.load(signal);
97
+ if (!isInMemoryStateAuthoritative()) {
98
+ connectorRouter.restore(loaded.connectorState);
99
+ }
100
+ return loaded;
101
+ },
102
+ async writeMetadata(metadata, signal) {
103
+ baseStorage.setConnectorState(connectorRouter.snapshot());
104
+ return baseStorage.writeMetadata(metadata, signal);
105
+ },
106
+ };
107
+ }
108
+ /**
109
+ * Construct an `AnnotatedToolFactory` for a mail-tool bundle. The
110
+ * factory binds `transport` from env at construction time and produces
111
+ * a bundle whose lifetime is tied to the agent. Disposal of the
112
+ * underlying mail tools is the caller's responsibility (the env is the
113
+ * agent's dependency contract; the caller owns what it puts in env);
114
+ * the agent itself does not call bundle disposers (see the
115
+ * `ToolBundle` contract in `@intx/agent`). Callers that need to
116
+ * dispose mail tools on shutdown retain a reference to the underlying
117
+ * `MailToolWrapper`'s output and invoke its `dispose` directly --
118
+ * routing disposal through the bundle the agent receives would still
119
+ * not fire since the agent never holds it.
120
+ *
121
+ * The `requires: ["transport", "address"]` declaration captures the
122
+ * env-key surface of the entire mail composition path -- the factory
123
+ * body reads `transport`, and `createHarness` (which the caller pairs
124
+ * this factory with) reads `env.address` to label rejected-message
125
+ * log records identifying which agent's router refused the message.
126
+ * No routing decision keys off `env.address` -- the connector router
127
+ * routes on per-message thread state, not on the agent's own
128
+ * address -- so the field is observability-only. It still belongs in
129
+ * `requires` because the harness's log record assumes the field is
130
+ * populated; declaring it here lets the agent's `validateEnv` blame a
131
+ * missing `address` at construction time rather than letting the
132
+ * watch loop discover it under operational load. Callers that hand-
133
+ * build a `defineTool` factory for a different mail-tool runner must
134
+ * remember to surface `address` on their own `requires` if their
135
+ * `createHarness` consumes it -- the agent has no way to deduce
136
+ * composition-layer env requirements from a factory body that does
137
+ * not itself read the field.
138
+ *
139
+ * The `requires` set is fixed at the two keys above by design; this
140
+ * helper is not the extension point for mail-tool runners that need
141
+ * additional env keys. A mail tool that wants to read (say) a tenant
142
+ * identifier from env should drop down to `defineTool` directly,
143
+ * declare its own `requires` with the full surface, and call the
144
+ * underlying mail-tool constructor inside that factory. Folding an
145
+ * additional `requires` parameter into `defineMailTools` would push
146
+ * the "what does the harness need vs. what does the tool runner
147
+ * need" partition onto the caller, which is exactly the partition
148
+ * this helper exists to hide.
149
+ */
150
+ export function defineMailTools(wrapper) {
151
+ return defineTool({
152
+ id: "@intx/harness/mail",
153
+ requires: ["transport", "address"],
154
+ factory: (env) => {
155
+ const bundle = wrapper(env.transport);
156
+ return {
157
+ definitions: bundle.definitions,
158
+ run: (call, signal) => bundle.run(call, signal),
159
+ };
160
+ },
161
+ });
162
+ }
163
+ /**
164
+ * Construct a composition-layer agent: the underlying agent wrapped
165
+ * with connector-state-aware storage, transport subscription, INBOX
166
+ * watch, and connector-reply forwarding.
167
+ *
168
+ * The reactor is wrapped exactly once -- inside `createAgent`.
169
+ * `createHarness` augments env.storage with connector-state load/save
170
+ * and subscribes to the agent's event stream to intercept
171
+ * `connector.reply` events for outbound transport sends.
172
+ */
173
+ export async function createHarness(def, env) {
174
+ const transport = env.transport;
175
+ // The wrappedStorage's load() needs to know whether the router's
176
+ // in-memory state is "fresher" than disk. The dirty bit flips on the
177
+ // first state change emitted by the router (commit() in the watch
178
+ // loop, onReplySent() after a connector.reply) and never flips back.
179
+ // Once dirty, the wrappedStorage refuses to restore from disk -- the
180
+ // router's in-memory state is authoritative.
181
+ //
182
+ // The wrappedStorage subscribes to the router's onStateChanged so the
183
+ // dirty bit is set the same tick commit() runs, even if a
184
+ // contextStore.load() races behind it.
185
+ let inMemoryStateAuthoritative = false;
186
+ const userOnStateChanged = env.onConnectorStateChanged;
187
+ const connectorRouter = createConnectorRouter({
188
+ onStateChanged: (state) => {
189
+ inMemoryStateAuthoritative = true;
190
+ if (userOnStateChanged !== undefined)
191
+ userOnStateChanged(state);
192
+ },
193
+ });
194
+ // Wrap env.storage. The first load() restores connector state from
195
+ // disk only if no router commit has happened yet -- once a commit
196
+ // makes the router's state authoritative, subsequent loads return
197
+ // the store's payload unchanged and leave the in-memory state
198
+ // intact.
199
+ //
200
+ // The router's in-memory state diverges from disk between commit()
201
+ // (in the watch callback) and the next writeMetadata (at the
202
+ // reactor's per-cycle checkpoint). A load() landing in that window
203
+ // must not clobber the in-memory state with the stale disk value --
204
+ // doing so makes the harness's outbound connector.reply path drop
205
+ // replies with NoActiveConnectorThreadError when composeReply() runs
206
+ // after a mid-cycle reload.
207
+ //
208
+ // The wrapper is implemented as a Proxy over env.storage so adding a
209
+ // new method to ContextStore does not require touching the harness:
210
+ // any method not named in `overrides` forwards to env.storage with
211
+ // its `this` bound to env.storage. The two overrides intercept
212
+ // load (cold-boot restore) and writeMetadata (flush router snapshot
213
+ // before delegate). `setConnectorState` is left to the default
214
+ // Proxy fall-through path since the harness adds no behaviour beyond
215
+ // delegation there.
216
+ const overrides = createWrappedStorageOverrides(env.storage, connectorRouter, () => inMemoryStateAuthoritative);
217
+ const wrappedStorage = new Proxy(env.storage, {
218
+ get(target, prop, _receiver) {
219
+ if (prop === "load")
220
+ return overrides.load;
221
+ if (prop === "writeMetadata")
222
+ return overrides.writeMetadata;
223
+ const value = Reflect.get(target, prop, target);
224
+ // Bind methods to the underlying store so isogit-style
225
+ // closure-captured state and prototype-bound this both resolve
226
+ // against the real store, not the proxy.
227
+ return typeof value === "function" ? value.bind(target) : value;
228
+ },
229
+ });
230
+ const agentEnv = { ...env, storage: wrappedStorage };
231
+ const agent = await createAgent(def, agentEnv);
232
+ // From here through the final `return`, the agent is constructed
233
+ // and the workdir lock is held. Anything that throws -- the reply
234
+ // drain's IIFE-construction expression, `transport.watch()`,
235
+ // anything in the watch callback's synchronous registration -- has
236
+ // to release the lock by closing the agent before re-raising; the
237
+ // caller never sees the agent and cannot do it themselves.
238
+ // `createAgent` covers its own internal failure paths via its
239
+ // `succeeded`/`finally` shape; this is the matching coverage for
240
+ // the harness's own construction tail.
241
+ let harnessSucceeded = false;
242
+ try {
243
+ // Background drain of the agent's event stream. Intercepts
244
+ // `connector.reply` to send the reply via transport; everything
245
+ // else flows past unobserved. Other consumers can subscribe to the
246
+ // exposed `stream()` method to see the same events.
247
+ //
248
+ // Reply sends are serialized through `replyChain` so two replies
249
+ // fired in quick succession do not interleave their
250
+ // composeReply / transport.send / onReplySent sequence -- the
251
+ // second reply waits for the first's receipt to land in the router
252
+ // before composing its own.
253
+ let stopReplyDrain = false;
254
+ let replyChain = Promise.resolve();
255
+ const replyDrainDone = (async () => {
256
+ try {
257
+ for await (const event of agent.stream()) {
258
+ if (stopReplyDrain)
259
+ break;
260
+ if (event.type === "connector.reply") {
261
+ const replyContent = event.data.content;
262
+ replyChain = replyChain.then(async () => {
263
+ try {
264
+ const parts = connectorRouter.composeReply();
265
+ const receipt = await transport.send({
266
+ ...parts,
267
+ content: replyContent,
268
+ type: "conversation.message",
269
+ });
270
+ connectorRouter.onReplySent(receipt);
271
+ }
272
+ catch (cause) {
273
+ // The reply is dropped and the router state stays at
274
+ // its pre-send value. Surface the loss to the caller's
275
+ // optional onReplySendFailed callback in addition to
276
+ // the operator-facing log so programmatic consumers
277
+ // (retries, alerting) can observe what logger.error
278
+ // alone hides.
279
+ logger.error `Failed to send connector reply: ${cause}`;
280
+ if (env.onReplySendFailed !== undefined) {
281
+ await invokeReplySendFailed(env.onReplySendFailed, cause);
282
+ }
283
+ }
284
+ });
285
+ }
286
+ }
287
+ // Drain any pending reply before the loop exits so close() sees
288
+ // a settled state.
289
+ await replyChain;
290
+ }
291
+ catch (cause) {
292
+ // The agent's stream throws on backpressure violations; log and
293
+ // exit the drain. The reply path stops working but the rest of
294
+ // the harness keeps running until close() tears it down.
295
+ // Surface the loss to the caller's optional
296
+ // `onReplyDrainTerminated` callback so programmatic consumers
297
+ // (alerting, watchdogs) can observe what `logger.warn` alone
298
+ // hides.
299
+ logger.warn `Reply-drain stream terminated: ${cause}`;
300
+ if (env.onReplyDrainTerminated !== undefined) {
301
+ await invokeReplyDrainTerminated(env.onReplyDrainTerminated, cause);
302
+ }
303
+ }
304
+ })();
305
+ // Delete a message from the INBOX after it has been delivered to the
306
+ // reactor.
307
+ //
308
+ // A failure here is logged and swallowed: the router state has
309
+ // already been committed and `agent.deliver` has accepted the
310
+ // message, so re-raising would unwind a half-applied delivery. The
311
+ // message stays in the INBOX and a future startup (or watch firing)
312
+ // re-fetches it, re-routes it, and re-delivers it. The router's
313
+ // persisted state makes that benign on the routing side: the sender
314
+ // is already a thread participant, so `route()` returns either a
315
+ // `continue` (which is a no-op state mutation since the sender is
316
+ // unchanged) or a `passthrough` (no headers match). The agent's
317
+ // director sees a duplicate `message.received`; idempotent
318
+ // directors are unaffected, and the audit trail records the
319
+ // duplicate for post-hoc reconciliation.
320
+ async function consumeFromInbox(message) {
321
+ try {
322
+ await transport.setFlags(message.ref, ["\\Deleted"]);
323
+ await transport.expunge("INBOX");
324
+ }
325
+ catch (cause) {
326
+ logger.warn `Failed to consume message uid=${message.ref.uid} from INBOX: ${cause}`;
327
+ }
328
+ }
329
+ // INBOX watch loop. Subscribe before the agent's reactor is fully
330
+ // settled so no message is missed in the window between subscription
331
+ // and the first watch callback.
332
+ let stopped = false;
333
+ const unsubscribe = transport.watch("INBOX", (event) => {
334
+ if (stopped)
335
+ return;
336
+ if (event.type !== "exists")
337
+ return;
338
+ const ref = { uid: event.uid, mailbox: "INBOX" };
339
+ void (async () => {
340
+ try {
341
+ let message;
342
+ try {
343
+ message = await transport.fetchFull(ref);
344
+ }
345
+ catch (cause) {
346
+ logger.error `Failed to fetch message uid=${event.uid}: ${cause}`;
347
+ return;
348
+ }
349
+ if (stopped)
350
+ return;
351
+ let decision;
352
+ try {
353
+ decision = connectorRouter.route(message);
354
+ }
355
+ catch (cause) {
356
+ // A router-rejected message (malformed headers, parse error
357
+ // inside the router, etc.) is still surfaced to the agent
358
+ // as an inbound `message.received`. The agent's director
359
+ // decides what the message means and how to respond;
360
+ // dropping it on the floor here would hide messages the
361
+ // operator may want to see. The router's state is *not*
362
+ // committed for the rejected message, so subsequent replies
363
+ // compose against the pre-rejection thread state.
364
+ logger.warn `Connector router rejected message uid=${message.ref.uid} for agent ${env.address}: ${cause instanceof Error ? cause.message : String(cause)}`;
365
+ if (stopped)
366
+ return;
367
+ agent.deliver(message);
368
+ return;
369
+ }
370
+ if (decision.kind === "passthrough") {
371
+ if (stopped)
372
+ return;
373
+ agent.deliver(message);
374
+ return;
375
+ }
376
+ // start or continue: commit router state synchronously before
377
+ // any await so a concurrent watch callback observes the
378
+ // updated state.
379
+ connectorRouter.commit(decision);
380
+ if (stopped)
381
+ return;
382
+ agent.deliver(message);
383
+ await consumeFromInbox(message);
384
+ }
385
+ catch (cause) {
386
+ // `agent.deliver` throws `AgentClosedError` synchronously when
387
+ // called after the agent has closed. The `if (stopped) return`
388
+ // guards above narrow the race window but cannot close it: a
389
+ // `close()` call landing between the guard and the synchronous
390
+ // throw still surfaces the rejection here. The fetched message
391
+ // is dropped; close() is in progress and the harness is
392
+ // tearing down, so the loss is expected. Without this catch
393
+ // the rejection would escape the void-IIFE as an unhandled
394
+ // promise rejection on the event loop.
395
+ if (cause instanceof Error && cause.name === "AgentClosedError") {
396
+ logger.warn `INBOX watch dropped uid=${event.uid} because the agent closed mid-delivery`;
397
+ return;
398
+ }
399
+ logger.error `INBOX watch failed for uid=${event.uid}: ${cause}`;
400
+ }
401
+ })();
402
+ });
403
+ async function close() {
404
+ if (stopped)
405
+ return;
406
+ stopped = true;
407
+ unsubscribe();
408
+ stopReplyDrain = true;
409
+ await agent.close();
410
+ // The reply-drain loop exits once the underlying stream closes
411
+ // (close() above terminates streamConsumers). Awaiting here makes
412
+ // close idempotent and lets callers rely on a settled state.
413
+ await replyDrainDone;
414
+ }
415
+ const harness = {
416
+ close,
417
+ deliver: (message) => agent.deliver(message),
418
+ setSource: (source) => agent.setSource(source),
419
+ setSources: (sources, defaultSource) => agent.setSources(sources, defaultSource),
420
+ stream: () => agent.stream(),
421
+ blobReader: agent.blobReader,
422
+ };
423
+ harnessSucceeded = true;
424
+ return harness;
425
+ }
426
+ finally {
427
+ if (!harnessSucceeded) {
428
+ // Close the agent without waiting on its shutdown timeout so a
429
+ // synchronous post-`createAgent` throw does not stall the
430
+ // caller's failure path. The `.catch` swallows any rejection
431
+ // from the close: the caller is already receiving the original
432
+ // throw, and a noisier-than-original close failure here would
433
+ // mask it.
434
+ void agent.close().catch(() => {
435
+ // Swallow per the comment above.
436
+ });
437
+ }
438
+ }
439
+ }
@@ -0,0 +1,5 @@
1
+ export { createHarness, defineMailTools, type Harness, type MailEnv, type MailToolWrapper, } from "./harness.js";
2
+ export { createHarnessRuntimeCapabilities } from "./runtime-capabilities.js";
3
+ export type { HarnessRuntimeCapabilitiesOptions } from "./runtime-capabilities.js";
4
+ export { createConnectorRouter, NoActiveConnectorThreadError, } from "./connector-router.js";
5
+ export type { ConnectorRouter, ConnectorReplyParts, ConnectorRouterOptions, RouteDecision, } from "./connector-router.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createHarness, defineMailTools, } from "./harness.js";
2
+ export { createHarnessRuntimeCapabilities } from "./runtime-capabilities.js";
3
+ export { createConnectorRouter, NoActiveConnectorThreadError, } from "./connector-router.js";
@@ -0,0 +1,6 @@
1
+ import { type RuntimeCapabilities } from "@intx/types/runtime-capabilities";
2
+ import type { MessageTransport } from "@intx/types/runtime";
3
+ export interface HarnessRuntimeCapabilitiesOptions {
4
+ transport: MessageTransport;
5
+ }
6
+ export declare function createHarnessRuntimeCapabilities(opts: HarnessRuntimeCapabilitiesOptions): RuntimeCapabilities;
@@ -0,0 +1,10 @@
1
+ // Harness-side factory for the RuntimeCapabilities that tool packages
2
+ // consume. The wrapper exists so callers (sidecar, alternate runtimes)
3
+ // pass a config object keyed by domain (`transport`) and the harness
4
+ // owns the translation to RuntimeCapabilityMap keys (`mail.transport`).
5
+ // When new capabilities are added, callers' shapes evolve through this
6
+ // wrapper, not at the call site.
7
+ import { createRuntimeCapabilities, } from "@intx/types/runtime-capabilities";
8
+ export function createHarnessRuntimeCapabilities(opts) {
9
+ return createRuntimeCapabilities({ "mail.transport": opts.transport });
10
+ }