@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.
@@ -0,0 +1,223 @@
1
+ import { ContextLayer, computeHash, type LayerState } from "./context-layer";
2
+ import type { OwnershipScope } from "./scope";
3
+
4
+ export type LayerFilter = (layer: ContextLayer) => boolean;
5
+
6
+ /**
7
+ * Read-only view of a ContextStack for middleware introspection.
8
+ *
9
+ * Middleware needs to know what a thread already knows — which layers
10
+ * are present, what they contain, and whether they're warm — without
11
+ * being able to mutate the stack. This interface provides exactly that.
12
+ */
13
+ export interface ContextStackView {
14
+ /** Check if a specific layer exists and is warm. */
15
+ hasLayer(id: string): boolean;
16
+ /** Get a layer's current content (returns empty string if not warm). */
17
+ getContent(id: string): string;
18
+ /** Get a layer's current state. */
19
+ getState(id: string): LayerState | undefined;
20
+ /** List all layer IDs. */
21
+ readonly layerIds: readonly string[];
22
+ /** Estimated total tokens across all warm layers. */
23
+ readonly estimatedTokens: number;
24
+ }
25
+
26
+ /** A single block in the assembled prompt output. */
27
+ export interface PromptBlock {
28
+ readonly role: "system" | "layer" | "content";
29
+ readonly id?: string;
30
+ readonly text: string;
31
+ /**
32
+ * Explicit injection segment (instructions, domain-knowledge,
33
+ * thread-knowledge, routing, guard-findings). Decorated blocks declare it;
34
+ * plain layer blocks leave it unset and are classified by the artifact builder.
35
+ */
36
+ readonly segment?: string;
37
+ /** Who produced this block (a domain, the router, a layer id). */
38
+ readonly source?: string;
39
+ }
40
+
41
+ /** Structured prompt assembled from agent prompt + layer prompts + layer content. */
42
+ export interface AssembledContext {
43
+ readonly blocks: PromptBlock[];
44
+ /** Convenience: all blocks joined into a single string. */
45
+ readonly text: string;
46
+ }
47
+
48
+ export interface ContextSnapshot {
49
+ readonly hash: string;
50
+ readonly content: string;
51
+ readonly layerHashes: Record<string, string>;
52
+ readonly timestamp: number;
53
+ }
54
+
55
+ export type LayerAddedCallback = (layer: ContextLayer) => void;
56
+
57
+ export class ContextStack {
58
+ private _layers: ContextLayer[] = [];
59
+ private _onLayerAdded: LayerAddedCallback[] = [];
60
+
61
+ constructor(layers?: ContextLayer[]) {
62
+ if (layers) this._layers = [...layers];
63
+ }
64
+
65
+ // -- Layer management --
66
+
67
+ get layers(): ReadonlyArray<ContextLayer> {
68
+ return this._layers;
69
+ }
70
+
71
+ /**
72
+ * New stack whose layers are independent clones of this stack's layers, in
73
+ * order. A scope binds every scope-aware source to the owning thread/project.
74
+ */
75
+ clone(scope?: OwnershipScope): ContextStack {
76
+ return new ContextStack(this._layers.map((layer) => layer.clone(scope)));
77
+ }
78
+
79
+ /** Register a callback for when layers are added (used by CacheLifecycle). */
80
+ onLayerAdded(cb: LayerAddedCallback): () => void {
81
+ this._onLayerAdded.push(cb);
82
+ return () => {
83
+ const idx = this._onLayerAdded.indexOf(cb);
84
+ if (idx !== -1) this._onLayerAdded.splice(idx, 1);
85
+ };
86
+ }
87
+
88
+ addLayer(layer: ContextLayer, position?: number): void {
89
+ if (position !== undefined) {
90
+ this._layers.splice(position, 0, layer);
91
+ } else {
92
+ this._layers.push(layer);
93
+ }
94
+ for (const cb of this._onLayerAdded) cb(layer);
95
+ }
96
+
97
+ removeLayer(id: string): boolean {
98
+ const idx = this._layers.findIndex((l) => l.id === id);
99
+ if (idx === -1) return false;
100
+ this._layers.splice(idx, 1);
101
+ return true;
102
+ }
103
+
104
+ getLayer(id: string): ContextLayer | undefined {
105
+ return this._layers.find((l) => l.id === id);
106
+ }
107
+
108
+ reorder(ids: string[]): void {
109
+ const layerMap = new Map(this._layers.map((l) => [l.id, l]));
110
+ const ordered: ContextLayer[] = [];
111
+ const seen = new Set<string>();
112
+
113
+ for (const id of ids) {
114
+ const layer = layerMap.get(id);
115
+ if (layer) {
116
+ ordered.push(layer);
117
+ seen.add(id);
118
+ }
119
+ }
120
+ for (const layer of this._layers) {
121
+ if (!seen.has(layer.id)) ordered.push(layer);
122
+ }
123
+ this._layers = ordered;
124
+ }
125
+
126
+ // -- Warming --
127
+
128
+ async warmAll(): Promise<void> {
129
+ // Check staleness before deciding what to warm
130
+ for (const l of this._layers) l.checkStaleness();
131
+ await Promise.all(
132
+ this._layers.filter((l) => !l.isWarm).map((l) => l.warm())
133
+ );
134
+ }
135
+
136
+ async refresh(): Promise<void> {
137
+ for (const l of this._layers) l.checkStaleness();
138
+ await Promise.all(
139
+ this._layers.filter((l) => l.isStale).map((l) => l.warm())
140
+ );
141
+ }
142
+
143
+ // -- Merging --
144
+
145
+ merge(filter?: LayerFilter): string {
146
+ const layers = filter ? this._layers.filter(filter) : this._layers;
147
+ return layers
148
+ .filter((l) => l.isWarm && l.content.length > 0)
149
+ .map((l) => l.content)
150
+ .join("\n\n");
151
+ }
152
+
153
+ slice(filter: LayerFilter): string {
154
+ return this.merge(filter);
155
+ }
156
+
157
+ sliceByIds(...ids: string[]): string {
158
+ const idSet = new Set(ids);
159
+ return this.merge((l) => idSet.has(l.id));
160
+ }
161
+
162
+ /**
163
+ * Assemble structured prompt blocks from an optional agent system prompt,
164
+ * layer prompts, and layer content. Each layer with a prompt gets a
165
+ * prompt block followed by its content block. Layers without prompts
166
+ * get just a content block.
167
+ */
168
+ assemble(agentPrompt?: string, filter?: LayerFilter): AssembledContext {
169
+ const blocks: PromptBlock[] = [];
170
+
171
+ if (agentPrompt) {
172
+ blocks.push({ role: "system", text: agentPrompt });
173
+ }
174
+
175
+ const layers = filter ? this._layers.filter(filter) : this._layers;
176
+
177
+ for (const layer of layers) {
178
+ if (!layer.isWarm || layer.content.length === 0) continue;
179
+
180
+ if (layer.prompt) {
181
+ // A layer prompt is always an instruction; it never inherits the content segment.
182
+ blocks.push({ role: "layer", id: layer.id, text: layer.prompt });
183
+ }
184
+ // The content block carries the layer's declared segment when it has one; legacy
185
+ // layers leave it unset and the artifact builder falls back to its id heuristic.
186
+ blocks.push({ role: "content", id: layer.id, text: layer.content, ...(layer.segment ? { segment: layer.segment } : {}) });
187
+ }
188
+
189
+ const text = blocks.map((b) => b.text).join("\n\n");
190
+ return { blocks, text };
191
+ }
192
+
193
+ // -- Snapshots --
194
+
195
+ snapshot(): ContextSnapshot {
196
+ const content = this.merge();
197
+ const layerHashes: Record<string, string> = {};
198
+ for (const layer of this._layers) {
199
+ if (layer.isWarm) {
200
+ layerHashes[layer.id] = layer.hash;
201
+ }
202
+ }
203
+
204
+ return {
205
+ hash: computeHash(content),
206
+ content,
207
+ layerHashes,
208
+ timestamp: Date.now(),
209
+ };
210
+ }
211
+
212
+ // -- Utilities --
213
+
214
+ estimateTokens(): number {
215
+ return Math.ceil(this.merge().length / 4);
216
+ }
217
+
218
+ invalidateAll(): void {
219
+ for (const layer of this._layers) {
220
+ layer.invalidate();
221
+ }
222
+ }
223
+ }
@@ -0,0 +1,14 @@
1
+ /** Credential references belong in configuration; secrets stay behind the resolver. */
2
+ export type CredentialReference = { type: 'managed'; id: string } | { type: 'kingdom-runtime' };
3
+
4
+ export interface CredentialScope {
5
+ service: string;
6
+ url: string;
7
+ projectId: string;
8
+ kastleId?: string;
9
+ }
10
+
11
+ export interface CredentialResolver {
12
+ /** Resolve afresh for each request. Reject a reference outside its permitted scope. */
13
+ resolve(reference: CredentialReference, scope: CredentialScope): Promise<string>;
14
+ }
package/src/decider.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { computeHash } from "./context-layer";
2
+ import { BaseAgent, type AgentConfig, type ExecutionResult } from "./base-agent";
3
+ import type { ContextStack, LayerFilter } from "./context-stack";
4
+ import type { ExecuteMeta } from "./executor";
5
+
6
+ export interface Decision<T = unknown> {
7
+ readonly value: T;
8
+ readonly confidence?: number;
9
+ readonly reasoning?: string;
10
+ }
11
+
12
+ /**
13
+ * Decision handlers receive the same dispatch metadata as executors (thread
14
+ * id, cwd, annotations) so session-aware providers can give each thread's
15
+ * auxiliary decisions their own identity instead of a shared default session.
16
+ */
17
+ export type DecideHandler<TPayload, TDecision> = (
18
+ context: string,
19
+ payload: TPayload,
20
+ meta?: ExecuteMeta
21
+ ) => Promise<Decision<TDecision>>;
22
+
23
+ export interface DeciderConfig<TPayload = unknown, TDecision = unknown>
24
+ extends AgentConfig {
25
+ handler: DecideHandler<TPayload, TDecision>;
26
+ }
27
+
28
+ /**
29
+ * A Decider takes context + payload, makes a decision, and returns
30
+ * ONLY the decision — not the context it used to make it.
31
+ *
32
+ * This is the "trusted authority" pattern. The Decider has rich context
33
+ * (docs, memory, taxonomy, whatever) but the caller just gets back a
34
+ * slim decision. The caller trusts the Decider because it had the context.
35
+ */
36
+ export class Decider<TPayload = unknown, TDecision = unknown> extends BaseAgent<
37
+ TPayload,
38
+ Decision<TDecision>
39
+ > {
40
+ protected _handler: DecideHandler<TPayload, TDecision>;
41
+
42
+ constructor(config: DeciderConfig<TPayload, TDecision>) {
43
+ super(config);
44
+ this._handler = config.handler;
45
+ }
46
+
47
+ withStack(stack: ContextStack): Decider<TPayload, TDecision> {
48
+ return new Decider<TPayload, TDecision>({ ...this.agentConfig(stack), handler: this._handler });
49
+ }
50
+
51
+ async run(
52
+ payload: TPayload,
53
+ filterOverride?: LayerFilter,
54
+ meta?: ExecuteMeta
55
+ ): Promise<ExecutionResult<Decision<TDecision>>> {
56
+ const context = this.getContextWith(filterOverride);
57
+ const contextHash = computeHash(context);
58
+ const decision = await this._handler(context, payload, meta);
59
+
60
+ return { output: decision, contextHash };
61
+ }
62
+ }
@@ -0,0 +1,85 @@
1
+ /** Portable SHA-256 for public evidence, shared by Bun and the historical browser.
2
+ * This is an integrity join, not a signature or native delivery acknowledgment. */
3
+ export function evidenceDigest(text: string): string {
4
+ const input = new TextEncoder().encode(text), bytes = new Uint8Array(Math.ceil((input.length + 9) / 64) * 64);
5
+ bytes.set(input); bytes[input.length] = 128;
6
+ const view = new DataView(bytes.buffer), bits = input.length * 8;
7
+ view.setUint32(bytes.length - 8, Math.floor(bits / 4294967296)); view.setUint32(bytes.length - 4, bits >>> 0);
8
+ const h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
9
+ const k = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
10
+ const rr = (v: number, n: number) => (v >>> n) | (v << (32 - n)), w = new Uint32Array(64);
11
+ for (let offset = 0; offset < bytes.length; offset += 64) {
12
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + 4 * i);
13
+ for (let i = 16; i < 64; i++) { const x=w[i-15]!, y=w[i-2]!; w[i]=w[i-16]!+(rr(x,7)^rr(x,18)^(x>>>3))+w[i-7]!+(rr(y,17)^rr(y,19)^(y>>>10)); }
14
+ let [a,b,c,d,e,f,g,j] = h as [number,number,number,number,number,number,number,number];
15
+ for (let i=0;i<64;i++) { const t=(j+(rr(e,6)^rr(e,11)^rr(e,25))+((e&f)^(~e&g))+k[i]!+w[i]!)|0;
16
+ const u=((rr(a,2)^rr(a,13)^rr(a,22))+((a&b)^(a&c)^(b&c)))|0; j=g;g=f;f=e;e=(d+t)|0;d=c;c=b;b=a;a=(t+u)|0; }
17
+ [a,b,c,d,e,f,g,j].forEach((v,i)=>{h[i]=(h[i]!+v)>>>0;});
18
+ }
19
+ return h.map(v=>v.toString(16).padStart(8,"0")).join("");
20
+ }
21
+
22
+ export interface DeliveryOwner { threadId: string; messageId: string; projectId?: string }
23
+ export interface ProviderBoundaryReceipt {
24
+ schema: 1; owner: DeliveryOwner; assemblyHash: string; messagesHash: string;
25
+ /** Exactly one initial system message starts with the assembled context. */
26
+ systemMessageIndex: number;
27
+ }
28
+ // Fixed top-level field order over the preserved serialization, never the current cache.
29
+ export function assemblyDigest(i: any): string {
30
+ return evidenceDigest(JSON.stringify([i.threadId,i.projectId ?? null,i.messageId,i.executorContext,i.userMessage,
31
+ i.layers,i.blocks,i.decoration]));
32
+ }
33
+ export function boundaryReceipt(i: any, owner: DeliveryOwner): ProviderBoundaryReceipt {
34
+ const matches = i.providerMessages.flatMap((m: any, n: number) => m.role === "system" && typeof m.content === "string"
35
+ && typeof i.executorContext === "string" && i.executorContext.length > 0
36
+ && (m.content === i.executorContext || m.content.startsWith(i.executorContext + "\n\n")) ? [n] : []);
37
+ return { schema: 1, owner: { ...owner }, assemblyHash: assemblyDigest(i),
38
+ messagesHash: evidenceDigest(JSON.stringify(i.providerMessages)), systemMessageIndex: matches.length === 1 ? matches[0] : -1 };
39
+ }
40
+ export interface ExpertDeliveryProof {
41
+ domain: string; layerId: string; assessedRevision: number; deliveredRevision: number;
42
+ deliveredHash: string; relation: "assessed" | "advanced"; content: string;
43
+ }
44
+ /** One validation contract used by inspection, the runner and the copy-only audit. */
45
+ export function verifyExpertDelivery(i: any, delivery: any, domain: string, expected: DeliveryOwner): ExpertDeliveryProof {
46
+ const fail = () => { throw Error("Recorded expert delivery invalid"); };
47
+ const one = (rows: any, predicate: (row: any) => boolean): any => {
48
+ if (!Array.isArray(rows)) return fail(); const found = rows.filter(predicate); return found.length === 1 ? found[0] : fail();
49
+ };
50
+ const owner = (x: any) => x?.threadId === expected.threadId && x?.projectId === expected.projectId;
51
+ const receipt = i?.providerBoundary;
52
+ if (!expected.threadId || !expected.messageId || !owner(i) || i.messageId !== expected.messageId
53
+ || !owner(i.decoration?.input?.currentMessage) || i.decoration.input.currentMessage.messageId !== expected.messageId
54
+ || receipt?.schema !== 1 || !owner(receipt.owner) || receipt.owner.messageId !== expected.messageId
55
+ || receipt.assemblyHash !== assemblyDigest(i) || !Array.isArray(i.providerMessages)
56
+ || receipt.messagesHash !== evidenceDigest(JSON.stringify(i.providerMessages))) fail();
57
+ const system = i.providerMessages[receipt.systemMessageIndex];
58
+ if (!Number.isSafeInteger(receipt.systemMessageIndex) || system?.role !== "system" || typeof system.content !== "string"
59
+ || !i.executorContext || !(system.content === i.executorContext || system.content.startsWith(i.executorContext + "\n\n"))) fail();
60
+ const p = one(i.decoration?.participants, p => p?.id === domain), id = `thread-knowledge:${domain}`;
61
+ const e = one(delivery?.layers, e => e?.id === id), l = one(i.layers, l => l?.id === id);
62
+ if (new Set(delivery.layers.map((row: any) => row?.id)).size !== delivery.layers.length) fail();
63
+ for (const row of delivery.layers.filter((x: any) => x?.domain === domain && x.id !== id)) {
64
+ const cache = one(i.layers, l => l?.id === row.id);
65
+ if (!owner(cache) || !owner(row) || cache.included !== true || row.assessedRevision !== undefined
66
+ || row.relation !== undefined || row.assessedHash !== p.provenance?.cacheHash
67
+ || row.deliveredHash !== cache.hash || row.drift !== (row.assessedHash !== row.deliveredHash)) fail();
68
+ }
69
+ if (delivery.layers.filter((e: any) => e?.domain === domain && e?.assessedRevision !== undefined).length !== 1) fail();
70
+ const v=l.version, ar=p.provenance?.threadKnowledgeRevision, ah=p.provenance?.threadKnowledgeHash, r=e.deliveredRevision;
71
+ if (!owner(l) || l.included !== true || l.segment !== "thread-knowledge" || l.definitionId !== id
72
+ || !owner(v) || v?.domain !== domain || e.domain !== domain || !owner(e)
73
+ || !Number.isSafeInteger(ar) || ar < 0 || !Number.isSafeInteger(r) || r < 0
74
+ || e.assessedRevision !== ar || e.assessedHash !== ah || typeof ah !== "string"
75
+ || typeof p.segments?.threadKnowledge !== "string" || typeof l.content !== "string"
76
+ || e.deliveredHash !== l.hash || v.hash !== l.hash || v.revision !== r
77
+ || e.drift !== (e.assessedHash !== e.deliveredHash)
78
+ || (e.relation === "assessed" ? r !== ar || ah !== l.hash : e.relation === "advanced" ? r <= ar : true)) fail();
79
+ const b = one(i.blocks, b => b?.source === id && b.kind === "thread-knowledge");
80
+ if (b.text !== l.content || b.hash !== l.hash || b.id !== `thread-knowledge:${id}:${l.hash}`) fail();
81
+ // Intended assembled block, not a matching quotation in the user message.
82
+ const body = i.blocks.map((b: any) => b.text).join("\n\n");
83
+ if (body !== i.executorContext) fail();
84
+ return { domain, layerId:id, assessedRevision:ar, deliveredRevision:r, deliveredHash:l.hash, relation:e.relation, content:l.content };
85
+ }
@@ -0,0 +1,105 @@
1
+ import type { LifecycleEvent } from "./cache-lifecycle";
2
+ import type { Dispatch } from "./thread";
3
+ import type { Signal } from "./signal";
4
+ import type { DispatchContext } from "./middleware";
5
+ import type { ActionPrompt } from "./action-prompt";
6
+
7
+ /** Session lifecycle events (minimal type for event stream). */
8
+ export interface SessionEvent {
9
+ readonly type: "thread:added" | "thread:removed" | "thread:spawned";
10
+ readonly threadId: string;
11
+ readonly parentId?: string;
12
+ readonly timestamp: number;
13
+ }
14
+
15
+ /**
16
+ * Every observable event in the system, tagged by origin.
17
+ */
18
+ export type StreamEvent =
19
+ | { kind: "journal"; threadId: string; turnId: string | null; projectId?: string; timestamp: number; scope?: "phase" | "learning"; outcome?: "durable" | "failed" }
20
+ | { kind: "layer"; threadId: string; event: LifecycleEvent }
21
+ | { kind: "dispatch"; threadId: string; dispatch: Dispatch }
22
+ | { kind: "signal"; threadId: string; signal: Signal }
23
+ | { kind: "prompt"; threadId: string; prompt: ActionPrompt }
24
+ | { kind: "session"; event: SessionEvent }
25
+ | { kind: "middleware"; threadId: string; phase: "before" | "after"; context: DispatchContext }
26
+ | { kind: "error"; source: string; message: string; severity: "error" | "warn"; timestamp: number };
27
+
28
+ /**
29
+ * Unified event stream across all threads and sessions.
30
+ *
31
+ * The UI subscribes to this — it's the single source of truth for
32
+ * what's happening in the system. Every layer state change, every dispatch,
33
+ * every signal, every thread spawn shows up here in order.
34
+ *
35
+ * Supports both push (subscribe) and pull (recent history) access patterns.
36
+ */
37
+ export class EventStream {
38
+ private _listeners: Array<(event: StreamEvent) => void> = [];
39
+ private _history: StreamEvent[] = [];
40
+ private _maxHistory: number;
41
+
42
+ constructor(maxHistory: number = 5000) {
43
+ this._maxHistory = maxHistory;
44
+ }
45
+
46
+ /** Push an event into the stream. */
47
+ push(event: StreamEvent): void {
48
+ this._history.push(event);
49
+ if (this._history.length > this._maxHistory) {
50
+ this._history.shift();
51
+ }
52
+
53
+ // Snapshot to avoid mutation during iteration (listener may unsubscribe)
54
+ const listeners = [...this._listeners];
55
+ for (const listener of listeners) {
56
+ try {
57
+ listener(event);
58
+ } catch (err) {
59
+ console.warn("[EventStream] listener error:", (err as Error).message ?? err);
60
+ }
61
+ }
62
+ }
63
+
64
+ /** Subscribe to all events. Returns unsubscribe function. */
65
+ subscribe(listener: (event: StreamEvent) => void): () => void {
66
+ this._listeners.push(listener);
67
+ return () => {
68
+ const idx = this._listeners.indexOf(listener);
69
+ if (idx !== -1) this._listeners.splice(idx, 1);
70
+ };
71
+ }
72
+
73
+ /** Get recent events, optionally filtered by kind and/or thread. */
74
+ recent(opts?: {
75
+ kind?: StreamEvent["kind"];
76
+ threadId?: string;
77
+ limit?: number;
78
+ }): ReadonlyArray<StreamEvent> {
79
+ let events: StreamEvent[] = this._history;
80
+
81
+ if (opts?.kind) {
82
+ events = events.filter((e) => e.kind === opts.kind);
83
+ }
84
+
85
+ if (opts?.threadId) {
86
+ events = events.filter((e) => {
87
+ if (e.kind === "session") return true; // session events are global
88
+ return "threadId" in e && e.threadId === opts.threadId;
89
+ });
90
+ }
91
+
92
+ const limit = opts?.limit ?? 100;
93
+ return events.slice(-limit);
94
+ }
95
+
96
+ /** Push an error event — surfaced as a toast in the viewer UI. */
97
+ pushError(source: string, message: string, severity: "error" | "warn" = "error"): void {
98
+ this.push({ kind: "error", source, message, severity, timestamp: Date.now() });
99
+ }
100
+
101
+ /** Clear history. */
102
+ clear(): void {
103
+ this._history = [];
104
+ }
105
+ }
@@ -0,0 +1,185 @@
1
+ import { computeHash, REQUIRED_CONTEXT_BLOCKED } from "./context-layer";
2
+ import { BaseAgent, type AgentConfig, type ExecutionResult } from "./base-agent";
3
+ import type { AssembledContext, ContextStack, LayerFilter } from "./context-stack";
4
+ import { buildInjectionArtifact, type InjectionArtifact, type MessageDecoration } from "./messages";
5
+ import type { LLMMessage } from "./types";
6
+ import type { ToolCallObservation } from "./tools";
7
+ import type { NativeEvidence, NativeObservation } from "./native-evidence";
8
+
9
+ /** Runtime metadata passed to executor handlers at dispatch time. */
10
+ export interface ExecuteMeta {
11
+ nativeObservation?: NativeObservation;
12
+ recordNative?: (evidence: NativeEvidence) => void;
13
+ recordCompleted?: (output: unknown) => void;
14
+ /** Working directory for this dispatch (from the thread's worktree). */
15
+ cwd?: string;
16
+ /** Thread ID that dispatched this execution. */
17
+ threadId?: string;
18
+ /** Project that owns the dispatching thread. Scopes memory tool reads. */
19
+ projectId?: string;
20
+ /** Logical message identity, not a native session or dispatch ID. */
21
+ messageId?: string;
22
+ /** Identity of this dispatch. Every tool the handler executes is attributed to it. */
23
+ dispatchId?: string;
24
+ /**
25
+ * Report one executed tool call. The thread turns it into a
26
+ * `tool_observation` signal carrying the dispatch identity, so evidence
27
+ * is correlated to this work and never to whichever turn finishes next.
28
+ */
29
+ observeTool?: (observation: ToolCallObservation) => void;
30
+ /** Arbitrary annotations from middleware. */
31
+ annotations?: Record<string, unknown>;
32
+ /**
33
+ * Streaming sink — when provided, the executor should emit text deltas
34
+ * here as they arrive. Accumulates upstream into a StreamBuffer.
35
+ * Absent ⇒ non-streaming (complete → full result) semantics.
36
+ */
37
+ onDelta?: (text: string) => void;
38
+ /** Record the initial messages at the provider boundary for inspection. */
39
+ recordProviderInput?: (messages: LLMMessage[]) => void;
40
+ /** Capture prepared input even on failure; this is not delivery acknowledgment. */
41
+ recordInjection?: (artifact: InjectionArtifact) => void;
42
+ }
43
+
44
+ export type ExecuteHandler<TPayload, TResult> = (
45
+ context: string,
46
+ payload: TPayload,
47
+ meta?: ExecuteMeta
48
+ ) => Promise<TResult>;
49
+
50
+ export interface ExecutorConfig<TPayload = unknown, TResult = unknown>
51
+ extends AgentConfig {
52
+ handler: ExecuteHandler<TPayload, TResult>;
53
+ }
54
+
55
+ /**
56
+ * An Executor takes context + payload, goes and does work, returns full results.
57
+ */
58
+ export class Executor<TPayload = unknown, TResult = unknown> extends BaseAgent<
59
+ TPayload,
60
+ TResult
61
+ > {
62
+ private _handler: ExecuteHandler<TPayload, TResult>;
63
+
64
+ constructor(config: ExecutorConfig<TPayload, TResult>) {
65
+ super(config);
66
+ this._handler = config.handler;
67
+ }
68
+
69
+ withStack(stack: ContextStack): Executor<TPayload, TResult> {
70
+ return new Executor<TPayload, TResult>({ ...this.agentConfig(stack), handler: this._handler });
71
+ }
72
+
73
+ async run(
74
+ payload: TPayload,
75
+ filterOverride?: LayerFilter,
76
+ meta?: ExecuteMeta
77
+ ): Promise<ExecutionResult<TResult>> {
78
+ // Decoration composed by pre-message middleware (parallel domain advice)
79
+ // rides in on annotations and is appended after the assembled layers, so
80
+ // the executor's context, the provider input and the artifact agree.
81
+ const decoration = meta?.annotations?.decoration as MessageDecoration | undefined;
82
+ const plan = meta?.annotations?.injectionPlan;
83
+ const base = this.assembleContext(filterOverride);
84
+ const blocks = decoration?.blocks.length ? [...base.blocks, ...decoration.blocks] : base.blocks;
85
+ const assembled: AssembledContext = decoration?.blocks.length
86
+ ? { blocks, text: blocks.map((block) => block.text).join("\n\n") }
87
+ : base;
88
+ const context = assembled.text;
89
+ const contextHash = computeHash(context);
90
+ const capturedAt = Date.now();
91
+ const included = new Set(assembled.blocks.map(block => block.id));
92
+ const layers = this._stack.layers.map(layer => ({
93
+ ...layer.snapshotInstance(meta?.threadId),
94
+ id: layer.id,
95
+ prompt: layer.prompt,
96
+ sourceIds: layer.sources.map(source => source.id),
97
+ included: included.has(layer.id),
98
+ }));
99
+ const userMessage = typeof payload === "string" ? payload : JSON.stringify(payload);
100
+ const injection = buildInjectionArtifact({
101
+ userMessage,
102
+ assembled: base,
103
+ decoration,
104
+ layerFreshness: Object.fromEntries(layers.map(layer => [layer.id, {
105
+ state: layer.state, lastWarmed: layer.lastWarmed,
106
+ }])),
107
+ });
108
+ let providerMessages: LLMMessage[] | undefined;
109
+ let providerBoundary: ProviderBoundaryReceipt | undefined;
110
+ const capture = (): InjectionArtifact => ({ ...injection, capturedAt, threadId: meta?.threadId,
111
+ projectId: meta?.projectId, messageId: meta?.messageId, providerBoundary,
112
+ executorContext: context, providerMessages, layers,
113
+ ...(plan !== undefined ? { plan } : {}) });
114
+ const observe = (callback: () => void) => {
115
+ try { callback(); }
116
+ catch (error) { console.warn("[Executor] input evidence observer failed:", error); }
117
+ };
118
+ const record = () => observe(() => meta?.recordInjection?.(structuredClone(capture())));
119
+ // These are in-memory preparation observations, not durable writes or
120
+ // acknowledgment by a provider. A process crash can still lose them.
121
+ record();
122
+ // Shared layers may finish a coalesced load for a later request. Preserve
123
+ // the actual prepared evidence above, but never send it as this request's
124
+ // selection. No retry/replay is implied by refusing this provider call.
125
+ const mismatched = layers.filter(layer => {
126
+ if (!layer.included || !layer.selection) return false;
127
+ const selectedFor = layer.selection.currentMessage;
128
+ // Standalone legacy sources have no request-identity contract. A flow
129
+ // plan does: even its unidentified loads must not replace identified work.
130
+ if (!selectedFor && plan === undefined) return false;
131
+ const expectedId = meta?.messageId && meta.threadId ? meta.messageId : undefined;
132
+ return selectedFor?.messageId !== expectedId || (selectedFor !== undefined &&
133
+ (selectedFor.threadId !== meta?.threadId || selectedFor.projectId !== meta?.projectId));
134
+ });
135
+ if (mismatched.length) {
136
+ throw new Error(`Memory selection changed before provider execution: logical message ownership differs for layer(s) ${mismatched.map(l => l.id).join(", ")}`);
137
+ }
138
+ // A selecting source that could not carry required context is a reason to
139
+ // stop, not a note for the model. Refuse before any provider call; the
140
+ // recorded artifact above keeps the conflict and record ids inspectable.
141
+ const blocked = layers.filter(layer => layer.included).flatMap(layer => (layer.selection?.sources ?? []).flatMap(source =>
142
+ source.report.conflicts.filter(conflict => conflict.kind === REQUIRED_CONTEXT_BLOCKED)
143
+ .map(conflict => `layer ${layer.id}, source ${source.sourceId}: ${conflict.ids.join(", ")}`)));
144
+ if (blocked.length) {
145
+ throw new Error(`Required memory context blocked before provider execution: pinned records exceed the selection cap and no scoped retrieval is available (${blocked.join("; ")})`);
146
+ }
147
+ let output: TResult;
148
+ let native: NativeEvidence | undefined;
149
+ let recording = true;
150
+ try {
151
+ output = await this._handler(context, payload, {
152
+ ...meta,
153
+ recordNative: evidence => { if (recording) { native = evidence; observe(() => meta?.recordNative?.(evidence)); } },
154
+ recordProviderInput: messages => {
155
+ // The initial boundary input must not be replaced by tool followups.
156
+ if (providerMessages === undefined) {
157
+ providerMessages = structuredClone(messages);
158
+ if (meta?.threadId && meta.messageId) providerBoundary = boundaryReceipt(capture(), {
159
+ threadId: meta.threadId, messageId: meta.messageId, ...(meta.projectId ? { projectId: meta.projectId } : {}),
160
+ });
161
+ }
162
+ record();
163
+ observe(() => meta?.recordProviderInput?.(structuredClone(messages)));
164
+ },
165
+ });
166
+ } finally {
167
+ recording = false;
168
+ // The handler can throw a frozen error or a primitive. Observe evidence
169
+ // independently rather than modifying the thrown value or inventing output.
170
+ record();
171
+ }
172
+
173
+ observe(() => meta?.recordCompleted?.(structuredClone(output)));
174
+
175
+ return {
176
+ output,
177
+ contextHash,
178
+ meta: {
179
+ ...(native ? { native } : {}),
180
+ injection: capture(),
181
+ },
182
+ };
183
+ }
184
+ }
185
+ import { boundaryReceipt, type ProviderBoundaryReceipt } from "./delivery-evidence";