@intx/harness 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.
@@ -0,0 +1,446 @@
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
+ * `definitions` is the static declaration `defineTool` requires: the
151
+ * tool names this factory contributes, enumerable without invoking the
152
+ * wrapper. The caller supplies it because the wrapper binds `transport`
153
+ * from env and cannot run at declaration time; the caller already holds
154
+ * the mail-tool runner whose `definitions` name the same tools.
155
+ */
156
+ export function defineMailTools(wrapper, definitions) {
157
+ return defineTool({
158
+ id: "@intx/harness/mail",
159
+ requires: ["transport", "address"],
160
+ definitions,
161
+ factory: (env) => {
162
+ const bundle = wrapper(env.transport);
163
+ return {
164
+ definitions: bundle.definitions,
165
+ run: (call, signal) => bundle.run(call, signal),
166
+ };
167
+ },
168
+ });
169
+ }
170
+ /**
171
+ * Construct a composition-layer agent: the underlying agent wrapped
172
+ * with connector-state-aware storage, transport subscription, INBOX
173
+ * watch, and connector-reply forwarding.
174
+ *
175
+ * The reactor is wrapped exactly once -- inside `createAgent`.
176
+ * `createHarness` augments env.storage with connector-state load/save
177
+ * and subscribes to the agent's event stream to intercept
178
+ * `connector.reply` events for outbound transport sends.
179
+ */
180
+ export async function createHarness(def, env) {
181
+ const transport = env.transport;
182
+ // The wrappedStorage's load() needs to know whether the router's
183
+ // in-memory state is "fresher" than disk. The dirty bit flips on the
184
+ // first state change emitted by the router (commit() in the watch
185
+ // loop, onReplySent() after a connector.reply) and never flips back.
186
+ // Once dirty, the wrappedStorage refuses to restore from disk -- the
187
+ // router's in-memory state is authoritative.
188
+ //
189
+ // The wrappedStorage subscribes to the router's onStateChanged so the
190
+ // dirty bit is set the same tick commit() runs, even if a
191
+ // contextStore.load() races behind it.
192
+ let inMemoryStateAuthoritative = false;
193
+ const userOnStateChanged = env.onConnectorStateChanged;
194
+ const connectorRouter = createConnectorRouter({
195
+ onStateChanged: (state) => {
196
+ inMemoryStateAuthoritative = true;
197
+ if (userOnStateChanged !== undefined)
198
+ userOnStateChanged(state);
199
+ },
200
+ });
201
+ // Wrap env.storage. The first load() restores connector state from
202
+ // disk only if no router commit has happened yet -- once a commit
203
+ // makes the router's state authoritative, subsequent loads return
204
+ // the store's payload unchanged and leave the in-memory state
205
+ // intact.
206
+ //
207
+ // The router's in-memory state diverges from disk between commit()
208
+ // (in the watch callback) and the next writeMetadata (at the
209
+ // reactor's per-cycle checkpoint). A load() landing in that window
210
+ // must not clobber the in-memory state with the stale disk value --
211
+ // doing so makes the harness's outbound connector.reply path drop
212
+ // replies with NoActiveConnectorThreadError when composeReply() runs
213
+ // after a mid-cycle reload.
214
+ //
215
+ // The wrapper is implemented as a Proxy over env.storage so adding a
216
+ // new method to ContextStore does not require touching the harness:
217
+ // any method not named in `overrides` forwards to env.storage with
218
+ // its `this` bound to env.storage. The two overrides intercept
219
+ // load (cold-boot restore) and writeMetadata (flush router snapshot
220
+ // before delegate). `setConnectorState` is left to the default
221
+ // Proxy fall-through path since the harness adds no behaviour beyond
222
+ // delegation there.
223
+ const overrides = createWrappedStorageOverrides(env.storage, connectorRouter, () => inMemoryStateAuthoritative);
224
+ const wrappedStorage = new Proxy(env.storage, {
225
+ get(target, prop, _receiver) {
226
+ if (prop === "load")
227
+ return overrides.load;
228
+ if (prop === "writeMetadata")
229
+ return overrides.writeMetadata;
230
+ const value = Reflect.get(target, prop, target);
231
+ // Bind methods to the underlying store so isogit-style
232
+ // closure-captured state and prototype-bound this both resolve
233
+ // against the real store, not the proxy.
234
+ return typeof value === "function" ? value.bind(target) : value;
235
+ },
236
+ });
237
+ const agentEnv = { ...env, storage: wrappedStorage };
238
+ const agent = await createAgent(def, agentEnv);
239
+ // From here through the final `return`, the agent is constructed
240
+ // and the workdir lock is held. Anything that throws -- the reply
241
+ // drain's IIFE-construction expression, `transport.watch()`,
242
+ // anything in the watch callback's synchronous registration -- has
243
+ // to release the lock by closing the agent before re-raising; the
244
+ // caller never sees the agent and cannot do it themselves.
245
+ // `createAgent` covers its own internal failure paths via its
246
+ // `succeeded`/`finally` shape; this is the matching coverage for
247
+ // the harness's own construction tail.
248
+ let harnessSucceeded = false;
249
+ try {
250
+ // Background drain of the agent's event stream. Intercepts
251
+ // `connector.reply` to send the reply via transport; everything
252
+ // else flows past unobserved. Other consumers can subscribe to the
253
+ // exposed `stream()` method to see the same events.
254
+ //
255
+ // Reply sends are serialized through `replyChain` so two replies
256
+ // fired in quick succession do not interleave their
257
+ // composeReply / transport.send / onReplySent sequence -- the
258
+ // second reply waits for the first's receipt to land in the router
259
+ // before composing its own.
260
+ let stopReplyDrain = false;
261
+ let replyChain = Promise.resolve();
262
+ const replyDrainDone = (async () => {
263
+ try {
264
+ for await (const event of agent.stream()) {
265
+ if (stopReplyDrain)
266
+ break;
267
+ if (event.type === "connector.reply") {
268
+ const replyContent = event.data.content;
269
+ replyChain = replyChain.then(async () => {
270
+ try {
271
+ const parts = connectorRouter.composeReply();
272
+ const receipt = await transport.send({
273
+ ...parts,
274
+ content: replyContent,
275
+ type: "conversation.message",
276
+ });
277
+ connectorRouter.onReplySent(receipt);
278
+ }
279
+ catch (cause) {
280
+ // The reply is dropped and the router state stays at
281
+ // its pre-send value. Surface the loss to the caller's
282
+ // optional onReplySendFailed callback in addition to
283
+ // the operator-facing log so programmatic consumers
284
+ // (retries, alerting) can observe what logger.error
285
+ // alone hides.
286
+ logger.error `Failed to send connector reply: ${cause}`;
287
+ if (env.onReplySendFailed !== undefined) {
288
+ await invokeReplySendFailed(env.onReplySendFailed, cause);
289
+ }
290
+ }
291
+ });
292
+ }
293
+ }
294
+ // Drain any pending reply before the loop exits so close() sees
295
+ // a settled state.
296
+ await replyChain;
297
+ }
298
+ catch (cause) {
299
+ // The agent's stream throws on backpressure violations; log and
300
+ // exit the drain. The reply path stops working but the rest of
301
+ // the harness keeps running until close() tears it down.
302
+ // Surface the loss to the caller's optional
303
+ // `onReplyDrainTerminated` callback so programmatic consumers
304
+ // (alerting, watchdogs) can observe what `logger.warn` alone
305
+ // hides.
306
+ logger.warn `Reply-drain stream terminated: ${cause}`;
307
+ if (env.onReplyDrainTerminated !== undefined) {
308
+ await invokeReplyDrainTerminated(env.onReplyDrainTerminated, cause);
309
+ }
310
+ }
311
+ })();
312
+ // Delete a message from the INBOX after it has been delivered to the
313
+ // reactor.
314
+ //
315
+ // A failure here is logged and swallowed: the router state has
316
+ // already been committed and `agent.deliver` has accepted the
317
+ // message, so re-raising would unwind a half-applied delivery. The
318
+ // message stays in the INBOX and a future startup (or watch firing)
319
+ // re-fetches it, re-routes it, and re-delivers it. The router's
320
+ // persisted state makes that benign on the routing side: the sender
321
+ // is already a thread participant, so `route()` returns either a
322
+ // `continue` (which is a no-op state mutation since the sender is
323
+ // unchanged) or a `passthrough` (no headers match). The agent's
324
+ // director sees a duplicate `message.received`; idempotent
325
+ // directors are unaffected, and the audit trail records the
326
+ // duplicate for post-hoc reconciliation.
327
+ async function consumeFromInbox(message) {
328
+ try {
329
+ await transport.setFlags(message.ref, ["\\Deleted"]);
330
+ await transport.expunge("INBOX");
331
+ }
332
+ catch (cause) {
333
+ logger.warn `Failed to consume message uid=${message.ref.uid} from INBOX: ${cause}`;
334
+ }
335
+ }
336
+ // INBOX watch loop. Subscribe before the agent's reactor is fully
337
+ // settled so no message is missed in the window between subscription
338
+ // and the first watch callback.
339
+ let stopped = false;
340
+ const unsubscribe = transport.watch("INBOX", (event) => {
341
+ if (stopped)
342
+ return;
343
+ if (event.type !== "exists")
344
+ return;
345
+ const ref = { uid: event.uid, mailbox: "INBOX" };
346
+ void (async () => {
347
+ try {
348
+ let message;
349
+ try {
350
+ message = await transport.fetchFull(ref);
351
+ }
352
+ catch (cause) {
353
+ logger.error `Failed to fetch message uid=${event.uid}: ${cause}`;
354
+ return;
355
+ }
356
+ if (stopped)
357
+ return;
358
+ let decision;
359
+ try {
360
+ decision = connectorRouter.route(message);
361
+ }
362
+ catch (cause) {
363
+ // A router-rejected message (malformed headers, parse error
364
+ // inside the router, etc.) is still surfaced to the agent
365
+ // as an inbound `message.received`. The agent's director
366
+ // decides what the message means and how to respond;
367
+ // dropping it on the floor here would hide messages the
368
+ // operator may want to see. The router's state is *not*
369
+ // committed for the rejected message, so subsequent replies
370
+ // compose against the pre-rejection thread state.
371
+ logger.warn `Connector router rejected message uid=${message.ref.uid} for agent ${env.address}: ${cause instanceof Error ? cause.message : String(cause)}`;
372
+ if (stopped)
373
+ return;
374
+ agent.deliver(message);
375
+ return;
376
+ }
377
+ if (decision.kind === "passthrough") {
378
+ if (stopped)
379
+ return;
380
+ agent.deliver(message);
381
+ return;
382
+ }
383
+ // start or continue: commit router state synchronously before
384
+ // any await so a concurrent watch callback observes the
385
+ // updated state.
386
+ connectorRouter.commit(decision);
387
+ if (stopped)
388
+ return;
389
+ agent.deliver(message);
390
+ await consumeFromInbox(message);
391
+ }
392
+ catch (cause) {
393
+ // `agent.deliver` throws `AgentClosedError` synchronously when
394
+ // called after the agent has closed. The `if (stopped) return`
395
+ // guards above narrow the race window but cannot close it: a
396
+ // `close()` call landing between the guard and the synchronous
397
+ // throw still surfaces the rejection here. The fetched message
398
+ // is dropped; close() is in progress and the harness is
399
+ // tearing down, so the loss is expected. Without this catch
400
+ // the rejection would escape the void-IIFE as an unhandled
401
+ // promise rejection on the event loop.
402
+ if (cause instanceof Error && cause.name === "AgentClosedError") {
403
+ logger.warn `INBOX watch dropped uid=${event.uid} because the agent closed mid-delivery`;
404
+ return;
405
+ }
406
+ logger.error `INBOX watch failed for uid=${event.uid}: ${cause}`;
407
+ }
408
+ })();
409
+ });
410
+ async function close() {
411
+ if (stopped)
412
+ return;
413
+ stopped = true;
414
+ unsubscribe();
415
+ stopReplyDrain = true;
416
+ await agent.close();
417
+ // The reply-drain loop exits once the underlying stream closes
418
+ // (close() above terminates streamConsumers). Awaiting here makes
419
+ // close idempotent and lets callers rely on a settled state.
420
+ await replyDrainDone;
421
+ }
422
+ const harness = {
423
+ close,
424
+ deliver: (message) => agent.deliver(message),
425
+ setSource: (source) => agent.setSource(source),
426
+ setSources: (sources, defaultSource) => agent.setSources(sources, defaultSource),
427
+ stream: () => agent.stream(),
428
+ blobReader: agent.blobReader,
429
+ };
430
+ harnessSucceeded = true;
431
+ return harness;
432
+ }
433
+ finally {
434
+ if (!harnessSucceeded) {
435
+ // Close the agent without waiting on its shutdown timeout so a
436
+ // synchronous post-`createAgent` throw does not stall the
437
+ // caller's failure path. The `.catch` swallows any rejection
438
+ // from the close: the caller is already receiving the original
439
+ // throw, and a noisier-than-original close failure here would
440
+ // mask it.
441
+ void agent.close().catch(() => {
442
+ // Swallow per the comment above.
443
+ });
444
+ }
445
+ }
446
+ }
@@ -0,0 +1,9 @@
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 { createCredentialProviderRegistry, createHttpCredentialProvider, builtinCredentialProviders, } from "./credential-providers.js";
5
+ export type { CredentialProviderRegistry, FetchLike, HttpCredentialProviderOptions, } from "./credential-providers.js";
6
+ export { createCredentialCapability, reconcileDeclaredCredentials, } from "./credential-capability.js";
7
+ export type { CredentialCapabilityDeps, HostCredentialCapability, ResolvedCredentialBinding, } from "./credential-capability.js";
8
+ export { createConnectorRouter, NoActiveConnectorThreadError, } from "./connector-router.js";
9
+ export type { ConnectorRouter, ConnectorReplyParts, ConnectorRouterOptions, RouteDecision, } from "./connector-router.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { createHarness, defineMailTools, } from "./harness.js";
2
+ export { createHarnessRuntimeCapabilities } from "./runtime-capabilities.js";
3
+ export { createCredentialProviderRegistry, createHttpCredentialProvider, builtinCredentialProviders, } from "./credential-providers.js";
4
+ export { createCredentialCapability, reconcileDeclaredCredentials, } from "./credential-capability.js";
5
+ 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
+ }
package/package.json CHANGED
@@ -1,19 +1,35 @@
1
1
  {
2
2
  "name": "@intx/harness",
3
- "version": "0.1.2",
3
+ "description": "Mail-transport composition layer over @intx/agent adding INBOX watch and connector routing",
4
+ "version": "0.3.0",
4
5
  "license": "LGPL-2.1-only",
5
6
  "type": "module",
6
7
  "exports": {
7
8
  ".": {
8
- "types": "./src/index.ts",
9
- "default": "./src/index.ts"
9
+ "intx-src": "./src/index.ts",
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
10
12
  }
11
13
  },
12
14
  "dependencies": {
13
- "@intx/inference": "0.0.0",
14
- "@intx/log": "0.0.0",
15
- "@intx/mime": "0.0.0",
16
- "@intx/types": "0.0.0",
15
+ "@intx/agent": "0.3.0",
16
+ "@intx/authz": "0.3.0",
17
+ "@intx/log": "0.3.0",
18
+ "@intx/types": "0.3.0"
19
+ },
20
+ "devDependencies": {
21
+ "@intx/inference-testing": "0.3.0",
22
+ "@intx/mime": "0.3.0",
23
+ "@intx/storage-isogit": "0.3.0",
17
24
  "arktype": "^2.1.29"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "sideEffects": false,
32
+ "publishConfig": {
33
+ "access": "public"
18
34
  }
19
35
  }
package/src/config.ts DELETED
@@ -1,135 +0,0 @@
1
- import type {
2
- MessageTransport,
3
- CryptoProvider,
4
- ConnectorThreadState,
5
- ContextStore,
6
- AuditStore,
7
- ToolRunner,
8
- ToolDefinition,
9
- InferenceSource,
10
- InferenceEvent,
11
- ReactorDirector,
12
- BeforeToolExtension,
13
- } from "@intx/types/runtime";
14
- import type { AuthzCallResult, DefaultDirectorPolicy } from "@intx/inference";
15
-
16
- /**
17
- * Configuration passed to `createHarness`. All required fields must be
18
- * provided; none have defaults that silently mask missing values.
19
- */
20
- export type HarnessConfig = {
21
- /** The agent's SMTP address, e.g. "agent@tenant.interchange.network". */
22
- address: string;
23
-
24
- /** System prompt for the agent's reasoning. */
25
- systemPrompt: string;
26
-
27
- /** Active inference source (id, provider, model, API key, etc.). */
28
- source: InferenceSource;
29
-
30
- /** Message transport implementation (SMTP/IMAP or in-memory). */
31
- transport: MessageTransport;
32
-
33
- /** Cryptographic provider for signing outbound messages. */
34
- crypto: CryptoProvider;
35
-
36
- /** Context store for persisting message history. */
37
- storage: ContextStore;
38
-
39
- /**
40
- * Caller-supplied tool runner with the full set of tool definitions
41
- * the model should see. The harness forwards this runner directly to
42
- * the reactor; it does not layer additional tools on top. Callers
43
- * composing multiple tool packages (e.g. mail + posix) should merge
44
- * them with `mergeToolRunners` before passing the result here.
45
- */
46
- tools: ToolRunner & { definitions: ToolDefinition[] };
47
-
48
- /** Callback invoked for every inference event emitted by the reactor. */
49
- onEvent: (event: InferenceEvent) => void;
50
-
51
- /**
52
- * Optional callback invoked whenever the connector router's state changes
53
- * (commit of a start/continue decision, an outbound reply send advancing
54
- * lastMessageId, or load-time restore from the context store). Fires only
55
- * on a real state change, not on no-op operations. Used by the sidecar to
56
- * lift connector-state updates onto the hub-bound event channel.
57
- */
58
- onConnectorStateChanged?: (state: ConnectorThreadState | null) => void;
59
-
60
- /**
61
- * Optional custom director. When omitted, the default conversational director
62
- * is used (message.received → infer → execute_tools loop → reply → wait).
63
- */
64
- director?: ReactorDirector;
65
-
66
- /**
67
- * Policy overrides for the default director. Mutually exclusive with
68
- * `director`. Each field controls a specific decision point in the default
69
- * director's event handling loop.
70
- */
71
- defaultDirectorPolicy?: DefaultDirectorPolicy;
72
-
73
- /**
74
- * Extensions that run before each tool call. Return a string to block the
75
- * call (the string becomes the tool result), or undefined to allow it.
76
- * When using `authorize`, the harness creates and prepends an authz
77
- * extension automatically — do not also pass one here.
78
- */
79
- beforeToolExtensions?: BeforeToolExtension[];
80
-
81
- /**
82
- * Audit store for persisting tool invocation records. When provided,
83
- * the harness creates an audit collector and flushes completed records
84
- * at checkpoint boundaries and shutdown.
85
- *
86
- * Requires `authorize` to be set so the authz extension's onDecision
87
- * callback can feed governance decisions to the collector.
88
- */
89
- auditStore?: AuditStore;
90
-
91
- /**
92
- * Authorization function for tool calls. When provided, the harness
93
- * constructs an authz extension internally and prepends it to
94
- * `beforeToolExtensions`. Callers should not also pass a manually
95
- * constructed authz extension via `beforeToolExtensions`.
96
- */
97
- authorize?: (resource: string, action: string) => Promise<AuthzCallResult>;
98
- };
99
-
100
- export function validateConfig(config: HarnessConfig): void {
101
- if (config.address.trim() === "") {
102
- throw new Error("HarnessConfig.address must not be empty");
103
- }
104
- if (config.systemPrompt.trim() === "") {
105
- throw new Error("HarnessConfig.systemPrompt must not be empty");
106
- }
107
- if (config.source.id.trim() === "") {
108
- throw new Error("HarnessConfig.source.id must not be empty");
109
- }
110
- if (config.source.provider.trim() === "") {
111
- throw new Error("HarnessConfig.source.provider must not be empty");
112
- }
113
- if (config.source.model.trim() === "") {
114
- throw new Error("HarnessConfig.source.model must not be empty");
115
- }
116
- if (config.source.apiKey.trim() === "") {
117
- throw new Error("HarnessConfig.source.apiKey must not be empty");
118
- }
119
- if (config.source.baseURL.trim() === "") {
120
- throw new Error("HarnessConfig.source.baseURL must not be empty");
121
- }
122
- if (
123
- config.director !== undefined &&
124
- config.defaultDirectorPolicy !== undefined
125
- ) {
126
- throw new Error(
127
- "HarnessConfig.director and HarnessConfig.defaultDirectorPolicy are mutually exclusive",
128
- );
129
- }
130
- if (config.auditStore !== undefined && config.authorize === undefined) {
131
- throw new Error(
132
- "HarnessConfig.authorize is required when auditStore is provided",
133
- );
134
- }
135
- }