@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
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Connector-thread routing for the agent harness.
|
|
2
|
+
//
|
|
3
|
+
// The connector is one durable thread per agent. Participants accumulate
|
|
4
|
+
// as they speak; no one is displaced. `replyTo` tracks the most recent
|
|
5
|
+
// speaker (the primary recipient on the next outbound reply) and `cc`
|
|
6
|
+
// tracks every other participant who has spoken (carried on outbound so
|
|
7
|
+
// everyone stays in the loop).
|
|
8
|
+
//
|
|
9
|
+
// Two-phase decision: route() is pure and returns a discriminated kind
|
|
10
|
+
// plus an opaque carrier of the next state; commit() advances router
|
|
11
|
+
// state from that carrier. Separating the decision from the mutation
|
|
12
|
+
// lets the harness sequence the side effects (deliver, INBOX expunge)
|
|
13
|
+
// around the state change however it needs to.
|
|
14
|
+
|
|
15
|
+
import { getLogger } from "@intx/log";
|
|
16
|
+
import { extractAddrSpec } from "@intx/mime";
|
|
17
|
+
import type {
|
|
18
|
+
ConnectorThreadState,
|
|
19
|
+
InboundMessage,
|
|
20
|
+
SendReceipt,
|
|
21
|
+
} from "@intx/types/runtime";
|
|
22
|
+
|
|
23
|
+
const logger = getLogger(["interchange", "harness", "connector-router"]);
|
|
24
|
+
|
|
25
|
+
export type RouteDecision =
|
|
26
|
+
| { kind: "start" }
|
|
27
|
+
| { kind: "continue" }
|
|
28
|
+
| { kind: "passthrough" };
|
|
29
|
+
|
|
30
|
+
export type ConnectorReplyParts = {
|
|
31
|
+
to: string;
|
|
32
|
+
cc: string[];
|
|
33
|
+
inReplyTo: string;
|
|
34
|
+
subject?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export class NoActiveConnectorThreadError extends Error {
|
|
38
|
+
constructor() {
|
|
39
|
+
super("no active connector thread");
|
|
40
|
+
this.name = "NoActiveConnectorThreadError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ConnectorRouterOptions = {
|
|
45
|
+
/**
|
|
46
|
+
* Called synchronously after the router's internal state mutates and the
|
|
47
|
+
* new state is committed to internal storage. Fires only when the new
|
|
48
|
+
* state differs from the prior state — restore() into the same state,
|
|
49
|
+
* passthrough commits, and other no-ops do not fire. Single subscriber:
|
|
50
|
+
* the harness wiring that lifts state changes onto the hub-bound event
|
|
51
|
+
* channel.
|
|
52
|
+
*
|
|
53
|
+
* The router catches and logs any error this callback throws. The cache
|
|
54
|
+
* the callback feeds is a best-effort projection of router state, and
|
|
55
|
+
* the authoritative state remains in the router and the persisted
|
|
56
|
+
* context store. Dropping one notification means the projection stays
|
|
57
|
+
* stale until the next state change rebuilds it; that is the right
|
|
58
|
+
* trade-off versus aborting the call chain that invoked the
|
|
59
|
+
* commit/onReplySent that produced the notification.
|
|
60
|
+
*/
|
|
61
|
+
onStateChanged?(state: ConnectorThreadState | null): void;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export interface ConnectorRouter {
|
|
65
|
+
/**
|
|
66
|
+
* Classify an inbound message against the current connector state. Pure:
|
|
67
|
+
* does not mutate router state. The returned decision must be passed to
|
|
68
|
+
* `commit()` to take effect.
|
|
69
|
+
*
|
|
70
|
+
* Throws when `message.headers.from` is not a parseable bare addr-spec
|
|
71
|
+
* (per `extractAddrSpec` from `@intx/mime`). The production fetch path
|
|
72
|
+
* copies the wire `From:` header verbatim, so a malformed sender is a
|
|
73
|
+
* normal-shape runtime concern, not a programmer error. Callers should
|
|
74
|
+
* treat the throw as passthrough — deliver the message to the reactor
|
|
75
|
+
* but do not advance router state or consume the message from the
|
|
76
|
+
* INBOX.
|
|
77
|
+
*/
|
|
78
|
+
route(message: InboundMessage): RouteDecision;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Advance router state per a decision produced by `route()`. No-op for
|
|
82
|
+
* `passthrough`. For `start` and `continue`, throws if the decision was
|
|
83
|
+
* not produced by this router instance.
|
|
84
|
+
*/
|
|
85
|
+
commit(decision: RouteDecision): void;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Produce the threading headers needed to send a reply on the active
|
|
89
|
+
* connector thread. `to` is the most recent speaker; `cc` is everyone
|
|
90
|
+
* else who has spoken on the thread (deduplicated). The caller composes
|
|
91
|
+
* the full outbound message by adding its own `content` and `type`
|
|
92
|
+
* fields. Throws `NoActiveConnectorThreadError` when no thread is
|
|
93
|
+
* active.
|
|
94
|
+
*/
|
|
95
|
+
composeReply(): ConnectorReplyParts;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Update `lastMessageId` after a successful outbound reply send.
|
|
99
|
+
* Throws when called with no active thread — outbound state advance
|
|
100
|
+
* has no meaning without a thread.
|
|
101
|
+
*/
|
|
102
|
+
onReplySent(receipt: SendReceipt): void;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Return the current connector state as a serializable snapshot, or
|
|
106
|
+
* `null` when no thread is active. Matches the
|
|
107
|
+
* `ConnectorThreadState | null` shape used by the storage layer.
|
|
108
|
+
*/
|
|
109
|
+
snapshot(): ConnectorThreadState | null;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Install a snapshot as the router's current state. Used at startup
|
|
113
|
+
* to restore from the persisted context store, and in tests to set
|
|
114
|
+
* up scenarios. Passing `null` clears the active thread.
|
|
115
|
+
*/
|
|
116
|
+
restore(state: ConnectorThreadState | null): void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function statesEqual(
|
|
120
|
+
a: ConnectorThreadState | null,
|
|
121
|
+
b: ConnectorThreadState | null,
|
|
122
|
+
): boolean {
|
|
123
|
+
if (a === null || b === null) return a === b;
|
|
124
|
+
return (
|
|
125
|
+
a.threadRoot === b.threadRoot &&
|
|
126
|
+
a.lastMessageId === b.lastMessageId &&
|
|
127
|
+
a.replyTo === b.replyTo &&
|
|
128
|
+
a.subject === b.subject &&
|
|
129
|
+
a.cc.length === b.cc.length &&
|
|
130
|
+
a.cc.every((v, i) => v === b.cc[i])
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function createConnectorRouter(
|
|
135
|
+
options?: ConnectorRouterOptions,
|
|
136
|
+
): ConnectorRouter {
|
|
137
|
+
let state: ConnectorThreadState | null = null;
|
|
138
|
+
const onStateChanged = options?.onStateChanged;
|
|
139
|
+
|
|
140
|
+
// Pending state per decision is held off the decision object via a
|
|
141
|
+
// WeakMap so callers see only `{ kind }` — no path to inspect or
|
|
142
|
+
// mutate the next state, even via type assertions.
|
|
143
|
+
const pendingStates = new WeakMap<RouteDecision, ConnectorThreadState>();
|
|
144
|
+
|
|
145
|
+
function applyState(next: ConnectorThreadState | null): void {
|
|
146
|
+
// The null → X transition is what drives bootstrap on restore() — a
|
|
147
|
+
// future refactor that collapses null into a sentinel "no mutation"
|
|
148
|
+
// case would silently break the hub-side cache's only fill path
|
|
149
|
+
// outside live state mutations. Keep the equality check as-is; the
|
|
150
|
+
// null state is a value, not a non-event.
|
|
151
|
+
if (statesEqual(state, next)) return;
|
|
152
|
+
state = next;
|
|
153
|
+
if (onStateChanged !== undefined) {
|
|
154
|
+
// The callback feeds a best-effort projection of router state. A
|
|
155
|
+
// throwing subscriber would otherwise propagate out of commit() or
|
|
156
|
+
// onReplySent() and abort the caller; catching here drops one
|
|
157
|
+
// notification (cache stays stale until the next change) instead
|
|
158
|
+
// of corrupting the call chain. The authoritative state is
|
|
159
|
+
// already committed to the router by this point.
|
|
160
|
+
try {
|
|
161
|
+
onStateChanged(snapshot());
|
|
162
|
+
} catch (cause) {
|
|
163
|
+
logger.warn`onStateChanged subscriber threw: ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isContinuation(message: InboundMessage): boolean {
|
|
169
|
+
if (state === null) return false;
|
|
170
|
+
|
|
171
|
+
const { inReplyTo, references } = message.headers;
|
|
172
|
+
|
|
173
|
+
if (references !== undefined && references.includes(state.threadRoot)) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (inReplyTo !== undefined && inReplyTo === state.lastMessageId) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Append `value` to `existing` only when it is not already present.
|
|
185
|
+
// The thread's participant list is small enough that linear-scan dedup
|
|
186
|
+
// is the right cost.
|
|
187
|
+
function appendUnique(existing: readonly string[], value: string): string[] {
|
|
188
|
+
if (existing.includes(value)) return [...existing];
|
|
189
|
+
return [...existing, value];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function route(message: InboundMessage): RouteDecision {
|
|
193
|
+
if (state === null) {
|
|
194
|
+
const nextState: ConnectorThreadState = {
|
|
195
|
+
threadRoot: message.headers.messageId,
|
|
196
|
+
lastMessageId: message.headers.messageId,
|
|
197
|
+
replyTo: extractAddrSpec(message.headers.from),
|
|
198
|
+
cc: [],
|
|
199
|
+
...(message.headers.subject !== undefined
|
|
200
|
+
? { subject: message.headers.subject }
|
|
201
|
+
: {}),
|
|
202
|
+
};
|
|
203
|
+
const decision: RouteDecision = { kind: "start" };
|
|
204
|
+
pendingStates.set(decision, nextState);
|
|
205
|
+
return decision;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (isContinuation(message)) {
|
|
209
|
+
const nextSpeaker = extractAddrSpec(message.headers.from);
|
|
210
|
+
// The previous most-recent speaker moves into the cc list; the
|
|
211
|
+
// new speaker becomes replyTo. Dedup so a sender returning after
|
|
212
|
+
// others have spoken doesn't appear twice.
|
|
213
|
+
const carriedCc = appendUnique(state.cc, state.replyTo).filter(
|
|
214
|
+
(addr) => addr !== nextSpeaker,
|
|
215
|
+
);
|
|
216
|
+
const nextState: ConnectorThreadState = {
|
|
217
|
+
threadRoot: state.threadRoot,
|
|
218
|
+
lastMessageId: message.headers.messageId,
|
|
219
|
+
replyTo: nextSpeaker,
|
|
220
|
+
cc: carriedCc,
|
|
221
|
+
...(state.subject !== undefined ? { subject: state.subject } : {}),
|
|
222
|
+
};
|
|
223
|
+
const decision: RouteDecision = { kind: "continue" };
|
|
224
|
+
pendingStates.set(decision, nextState);
|
|
225
|
+
return decision;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { kind: "passthrough" };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function commit(decision: RouteDecision): void {
|
|
232
|
+
if (decision.kind === "passthrough") return;
|
|
233
|
+
|
|
234
|
+
const nextState = pendingStates.get(decision);
|
|
235
|
+
if (nextState === undefined) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
"commit() called with a decision from a different router instance",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
pendingStates.delete(decision);
|
|
242
|
+
applyState(nextState);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function composeReply(): ConnectorReplyParts {
|
|
246
|
+
if (state === null) {
|
|
247
|
+
throw new NoActiveConnectorThreadError();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
to: state.replyTo,
|
|
252
|
+
cc: [...state.cc],
|
|
253
|
+
inReplyTo: state.lastMessageId,
|
|
254
|
+
...(state.subject !== undefined ? { subject: state.subject } : {}),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function onReplySent(receipt: SendReceipt): void {
|
|
259
|
+
if (state === null) {
|
|
260
|
+
throw new NoActiveConnectorThreadError();
|
|
261
|
+
}
|
|
262
|
+
applyState({
|
|
263
|
+
threadRoot: state.threadRoot,
|
|
264
|
+
lastMessageId: receipt.messageId,
|
|
265
|
+
replyTo: state.replyTo,
|
|
266
|
+
cc: [...state.cc],
|
|
267
|
+
...(state.subject !== undefined ? { subject: state.subject } : {}),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function snapshot(): ConnectorThreadState | null {
|
|
272
|
+
if (state === null) return null;
|
|
273
|
+
return {
|
|
274
|
+
threadRoot: state.threadRoot,
|
|
275
|
+
lastMessageId: state.lastMessageId,
|
|
276
|
+
replyTo: state.replyTo,
|
|
277
|
+
cc: [...state.cc],
|
|
278
|
+
...(state.subject !== undefined ? { subject: state.subject } : {}),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function restore(next: ConnectorThreadState | null): void {
|
|
283
|
+
applyState(
|
|
284
|
+
next === null
|
|
285
|
+
? null
|
|
286
|
+
: {
|
|
287
|
+
threadRoot: next.threadRoot,
|
|
288
|
+
lastMessageId: next.lastMessageId,
|
|
289
|
+
replyTo: next.replyTo,
|
|
290
|
+
cc: [...next.cc],
|
|
291
|
+
...(next.subject !== undefined ? { subject: next.subject } : {}),
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
route,
|
|
298
|
+
commit,
|
|
299
|
+
composeReply,
|
|
300
|
+
onReplySent,
|
|
301
|
+
snapshot,
|
|
302
|
+
restore,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { readDeployTree } from "./deploy-tree";
|
|
6
|
+
|
|
7
|
+
const tempDirs: string[] = [];
|
|
8
|
+
|
|
9
|
+
async function tempDir(): Promise<string> {
|
|
10
|
+
const d = await fs.promises.mkdtemp(
|
|
11
|
+
path.join(os.tmpdir(), "deploy-tree-test-"),
|
|
12
|
+
);
|
|
13
|
+
tempDirs.push(d);
|
|
14
|
+
return d;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
const dirs = tempDirs.splice(0);
|
|
19
|
+
await Promise.all(
|
|
20
|
+
dirs.map((d) => fs.promises.rm(d, { recursive: true, force: true })),
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("readDeployTree", () => {
|
|
25
|
+
test("returns undefined prompt when no deploy dir exists", async () => {
|
|
26
|
+
const dir = await tempDir();
|
|
27
|
+
const result = await readDeployTree(dir);
|
|
28
|
+
expect(result.systemPrompt).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("reads prompt.md from deploy directory", async () => {
|
|
32
|
+
const dir = await tempDir();
|
|
33
|
+
await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true });
|
|
34
|
+
await fs.promises.writeFile(
|
|
35
|
+
path.join(dir, "deploy", "prompt.md"),
|
|
36
|
+
"You are a test agent.",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const result = await readDeployTree(dir);
|
|
40
|
+
expect(result.systemPrompt).toBe("You are a test agent.");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("treats empty prompt.md as undefined", async () => {
|
|
44
|
+
const dir = await tempDir();
|
|
45
|
+
await fs.promises.mkdir(path.join(dir, "deploy"), { recursive: true });
|
|
46
|
+
await fs.promises.writeFile(path.join(dir, "deploy", "prompt.md"), "");
|
|
47
|
+
|
|
48
|
+
const result = await readDeployTree(dir);
|
|
49
|
+
expect(result.systemPrompt).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Deploy tree reader: extracts the system prompt from the deploy
|
|
2
|
+
// directory of an agent's git repository.
|
|
3
|
+
//
|
|
4
|
+
// The deploy tree is written by `applyDeployPack` and contains:
|
|
5
|
+
// deploy/prompt.md — system prompt for inference
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
export type DeployTree = {
|
|
11
|
+
systemPrompt: string | undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read the system prompt from the deploy directory. Returns undefined for
|
|
16
|
+
* systemPrompt when deploy/prompt.md does not exist (agent has not yet
|
|
17
|
+
* received a deploy pack).
|
|
18
|
+
*/
|
|
19
|
+
export async function readDeployTree(dir: string): Promise<DeployTree> {
|
|
20
|
+
const promptPath = path.join(dir, "deploy", "prompt.md");
|
|
21
|
+
|
|
22
|
+
let systemPrompt: string | undefined;
|
|
23
|
+
try {
|
|
24
|
+
const raw = await fs.promises.readFile(promptPath, "utf-8");
|
|
25
|
+
systemPrompt = raw.trim() === "" ? undefined : raw;
|
|
26
|
+
} catch (e) {
|
|
27
|
+
if (e instanceof Error && "code" in e && e.code === "ENOENT") {
|
|
28
|
+
systemPrompt = undefined;
|
|
29
|
+
} else {
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { systemPrompt };
|
|
35
|
+
}
|