@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.
- package/LICENSE +176 -0
- package/README.md +56 -27
- package/dist/connector-router.d.ts +86 -0
- package/dist/connector-router.js +181 -0
- package/dist/harness.d.ts +182 -0
- package/dist/harness.js +439 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/runtime-capabilities.d.ts +6 -0
- package/dist/runtime-capabilities.js +10 -0
- package/package.json +21 -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
|
@@ -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>;
|
package/dist/harness.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,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
|
+
}
|