@intx/harness 0.1.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/README.md +38 -0
- package/package.json +19 -0
- package/src/config.ts +135 -0
- package/src/connector-router.test.ts +718 -0
- package/src/connector-router.ts +304 -0
- package/src/deploy-tree.test.ts +51 -0
- package/src/deploy-tree.ts +35 -0
- package/src/harness.test.ts +1747 -0
- package/src/harness.ts +379 -0
- package/src/index.ts +31 -0
- package/src/merge-tool-runners.test.ts +149 -0
- package/src/merge-tool-runners.ts +90 -0
- package/src/runtime-capabilities.test.ts +19 -0
- package/src/runtime-capabilities.ts +22 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/harness.ts
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// Agent harness: supervisor, connector, and reactor wiring.
|
|
2
|
+
//
|
|
3
|
+
// The harness is the supervisor layer between the message transport and the
|
|
4
|
+
// reactor. It watches the agent's INBOX, routes messages by thread, and
|
|
5
|
+
// manages the connector lifecycle.
|
|
6
|
+
//
|
|
7
|
+
// Connector semantics:
|
|
8
|
+
// - Messages in the active connector thread are fetched, delivered to the
|
|
9
|
+
// reactor, and deleted from the INBOX (consumed).
|
|
10
|
+
// - All other inbound messages (replies to agent sends, unsolicited
|
|
11
|
+
// inter-agent mail) are delivered to the reactor and stay in the INBOX.
|
|
12
|
+
// - Outbound replies are sent by the harness when the reactor emits a
|
|
13
|
+
// connector.reply event, with correct threading headers.
|
|
14
|
+
//
|
|
15
|
+
// The INBOX is a delivery queue — the persistent conversation record lives in
|
|
16
|
+
// the context store (git), not the mailbox.
|
|
17
|
+
//
|
|
18
|
+
// (ARCHITECTURE.md § Agent Harness, INFERENCE.md § Relationship to Harness)
|
|
19
|
+
|
|
20
|
+
import { getLogger } from "@intx/log";
|
|
21
|
+
import { createReactorAssembly, createDefaultDirector } from "@intx/inference";
|
|
22
|
+
import type { ReactorEmittedEvent } from "@intx/inference";
|
|
23
|
+
import {
|
|
24
|
+
InferenceSource,
|
|
25
|
+
applyInferenceSourceFields,
|
|
26
|
+
type BlobReader,
|
|
27
|
+
type ContextStore,
|
|
28
|
+
type InboundMessage,
|
|
29
|
+
type Unsubscribe,
|
|
30
|
+
type ReactorDirector,
|
|
31
|
+
} from "@intx/types/runtime";
|
|
32
|
+
|
|
33
|
+
import type { ErrorRecord } from "@intx/types/audit";
|
|
34
|
+
|
|
35
|
+
import type { HarnessConfig } from "./config";
|
|
36
|
+
import { validateConfig } from "./config";
|
|
37
|
+
import { createConnectorRouter, type RouteDecision } from "./connector-router";
|
|
38
|
+
import { type } from "arktype";
|
|
39
|
+
|
|
40
|
+
const logger = getLogger(["interchange", "harness"]);
|
|
41
|
+
|
|
42
|
+
export type Harness = {
|
|
43
|
+
/**
|
|
44
|
+
* Begin watching the agent's INBOX and start the reactor event loop.
|
|
45
|
+
* Must be called exactly once.
|
|
46
|
+
*/
|
|
47
|
+
start(): void;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Initiate graceful shutdown: abort the reactor, unsubscribe from the
|
|
51
|
+
* transport watch, and flush state to the context store.
|
|
52
|
+
*/
|
|
53
|
+
stop(): void;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Inject an already-fetched inbound message directly into the reactor.
|
|
57
|
+
* Useful for testing and for messages the harness receives through channels
|
|
58
|
+
* other than the INBOX watch.
|
|
59
|
+
*/
|
|
60
|
+
deliver(message: InboundMessage): void;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hot-swap the active inference source. Takes effect on the next
|
|
64
|
+
* inference call — in-flight calls continue with the previous source.
|
|
65
|
+
*/
|
|
66
|
+
setSource(source: InferenceSource): void;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read-only blob reader backed by this harness's context store. Pass it to
|
|
70
|
+
* the tool factory (e.g. `createPosixTools({ blobReader })`) so the agent
|
|
71
|
+
* can resolve `tool-output:///{callId}` URIs through the same store the
|
|
72
|
+
* reactor commits to.
|
|
73
|
+
*/
|
|
74
|
+
readonly blobReader: BlobReader;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function createHarness(config: HarnessConfig): Harness {
|
|
78
|
+
validateConfig(config);
|
|
79
|
+
|
|
80
|
+
const { transport, storage, source, tools, onEvent } = config;
|
|
81
|
+
|
|
82
|
+
let director: ReactorDirector;
|
|
83
|
+
if (config.director !== undefined) {
|
|
84
|
+
director = config.director;
|
|
85
|
+
} else {
|
|
86
|
+
// The caller-supplied tools runner carries the full set of tool
|
|
87
|
+
// definitions the model should see; pass them through to the
|
|
88
|
+
// director as-is.
|
|
89
|
+
director = createDefaultDirector(
|
|
90
|
+
config.systemPrompt,
|
|
91
|
+
tools.definitions,
|
|
92
|
+
config.defaultDirectorPolicy ?? {},
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sessionId = crypto.randomUUID();
|
|
97
|
+
|
|
98
|
+
const auditStore = config.auditStore;
|
|
99
|
+
|
|
100
|
+
const accumulatedErrors: ErrorRecord[] = [];
|
|
101
|
+
let errorSeq = 0;
|
|
102
|
+
|
|
103
|
+
// -------------------------------------------------------------------------
|
|
104
|
+
// Connector state: track which thread(s) this reactor owns.
|
|
105
|
+
// -------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
const connectorRouter = createConnectorRouter(
|
|
108
|
+
config.onConnectorStateChanged !== undefined
|
|
109
|
+
? { onStateChanged: config.onConnectorStateChanged }
|
|
110
|
+
: undefined,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Wrap the context store so load() restores connector state and the reactor's
|
|
114
|
+
// per-cycle writeMetadata picks up the live connector state via the underlying
|
|
115
|
+
// store's setConnectorState buffer (Phase 4: connector state rides along with
|
|
116
|
+
// metadata.json rather than being injected during commit).
|
|
117
|
+
const wrappedStore: ContextStore = {
|
|
118
|
+
async load(signal) {
|
|
119
|
+
const loaded = await storage.load(signal);
|
|
120
|
+
connectorRouter.restore(loaded.connectorState);
|
|
121
|
+
return loaded;
|
|
122
|
+
},
|
|
123
|
+
setConnectorState(state) {
|
|
124
|
+
storage.setConnectorState(state);
|
|
125
|
+
},
|
|
126
|
+
async commit(options, signal) {
|
|
127
|
+
return storage.commit(options, signal);
|
|
128
|
+
},
|
|
129
|
+
async branch(name, signal) {
|
|
130
|
+
return storage.branch(name, signal);
|
|
131
|
+
},
|
|
132
|
+
async log(limit, signal) {
|
|
133
|
+
return storage.log(limit, signal);
|
|
134
|
+
},
|
|
135
|
+
async readAt(hash, signal) {
|
|
136
|
+
return storage.readAt(hash, signal);
|
|
137
|
+
},
|
|
138
|
+
async writeBlob(key, bytes, contentType, signal) {
|
|
139
|
+
return storage.writeBlob(key, bytes, contentType, signal);
|
|
140
|
+
},
|
|
141
|
+
async readBlob(key, signal) {
|
|
142
|
+
return storage.readBlob(key, signal);
|
|
143
|
+
},
|
|
144
|
+
async writePrompt(turns, signal) {
|
|
145
|
+
return storage.writePrompt(turns, signal);
|
|
146
|
+
},
|
|
147
|
+
async writeResponse(turn, signal) {
|
|
148
|
+
return storage.writeResponse(turn, signal);
|
|
149
|
+
},
|
|
150
|
+
async writeManifest(records, signal) {
|
|
151
|
+
return storage.writeManifest(records, signal);
|
|
152
|
+
},
|
|
153
|
+
async writeTurns(turns, signal) {
|
|
154
|
+
return storage.writeTurns(turns, signal);
|
|
155
|
+
},
|
|
156
|
+
async writeMetadata(metadata, signal) {
|
|
157
|
+
// Flush the current in-memory connector state into the wrapped store's
|
|
158
|
+
// buffer so writeMetadata picks it up alongside pendingOperations and
|
|
159
|
+
// tokenUsage. This is the reactor's per-cycle moment to durably record
|
|
160
|
+
// connector thread state.
|
|
161
|
+
storage.setConnectorState(connectorRouter.snapshot());
|
|
162
|
+
return storage.writeMetadata(metadata, signal);
|
|
163
|
+
},
|
|
164
|
+
async readManifestHistory(limit, signal) {
|
|
165
|
+
return storage.readManifestHistory(limit, signal);
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Delete a message from the INBOX after it has been delivered to the reactor.
|
|
171
|
+
*/
|
|
172
|
+
async function consumeFromInbox(message: InboundMessage): Promise<void> {
|
|
173
|
+
try {
|
|
174
|
+
await transport.setFlags(message.ref, ["\\Deleted"]);
|
|
175
|
+
await transport.expunge("INBOX");
|
|
176
|
+
} catch (cause) {
|
|
177
|
+
logger.warn`Failed to consume message uid=${message.ref.uid} from INBOX: ${cause}`;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// -------------------------------------------------------------------------
|
|
182
|
+
// Event interception
|
|
183
|
+
// -------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
function handleEvent(event: ReactorEmittedEvent): void {
|
|
186
|
+
// Handle connector.reply: send the reply via transport.
|
|
187
|
+
if (event.type === "connector.reply") {
|
|
188
|
+
const replyContent = event.data.content;
|
|
189
|
+
|
|
190
|
+
void (async () => {
|
|
191
|
+
try {
|
|
192
|
+
const parts = connectorRouter.composeReply();
|
|
193
|
+
const receipt = await transport.send({
|
|
194
|
+
...parts,
|
|
195
|
+
content: replyContent,
|
|
196
|
+
type: "conversation.message",
|
|
197
|
+
});
|
|
198
|
+
connectorRouter.onReplySent(receipt);
|
|
199
|
+
} catch (cause) {
|
|
200
|
+
logger.error`Failed to send connector reply: ${cause}`;
|
|
201
|
+
}
|
|
202
|
+
})();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// message.received is reactor-internal; do not forward to the caller.
|
|
206
|
+
if (event.type === "message.received") return;
|
|
207
|
+
|
|
208
|
+
if (event.type === "inference.error" && auditStore) {
|
|
209
|
+
accumulatedErrors.push({
|
|
210
|
+
source: "inference",
|
|
211
|
+
category: event.data.error.category,
|
|
212
|
+
message: event.data.error.message,
|
|
213
|
+
fatal: false,
|
|
214
|
+
timestamp: new Date().toISOString(),
|
|
215
|
+
sessionId,
|
|
216
|
+
seq: errorSeq++,
|
|
217
|
+
...(event.data.error.statusCode !== undefined
|
|
218
|
+
? { statusCode: event.data.error.statusCode }
|
|
219
|
+
: {}),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (event.type === "reactor.error" && auditStore) {
|
|
224
|
+
accumulatedErrors.push({
|
|
225
|
+
source: "reactor",
|
|
226
|
+
category: "reactor_error",
|
|
227
|
+
message: event.data.error,
|
|
228
|
+
fatal: event.data.fatal,
|
|
229
|
+
timestamp: new Date().toISOString(),
|
|
230
|
+
sessionId,
|
|
231
|
+
seq: errorSeq++,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
onEvent(event);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// -------------------------------------------------------------------------
|
|
239
|
+
// Reactor
|
|
240
|
+
// -------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
async function flushErrors(): Promise<void> {
|
|
243
|
+
if (accumulatedErrors.length === 0) return;
|
|
244
|
+
if (auditStore === undefined) return;
|
|
245
|
+
const count = accumulatedErrors.length;
|
|
246
|
+
await auditStore.commitErrors(accumulatedErrors.slice(0, count));
|
|
247
|
+
accumulatedErrors.splice(0, count);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// activeSource is held as a single mutable object whose reference is
|
|
251
|
+
// shared with the reactor's config (via the assembly helper). The reactor
|
|
252
|
+
// reads the source lazily at each inference call, so mutating the
|
|
253
|
+
// fields on this object hot-swaps credentials and model without
|
|
254
|
+
// restarting.
|
|
255
|
+
const activeSource: InferenceSource = { ...source };
|
|
256
|
+
|
|
257
|
+
const { reactor, blobReader } = createReactorAssembly({
|
|
258
|
+
sessionId,
|
|
259
|
+
director,
|
|
260
|
+
source: activeSource,
|
|
261
|
+
toolRunner: tools,
|
|
262
|
+
contextStore: wrappedStore,
|
|
263
|
+
onEvent: handleEvent,
|
|
264
|
+
...(config.authorize !== undefined ? { authorize: config.authorize } : {}),
|
|
265
|
+
...(config.auditStore !== undefined
|
|
266
|
+
? { auditStore: config.auditStore }
|
|
267
|
+
: {}),
|
|
268
|
+
...(config.beforeToolExtensions !== undefined
|
|
269
|
+
? { beforeToolExtensions: config.beforeToolExtensions }
|
|
270
|
+
: {}),
|
|
271
|
+
// flushErrors only runs when audit is wired — preserves today's
|
|
272
|
+
// behavior where harness.ts only invokes flushErrors inside the
|
|
273
|
+
// auditCollector branch.
|
|
274
|
+
...(config.auditStore !== undefined
|
|
275
|
+
? { afterCheckpoint: flushErrors, onShutdown: flushErrors }
|
|
276
|
+
: {}),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
let unsubscribe: Unsubscribe | null = null;
|
|
280
|
+
let started = false;
|
|
281
|
+
let stopped = false;
|
|
282
|
+
|
|
283
|
+
function start(): void {
|
|
284
|
+
if (started) {
|
|
285
|
+
throw new Error("Harness is already started");
|
|
286
|
+
}
|
|
287
|
+
started = true;
|
|
288
|
+
|
|
289
|
+
// Subscribe to the INBOX before starting the reactor so no messages are
|
|
290
|
+
// missed in the window between subscription and first watch callback.
|
|
291
|
+
unsubscribe = transport.watch("INBOX", (event) => {
|
|
292
|
+
if (stopped) return;
|
|
293
|
+
|
|
294
|
+
if (event.type !== "exists") {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const ref = { uid: event.uid, mailbox: "INBOX" };
|
|
299
|
+
|
|
300
|
+
void (async () => {
|
|
301
|
+
let message;
|
|
302
|
+
try {
|
|
303
|
+
message = await transport.fetchFull(ref);
|
|
304
|
+
} catch (cause) {
|
|
305
|
+
logger.error`Failed to fetch message uid=${event.uid}: ${cause}`;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (stopped) return;
|
|
310
|
+
|
|
311
|
+
// Only connector-thread messages are consumed from the INBOX.
|
|
312
|
+
// Everything else is delivered to the reactor and stays in the
|
|
313
|
+
// INBOX so message tools can access it.
|
|
314
|
+
//
|
|
315
|
+
// route() can throw on malformed inbound headers (e.g. a From
|
|
316
|
+
// header that is not a valid RFC 5322 address). Surface the
|
|
317
|
+
// failure via the logger and fall through to passthrough so
|
|
318
|
+
// the message still reaches the reactor for inspection, but
|
|
319
|
+
// do not advance router state or consume.
|
|
320
|
+
let decision: RouteDecision;
|
|
321
|
+
try {
|
|
322
|
+
decision = connectorRouter.route(message);
|
|
323
|
+
} catch (cause) {
|
|
324
|
+
logger.warn`Connector router rejected message uid=${message.ref.uid} for agent ${config.address}: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
325
|
+
reactor.deliver(message);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (decision.kind === "passthrough") {
|
|
330
|
+
// Non-connector mail (replies to agent sends, unsolicited
|
|
331
|
+
// inter-agent mail, etc.). Deliver to reactor for notification
|
|
332
|
+
// but leave in INBOX for message tools.
|
|
333
|
+
reactor.deliver(message);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// start or continue: commit router state synchronously before
|
|
338
|
+
// any await so that a concurrent watch callback fired during
|
|
339
|
+
// consumeFromInbox observes the updated state.
|
|
340
|
+
connectorRouter.commit(decision);
|
|
341
|
+
reactor.deliver(message);
|
|
342
|
+
await consumeFromInbox(message);
|
|
343
|
+
})();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
reactor.start();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function stop(): void {
|
|
350
|
+
if (stopped) return;
|
|
351
|
+
stopped = true;
|
|
352
|
+
|
|
353
|
+
reactor.abort("user_disconnect");
|
|
354
|
+
|
|
355
|
+
if (unsubscribe !== null) {
|
|
356
|
+
unsubscribe();
|
|
357
|
+
unsubscribe = null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function deliver(message: InboundMessage): void {
|
|
362
|
+
reactor.deliver(message);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function setSource(newSource: InferenceSource): void {
|
|
366
|
+
const parsed = InferenceSource(newSource);
|
|
367
|
+
if (parsed instanceof type.errors) {
|
|
368
|
+
throw new Error(`Invalid InferenceSource: ${parsed.summary}`);
|
|
369
|
+
}
|
|
370
|
+
// Mutate the shared activeSource object in place so the reactor's
|
|
371
|
+
// next inference call (which reads the source lazily through the
|
|
372
|
+
// same reference held by the assembly helper) observes the new
|
|
373
|
+
// fields. Defaults and capabilities rotate alongside the
|
|
374
|
+
// credentials.
|
|
375
|
+
applyInferenceSourceFields(activeSource, parsed);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return { start, stop, deliver, setSource, blobReader };
|
|
379
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export { createHarness } from "./harness";
|
|
2
|
+
export type { Harness } from "./harness";
|
|
3
|
+
|
|
4
|
+
export type { HarnessConfig } from "./config";
|
|
5
|
+
export { validateConfig } from "./config";
|
|
6
|
+
export type { BeforeToolExtension } from "@intx/types/runtime";
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
createDefaultDirector,
|
|
10
|
+
DefaultDirector,
|
|
11
|
+
type DefaultDirectorPolicy,
|
|
12
|
+
} from "@intx/inference";
|
|
13
|
+
|
|
14
|
+
export { mergeToolRunners } from "./merge-tool-runners";
|
|
15
|
+
|
|
16
|
+
export { createHarnessRuntimeCapabilities } from "./runtime-capabilities";
|
|
17
|
+
export type { HarnessRuntimeCapabilitiesOptions } from "./runtime-capabilities";
|
|
18
|
+
|
|
19
|
+
export { readDeployTree } from "./deploy-tree";
|
|
20
|
+
export type { DeployTree } from "./deploy-tree";
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
createConnectorRouter,
|
|
24
|
+
NoActiveConnectorThreadError,
|
|
25
|
+
} from "./connector-router";
|
|
26
|
+
export type {
|
|
27
|
+
ConnectorRouter,
|
|
28
|
+
ConnectorReplyParts,
|
|
29
|
+
ConnectorRouterOptions,
|
|
30
|
+
RouteDecision,
|
|
31
|
+
} from "./connector-router";
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
ToolCall,
|
|
4
|
+
ToolDefinition,
|
|
5
|
+
ToolResult,
|
|
6
|
+
ToolRunner,
|
|
7
|
+
} from "@intx/types/runtime";
|
|
8
|
+
|
|
9
|
+
import { mergeToolRunners } from "./merge-tool-runners";
|
|
10
|
+
|
|
11
|
+
function makeRunner(
|
|
12
|
+
label: string,
|
|
13
|
+
definitions: ToolDefinition[],
|
|
14
|
+
): ToolRunner & { definitions: ToolDefinition[] } {
|
|
15
|
+
return {
|
|
16
|
+
definitions,
|
|
17
|
+
async run(call: ToolCall): Promise<ToolResult> {
|
|
18
|
+
return { callId: call.id, content: `${label}:${call.name}` };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TOOL_DEF = (name: string): ToolDefinition => ({
|
|
24
|
+
name,
|
|
25
|
+
description: `Tool ${name}`,
|
|
26
|
+
inputSchema: { type: "object", properties: {} },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const signal = AbortSignal.timeout(5000);
|
|
30
|
+
|
|
31
|
+
describe("mergeToolRunners dispatch", () => {
|
|
32
|
+
test("routes each call to the runner whose definitions declare it", async () => {
|
|
33
|
+
const a = makeRunner("a", [TOOL_DEF("read_file")]);
|
|
34
|
+
const b = makeRunner("b", [TOOL_DEF("mail_send")]);
|
|
35
|
+
|
|
36
|
+
const merged = mergeToolRunners([a, b]);
|
|
37
|
+
|
|
38
|
+
const r1 = await merged.run(
|
|
39
|
+
{ id: "c1", name: "read_file", arguments: {} },
|
|
40
|
+
signal,
|
|
41
|
+
);
|
|
42
|
+
const r2 = await merged.run(
|
|
43
|
+
{ id: "c2", name: "mail_send", arguments: {} },
|
|
44
|
+
signal,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(r1.content).toBe("a:read_file");
|
|
48
|
+
expect(r2.content).toBe("b:mail_send");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("returns Unknown tool error for a name not declared by any runner", async () => {
|
|
52
|
+
const a = makeRunner("a", [TOOL_DEF("read_file")]);
|
|
53
|
+
|
|
54
|
+
const merged = mergeToolRunners([a]);
|
|
55
|
+
const result = await merged.run(
|
|
56
|
+
{ id: "c1", name: "nonexistent", arguments: {} },
|
|
57
|
+
signal,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(result.callId).toBe("c1");
|
|
61
|
+
expect(result.isError).toBe(true);
|
|
62
|
+
expect(result.content).toEqual({ error: `Unknown tool: "nonexistent"` });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("three-way merge dispatches to each runner", async () => {
|
|
66
|
+
const a = makeRunner("a", [TOOL_DEF("alpha")]);
|
|
67
|
+
const b = makeRunner("b", [TOOL_DEF("beta")]);
|
|
68
|
+
const c = makeRunner("c", [TOOL_DEF("gamma")]);
|
|
69
|
+
|
|
70
|
+
const merged = mergeToolRunners([a, b, c]);
|
|
71
|
+
|
|
72
|
+
const r1 = await merged.run(
|
|
73
|
+
{ id: "1", name: "alpha", arguments: {} },
|
|
74
|
+
signal,
|
|
75
|
+
);
|
|
76
|
+
const r2 = await merged.run(
|
|
77
|
+
{ id: "2", name: "beta", arguments: {} },
|
|
78
|
+
signal,
|
|
79
|
+
);
|
|
80
|
+
const r3 = await merged.run(
|
|
81
|
+
{ id: "3", name: "gamma", arguments: {} },
|
|
82
|
+
signal,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
expect(r1.content).toBe("a:alpha");
|
|
86
|
+
expect(r2.content).toBe("b:beta");
|
|
87
|
+
expect(r3.content).toBe("c:gamma");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("combined definitions preserve input order and within-runner order", () => {
|
|
91
|
+
const a = makeRunner("a", [TOOL_DEF("a1"), TOOL_DEF("a2")]);
|
|
92
|
+
const b = makeRunner("b", [TOOL_DEF("b1"), TOOL_DEF("b2")]);
|
|
93
|
+
|
|
94
|
+
const merged = mergeToolRunners([a, b]);
|
|
95
|
+
|
|
96
|
+
expect(merged.definitions.map((d) => d.name)).toEqual([
|
|
97
|
+
"a1",
|
|
98
|
+
"a2",
|
|
99
|
+
"b1",
|
|
100
|
+
"b2",
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("forwards the caller's AbortSignal to the underlying runner", async () => {
|
|
105
|
+
let receivedSignal: AbortSignal | undefined;
|
|
106
|
+
const captureRunner: ToolRunner & { definitions: ToolDefinition[] } = {
|
|
107
|
+
definitions: [TOOL_DEF("capture")],
|
|
108
|
+
async run(call: ToolCall, sig: AbortSignal): Promise<ToolResult> {
|
|
109
|
+
receivedSignal = sig;
|
|
110
|
+
return { callId: call.id, content: "ok" };
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const merged = mergeToolRunners([captureRunner]);
|
|
115
|
+
const ctl = new AbortController();
|
|
116
|
+
await merged.run({ id: "1", name: "capture", arguments: {} }, ctl.signal);
|
|
117
|
+
|
|
118
|
+
expect(receivedSignal).toBe(ctl.signal);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("mergeToolRunners collision detection", () => {
|
|
123
|
+
test("throws on duplicate name across two runners, naming both indices", () => {
|
|
124
|
+
const a = makeRunner("a", [TOOL_DEF("shared")]);
|
|
125
|
+
const b = makeRunner("b", [TOOL_DEF("shared")]);
|
|
126
|
+
|
|
127
|
+
expect(() => mergeToolRunners([a, b])).toThrow(
|
|
128
|
+
new Error(
|
|
129
|
+
'Tool name collision on "shared": registered by both runners[0] and runners[1]',
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("throws when a single runner declares the same name twice", () => {
|
|
135
|
+
const a = makeRunner("a", [TOOL_DEF("dup"), TOOL_DEF("dup")]);
|
|
136
|
+
|
|
137
|
+
expect(() => mergeToolRunners([a])).toThrow(
|
|
138
|
+
new Error('Tool name collision on "dup": runners[0] declares it twice'),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("mergeToolRunners empty input", () => {
|
|
144
|
+
test("throws on an empty runners list", () => {
|
|
145
|
+
expect(() => mergeToolRunners([])).toThrow(
|
|
146
|
+
new Error("mergeToolRunners called with no runners"),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Generic, mail-agnostic merger for tool runners. Takes an arbitrary list
|
|
2
|
+
// of (runner + its declared definitions) and produces a single runner
|
|
3
|
+
// whose definitions list is the concatenation, with name-collision
|
|
4
|
+
// detection at construction.
|
|
5
|
+
//
|
|
6
|
+
// Used by hosts (e.g. the sidecar) that want to compose multiple tool
|
|
7
|
+
// packages — mail, posix, lsp, third-party — into the single
|
|
8
|
+
// `ToolRunner & { definitions }` shape the harness accepts.
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
ToolCall,
|
|
12
|
+
ToolDefinition,
|
|
13
|
+
ToolResult,
|
|
14
|
+
ToolRunner,
|
|
15
|
+
} from "@intx/types/runtime";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Merge an arbitrary list of tool runners into a single runner with a
|
|
19
|
+
* combined `definitions` list.
|
|
20
|
+
*
|
|
21
|
+
* Ordering: the combined `definitions` array preserves the order of the
|
|
22
|
+
* input runners, and the order of definitions within each input runner.
|
|
23
|
+
* This is observable by the model through the prompt the director
|
|
24
|
+
* assembles from `definitions`, so callers that care about
|
|
25
|
+
* model-facing ordering control it by sequencing the input array.
|
|
26
|
+
*
|
|
27
|
+
* Collision: a tool name that appears in more than one input runner's
|
|
28
|
+
* `definitions` throws at construction time, naming the two source
|
|
29
|
+
* runner indices and the colliding name. A tool name that appears
|
|
30
|
+
* twice within a single runner's `definitions` is the runner's bug;
|
|
31
|
+
* this function surfaces it with a distinct message.
|
|
32
|
+
*
|
|
33
|
+
* Dispatch: a call whose name is not declared by any input runner
|
|
34
|
+
* resolves to a result with `isError: true` and object-shaped content
|
|
35
|
+
* `{ error: 'Unknown tool: "<name>"' }`. The object shape matches the
|
|
36
|
+
* per-handler error shape that ToolRunner implementations across the
|
|
37
|
+
* codebase use, so callers see one error shape regardless of whether
|
|
38
|
+
* the failure came from dispatch or from a runner's own handler.
|
|
39
|
+
*
|
|
40
|
+
* Empty input throws — `mergeToolRunners` with no runners is almost
|
|
41
|
+
* always a wiring bug. A caller that legitimately wants an empty
|
|
42
|
+
* runner constructs one explicitly at the call site. A runner whose
|
|
43
|
+
* own `definitions` array is empty is accepted: it contributes nothing
|
|
44
|
+
* to the merged dispatch and is treated as the caller's choice.
|
|
45
|
+
*/
|
|
46
|
+
export function mergeToolRunners(
|
|
47
|
+
runners: readonly (ToolRunner & { definitions: ToolDefinition[] })[],
|
|
48
|
+
): ToolRunner & { definitions: ToolDefinition[] } {
|
|
49
|
+
if (runners.length === 0) {
|
|
50
|
+
throw new Error("mergeToolRunners called with no runners");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const definitions: ToolDefinition[] = [];
|
|
54
|
+
// Per declared name: which runner provides it (for dispatch) and at
|
|
55
|
+
// which input index (so collisions can name both sides).
|
|
56
|
+
const owners = new Map<string, { runner: ToolRunner; index: number }>();
|
|
57
|
+
|
|
58
|
+
for (const [i, runner] of runners.entries()) {
|
|
59
|
+
for (const def of runner.definitions) {
|
|
60
|
+
const existing = owners.get(def.name);
|
|
61
|
+
if (existing !== undefined) {
|
|
62
|
+
if (existing.index === i) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Tool name collision on "${def.name}": runners[${i}] declares it twice`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Tool name collision on "${def.name}": registered by both runners[${existing.index}] and runners[${i}]`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
owners.set(def.name, { runner, index: i });
|
|
72
|
+
definitions.push(def);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
definitions,
|
|
78
|
+
async run(call: ToolCall, signal: AbortSignal): Promise<ToolResult> {
|
|
79
|
+
const entry = owners.get(call.name);
|
|
80
|
+
if (entry === undefined) {
|
|
81
|
+
return {
|
|
82
|
+
callId: call.id,
|
|
83
|
+
content: { error: `Unknown tool: "${call.name}"` },
|
|
84
|
+
isError: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return entry.runner.run(call, signal);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import type { MessageTransport } from "@intx/types/runtime";
|
|
3
|
+
|
|
4
|
+
import { createHarnessRuntimeCapabilities } from "./runtime-capabilities";
|
|
5
|
+
|
|
6
|
+
// Minimal stand-in for MessageTransport. The factory passes the handle
|
|
7
|
+
// through; it does not invoke any methods on it.
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only stand-in; factory never calls these methods
|
|
9
|
+
const stubTransport = {} as unknown as MessageTransport;
|
|
10
|
+
|
|
11
|
+
describe("createHarnessRuntimeCapabilities", () => {
|
|
12
|
+
test("resolve('mail.transport') returns the supplied transport reference", () => {
|
|
13
|
+
const capabilities = createHarnessRuntimeCapabilities({
|
|
14
|
+
transport: stubTransport,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(capabilities.resolve("mail.transport")).toBe(stubTransport);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
|
|
8
|
+
import {
|
|
9
|
+
createRuntimeCapabilities,
|
|
10
|
+
type RuntimeCapabilities,
|
|
11
|
+
} from "@intx/types/runtime-capabilities";
|
|
12
|
+
import type { MessageTransport } from "@intx/types/runtime";
|
|
13
|
+
|
|
14
|
+
export interface HarnessRuntimeCapabilitiesOptions {
|
|
15
|
+
transport: MessageTransport;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createHarnessRuntimeCapabilities(
|
|
19
|
+
opts: HarnessRuntimeCapabilitiesOptions,
|
|
20
|
+
): RuntimeCapabilities {
|
|
21
|
+
return createRuntimeCapabilities({ "mail.transport": opts.transport });
|
|
22
|
+
}
|
package/tsconfig.json
ADDED