@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/LICENSE +21 -0
- package/package.json +39 -0
- package/src/action-prompt.ts +248 -0
- package/src/adapters/file-memory.ts +718 -0
- package/src/adapters/http-memory.ts +141 -0
- package/src/adapters/index.ts +4 -0
- package/src/adapters/markdown-docs.ts +213 -0
- package/src/adapters/sqlite-memory.ts +188 -0
- package/src/base-agent.ts +137 -0
- package/src/bounded-set.ts +42 -0
- package/src/cache-lifecycle.ts +187 -0
- package/src/capability.ts +252 -0
- package/src/classifier.ts +43 -0
- package/src/context-layer.ts +706 -0
- package/src/context-stack.ts +223 -0
- package/src/credentials.ts +14 -0
- package/src/decider.ts +62 -0
- package/src/delivery-evidence.ts +85 -0
- package/src/event-stream.ts +105 -0
- package/src/executor.ts +185 -0
- package/src/harness.ts +749 -0
- package/src/hooks.ts +133 -0
- package/src/hydrator.ts +148 -0
- package/src/id.ts +39 -0
- package/src/index.ts +273 -0
- package/src/intervention.ts +134 -0
- package/src/messages.ts +315 -0
- package/src/middleware.ts +118 -0
- package/src/native-evidence.ts +140 -0
- package/src/permission-middleware.ts +92 -0
- package/src/retry.ts +91 -0
- package/src/router.ts +41 -0
- package/src/scope.ts +20 -0
- package/src/signal.ts +117 -0
- package/src/thread.ts +528 -0
- package/src/token-counts.ts +30 -0
- package/src/token-tracker.ts +433 -0
- package/src/tools.ts +714 -0
- package/src/trace.ts +261 -0
- package/src/types.ts +167 -0
package/src/harness.ts
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
import { Thread, type FanResult, type BackgroundHandle } from "./thread";
|
|
2
|
+
import type { ExecutionResult } from "./base-agent";
|
|
3
|
+
import type { Decision } from "./decider";
|
|
4
|
+
import type { Classification } from "./classifier";
|
|
5
|
+
import type { Route } from "./router";
|
|
6
|
+
import type { LayerFilter } from "./context-stack";
|
|
7
|
+
import type { ContextLayer } from "./context-layer";
|
|
8
|
+
import { Trace } from "./trace";
|
|
9
|
+
import { BoundedSet } from "./bounded-set";
|
|
10
|
+
import { newId } from "./id";
|
|
11
|
+
import type {
|
|
12
|
+
InvocationCondition,
|
|
13
|
+
AgentModeConfig,
|
|
14
|
+
LayerModeConfig,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
export interface Message<T = unknown> {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly payload: T;
|
|
20
|
+
readonly meta?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface HarnessResult<T = unknown> {
|
|
24
|
+
readonly messageId: string;
|
|
25
|
+
readonly classification?: Decision<Classification>;
|
|
26
|
+
readonly route?: Decision<Route>;
|
|
27
|
+
readonly result: ExecutionResult<T>;
|
|
28
|
+
readonly trace: Trace;
|
|
29
|
+
readonly timestamp: number;
|
|
30
|
+
/** Which agents were invoked (and how: always/conditional/on-demand). */
|
|
31
|
+
readonly invokedAgents?: Array<{ id: string; mode: string }>;
|
|
32
|
+
/** Which layers were active in the final context. */
|
|
33
|
+
readonly activeLayers?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BackgroundResult {
|
|
37
|
+
readonly messageId: string;
|
|
38
|
+
readonly agentId: string;
|
|
39
|
+
readonly stage: FlowStage;
|
|
40
|
+
readonly result: ExecutionResult;
|
|
41
|
+
readonly durationMs: number;
|
|
42
|
+
readonly error?: Error;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type BackgroundResultHandler = (result: BackgroundResult) => void;
|
|
46
|
+
|
|
47
|
+
export interface PipelineStep<TIn = unknown, TOut = unknown> {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
run(input: TIn, harness: Harness, trace: Trace): Promise<TOut>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Flow configuration — defines the configurable pipeline
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export interface FlowStage {
|
|
57
|
+
/** Agent ID for this stage. Use "routed" to use route.destination. */
|
|
58
|
+
agentId: string | "routed";
|
|
59
|
+
/** Pipeline role (for tracing and UI). */
|
|
60
|
+
role: "classify" | "route" | "execute" | "enrich" | "guard" | "observe";
|
|
61
|
+
/**
|
|
62
|
+
* When this stage runs:
|
|
63
|
+
* - "always": every request
|
|
64
|
+
* - "on-demand": only when explicitly requested by middleware or prior stage
|
|
65
|
+
* - "conditional": when condition matches classification/route context
|
|
66
|
+
*/
|
|
67
|
+
invocation: "always" | "on-demand" | "conditional";
|
|
68
|
+
/** Condition for conditional invocation. */
|
|
69
|
+
condition?: InvocationCondition;
|
|
70
|
+
/**
|
|
71
|
+
* Run this stage in the background (fire-and-forget).
|
|
72
|
+
* The stage dispatches but doesn't block the pipeline response.
|
|
73
|
+
* Useful for post-dispatch observers, reviewers, distillers.
|
|
74
|
+
*/
|
|
75
|
+
background?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface FlowConfig {
|
|
79
|
+
/** Ordered pipeline stages. */
|
|
80
|
+
stages: FlowStage[];
|
|
81
|
+
/** Default executor if no route specifies a destination. */
|
|
82
|
+
defaultExecutor?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Context available to middleware for requesting on-demand agents/layers.
|
|
87
|
+
*/
|
|
88
|
+
export interface RequestContext {
|
|
89
|
+
readonly classification?: Decision<Classification>;
|
|
90
|
+
readonly route?: Decision<Route>;
|
|
91
|
+
/** Request an on-demand agent to run after the current stage. */
|
|
92
|
+
requestAgent(agentId: string): void;
|
|
93
|
+
/** Request an on-demand layer to be included in context assembly. */
|
|
94
|
+
requestLayer(layerId: string): void;
|
|
95
|
+
/** Check which agents/layers have been requested so far. */
|
|
96
|
+
readonly requestedAgents: ReadonlySet<string>;
|
|
97
|
+
readonly requestedLayers: ReadonlySet<string>;
|
|
98
|
+
/** Results from prior stages (keyed by agentId). */
|
|
99
|
+
readonly stageResults: ReadonlyMap<string, unknown>;
|
|
100
|
+
/** Arbitrary data passed between stages — stages can read/write freely. */
|
|
101
|
+
readonly data: Map<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Condition matching
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export function matchesCondition(
|
|
109
|
+
condition: InvocationCondition | undefined,
|
|
110
|
+
classification?: Decision<Classification>,
|
|
111
|
+
route?: Decision<Route>,
|
|
112
|
+
): boolean {
|
|
113
|
+
if (!condition) return false;
|
|
114
|
+
|
|
115
|
+
// Match ANY field (OR across fields)
|
|
116
|
+
if (condition.categories?.length && classification?.value) {
|
|
117
|
+
if (condition.categories.includes(classification.value.category)) return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (condition.tags?.length && classification?.value) {
|
|
121
|
+
const classTags = classification.value.tags ?? [];
|
|
122
|
+
if (condition.tags.some((t) => classTags.includes(t))) return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (condition.routes?.length && route?.value) {
|
|
126
|
+
if (condition.routes.includes(route.value.destination)) return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Harness
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
interface SendOptions {
|
|
137
|
+
nativeObservation?: import("./thread").DispatchOptions["nativeObservation"];
|
|
138
|
+
onDelta?: (text: string) => void;
|
|
139
|
+
/** Final trace for this attempt, independent of the bounded history cache. */
|
|
140
|
+
onTrace?: (trace: Trace) => void;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The Harness is the entry point for external callers.
|
|
145
|
+
*
|
|
146
|
+
* Supports two configuration styles that both execute through a single
|
|
147
|
+
* flow pipeline:
|
|
148
|
+
*
|
|
149
|
+
* 1. **Classic sugar** (setClassifier/setRouter/setDefaultExecutor) —
|
|
150
|
+
* internally builds a FlowConfig with classify → route → execute stages.
|
|
151
|
+
*
|
|
152
|
+
* 2. **Explicit flow** (setFlow) — full control over pipeline stages with
|
|
153
|
+
* invocation modes (always/on-demand/conditional).
|
|
154
|
+
*
|
|
155
|
+
* Every message gets a Trace — a full record of its journey
|
|
156
|
+
* with timing, layer visibility, inputs/outputs at each stage.
|
|
157
|
+
*/
|
|
158
|
+
export class Harness {
|
|
159
|
+
readonly thread: Thread;
|
|
160
|
+
|
|
161
|
+
// Classic mode fields — used to build a FlowConfig on demand
|
|
162
|
+
private _classifierId: string | null = null;
|
|
163
|
+
private _routerId: string | null = null;
|
|
164
|
+
private _defaultExecutorId: string | null = null;
|
|
165
|
+
|
|
166
|
+
// Explicit flow config (takes precedence over classic fields)
|
|
167
|
+
private _explicitFlow: FlowConfig | null = null;
|
|
168
|
+
|
|
169
|
+
// Agent/layer invocation metadata (from config)
|
|
170
|
+
private _agentModes: Map<string, { invocation: string; condition?: InvocationCondition }> = new Map();
|
|
171
|
+
private _layerModes: Map<string, { activation: string; condition?: InvocationCondition }> = new Map();
|
|
172
|
+
|
|
173
|
+
private _pipeline: PipelineStep[] = [];
|
|
174
|
+
private _traces: Trace[] = [];
|
|
175
|
+
private _maxTraces: number;
|
|
176
|
+
private _backgroundHandlers: BackgroundResultHandler[] = [];
|
|
177
|
+
|
|
178
|
+
// Idempotency
|
|
179
|
+
private _seenMessages: BoundedSet<string>;
|
|
180
|
+
private _resultCache: Map<string, HarnessResult> = new Map();
|
|
181
|
+
private _dedupCapacity: number;
|
|
182
|
+
|
|
183
|
+
constructor(thread: Thread, opts?: { maxTraces?: number; dedup?: boolean | number }) {
|
|
184
|
+
this.thread = thread;
|
|
185
|
+
this._maxTraces = opts?.maxTraces ?? 1000;
|
|
186
|
+
|
|
187
|
+
this._dedupCapacity = opts?.dedup === true ? 1000
|
|
188
|
+
: typeof opts?.dedup === "number" ? opts.dedup
|
|
189
|
+
: 0;
|
|
190
|
+
this._seenMessages = new BoundedSet<string>(Math.max(this._dedupCapacity, 1));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// -- Classic configuration (sugar for flow) --
|
|
194
|
+
|
|
195
|
+
setClassifier(agentId: string): void {
|
|
196
|
+
this._classifierId = agentId;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
setRouter(agentId: string): void {
|
|
200
|
+
this._routerId = agentId;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
setDefaultExecutor(agentId: string): void {
|
|
204
|
+
this._defaultExecutorId = agentId;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// -- Explicit flow configuration --
|
|
208
|
+
|
|
209
|
+
setFlow(flow: FlowConfig): void {
|
|
210
|
+
this._explicitFlow = flow;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Register invocation mode for an agent (from config). */
|
|
214
|
+
setAgentMode(agentId: string, invocation: string, condition?: InvocationCondition): void {
|
|
215
|
+
this._agentModes.set(agentId, { invocation, condition });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Register activation mode for a layer (from config). */
|
|
219
|
+
setLayerMode(layerId: string, activation: string, condition?: InvocationCondition): void {
|
|
220
|
+
this._layerModes.set(layerId, { activation, condition });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Bulk-load modes from config. */
|
|
224
|
+
loadModes(
|
|
225
|
+
agents: Record<string, AgentModeConfig>,
|
|
226
|
+
layers: Record<string, LayerModeConfig>,
|
|
227
|
+
): void {
|
|
228
|
+
for (const [id, cfg] of Object.entries(agents)) {
|
|
229
|
+
this.setAgentMode(id, cfg.invocation ?? "always", cfg.condition);
|
|
230
|
+
}
|
|
231
|
+
for (const [id, cfg] of Object.entries(layers)) {
|
|
232
|
+
this.setLayerMode(id, cfg.activation ?? "always", cfg.condition);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
addStep(step: PipelineStep): void {
|
|
237
|
+
this._pipeline.push(step);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
removeStep(id: string): boolean {
|
|
241
|
+
const idx = this._pipeline.findIndex((s) => s.id === id);
|
|
242
|
+
if (idx === -1) return false;
|
|
243
|
+
this._pipeline.splice(idx, 1);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// -- Background stage callbacks --
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Subscribe to background stage completions.
|
|
251
|
+
* Returns an unsubscribe function.
|
|
252
|
+
*
|
|
253
|
+
* Background stages (FlowStage.background=true) fire-and-forget —
|
|
254
|
+
* they don't block the pipeline response. Use this to observe their
|
|
255
|
+
* results for logging, signal emission, UI updates, etc.
|
|
256
|
+
*/
|
|
257
|
+
onBackground(handler: BackgroundResultHandler): () => void {
|
|
258
|
+
this._backgroundHandlers.push(handler);
|
|
259
|
+
return () => {
|
|
260
|
+
const idx = this._backgroundHandlers.indexOf(handler);
|
|
261
|
+
if (idx !== -1) this._backgroundHandlers.splice(idx, 1);
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private _emitBackground(result: BackgroundResult): void {
|
|
266
|
+
for (const handler of [...this._backgroundHandlers]) {
|
|
267
|
+
try {
|
|
268
|
+
handler(result);
|
|
269
|
+
} catch {
|
|
270
|
+
// handler errors shouldn't break the background pipeline
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// -- Entry point --
|
|
276
|
+
|
|
277
|
+
async send<T>(
|
|
278
|
+
message: Message<T>,
|
|
279
|
+
opts?: SendOptions,
|
|
280
|
+
): Promise<HarnessResult> {
|
|
281
|
+
// Idempotency: return cached result for duplicate messages
|
|
282
|
+
if (this._dedupCapacity > 0 && this._seenMessages.has(message.id)) {
|
|
283
|
+
const cached = this._resultCache.get(message.id);
|
|
284
|
+
if (cached) return cached;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const flow = this._resolveFlow();
|
|
288
|
+
const result = await this._runFlow(message, flow, opts);
|
|
289
|
+
|
|
290
|
+
// Cache for dedup
|
|
291
|
+
if (this._dedupCapacity > 0) {
|
|
292
|
+
this._seenMessages.add(message.id);
|
|
293
|
+
this._resultCache.set(message.id, result);
|
|
294
|
+
|
|
295
|
+
// Prune cache to match bounded set capacity
|
|
296
|
+
if (this._resultCache.size > this._dedupCapacity) {
|
|
297
|
+
const oldest = this._resultCache.keys().next().value;
|
|
298
|
+
if (oldest) this._resultCache.delete(oldest);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Stream a message — yields text deltas as they arrive from the executor,
|
|
307
|
+
* then a final "done" event with the full HarnessResult. Deltas are
|
|
308
|
+
* gathered from the executor's provider.stream() path (when available);
|
|
309
|
+
* callers should concatenate deltas to reconstruct the text, or just use
|
|
310
|
+
* the done event's result.
|
|
311
|
+
*/
|
|
312
|
+
async *sendStream<T>(
|
|
313
|
+
message: Message<T>,
|
|
314
|
+
opts?: Pick<SendOptions, "onTrace" | "nativeObservation">,
|
|
315
|
+
): AsyncGenerator<
|
|
316
|
+
| { kind: "delta"; text: string }
|
|
317
|
+
| { kind: "done"; result: HarnessResult }
|
|
318
|
+
> {
|
|
319
|
+
// Bounded buffer so deltas emitted while we're yielding don't get lost.
|
|
320
|
+
const pending: string[] = [];
|
|
321
|
+
let resolveWaiter: (() => void) | null = null;
|
|
322
|
+
|
|
323
|
+
const onDelta = (text: string) => {
|
|
324
|
+
pending.push(text);
|
|
325
|
+
if (resolveWaiter) {
|
|
326
|
+
const r = resolveWaiter;
|
|
327
|
+
resolveWaiter = null;
|
|
328
|
+
r();
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
let done = false;
|
|
333
|
+
let finalResult: HarnessResult | undefined;
|
|
334
|
+
let thrown: unknown;
|
|
335
|
+
let failed = false;
|
|
336
|
+
|
|
337
|
+
const runPromise = this.send(message, { ...opts, onDelta })
|
|
338
|
+
.then((r) => { finalResult = r; })
|
|
339
|
+
.catch((e) => { failed = true; thrown = e; })
|
|
340
|
+
.finally(() => {
|
|
341
|
+
done = true;
|
|
342
|
+
if (resolveWaiter) { const r = resolveWaiter; resolveWaiter = null; r(); }
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
while (!done || pending.length > 0) {
|
|
346
|
+
if (pending.length > 0) {
|
|
347
|
+
yield { kind: "delta", text: pending.shift()! };
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (done) break;
|
|
351
|
+
await new Promise<void>((r) => { resolveWaiter = r; });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
await runPromise;
|
|
355
|
+
if (failed) throw thrown;
|
|
356
|
+
if (finalResult) yield { kind: "done", result: finalResult };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Build a layer filter that respects activation modes.
|
|
361
|
+
*
|
|
362
|
+
* - "always" layers: always included
|
|
363
|
+
* - "conditional" layers: included when condition matches
|
|
364
|
+
* - "on-demand" layers: included only if in requestedLayers or route.contextSlice
|
|
365
|
+
*/
|
|
366
|
+
buildLayerFilter(
|
|
367
|
+
classification?: Decision<Classification>,
|
|
368
|
+
route?: Decision<Route>,
|
|
369
|
+
requestedLayers?: ReadonlySet<string>,
|
|
370
|
+
): LayerFilter {
|
|
371
|
+
const routeSlice = route?.value?.contextSlice
|
|
372
|
+
? new Set(route.value.contextSlice)
|
|
373
|
+
: null;
|
|
374
|
+
|
|
375
|
+
return (layer: ContextLayer) => {
|
|
376
|
+
const mode = this._layerModes.get(layer.id);
|
|
377
|
+
const activation = mode?.activation ?? "always";
|
|
378
|
+
|
|
379
|
+
switch (activation) {
|
|
380
|
+
case "always":
|
|
381
|
+
return true;
|
|
382
|
+
|
|
383
|
+
case "conditional":
|
|
384
|
+
return matchesCondition(mode?.condition, classification, route);
|
|
385
|
+
|
|
386
|
+
case "on-demand":
|
|
387
|
+
// Included if router requested it via contextSlice, or middleware requested it
|
|
388
|
+
if (routeSlice?.has(layer.id)) return true;
|
|
389
|
+
if (requestedLayers?.has(layer.id)) return true;
|
|
390
|
+
return false;
|
|
391
|
+
|
|
392
|
+
default:
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// -- Resolve effective flow --
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* If an explicit flow is set, use it. Otherwise build one from classic
|
|
402
|
+
* setClassifier/setRouter/setDefaultExecutor fields + conditional agents
|
|
403
|
+
* from _agentModes.
|
|
404
|
+
*/
|
|
405
|
+
private _resolveFlow(): FlowConfig {
|
|
406
|
+
if (this._explicitFlow) return this._explicitFlow;
|
|
407
|
+
|
|
408
|
+
const stages: FlowStage[] = [];
|
|
409
|
+
|
|
410
|
+
// Classify stage
|
|
411
|
+
if (this._classifierId) {
|
|
412
|
+
stages.push({
|
|
413
|
+
agentId: this._classifierId,
|
|
414
|
+
role: "classify",
|
|
415
|
+
invocation: "always",
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Route stage
|
|
420
|
+
if (this._routerId) {
|
|
421
|
+
stages.push({
|
|
422
|
+
agentId: this._routerId,
|
|
423
|
+
role: "route",
|
|
424
|
+
invocation: "always",
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Conditional agents from _agentModes (enrich role)
|
|
429
|
+
for (const [agentId, mode] of this._agentModes) {
|
|
430
|
+
// Skip classifier/router/executor — they have explicit stages
|
|
431
|
+
if (agentId === this._classifierId) continue;
|
|
432
|
+
if (agentId === this._routerId) continue;
|
|
433
|
+
if (agentId === this._defaultExecutorId) continue;
|
|
434
|
+
|
|
435
|
+
if (mode.invocation === "conditional") {
|
|
436
|
+
stages.push({
|
|
437
|
+
agentId,
|
|
438
|
+
role: "enrich",
|
|
439
|
+
invocation: "conditional",
|
|
440
|
+
condition: mode.condition,
|
|
441
|
+
});
|
|
442
|
+
} else if (mode.invocation === "on-demand") {
|
|
443
|
+
stages.push({
|
|
444
|
+
agentId,
|
|
445
|
+
role: "enrich",
|
|
446
|
+
invocation: "on-demand",
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Execute stage (uses routed destination or default)
|
|
452
|
+
stages.push({
|
|
453
|
+
agentId: "routed",
|
|
454
|
+
role: "execute",
|
|
455
|
+
invocation: "always",
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
stages,
|
|
460
|
+
defaultExecutor: this._defaultExecutorId ?? undefined,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// -- Unified flow pipeline --
|
|
465
|
+
|
|
466
|
+
private async _runFlow<T>(
|
|
467
|
+
message: Message<T>,
|
|
468
|
+
flow: FlowConfig,
|
|
469
|
+
opts?: SendOptions,
|
|
470
|
+
): Promise<HarnessResult> {
|
|
471
|
+
const trace = new Trace(message.id, { input: structuredClone(message.payload), threadId: this.thread.id });
|
|
472
|
+
const invokedAgents: Array<{ id: string; mode: string }> = [];
|
|
473
|
+
|
|
474
|
+
const requestedAgents = new Set<string>();
|
|
475
|
+
const requestedLayers = new Set<string>();
|
|
476
|
+
const stageResults = new Map<string, ExecutionResult>();
|
|
477
|
+
const data = new Map<string, unknown>();
|
|
478
|
+
|
|
479
|
+
let classification: Decision<Classification> | undefined;
|
|
480
|
+
let route: Decision<Route> | undefined;
|
|
481
|
+
let lastExecuteAgentId: string | undefined;
|
|
482
|
+
|
|
483
|
+
// Build request context for middleware
|
|
484
|
+
const reqCtx: RequestContext = {
|
|
485
|
+
get classification() { return classification; },
|
|
486
|
+
get route() { return route; },
|
|
487
|
+
requestAgent: (id) => requestedAgents.add(id),
|
|
488
|
+
requestLayer: (id) => requestedLayers.add(id),
|
|
489
|
+
get requestedAgents() { return requestedAgents; },
|
|
490
|
+
get requestedLayers() { return requestedLayers; },
|
|
491
|
+
get stageResults() { return stageResults; },
|
|
492
|
+
data,
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
// Run each stage in order
|
|
497
|
+
for (const stage of flow.stages) {
|
|
498
|
+
// Should this stage run?
|
|
499
|
+
if (stage.invocation === "on-demand") {
|
|
500
|
+
const resolvedId = stage.agentId === "routed"
|
|
501
|
+
? route?.value?.destination
|
|
502
|
+
: stage.agentId;
|
|
503
|
+
if (!resolvedId || !requestedAgents.has(resolvedId)) continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (stage.invocation === "conditional") {
|
|
507
|
+
if (!matchesCondition(stage.condition, classification, route)) continue;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Resolve agent ID
|
|
511
|
+
const agentId = stage.agentId === "routed"
|
|
512
|
+
? (route?.value?.destination ?? flow.defaultExecutor)
|
|
513
|
+
: stage.agentId;
|
|
514
|
+
|
|
515
|
+
if (!agentId) {
|
|
516
|
+
if (stage.role === "execute") {
|
|
517
|
+
throw new Error(
|
|
518
|
+
"No target agent: router returned no destination and no default executor is configured",
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (!this.thread.getAgent(agentId)) {
|
|
524
|
+
if (stage.role === "execute") throw new Error(`Execution target "${agentId}" is not registered`);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Build layer filter for this dispatch
|
|
529
|
+
const layerFilter = this.buildLayerFilter(classification, route, requestedLayers);
|
|
530
|
+
|
|
531
|
+
// Background stages — fire-and-forget, report via onBackground callback
|
|
532
|
+
if (stage.background) {
|
|
533
|
+
const bgStart = performance.now();
|
|
534
|
+
const msgId = message.id;
|
|
535
|
+
const bgStage = stage;
|
|
536
|
+
const { promise } = this.thread.dispatchBackground(agentId, message.payload, layerFilter, {
|
|
537
|
+
role: stage.role,
|
|
538
|
+
messageId: message.id,
|
|
539
|
+
invocation: "background",
|
|
540
|
+
});
|
|
541
|
+
invokedAgents.push({ id: agentId, mode: "background" });
|
|
542
|
+
|
|
543
|
+
// Wire callback — runs when the background dispatch completes
|
|
544
|
+
promise.then((result) => {
|
|
545
|
+
this._emitBackground({
|
|
546
|
+
messageId: msgId,
|
|
547
|
+
agentId: agentId!,
|
|
548
|
+
stage: bgStage,
|
|
549
|
+
result,
|
|
550
|
+
durationMs: performance.now() - bgStart,
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
continue; // don't block the pipeline
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Trace and dispatch (foreground — blocks pipeline)
|
|
558
|
+
const stageSpan = trace.start(`${stage.role}:${agentId}`, stage.role as any, {
|
|
559
|
+
agentId,
|
|
560
|
+
threadId: this.thread.id,
|
|
561
|
+
input: message.payload,
|
|
562
|
+
annotations: { invocation: stage.invocation },
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// Route stage receives classification context; others get raw payload
|
|
566
|
+
const stagePayload = stage.role === "route" && classification
|
|
567
|
+
? { payload: message.payload, classification: classification.value }
|
|
568
|
+
: message.payload;
|
|
569
|
+
|
|
570
|
+
// The thread observes this dispatch once at its boundary; the stage
|
|
571
|
+
// only contributes correlation. Only the execute stage streams deltas:
|
|
572
|
+
// classifier/router/enricher output isn't user-visible content.
|
|
573
|
+
const result = await this.thread.dispatch(agentId, stagePayload, layerFilter, {
|
|
574
|
+
onDelta: stage.role === "execute" ? opts?.onDelta : undefined,
|
|
575
|
+
role: stage.role,
|
|
576
|
+
messageId: message.id,
|
|
577
|
+
invocation: stage.invocation,
|
|
578
|
+
recordInjection: artifact => { stageSpan.annotations.injection = artifact; },
|
|
579
|
+
nativeObservation: opts?.nativeObservation,
|
|
580
|
+
recordNative: evidence => { stageSpan.annotations.native = evidence; },
|
|
581
|
+
recordCompleted: output => { stageSpan.annotations.executorCompletion = { output }; },
|
|
582
|
+
});
|
|
583
|
+
invokedAgents.push({ id: agentId, mode: stage.invocation });
|
|
584
|
+
stageResults.set(agentId, result);
|
|
585
|
+
|
|
586
|
+
// Track the last execute-role agent for final result extraction
|
|
587
|
+
if (stage.role === "execute") {
|
|
588
|
+
lastExecuteAgentId = agentId;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Capture classification/route from appropriate stages
|
|
592
|
+
if (stage.role === "classify") {
|
|
593
|
+
classification = result.output as Decision<Classification>;
|
|
594
|
+
if (classification?.value) {
|
|
595
|
+
await this.thread.signals.emit({
|
|
596
|
+
id: newId("sig-classify"),
|
|
597
|
+
kind: "classification",
|
|
598
|
+
source: `harness:${agentId}`,
|
|
599
|
+
content: classification.value,
|
|
600
|
+
confidence: classification.confidence,
|
|
601
|
+
timestamp: Date.now(),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
} else if (stage.role === "route") {
|
|
605
|
+
route = result.output as Decision<Route>;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (trace.current && result.meta?.injection) {
|
|
609
|
+
trace.current.annotations.injection = result.meta.injection;
|
|
610
|
+
}
|
|
611
|
+
trace.end(result.output);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Run any remaining on-demand agents requested by middleware
|
|
615
|
+
for (const agentId of requestedAgents) {
|
|
616
|
+
const alreadyRan = invokedAgents.some((a) => a.id === agentId);
|
|
617
|
+
if (alreadyRan) continue;
|
|
618
|
+
if (!this.thread.getAgent(agentId)) continue;
|
|
619
|
+
|
|
620
|
+
const layerFilter = this.buildLayerFilter(classification, route, requestedLayers);
|
|
621
|
+
trace.start(`on-demand:${agentId}`, "enrich", { agentId });
|
|
622
|
+
const result = await this.thread.dispatch(agentId, message.payload, layerFilter, {
|
|
623
|
+
role: "enrich",
|
|
624
|
+
messageId: message.id,
|
|
625
|
+
invocation: "on-demand",
|
|
626
|
+
});
|
|
627
|
+
invokedAgents.push({ id: agentId, mode: "on-demand" });
|
|
628
|
+
stageResults.set(agentId, result);
|
|
629
|
+
trace.end(result.output);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
trace.finish();
|
|
633
|
+
this._recordTrace(trace, opts?.onTrace);
|
|
634
|
+
|
|
635
|
+
// Final result: from last execute-role agent, or last invoked agent
|
|
636
|
+
const finalAgentId = lastExecuteAgentId
|
|
637
|
+
?? invokedAgents[invokedAgents.length - 1]?.id;
|
|
638
|
+
const finalResult = (finalAgentId ? stageResults.get(finalAgentId) : undefined)
|
|
639
|
+
?? { output: null, contextHash: "" } as ExecutionResult;
|
|
640
|
+
|
|
641
|
+
const layerFilter = this.buildLayerFilter(classification, route, requestedLayers);
|
|
642
|
+
const activeLayers = this.thread.stack.layers.filter(layerFilter).map((l) => l.id);
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
messageId: message.id,
|
|
646
|
+
classification,
|
|
647
|
+
route,
|
|
648
|
+
result: finalResult,
|
|
649
|
+
trace,
|
|
650
|
+
timestamp: Date.now(),
|
|
651
|
+
invokedAgents,
|
|
652
|
+
activeLayers,
|
|
653
|
+
};
|
|
654
|
+
} catch (err) {
|
|
655
|
+
const error = err instanceof Error ? { name: err.name, message: err.message } : { message: String(err) };
|
|
656
|
+
while (trace.current) trace.end(undefined, error);
|
|
657
|
+
trace.finish();
|
|
658
|
+
this._recordTrace(trace, opts?.onTrace);
|
|
659
|
+
throw err;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// -- Direct dispatch (bypass pipeline) --
|
|
664
|
+
|
|
665
|
+
async dispatch<T>(
|
|
666
|
+
agentId: string,
|
|
667
|
+
payload: T,
|
|
668
|
+
filterOverride?: LayerFilter,
|
|
669
|
+
): Promise<ExecutionResult> {
|
|
670
|
+
return this.thread.dispatch(agentId, payload, filterOverride);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async fan<T>(
|
|
674
|
+
agentIds: string[],
|
|
675
|
+
payload: T,
|
|
676
|
+
filterOverride?: LayerFilter,
|
|
677
|
+
): Promise<FanResult[]> {
|
|
678
|
+
return this.thread.fan(agentIds, payload, filterOverride);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Explicitly invoke an on-demand agent (for use by middleware or external callers). */
|
|
682
|
+
async invokeOnDemand<T>(
|
|
683
|
+
agentId: string,
|
|
684
|
+
payload: T,
|
|
685
|
+
classification?: Decision<Classification>,
|
|
686
|
+
route?: Decision<Route>,
|
|
687
|
+
): Promise<ExecutionResult> {
|
|
688
|
+
const layerFilter = this.buildLayerFilter(classification, route);
|
|
689
|
+
return this.thread.dispatch(agentId, payload, layerFilter, { role: "enrich", invocation: "on-demand" });
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async runPipeline<T>(input: T, trace?: Trace): Promise<unknown> {
|
|
693
|
+
let current: unknown = input;
|
|
694
|
+
const t = trace ?? new Trace("pipeline");
|
|
695
|
+
|
|
696
|
+
for (const step of this._pipeline) {
|
|
697
|
+
t.start(step.id, "middleware", { input: current });
|
|
698
|
+
current = await step.run(current, this, t);
|
|
699
|
+
t.end(current);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (!trace) t.finish();
|
|
703
|
+
return current;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// -- Flow inspection --
|
|
707
|
+
|
|
708
|
+
get flow(): FlowConfig | null {
|
|
709
|
+
return this._explicitFlow;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Get the effective flow (explicit or built from classic settings). */
|
|
713
|
+
get effectiveFlow(): FlowConfig {
|
|
714
|
+
return this._resolveFlow();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
get agentModes(): ReadonlyMap<string, { invocation: string; condition?: InvocationCondition }> {
|
|
718
|
+
return this._agentModes;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
get layerModes(): ReadonlyMap<string, { activation: string; condition?: InvocationCondition }> {
|
|
722
|
+
return this._layerModes;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// -- Trace history --
|
|
726
|
+
|
|
727
|
+
get traces(): ReadonlyArray<Trace> {
|
|
728
|
+
return this._traces;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
getTrace(traceId: string): Trace | undefined {
|
|
732
|
+
return this._traces.find((t) => t.id === traceId);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
getTraceForMessage(messageId: string): Trace | undefined {
|
|
736
|
+
return this._traces.find((t) => t.messageId === messageId);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// -- Internal --
|
|
740
|
+
|
|
741
|
+
private _recordTrace(trace: Trace, onTrace?: (trace: Trace) => void): void {
|
|
742
|
+
this._traces.push(trace);
|
|
743
|
+
if (this._traces.length > this._maxTraces) {
|
|
744
|
+
this._traces.shift();
|
|
745
|
+
}
|
|
746
|
+
try { onTrace?.(trace); }
|
|
747
|
+
catch (error) { console.warn("[Harness] trace observer failed:", error); }
|
|
748
|
+
}
|
|
749
|
+
}
|