@inixiative/foundry-core 0.1.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/src/thread.ts ADDED
@@ -0,0 +1,528 @@
1
+ import { ContextStack } from "./context-stack";
2
+ import type { LayerFilter, ContextStackView } from "./context-stack";
3
+ import { CacheLifecycle } from "./cache-lifecycle";
4
+ import { BaseAgent, type ExecutionResult } from "./base-agent";
5
+ import { MiddlewareChain, type DispatchContext } from "./middleware";
6
+ import { SignalBus } from "./signal";
7
+ import type { TokenTracker } from "./token-tracker";
8
+ import type { ToolCallObservation } from "./tools";
9
+ import type { InjectionArtifact } from "./messages";
10
+ import { newId } from "./id";
11
+
12
+ export type ThreadStatus = "idle" | "active" | "waiting" | "archived";
13
+
14
+ export interface ThreadMeta {
15
+ /** Living description — what this thread is doing right now. */
16
+ description: string;
17
+
18
+ /** Classification tags — what kind of work this thread handles. */
19
+ tags: string[];
20
+
21
+ /** Current status. */
22
+ status: ThreadStatus;
23
+
24
+ /** Working directory for this thread's tool execution (worktree path). */
25
+ cwd?: string;
26
+
27
+ /** Git branch this thread is assigned to. */
28
+ branch?: string;
29
+
30
+ /** Parent thread when this thread is a subagent spawned by another. */
31
+ parentThreadId?: string;
32
+
33
+ /** Project that owns this thread. Scopes memory sources and tool calls. */
34
+ projectId?: string;
35
+
36
+ /** When this thread was created. */
37
+ readonly createdAt: number;
38
+
39
+ /** Last time a dispatch happened on this thread. */
40
+ lastActiveAt: number;
41
+
42
+ /** When the thread was archived (if applicable). */
43
+ archivedAt?: number;
44
+ }
45
+
46
+ export interface Dispatch<T = unknown> {
47
+ readonly agentId: string;
48
+ readonly timestamp: number;
49
+ readonly contextHash: string;
50
+ readonly result: ExecutionResult<T>;
51
+ readonly durationMs: number;
52
+ }
53
+
54
+ export interface FanResult {
55
+ readonly agentId: string;
56
+ readonly status: "fulfilled" | "rejected";
57
+ readonly result?: ExecutionResult;
58
+ readonly error?: unknown;
59
+ }
60
+
61
+ export interface BackgroundHandle {
62
+ readonly agentId: string;
63
+ readonly promise: Promise<ExecutionResult>;
64
+ }
65
+
66
+ /**
67
+ * Per-dispatch options. Correlation fields are supplied by whoever drives the
68
+ * dispatch (a Harness stage, an on-demand request) and ride along on the
69
+ * observation signal; the thread never invents them.
70
+ */
71
+ export interface DispatchOptions {
72
+ nativeObservation?: Omit<import("./native-evidence").NativeObservation, "owner"> & { generation: string };
73
+ recordNative?: (evidence: import("./native-evidence").NativeEvidence) => void;
74
+ recordCompleted?: (output: unknown) => void;
75
+ /** Streaming sink for executor text deltas. */
76
+ onDelta?: (text: string) => void;
77
+ recordInjection?: (artifact: InjectionArtifact) => void;
78
+ /** Pipeline role when dispatched by a Harness stage (classify, route, execute, ...). */
79
+ role?: string;
80
+ /** Harness message this dispatch serves, when there is one. */
81
+ messageId?: string;
82
+ /** How the caller invoked the agent: always, conditional, on-demand, background. */
83
+ invocation?: string;
84
+ }
85
+
86
+ /**
87
+ * Structured payload of a `dispatch` signal. Emitted exactly once per
88
+ * dispatch at the thread boundary, for every entry point (direct, background,
89
+ * fan, Harness stages), on the dispatching thread's own bus.
90
+ */
91
+ export interface DispatchObservation {
92
+ readonly threadId: string;
93
+ /** Identity of the dispatch; tool observations for this work carry the same id. */
94
+ readonly dispatchId: string;
95
+ readonly agentId: string;
96
+ /** Serialized payload, bounded to MAX_OBSERVED_PAYLOAD characters. */
97
+ readonly payload: string;
98
+ readonly durationMs: number;
99
+ readonly ok: boolean;
100
+ /** Error message when `ok` is false. */
101
+ readonly error?: string;
102
+ /** Context hash of the completed execution when `ok` is true. */
103
+ readonly contextHash?: string;
104
+ /** Serialized executor output when `ok` is true, bounded like `payload`. */
105
+ readonly output?: string;
106
+ readonly role?: string;
107
+ readonly messageId?: string;
108
+ readonly invocation?: string;
109
+ }
110
+
111
+ const MAX_OBSERVED_PAYLOAD = 2048;
112
+
113
+ export interface ThreadConfig {
114
+ maxDispatches?: number;
115
+ maxSignalHistory?: number;
116
+ description?: string;
117
+ tags?: string[];
118
+ /** Working directory for this thread's tool execution (worktree path). */
119
+ cwd?: string;
120
+ /** Git branch this thread is assigned to. */
121
+ branch?: string;
122
+ /** Parent thread when this thread is a subagent spawned by another. */
123
+ parentThreadId?: string;
124
+ /** Project that owns this thread. Scopes memory sources and tool calls. */
125
+ projectId?: string;
126
+ /** Optional token tracker — auto-records costs for every dispatch. */
127
+ tokenTracker?: TokenTracker;
128
+ }
129
+
130
+ /**
131
+ * The Thread is the orchestrator.
132
+ *
133
+ * It owns the ContextStack, manages agents, runs dispatch through
134
+ * a middleware chain, keeps a bounded dispatch log, and hosts the signal bus.
135
+ *
136
+ * Threads are self-describing — they carry a living description,
137
+ * classification tags, and a status that updates as work happens.
138
+ *
139
+ * Disposal is terminal: a disposed Thread object never dispatches again and
140
+ * cannot be re-wired. Status (idle/active/waiting/archived) is a separate,
141
+ * non-terminal concern; archiving is "set status archived, then dispose".
142
+ */
143
+ export class Thread {
144
+ readonly id: string;
145
+ readonly stack: ContextStack;
146
+ readonly lifecycle: CacheLifecycle;
147
+ readonly middleware: MiddlewareChain;
148
+ readonly signals: SignalBus;
149
+ readonly meta: ThreadMeta;
150
+
151
+ private _agents: Map<string, BaseAgent<any, any>> = new Map();
152
+ private _dispatches: Dispatch[] = [];
153
+ private _maxDispatches: number;
154
+ private _tokenTracker?: TokenTracker;
155
+ private _disposers: Array<() => void> = [];
156
+ private _disposed = false;
157
+ private _activeDispatches = 0;
158
+
159
+ constructor(id: string, stack: ContextStack, opts?: ThreadConfig) {
160
+ this.id = id;
161
+ this.stack = stack;
162
+ this.lifecycle = new CacheLifecycle(stack);
163
+ this.middleware = new MiddlewareChain();
164
+ this.signals = new SignalBus(opts?.maxSignalHistory ?? 1000);
165
+ this._maxDispatches = opts?.maxDispatches ?? 10000;
166
+ this._tokenTracker = opts?.tokenTracker;
167
+
168
+ const now = Date.now();
169
+ this.meta = {
170
+ description: opts?.description ?? "",
171
+ tags: opts?.tags ?? [],
172
+ status: "idle",
173
+ cwd: opts?.cwd,
174
+ branch: opts?.branch,
175
+ parentThreadId: opts?.parentThreadId,
176
+ projectId: opts?.projectId,
177
+ createdAt: now,
178
+ lastActiveAt: now,
179
+ };
180
+ }
181
+
182
+ // -- Agent management --
183
+
184
+ register(agent: BaseAgent<any, any>): void {
185
+ this._agents.set(agent.id, agent);
186
+ }
187
+
188
+ unregister(id: string): boolean {
189
+ return this._agents.delete(id);
190
+ }
191
+
192
+ getAgent(id: string): BaseAgent<any, any> | undefined {
193
+ return this._agents.get(id);
194
+ }
195
+
196
+ get agents(): ReadonlyMap<string, BaseAgent<any, any>> {
197
+ return this._agents;
198
+ }
199
+
200
+ // -- Metadata --
201
+
202
+ /** Update the living description. */
203
+ describe(description: string): void {
204
+ this.meta.description = description;
205
+ }
206
+
207
+ /** Update tags. */
208
+ tag(...tags: string[]): void {
209
+ for (const t of tags) {
210
+ if (!this.meta.tags.includes(t)) this.meta.tags.push(t);
211
+ }
212
+ }
213
+
214
+ /** Whether this thread has been disposed. Terminal. */
215
+ get disposed(): boolean {
216
+ return this._disposed;
217
+ }
218
+
219
+ /** Number of dispatches currently executing. */
220
+ get activeDispatches(): number {
221
+ return this._activeDispatches;
222
+ }
223
+
224
+ /**
225
+ * Register cleanup that runs when this thread is archived or disposed.
226
+ * Runtime wiring (middleware, subscriptions, owned layers) registers here so
227
+ * whoever archives the thread tears it down without knowing the details.
228
+ * Returns an unregister function.
229
+ */
230
+ onDispose(fn: () => void): () => void {
231
+ this._disposers.push(fn);
232
+ return () => {
233
+ const idx = this._disposers.indexOf(fn);
234
+ if (idx !== -1) this._disposers.splice(idx, 1);
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Permanently close this thread: run registered cleanup exactly once, stop
240
+ * the lifecycle, and refuse every later dispatch. Idempotent.
241
+ */
242
+ dispose(): void {
243
+ if (this._disposed) return;
244
+ this._disposed = true;
245
+ const disposers = this._disposers;
246
+ this._disposers = [];
247
+ for (const fn of disposers) {
248
+ try {
249
+ fn();
250
+ } catch (err) {
251
+ console.warn(`[Thread] disposer failed for "${this.id}":`, (err as Error).message ?? err);
252
+ }
253
+ }
254
+ this.stop();
255
+ }
256
+
257
+ /** Archive this thread. Sets the terminal status, then disposes. */
258
+ archive(): void {
259
+ this.meta.status = "archived";
260
+ this.meta.archivedAt = Date.now();
261
+ this.dispose();
262
+ }
263
+
264
+ // -- Dispatching (with middleware) --
265
+
266
+ /**
267
+ * The single dispatch boundary. Every entry point (direct, background, fan,
268
+ * Harness stages) passes through here, so each dispatch is observed exactly
269
+ * once on this thread's bus, including failures.
270
+ */
271
+ async dispatch<TPayload>(
272
+ agentId: string,
273
+ payload: TPayload,
274
+ filterOverride?: LayerFilter,
275
+ opts?: DispatchOptions,
276
+ ): Promise<ExecutionResult> {
277
+ // This dispatch's observation and middleware must retain the same logical
278
+ // identity even if a caller later reuses/mutates its options object.
279
+ opts = opts ? { ...opts } : undefined;
280
+ if (this.meta.status === "archived") {
281
+ throw new Error(`Thread archived: ${this.id} cannot dispatch "${agentId}"`);
282
+ }
283
+ if (this._disposed) {
284
+ throw new Error(`Thread disposed: ${this.id} cannot dispatch "${agentId}"`);
285
+ }
286
+ const agent = this._agents.get(agentId);
287
+ if (!agent) throw new Error(`Agent not found: ${agentId}`);
288
+
289
+ this._activeDispatches++;
290
+ this.meta.status = "active";
291
+ this.meta.lastActiveAt = Date.now();
292
+
293
+ // Explicit identity for this dispatch. Middleware sees it, tool calls
294
+ // executed on its behalf are observed with it, and the completion
295
+ // observation carries it, so evidence never drifts to another completion.
296
+ const dispatchId = newId("dispatch");
297
+ const ctx: DispatchContext<TPayload> = {
298
+ agentId,
299
+ payload,
300
+ timestamp: Date.now(),
301
+ annotations: {},
302
+ threadId: this.id,
303
+ dispatchId,
304
+ stack: this._createStackView(),
305
+ projectId: this.meta.projectId,
306
+ messageId: opts?.messageId,
307
+ };
308
+
309
+ const start = performance.now();
310
+
311
+ const meta = {
312
+ cwd: this.meta.cwd,
313
+ threadId: this.id,
314
+ projectId: this.meta.projectId,
315
+ messageId: ctx.messageId,
316
+ dispatchId,
317
+ observeTool: (observation: ToolCallObservation) => {
318
+ void this.signals.emit({
319
+ id: newId("sig-tool"),
320
+ kind: "tool_observation",
321
+ source: `thread:${this.id}`,
322
+ content: { threadId: this.id, dispatchId, agentId, ...observation, timestamp: Date.now() },
323
+ timestamp: Date.now(),
324
+ }).catch((err) => {
325
+ console.warn(`[Thread] tool observation failed for "${agentId}":`, (err as Error).message ?? err);
326
+ });
327
+ },
328
+ annotations: ctx.annotations,
329
+ onDelta: opts?.onDelta,
330
+ recordInjection: opts?.recordInjection,
331
+ recordNative: opts?.recordNative,
332
+ recordCompleted: opts?.recordCompleted,
333
+ nativeObservation: opts?.nativeObservation ? {
334
+ preflight: opts.nativeObservation.preflight,
335
+ bridge: opts.nativeObservation.bridge,
336
+ register: opts.nativeObservation.register, observe: opts.nativeObservation.observe,
337
+ owner: Object.freeze({ threadId: this.id, projectId: this.meta.projectId, generation: opts.nativeObservation.generation,
338
+ messageId: ctx.messageId, dispatchId }),
339
+ } : undefined,
340
+ };
341
+
342
+ try {
343
+ const result = await this.middleware.execute(ctx, () =>
344
+ agent.run(payload, filterOverride, meta)
345
+ );
346
+
347
+ const durationMs = performance.now() - start;
348
+
349
+ // Auto-record token usage if tracker is configured
350
+ if (this._tokenTracker && result.tokens) {
351
+ const llm = agent.llm;
352
+ this._tokenTracker.record({
353
+ provider: llm?.provider ?? "unknown",
354
+ model: llm?.model ?? "unknown",
355
+ agentId,
356
+ threadId: this.id,
357
+ tokens: result.tokens,
358
+ });
359
+ }
360
+
361
+ this._dispatches.push({
362
+ agentId,
363
+ timestamp: ctx.timestamp,
364
+ contextHash: result.contextHash,
365
+ result,
366
+ durationMs,
367
+ });
368
+
369
+ if (this._dispatches.length > this._maxDispatches) {
370
+ this._dispatches.shift();
371
+ }
372
+
373
+ await this._observe(agentId, payload, opts, dispatchId, {
374
+ durationMs,
375
+ ok: true,
376
+ contextHash: result.contextHash,
377
+ output: serializePayload(result.output),
378
+ });
379
+
380
+ return result;
381
+ } catch (err) {
382
+ await this._observe(agentId, payload, opts, dispatchId, {
383
+ durationMs: performance.now() - start,
384
+ ok: false,
385
+ error: (err as Error)?.message ?? String(err),
386
+ });
387
+ throw err;
388
+ } finally {
389
+ this._activeDispatches--;
390
+ // Only the last finishing dispatch returns the thread to idle, and a
391
+ // late completion must not resurrect an archived (or paused) thread.
392
+ if (this._activeDispatches === 0 && this.meta.status === "active") {
393
+ this.meta.status = "idle";
394
+ }
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Fire-and-forget dispatch. Returns a handle with the promise
400
+ * but does not block the caller. Errors are logged, not thrown.
401
+ */
402
+ dispatchBackground<TPayload>(
403
+ agentId: string,
404
+ payload: TPayload,
405
+ filterOverride?: LayerFilter,
406
+ opts?: DispatchOptions,
407
+ ): BackgroundHandle {
408
+ const promise = this.dispatch(agentId, payload, filterOverride, opts).catch(
409
+ async (err) => {
410
+ console.warn(`[Thread] background dispatch "${agentId}" failed:`, (err as Error).message);
411
+ return { output: null, contextHash: "" } as ExecutionResult;
412
+ }
413
+ );
414
+ return { agentId, promise };
415
+ }
416
+
417
+ /**
418
+ * Dispatch to multiple agents in parallel.
419
+ * Uses allSettled — one failure doesn't kill the rest.
420
+ */
421
+ async fan<TPayload>(
422
+ agentIds: string[],
423
+ payload: TPayload,
424
+ filterOverride?: LayerFilter,
425
+ opts?: DispatchOptions,
426
+ ): Promise<FanResult[]> {
427
+ const settled = await Promise.allSettled(
428
+ agentIds.map((id) => this.dispatch(id, payload, filterOverride, opts))
429
+ );
430
+
431
+ return settled.map((s, i) => {
432
+ if (s.status === "fulfilled") {
433
+ return { agentId: agentIds[i], status: "fulfilled" as const, result: s.value };
434
+ } else {
435
+ return { agentId: agentIds[i], status: "rejected" as const, error: s.reason };
436
+ }
437
+ });
438
+ }
439
+
440
+ // -- Observation --
441
+
442
+ private async _observe(
443
+ agentId: string,
444
+ payload: unknown,
445
+ opts: DispatchOptions | undefined,
446
+ dispatchId: string,
447
+ outcome: Pick<DispatchObservation, "durationMs" | "ok" | "error" | "contextHash" | "output">,
448
+ ): Promise<void> {
449
+ const content: DispatchObservation = {
450
+ threadId: this.id,
451
+ dispatchId,
452
+ agentId,
453
+ payload: serializePayload(payload),
454
+ ...outcome,
455
+ ...(opts?.role ? { role: opts.role } : {}),
456
+ ...(opts?.messageId ? { messageId: opts.messageId } : {}),
457
+ ...(opts?.invocation ? { invocation: opts.invocation } : {}),
458
+ };
459
+ try {
460
+ await this.signals.emit({
461
+ id: newId("sig-dispatch"),
462
+ kind: "dispatch",
463
+ source: opts?.role ? `harness:${agentId}` : `thread:${this.id}`,
464
+ content,
465
+ timestamp: Date.now(),
466
+ });
467
+ } catch (err) {
468
+ console.warn(`[Thread] dispatch observation failed for "${agentId}":`, (err as Error).message ?? err);
469
+ }
470
+ }
471
+
472
+ // -- Stack view --
473
+
474
+ /** Create a live read-only view of the context stack for middleware. */
475
+ private _createStackView(): ContextStackView {
476
+ const stack = this.stack;
477
+ return {
478
+ hasLayer: (id) => {
479
+ const l = stack.getLayer(id);
480
+ return l ? l.isWarm : false;
481
+ },
482
+ getContent: (id) => {
483
+ const l = stack.getLayer(id);
484
+ return l?.isWarm ? l.content : "";
485
+ },
486
+ getState: (id) => stack.getLayer(id)?.state,
487
+ get layerIds() { return stack.layers.map((l) => l.id); },
488
+ get estimatedTokens() { return stack.estimateTokens(); },
489
+ };
490
+ }
491
+
492
+ // -- History --
493
+
494
+ get dispatches(): ReadonlyArray<Dispatch> {
495
+ return this._dispatches;
496
+ }
497
+
498
+ /**
499
+ * Start (or resume) the cache lifecycle. Pause/resume is stop/start on a
500
+ * live thread; a disposed thread is terminal and cannot be resumed, only
501
+ * replaced by a new thread.
502
+ */
503
+ start(): void {
504
+ if (this._disposed) {
505
+ throw new Error(`Thread disposed: ${this.id} cannot be started; create a new thread to restore it`);
506
+ }
507
+ this.lifecycle.start();
508
+ }
509
+
510
+ stop(): void {
511
+ this.lifecycle.stop();
512
+ }
513
+ }
514
+
515
+ /** Serialize a dispatch payload for observation, bounded in size. */
516
+ function serializePayload(payload: unknown): string {
517
+ let text: string;
518
+ if (typeof payload === "string") {
519
+ text = payload;
520
+ } else {
521
+ try {
522
+ text = JSON.stringify(payload) ?? String(payload);
523
+ } catch {
524
+ text = String(payload);
525
+ }
526
+ }
527
+ return text.length > MAX_OBSERVED_PAYLOAD ? text.slice(0, MAX_OBSERVED_PAYLOAD) : text;
528
+ }
@@ -0,0 +1,30 @@
1
+ /** Cache counts, when supplied, are disjoint from input. Thinking and TTL
2
+ * breakdowns are subsets of output/cacheWrite, never additional tokens.
3
+ * Missing optional counters mean unreported, not a measured zero.
4
+ */
5
+ export interface TokenCounts {
6
+ input: number;
7
+ output: number;
8
+ cacheRead?: number;
9
+ cacheWrite?: number;
10
+ cacheWrite5m?: number;
11
+ cacheWrite1h?: number;
12
+ thinking?: number;
13
+ providerUsage?: Readonly<Record<string, unknown>>;
14
+ }
15
+
16
+ export function totalTokenCount(tokens: TokenCounts): number {
17
+ return tokens.input + tokens.output + (tokens.cacheRead ?? 0) + (tokens.cacheWrite ?? 0);
18
+ }
19
+
20
+ /** Sum reported counters only. Provider tags remain on individual records. */
21
+ export function sumTokenCounts(entries: Iterable<TokenCounts>): TokenCounts {
22
+ const total: TokenCounts = { input: 0, output: 0 };
23
+ for (const entry of entries) {
24
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "cacheWrite5m", "cacheWrite1h", "thinking"] as const) {
25
+ const value = entry[key];
26
+ if (value !== undefined) total[key] = (total[key] ?? 0) + value;
27
+ }
28
+ }
29
+ return total;
30
+ }