@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.
- package/LICENSE +176 -0
- package/README.md +60 -27
- package/dist/connector-router.d.ts +86 -0
- package/dist/connector-router.js +181 -0
- package/dist/credential-capability.d.ts +65 -0
- package/dist/credential-capability.js +98 -0
- package/dist/credential-providers.d.ts +56 -0
- package/dist/credential-providers.js +123 -0
- package/dist/harness.d.ts +188 -0
- package/dist/harness.js +446 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +5 -0
- package/dist/runtime-capabilities.d.ts +6 -0
- package/dist/runtime-capabilities.js +10 -0
- package/package.json +23 -7
- package/src/config.ts +0 -135
- package/src/connector-router.test.ts +0 -718
- package/src/connector-router.ts +0 -304
- package/src/deploy-tree.test.ts +0 -51
- package/src/deploy-tree.ts +0 -35
- package/src/harness.test.ts +0 -1747
- package/src/harness.ts +0 -379
- package/src/index.ts +0 -31
- package/src/merge-tool-runners.test.ts +0 -149
- package/src/merge-tool-runners.ts +0 -90
- package/src/runtime-capabilities.test.ts +0 -19
- package/src/runtime-capabilities.ts +0 -22
- package/tsconfig.json +0 -4
- package/tsconfig.tsbuildinfo +0 -1
package/dist/harness.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
9
|
-
"
|
|
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/
|
|
14
|
-
"@intx/
|
|
15
|
-
"@intx/
|
|
16
|
-
"@intx/types": "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
|
-
}
|