@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.
@@ -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
+ }