@intx/agent 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +176 -0
- package/README.md +80 -5
- package/dist/agent.d.ts +116 -0
- package/dist/agent.js +682 -0
- package/dist/canonicalize.d.ts +15 -0
- package/dist/canonicalize.js +160 -0
- package/dist/default-director.d.ts +24 -0
- package/dist/default-director.js +45 -0
- package/dist/definition.d.ts +139 -0
- package/dist/definition.js +40 -0
- package/dist/director-registry.d.ts +47 -0
- package/dist/director-registry.js +87 -0
- package/dist/director-types.d.ts +80 -0
- package/dist/director-types.js +13 -0
- package/dist/director.d.ts +70 -0
- package/dist/director.js +131 -0
- package/dist/env-validation.d.ts +59 -0
- package/dist/env-validation.js +180 -0
- package/dist/env.d.ts +160 -0
- package/dist/env.js +53 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +23 -0
- package/dist/internal-fixtures/mail.d.ts +39 -0
- package/dist/internal-fixtures/mail.js +86 -0
- package/dist/internal-fixtures/planner.d.ts +19 -0
- package/dist/internal-fixtures/planner.js +49 -0
- package/dist/lock.d.ts +16 -0
- package/dist/lock.js +47 -0
- package/dist/namespace.d.ts +12 -0
- package/dist/namespace.js +39 -0
- package/dist/send-queue.d.ts +25 -0
- package/dist/send-queue.js +147 -0
- package/dist/source.d.ts +43 -0
- package/dist/source.js +118 -0
- package/dist/stream.d.ts +16 -0
- package/dist/stream.js +115 -0
- package/dist/testing/audit-noop.d.ts +7 -0
- package/dist/testing/audit-noop.js +25 -0
- package/dist/testing/authorize-allow.d.ts +8 -0
- package/dist/testing/authorize-allow.js +19 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +17 -0
- package/dist/tool.d.ts +238 -0
- package/dist/tool.js +244 -0
- package/package.json +26 -7
- package/src/agent.test.ts +0 -46
- package/src/agent.ts +0 -494
- package/src/index.ts +0 -38
- package/src/lock.test.ts +0 -93
- package/src/lock.ts +0 -57
- package/src/send-queue.test.ts +0 -207
- package/src/send-queue.ts +0 -200
- package/src/source.test.ts +0 -171
- package/src/source.ts +0 -93
- package/src/stream.test.ts +0 -167
- package/src/stream.ts +0 -142
- package/src/tool.test.ts +0 -217
- package/src/tool.ts +0 -148
- package/tsconfig.json +0 -4
- package/tsconfig.tsbuildinfo +0 -1
package/src/agent.ts
DELETED
|
@@ -1,494 +0,0 @@
|
|
|
1
|
-
// In-process agent runtime.
|
|
2
|
-
//
|
|
3
|
-
// `createAgent` resolves storage, builds the source registry and tool
|
|
4
|
-
// dispatcher, wires `createReactorAssembly`, and exposes the public Agent
|
|
5
|
-
// surface. The agent owns the active inference source object so
|
|
6
|
-
// `setSource` can mutate it in place and the reactor's lazy read at the
|
|
7
|
-
// start of each inference call picks up the new credentials.
|
|
8
|
-
//
|
|
9
|
-
// Composition:
|
|
10
|
-
// - `send()` enqueues into a FIFO `SendQueue` capped at `sendQueueMax`.
|
|
11
|
-
// Per-send `AbortSignal` removes queued items or rejects in-flight
|
|
12
|
-
// callers while letting the reactor cycle finish in the background.
|
|
13
|
-
// - `stream()` returns a bounded `StreamConsumer` iterator; consumers
|
|
14
|
-
// buffer independently and noisy backpressure poisons only the
|
|
15
|
-
// affected iterator.
|
|
16
|
-
// - `close()` aborts the reactor, drains the send queue with
|
|
17
|
-
// `AgentClosedError`, terminates every active stream iterator, waits
|
|
18
|
-
// up to `closeTimeoutMs` for the reactor's shutdown sequence to
|
|
19
|
-
// complete (audit flush, in-flight commits), and finally releases
|
|
20
|
-
// the singleton-per-`contextDir` lock so another agent can open the
|
|
21
|
-
// same directory.
|
|
22
|
-
//
|
|
23
|
-
// `setSource` covers the whole source: id/provider/baseURL/apiKey/model
|
|
24
|
-
// plus the model-bound `defaults` and `capabilities`. Credentials and
|
|
25
|
-
// model rotate together via the shared source object the reactor reads
|
|
26
|
-
// lazily at the start of each inference call. The director never names
|
|
27
|
-
// a model — `capabilities.infer(options?)` does not take one — so the
|
|
28
|
-
// active source's model is the single source of truth and rotations
|
|
29
|
-
// take effect on the next inference call without any wrapper.
|
|
30
|
-
|
|
31
|
-
import {
|
|
32
|
-
createDefaultDirector,
|
|
33
|
-
createReactorAssembly,
|
|
34
|
-
type AuthzExtensionOptions,
|
|
35
|
-
type Dependencies,
|
|
36
|
-
type ReactorEmittedEvent,
|
|
37
|
-
} from "@intx/inference";
|
|
38
|
-
import { createInboundMessage } from "@intx/mime";
|
|
39
|
-
import { createIsogitStore } from "@intx/storage-isogit";
|
|
40
|
-
import type {
|
|
41
|
-
AssistantTurn,
|
|
42
|
-
AuditStore,
|
|
43
|
-
BlobReader,
|
|
44
|
-
ContextCommit,
|
|
45
|
-
ContextStore,
|
|
46
|
-
ConversationTurn,
|
|
47
|
-
InboundMessage,
|
|
48
|
-
InferenceSource,
|
|
49
|
-
ReactorDirector,
|
|
50
|
-
} from "@intx/types/runtime";
|
|
51
|
-
|
|
52
|
-
import { acquireContextDirLock, type ContextDirLock } from "./lock";
|
|
53
|
-
import { createSourceRegistry, type SourceRegistry } from "./source";
|
|
54
|
-
import { createSendQueue, type SendQueue } from "./send-queue";
|
|
55
|
-
import { createStreamConsumer, type StreamConsumer } from "./stream";
|
|
56
|
-
import { createToolRunner, type AgentTool, type AgentToolRunner } from "./tool";
|
|
57
|
-
|
|
58
|
-
const DEFAULT_SEND_FROM = "user@local";
|
|
59
|
-
const DEFAULT_SEND_TO = "agent@local";
|
|
60
|
-
const DEFAULT_SEND_QUEUE_MAX = 16;
|
|
61
|
-
const DEFAULT_STREAM_BUFFER_MAX = 1024;
|
|
62
|
-
const DEFAULT_CLOSE_TIMEOUT_MS = 5000;
|
|
63
|
-
|
|
64
|
-
export type AgentConfig = {
|
|
65
|
-
/**
|
|
66
|
-
* The conversation/history store. Exactly one of `contextStore` or
|
|
67
|
-
* `contextDir` must be supplied. When `contextStore` is given the caller
|
|
68
|
-
* owns the store's lifetime; the singleton-per-directory lock is not
|
|
69
|
-
* acquired.
|
|
70
|
-
*/
|
|
71
|
-
contextStore?: ContextStore;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Path to a directory the agent will manage as an isogit-backed
|
|
75
|
-
* `ContextStore & AuditStore`. The singleton-per-directory lock is
|
|
76
|
-
* acquired when this form is used.
|
|
77
|
-
*/
|
|
78
|
-
contextDir?: string;
|
|
79
|
-
|
|
80
|
-
/** Pre-configured inference sources. Must be non-empty. */
|
|
81
|
-
sources: InferenceSource[];
|
|
82
|
-
/** Must match the `id` field of one of `sources`. */
|
|
83
|
-
defaultSource: string;
|
|
84
|
-
|
|
85
|
-
/** System prompt for the default director. */
|
|
86
|
-
systemPrompt: string;
|
|
87
|
-
/** Tools registered via `tool()` / `stringTool()`. */
|
|
88
|
-
tools: AgentTool[];
|
|
89
|
-
/** Director override. Defaults to `createDefaultDirector`. */
|
|
90
|
-
director?: ReactorDirector;
|
|
91
|
-
|
|
92
|
-
/** Audit store. When omitted with `contextDir`, the isogit store is used. */
|
|
93
|
-
auditStore?: AuditStore;
|
|
94
|
-
/** Authz extension hook. */
|
|
95
|
-
authorize?: AuthzExtensionOptions["authorize"];
|
|
96
|
-
/** Override the default 10k tool-result size cap. */
|
|
97
|
-
sizeCapMaxChars?: number;
|
|
98
|
-
|
|
99
|
-
/** Override the auto-generated session ID. */
|
|
100
|
-
sessionId?: string;
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Maximum number of pending sends (active + queued). Once reached,
|
|
104
|
-
* additional `send()` calls throw `SendQueueFullError` synchronously.
|
|
105
|
-
* Defaults to 16.
|
|
106
|
-
*/
|
|
107
|
-
sendQueueMax?: number;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Maximum events any single `stream()` consumer may buffer. When a
|
|
111
|
-
* consumer falls more than this many events behind the next read on
|
|
112
|
-
* that consumer's iterator throws `StreamBackpressureError`; other
|
|
113
|
-
* consumers are unaffected. Defaults to 1024.
|
|
114
|
-
*/
|
|
115
|
-
streamBufferMax?: number;
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Inference dependencies (notably `fetch`) for the reactor's underlying
|
|
119
|
-
* `runInference` call. Production callers should leave this undefined —
|
|
120
|
-
* the assembly falls back to `createDefaultDependencies()` which binds
|
|
121
|
-
* `globalThis.fetch`. Pass `setupHarness().deps` from
|
|
122
|
-
* `@intx/inference-testing` in tests to swap the fetch
|
|
123
|
-
* implementation for a deterministic stub.
|
|
124
|
-
*/
|
|
125
|
-
deps?: Dependencies;
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Maximum milliseconds `close()` waits for the reactor's shutdown
|
|
129
|
-
* sequence (which flushes audit and any pending commits) before
|
|
130
|
-
* releasing the singleton `contextDir` lock and returning. Defaults
|
|
131
|
-
* to 5000ms. Set to 0 to release immediately without waiting (useful
|
|
132
|
-
* for tests where the reactor's shutdown is intentionally blocked).
|
|
133
|
-
*/
|
|
134
|
-
closeTimeoutMs?: number;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
export type SendOptions = {
|
|
138
|
-
/**
|
|
139
|
-
* Abort signal for this send. When the signal fires before processing
|
|
140
|
-
* the call is dropped from the queue and the promise rejects with the
|
|
141
|
-
* signal's reason. When it fires mid-cycle the promise rejects
|
|
142
|
-
* immediately, but the underlying reactor cycle keeps running because
|
|
143
|
-
* the reactor does not expose per-cycle cancellation — the next queued
|
|
144
|
-
* send waits for that cycle to finish before starting. The reply (if
|
|
145
|
-
* any) is still visible via `stream()` and `history()`.
|
|
146
|
-
*/
|
|
147
|
-
signal?: AbortSignal;
|
|
148
|
-
/** Override the default "from" header on the synthetic inbound message. */
|
|
149
|
-
from?: string;
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
export type SendResult = {
|
|
153
|
-
/** Reply text emitted by the director's `reply` action. */
|
|
154
|
-
reply: string;
|
|
155
|
-
/**
|
|
156
|
-
* Full-fidelity assistant turn that produced the reply. Captured from
|
|
157
|
-
* the reactor's `inference.done` event preceding `connector.reply`.
|
|
158
|
-
*/
|
|
159
|
-
turn: ConversationTurn;
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
export type Agent = {
|
|
163
|
-
send(
|
|
164
|
-
content: string | InboundMessage,
|
|
165
|
-
opts?: SendOptions,
|
|
166
|
-
): Promise<SendResult>;
|
|
167
|
-
stream(): AsyncIterable<ReactorEmittedEvent>;
|
|
168
|
-
deliver(message: InboundMessage): void;
|
|
169
|
-
close(): Promise<void>;
|
|
170
|
-
/**
|
|
171
|
-
* Replace the active source's fields in place. Picked up at the start
|
|
172
|
-
* of the next inference call. `model` rotates alongside the
|
|
173
|
-
* credentials — the director does not name a model, so the active
|
|
174
|
-
* source's `model` is what the next inference call uses without any
|
|
175
|
-
* additional plumbing.
|
|
176
|
-
*/
|
|
177
|
-
setSource(source: InferenceSource): void;
|
|
178
|
-
/**
|
|
179
|
-
* Project conversation history from the underlying context store.
|
|
180
|
-
* Remains callable after `close()` — reads do not need the reactor
|
|
181
|
-
* and the store is not destroyed by close. Returns the full-fidelity
|
|
182
|
-
* `ConversationTurn[]` from the store's latest committed state.
|
|
183
|
-
*/
|
|
184
|
-
history(): Promise<ConversationTurn[]>;
|
|
185
|
-
/**
|
|
186
|
-
* List recent checkpoints from the context store. Remains callable
|
|
187
|
-
* after `close()` for the same reason as `history()`.
|
|
188
|
-
*/
|
|
189
|
-
checkpoints(limit?: number): Promise<ContextCommit[]>;
|
|
190
|
-
/**
|
|
191
|
-
* Read the conversation turns recorded at a specific commit hash.
|
|
192
|
-
* Remains callable after `close()` for the same reason as
|
|
193
|
-
* `history()`.
|
|
194
|
-
*/
|
|
195
|
-
readAt(hash: string): Promise<ConversationTurn[]>;
|
|
196
|
-
readonly blobReader: BlobReader;
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
export class AgentConfigError extends Error {
|
|
200
|
-
constructor(message: string) {
|
|
201
|
-
super(message);
|
|
202
|
-
this.name = "AgentConfigError";
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export class AgentClosedError extends Error {
|
|
207
|
-
constructor() {
|
|
208
|
-
super("agent is closed");
|
|
209
|
-
this.name = "AgentClosedError";
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export async function createAgent(config: AgentConfig): Promise<Agent> {
|
|
214
|
-
const hasStore = config.contextStore !== undefined;
|
|
215
|
-
const hasDir = config.contextDir !== undefined;
|
|
216
|
-
if (hasStore === hasDir) {
|
|
217
|
-
throw new AgentConfigError(
|
|
218
|
-
"exactly one of contextStore or contextDir is required",
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
let contextStore: ContextStore;
|
|
223
|
-
let auditStore: AuditStore | undefined;
|
|
224
|
-
let lock: ContextDirLock | undefined;
|
|
225
|
-
|
|
226
|
-
if (config.contextDir !== undefined) {
|
|
227
|
-
lock = acquireContextDirLock(config.contextDir);
|
|
228
|
-
try {
|
|
229
|
-
const store = await createIsogitStore(config.contextDir);
|
|
230
|
-
contextStore = store;
|
|
231
|
-
auditStore = config.auditStore ?? store;
|
|
232
|
-
} catch (cause) {
|
|
233
|
-
lock.release();
|
|
234
|
-
throw cause;
|
|
235
|
-
}
|
|
236
|
-
} else if (config.contextStore !== undefined) {
|
|
237
|
-
contextStore = config.contextStore;
|
|
238
|
-
auditStore = config.auditStore;
|
|
239
|
-
} else {
|
|
240
|
-
throw new AgentConfigError("unreachable: storage form validated above");
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
let sourceRegistry: SourceRegistry;
|
|
244
|
-
let toolRunner: AgentToolRunner;
|
|
245
|
-
try {
|
|
246
|
-
sourceRegistry = createSourceRegistry({
|
|
247
|
-
sources: config.sources,
|
|
248
|
-
defaultSource: config.defaultSource,
|
|
249
|
-
});
|
|
250
|
-
toolRunner = createToolRunner(config.tools);
|
|
251
|
-
} catch (cause) {
|
|
252
|
-
if (lock !== undefined) lock.release();
|
|
253
|
-
throw cause;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
let director: ReactorDirector;
|
|
257
|
-
if (config.director !== undefined) {
|
|
258
|
-
director = config.director;
|
|
259
|
-
} else {
|
|
260
|
-
director = createDefaultDirector(
|
|
261
|
-
config.systemPrompt,
|
|
262
|
-
[...toolRunner.definitions],
|
|
263
|
-
{},
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const sessionId = config.sessionId ?? crypto.randomUUID();
|
|
268
|
-
|
|
269
|
-
const streamBufferMax = config.streamBufferMax ?? DEFAULT_STREAM_BUFFER_MAX;
|
|
270
|
-
const streamConsumers = new Set<StreamConsumer>();
|
|
271
|
-
|
|
272
|
-
// Per-active-cycle bookkeeping for send(). The reactor produces one or
|
|
273
|
-
// more inference.done events during a cycle; we keep the most recent
|
|
274
|
-
// assistant turn so the final connector.reply can be paired with the
|
|
275
|
-
// full-fidelity turn (rather than a synthesized text-only fallback).
|
|
276
|
-
type ActiveCycle = { lastAssistantTurn: AssistantTurn | undefined };
|
|
277
|
-
let activeCycle: ActiveCycle | null = null;
|
|
278
|
-
|
|
279
|
-
// sendQueue is built after the reactor (since its `start` callback
|
|
280
|
-
// delivers into the reactor), but handleEvent — which is wired into the
|
|
281
|
-
// reactor's assembly — needs to see sendQueue. Assigned exactly once
|
|
282
|
-
// after the reactor exists and before reactor.start(); no event can
|
|
283
|
-
// reach handleEvent before the queue is wired.
|
|
284
|
-
// eslint-disable-next-line prefer-const -- forward declaration; const cannot express this ordering
|
|
285
|
-
let sendQueue: SendQueue<InboundMessage, SendResult>;
|
|
286
|
-
|
|
287
|
-
// shutdownComplete resolves from the assembly's onShutdown hook
|
|
288
|
-
// (composed after audit flush by the assembly) or, as a fallback, from
|
|
289
|
-
// handleEvent observing the reactor's terminal `reactor.done` event.
|
|
290
|
-
// close() awaits this (with a timeout) before releasing the
|
|
291
|
-
// contextDir lock so a subsequent createAgent on the same directory
|
|
292
|
-
// sees a quiesced store.
|
|
293
|
-
let resolveShutdown: () => void = () => {
|
|
294
|
-
// Reassigned by the Promise constructor below; seed a no-op so the
|
|
295
|
-
// forward-referenced call in handleEvent is safe even if the
|
|
296
|
-
// Promise constructor has not yet run (it does, synchronously, on
|
|
297
|
-
// the next line).
|
|
298
|
-
};
|
|
299
|
-
const shutdownComplete = new Promise<void>((resolve) => {
|
|
300
|
-
resolveShutdown = resolve;
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
function buildSyntheticTurn(text: string): ConversationTurn {
|
|
304
|
-
return {
|
|
305
|
-
role: "assistant",
|
|
306
|
-
content: [{ type: "text", text }],
|
|
307
|
-
model: sourceRegistry.active.model,
|
|
308
|
-
timestamp: Date.now(),
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function handleEvent(event: ReactorEmittedEvent): void {
|
|
313
|
-
if (activeCycle !== null && event.type === "inference.done") {
|
|
314
|
-
activeCycle.lastAssistantTurn = event.data.turn;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
if (activeCycle !== null) {
|
|
318
|
-
if (event.type === "connector.reply") {
|
|
319
|
-
const turn: ConversationTurn =
|
|
320
|
-
activeCycle.lastAssistantTurn ??
|
|
321
|
-
buildSyntheticTurn(event.data.content);
|
|
322
|
-
activeCycle = null;
|
|
323
|
-
sendQueue.resolveActive({ reply: event.data.content, turn });
|
|
324
|
-
} else if (event.type === "reactor.error" && event.data.fatal) {
|
|
325
|
-
// Only fatal reactor errors terminate the active send. Non-fatal
|
|
326
|
-
// errors (e.g. transient write/commit failures the reactor is
|
|
327
|
-
// recovering from) are surfaced via stream() but must not
|
|
328
|
-
// resolve send() — the cycle is still running and may yet
|
|
329
|
-
// produce connector.reply or a fatal error.
|
|
330
|
-
activeCycle = null;
|
|
331
|
-
sendQueue.rejectActive(new Error(`reactor error: ${event.data.error}`));
|
|
332
|
-
} else if (event.type === "reactor.done") {
|
|
333
|
-
activeCycle = null;
|
|
334
|
-
sendQueue.rejectActive(new AgentClosedError());
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// reactor.done is the reactor's terminal event. Resolve
|
|
339
|
-
// shutdownComplete here in addition to the onShutdown hook so close()
|
|
340
|
-
// does not hang for the full closeTimeoutMs on paths where the hook
|
|
341
|
-
// never fires (e.g. the reactor's context-store load fails during
|
|
342
|
-
// start, or the composed onShutdown wrapper throws during audit
|
|
343
|
-
// flush). resolveShutdown is idempotent.
|
|
344
|
-
if (event.type === "reactor.done") {
|
|
345
|
-
resolveShutdown();
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Iterate a snapshot so removing closed consumers mid-iteration is
|
|
349
|
-
// not just relying on Set's iteration tolerance.
|
|
350
|
-
for (const consumer of Array.from(streamConsumers)) {
|
|
351
|
-
consumer.push(event);
|
|
352
|
-
if (consumer.closed) {
|
|
353
|
-
streamConsumers.delete(consumer);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
const { reactor, blobReader } = createReactorAssembly({
|
|
359
|
-
sessionId,
|
|
360
|
-
director,
|
|
361
|
-
source: sourceRegistry.active,
|
|
362
|
-
toolRunner,
|
|
363
|
-
contextStore,
|
|
364
|
-
onEvent: handleEvent,
|
|
365
|
-
onShutdown: async () => {
|
|
366
|
-
resolveShutdown();
|
|
367
|
-
},
|
|
368
|
-
...(auditStore !== undefined ? { auditStore } : {}),
|
|
369
|
-
...(config.authorize !== undefined ? { authorize: config.authorize } : {}),
|
|
370
|
-
...(config.sizeCapMaxChars !== undefined
|
|
371
|
-
? { sizeCapMaxChars: config.sizeCapMaxChars }
|
|
372
|
-
: {}),
|
|
373
|
-
...(config.deps !== undefined ? { deps: config.deps } : {}),
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
sendQueue = createSendQueue<InboundMessage, SendResult>({
|
|
377
|
-
maxDepth: config.sendQueueMax ?? DEFAULT_SEND_QUEUE_MAX,
|
|
378
|
-
start: (message) => {
|
|
379
|
-
activeCycle = { lastAssistantTurn: undefined };
|
|
380
|
-
reactor.deliver(message);
|
|
381
|
-
},
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
reactor.start();
|
|
385
|
-
|
|
386
|
-
let closed = false;
|
|
387
|
-
|
|
388
|
-
function ensureOpen(): void {
|
|
389
|
-
if (closed) throw new AgentClosedError();
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function buildInboundMessage(
|
|
393
|
-
content: string | InboundMessage,
|
|
394
|
-
opts?: SendOptions,
|
|
395
|
-
): InboundMessage {
|
|
396
|
-
if (typeof content !== "string") return content;
|
|
397
|
-
// Conversation messages use `content` (a string); the mail-builder
|
|
398
|
-
// rejects passing `payload` for conversation types.
|
|
399
|
-
return createInboundMessage({
|
|
400
|
-
from: opts?.from ?? DEFAULT_SEND_FROM,
|
|
401
|
-
to: DEFAULT_SEND_TO,
|
|
402
|
-
content,
|
|
403
|
-
interchangeType: "conversation.message",
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function send(
|
|
408
|
-
content: string | InboundMessage,
|
|
409
|
-
opts?: SendOptions,
|
|
410
|
-
): Promise<SendResult> {
|
|
411
|
-
// Closed-agent errors come back as rejections so callers can handle
|
|
412
|
-
// them with `.catch()` instead of having to defensively wrap every
|
|
413
|
-
// `agent.send(...)` in a try/catch. `SendQueueFullError` from
|
|
414
|
-
// `sendQueue.enqueue` is left as a synchronous throw — it signals a
|
|
415
|
-
// programmer error (the caller exceeded the configured queue cap)
|
|
416
|
-
// and per the design must fail loud.
|
|
417
|
-
if (closed) return Promise.reject(new AgentClosedError());
|
|
418
|
-
const message = buildInboundMessage(content, opts);
|
|
419
|
-
return sendQueue.enqueue(message, opts?.signal);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function stream(): AsyncIterable<ReactorEmittedEvent> {
|
|
423
|
-
ensureOpen();
|
|
424
|
-
const consumer = createStreamConsumer(streamBufferMax);
|
|
425
|
-
streamConsumers.add(consumer);
|
|
426
|
-
return consumer.iterator();
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
function deliver(message: InboundMessage): void {
|
|
430
|
-
ensureOpen();
|
|
431
|
-
reactor.deliver(message);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function setSource(source: InferenceSource): void {
|
|
435
|
-
ensureOpen();
|
|
436
|
-
sourceRegistry.setSource(source);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
async function history(): Promise<ConversationTurn[]> {
|
|
440
|
-
const loaded = await contextStore.load();
|
|
441
|
-
return loaded.turns;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
async function checkpoints(limit?: number): Promise<ContextCommit[]> {
|
|
445
|
-
return contextStore.log(limit);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
async function readAt(hash: string): Promise<ConversationTurn[]> {
|
|
449
|
-
return contextStore.readAt(hash);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
async function close(): Promise<void> {
|
|
453
|
-
if (closed) return;
|
|
454
|
-
closed = true;
|
|
455
|
-
reactor.abort("user_disconnect");
|
|
456
|
-
sendQueue.drain(new AgentClosedError());
|
|
457
|
-
activeCycle = null;
|
|
458
|
-
for (const consumer of streamConsumers) consumer.close();
|
|
459
|
-
streamConsumers.clear();
|
|
460
|
-
|
|
461
|
-
// Wait for the reactor's shutdown sequence (audit flush, in-flight
|
|
462
|
-
// commits) before releasing the lock so a subsequent createAgent on
|
|
463
|
-
// the same contextDir does not race with background writers against
|
|
464
|
-
// the same .git directory. The timeout is a backstop: if the
|
|
465
|
-
// reactor's shutdown is genuinely stuck (e.g. a parked test fetch
|
|
466
|
-
// that never resolves) we release the lock anyway rather than
|
|
467
|
-
// deadlock the caller. `closeTimeoutMs: 0` disables the wait.
|
|
468
|
-
const timeoutMs = config.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
|
|
469
|
-
if (timeoutMs > 0) {
|
|
470
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
471
|
-
const timeout = new Promise<void>((resolve) => {
|
|
472
|
-
timer = setTimeout(resolve, timeoutMs);
|
|
473
|
-
});
|
|
474
|
-
try {
|
|
475
|
-
await Promise.race([shutdownComplete, timeout]);
|
|
476
|
-
} finally {
|
|
477
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
if (lock !== undefined) lock.release();
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
return {
|
|
484
|
-
send,
|
|
485
|
-
stream,
|
|
486
|
-
deliver,
|
|
487
|
-
close,
|
|
488
|
-
setSource,
|
|
489
|
-
history,
|
|
490
|
-
checkpoints,
|
|
491
|
-
readAt,
|
|
492
|
-
blobReader,
|
|
493
|
-
};
|
|
494
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
// @intx/agent — in-process agent runtime.
|
|
2
|
-
//
|
|
3
|
-
// Sits on top of `createReactorAssembly` from `@intx/inference` to
|
|
4
|
-
// provide a code-driven agent surface: send a message, stream events,
|
|
5
|
-
// project history, hot-swap inference sources. Peer to `@intx/harness`;
|
|
6
|
-
// the harness drives the reactor from a mail transport (INBOX watch,
|
|
7
|
-
// connector threads, outbound replies via MessageTransport) while the
|
|
8
|
-
// agent drives it from in-process calls.
|
|
9
|
-
|
|
10
|
-
export { AgentInUseError } from "./lock";
|
|
11
|
-
export {
|
|
12
|
-
type AgentTool,
|
|
13
|
-
type AgentToolRunner,
|
|
14
|
-
type StringToolHandler,
|
|
15
|
-
type ToolHandler,
|
|
16
|
-
DuplicateToolError,
|
|
17
|
-
createToolRunner,
|
|
18
|
-
fromToolRunner,
|
|
19
|
-
stringTool,
|
|
20
|
-
tool,
|
|
21
|
-
} from "./tool";
|
|
22
|
-
export {
|
|
23
|
-
type SourceRegistry,
|
|
24
|
-
InvalidInferenceSourceError,
|
|
25
|
-
SourceNotFoundError,
|
|
26
|
-
createSourceRegistry,
|
|
27
|
-
} from "./source";
|
|
28
|
-
export {
|
|
29
|
-
type Agent,
|
|
30
|
-
type AgentConfig,
|
|
31
|
-
type SendOptions,
|
|
32
|
-
type SendResult,
|
|
33
|
-
AgentClosedError,
|
|
34
|
-
AgentConfigError,
|
|
35
|
-
createAgent,
|
|
36
|
-
} from "./agent";
|
|
37
|
-
export { SendQueueFullError } from "./send-queue";
|
|
38
|
-
export { StreamBackpressureError } from "./stream";
|
package/src/lock.test.ts
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
|
-
|
|
4
|
-
import { acquireContextDirLock, AgentInUseError } from "./lock";
|
|
5
|
-
|
|
6
|
-
describe("acquireContextDirLock", () => {
|
|
7
|
-
test("returns a lock whose path is the resolved absolute path", () => {
|
|
8
|
-
const lock = acquireContextDirLock("/tmp/agent-lock-1");
|
|
9
|
-
try {
|
|
10
|
-
expect(lock.path).toBe(resolve("/tmp/agent-lock-1"));
|
|
11
|
-
} finally {
|
|
12
|
-
lock.release();
|
|
13
|
-
}
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test("rejects a second acquisition of the same directory", () => {
|
|
17
|
-
const lock = acquireContextDirLock("/tmp/agent-lock-2");
|
|
18
|
-
try {
|
|
19
|
-
expect(() => acquireContextDirLock("/tmp/agent-lock-2")).toThrow(
|
|
20
|
-
AgentInUseError,
|
|
21
|
-
);
|
|
22
|
-
} finally {
|
|
23
|
-
lock.release();
|
|
24
|
-
}
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test("re-acquires the same directory after release", () => {
|
|
28
|
-
const lock1 = acquireContextDirLock("/tmp/agent-lock-3");
|
|
29
|
-
lock1.release();
|
|
30
|
-
const lock2 = acquireContextDirLock("/tmp/agent-lock-3");
|
|
31
|
-
try {
|
|
32
|
-
expect(lock2.path).toBe(resolve("/tmp/agent-lock-3"));
|
|
33
|
-
} finally {
|
|
34
|
-
lock2.release();
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("collides on lexically distinct but equivalent paths", () => {
|
|
39
|
-
const lock = acquireContextDirLock("/tmp/agent-lock-4/../agent-lock-4/foo");
|
|
40
|
-
try {
|
|
41
|
-
expect(() => acquireContextDirLock("/tmp/agent-lock-4/foo")).toThrow(
|
|
42
|
-
AgentInUseError,
|
|
43
|
-
);
|
|
44
|
-
} finally {
|
|
45
|
-
lock.release();
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("collides on a relative path that resolves to the same absolute path", () => {
|
|
50
|
-
const absolute = resolve("relative-lock-test-dir");
|
|
51
|
-
const lock = acquireContextDirLock("relative-lock-test-dir");
|
|
52
|
-
try {
|
|
53
|
-
expect(lock.path).toBe(absolute);
|
|
54
|
-
expect(() => acquireContextDirLock(absolute)).toThrow(AgentInUseError);
|
|
55
|
-
} finally {
|
|
56
|
-
lock.release();
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("does not collide between distinct directories", () => {
|
|
61
|
-
const lock1 = acquireContextDirLock("/tmp/agent-lock-5a");
|
|
62
|
-
const lock2 = acquireContextDirLock("/tmp/agent-lock-5b");
|
|
63
|
-
try {
|
|
64
|
-
expect(lock1.path).not.toBe(lock2.path);
|
|
65
|
-
} finally {
|
|
66
|
-
lock1.release();
|
|
67
|
-
lock2.release();
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("release is idempotent", () => {
|
|
72
|
-
const lock = acquireContextDirLock("/tmp/agent-lock-6");
|
|
73
|
-
lock.release();
|
|
74
|
-
lock.release();
|
|
75
|
-
const reacquired = acquireContextDirLock("/tmp/agent-lock-6");
|
|
76
|
-
reacquired.release();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("AgentInUseError exposes contextDir as the resolved path", () => {
|
|
80
|
-
const lock = acquireContextDirLock("/tmp/agent-lock-7");
|
|
81
|
-
try {
|
|
82
|
-
acquireContextDirLock("/tmp/agent-lock-7");
|
|
83
|
-
throw new Error("should have thrown AgentInUseError");
|
|
84
|
-
} catch (err) {
|
|
85
|
-
expect(err).toBeInstanceOf(AgentInUseError);
|
|
86
|
-
if (err instanceof AgentInUseError) {
|
|
87
|
-
expect(err.contextDir).toBe(resolve("/tmp/agent-lock-7"));
|
|
88
|
-
}
|
|
89
|
-
} finally {
|
|
90
|
-
lock.release();
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
});
|
package/src/lock.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
// Process-wide registry of held context-directory locks.
|
|
2
|
-
//
|
|
3
|
-
// The agent enforces a runtime singleton-per-contextDir invariant: at most one
|
|
4
|
-
// in-process agent may own a given context directory at a time. Holding two
|
|
5
|
-
// agents against the same directory simultaneously corrupts both the git
|
|
6
|
-
// state and the audit collector's bookkeeping.
|
|
7
|
-
//
|
|
8
|
-
// This is a best-effort in-process check. It does not coordinate across OS
|
|
9
|
-
// processes, and it compares lexically-resolved absolute paths — two paths
|
|
10
|
-
// that point to the same directory through symlinks or `..`/`/./` segments
|
|
11
|
-
// are normalized by `path.resolve`, but a hard link or a separately mounted
|
|
12
|
-
// bind to the same inode will not be detected. Callers passing their own
|
|
13
|
-
// `contextStore` (rather than a `contextDir` string) bypass the lock; they
|
|
14
|
-
// are responsible for their store's lifetime.
|
|
15
|
-
|
|
16
|
-
import { resolve } from "node:path";
|
|
17
|
-
|
|
18
|
-
const heldLocks = new Set<string>();
|
|
19
|
-
|
|
20
|
-
export class AgentInUseError extends Error {
|
|
21
|
-
readonly contextDir: string;
|
|
22
|
-
|
|
23
|
-
constructor(contextDir: string) {
|
|
24
|
-
super(`an agent is already open for context directory: ${contextDir}`);
|
|
25
|
-
this.name = "AgentInUseError";
|
|
26
|
-
this.contextDir = contextDir;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export type ContextDirLock = {
|
|
31
|
-
/** Absolute, resolved path of the locked directory. */
|
|
32
|
-
readonly path: string;
|
|
33
|
-
/** Release the lock. Idempotent. */
|
|
34
|
-
release(): void;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Acquire the process-wide lock for `contextDir`. Throws `AgentInUseError`
|
|
39
|
-
* if another agent already holds it. The returned `release` is idempotent.
|
|
40
|
-
*/
|
|
41
|
-
export function acquireContextDirLock(contextDir: string): ContextDirLock {
|
|
42
|
-
const path = resolve(contextDir);
|
|
43
|
-
if (heldLocks.has(path)) {
|
|
44
|
-
throw new AgentInUseError(path);
|
|
45
|
-
}
|
|
46
|
-
heldLocks.add(path);
|
|
47
|
-
|
|
48
|
-
let released = false;
|
|
49
|
-
return {
|
|
50
|
-
path,
|
|
51
|
-
release() {
|
|
52
|
-
if (released) return;
|
|
53
|
-
released = true;
|
|
54
|
-
heldLocks.delete(path);
|
|
55
|
-
},
|
|
56
|
-
};
|
|
57
|
-
}
|