@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
|
@@ -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
|
+
}
|
package/src/executor.ts
ADDED
|
@@ -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";
|