@inixiative/foundry-core 0.4.0 → 0.5.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/package.json +1 -1
- package/src/action-prompt.ts +15 -16
- package/src/adapters/file-memory.ts +3 -2
- package/src/adapters/index.ts +37 -1
- package/src/adapters/markdown-docs.ts +243 -5
- package/src/adapters/markdown-hierarchy.ts +904 -0
- package/src/capability.ts +36 -28
- package/src/context-layer.ts +42 -4
- package/src/context-stack.ts +13 -4
- package/src/executor.ts +46 -3
- package/src/feedback/correction.ts +366 -0
- package/src/harness.ts +16 -0
- package/src/index.ts +73 -9
- package/src/messages.ts +7 -1
- package/src/middleware.ts +5 -0
- package/src/model-registry.ts +57 -6
- package/src/native-evidence.ts +11 -1
- package/src/scope.ts +2 -0
- package/src/session-delivery.ts +76 -0
- package/src/thread.ts +18 -3
- package/src/types.ts +8 -0
- package/src/permission-middleware.ts +0 -80
package/src/capability.ts
CHANGED
|
@@ -133,6 +133,41 @@ export interface GateContext {
|
|
|
133
133
|
meta?: Record<string, unknown>;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** The level a policy gives a capability, without prompting: what CapabilityGate checks. */
|
|
137
|
+
export function permissionLevel(
|
|
138
|
+
policy: PermissionPolicy,
|
|
139
|
+
capability: Capability,
|
|
140
|
+
ctx?: Partial<GateContext>,
|
|
141
|
+
): PermissionLevel {
|
|
142
|
+
const explicit = policy.capabilities[capability];
|
|
143
|
+
|
|
144
|
+
// Protected paths always hold: an explicit allow for the capability never switches them off.
|
|
145
|
+
if (
|
|
146
|
+
explicit !== 'deny' &&
|
|
147
|
+
(capability === 'file:write' || capability === 'file:delete') &&
|
|
148
|
+
policy.protectedPaths?.length &&
|
|
149
|
+
typeof ctx?.meta?.path === 'string'
|
|
150
|
+
) {
|
|
151
|
+
const path = ctx.meta.path;
|
|
152
|
+
if (policy.protectedPaths.some((p) => path.includes(p))) return 'prompt';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (explicit) return explicit;
|
|
156
|
+
|
|
157
|
+
// Cost threshold check for LLM calls
|
|
158
|
+
if (
|
|
159
|
+
capability === 'llm:call' &&
|
|
160
|
+
policy.costThreshold != null &&
|
|
161
|
+
ctx?.meta?.estimatedCost != null
|
|
162
|
+
) {
|
|
163
|
+
if ((ctx.meta.estimatedCost as number) > policy.costThreshold) {
|
|
164
|
+
return 'prompt';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return policy.defaults;
|
|
169
|
+
}
|
|
170
|
+
|
|
136
171
|
export class CapabilityGate {
|
|
137
172
|
private _policy: PermissionPolicy;
|
|
138
173
|
private _queue: ActionQueue;
|
|
@@ -201,34 +236,7 @@ export class CapabilityGate {
|
|
|
201
236
|
// -- Internal --
|
|
202
237
|
|
|
203
238
|
private _resolve(capability: Capability, ctx?: GateContext): PermissionLevel {
|
|
204
|
-
|
|
205
|
-
const explicit = this._policy.capabilities[capability];
|
|
206
|
-
if (explicit) return explicit;
|
|
207
|
-
|
|
208
|
-
// Cost threshold check for LLM calls
|
|
209
|
-
if (
|
|
210
|
-
capability === 'llm:call' &&
|
|
211
|
-
this._policy.costThreshold != null &&
|
|
212
|
-
ctx?.meta?.estimatedCost != null
|
|
213
|
-
) {
|
|
214
|
-
if ((ctx.meta.estimatedCost as number) > this._policy.costThreshold) {
|
|
215
|
-
return 'prompt';
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Protected paths check for file ops
|
|
220
|
-
if (
|
|
221
|
-
(capability === 'file:write' || capability === 'file:delete') &&
|
|
222
|
-
this._policy.protectedPaths?.length &&
|
|
223
|
-
ctx?.meta?.path
|
|
224
|
-
) {
|
|
225
|
-
const path = ctx.meta.path as string;
|
|
226
|
-
if (this._policy.protectedPaths.some((p) => path.includes(p))) {
|
|
227
|
-
return 'prompt';
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return this._policy.defaults;
|
|
239
|
+
return permissionLevel(this._policy, capability, ctx);
|
|
232
240
|
}
|
|
233
241
|
|
|
234
242
|
private _buildMessage(capability: Capability, ctx: GateContext): string {
|
package/src/context-layer.ts
CHANGED
|
@@ -53,6 +53,8 @@ export interface SourceLoadHint {
|
|
|
53
53
|
* automatic selection, never a substitute for the retained log.
|
|
54
54
|
*/
|
|
55
55
|
export interface SourceSelectionReport {
|
|
56
|
+
/** What the records are. Absent: memory records. */
|
|
57
|
+
readonly subject?: 'memory' | 'instructions';
|
|
56
58
|
readonly selected: Array<{
|
|
57
59
|
readonly id: string;
|
|
58
60
|
readonly reason: string;
|
|
@@ -69,6 +71,8 @@ export interface SourceSelectionReport {
|
|
|
69
71
|
readonly chars: number;
|
|
70
72
|
readonly kind?: string;
|
|
71
73
|
readonly excludedFor?: LogicalMessageIdentity;
|
|
74
|
+
/** The copy kept in its place, for a record left out as a duplicate. */
|
|
75
|
+
readonly duplicateOf?: string;
|
|
72
76
|
}>;
|
|
73
77
|
/** Records the reader was allowed to see. */
|
|
74
78
|
readonly considered: number;
|
|
@@ -99,6 +103,12 @@ export interface ContextSource {
|
|
|
99
103
|
readonly focusable?: boolean;
|
|
100
104
|
/** The selection report of the last load, when this source selects rather than dumps. */
|
|
101
105
|
report?(): SourceSelectionReport | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* True when what the last load read has changed since (an edited or added file). The owning warm
|
|
108
|
+
* layer then turns stale on its next staleness check, without waiting out its staleness window.
|
|
109
|
+
* Must be cheap: it runs before every assembly.
|
|
110
|
+
*/
|
|
111
|
+
changed?(): boolean;
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
/** Selection provenance carried on a layer instance for inspection. */
|
|
@@ -265,6 +275,11 @@ export interface ContextLayerConfig {
|
|
|
265
275
|
prompt?: string;
|
|
266
276
|
/** Semantic segment of the content; overrides the definition's segment when given. */
|
|
267
277
|
segment?: LayerSegment;
|
|
278
|
+
/**
|
|
279
|
+
* Content that changes nearly every turn (the Librarian's thread state). A session provider
|
|
280
|
+
* sends it with each turn and never holds it in a session's standing context.
|
|
281
|
+
*/
|
|
282
|
+
perTurn?: boolean;
|
|
268
283
|
}
|
|
269
284
|
|
|
270
285
|
export type LayerState = 'cold' | 'warming' | 'warm' | 'stale' | 'compressing';
|
|
@@ -301,6 +316,9 @@ export class ContextLayer {
|
|
|
301
316
|
*/
|
|
302
317
|
readonly segment: LayerSegment | undefined;
|
|
303
318
|
|
|
319
|
+
/** Sent with each turn, never part of a session's standing context (`ContextLayerConfig.perTurn`). */
|
|
320
|
+
readonly perTurn: boolean;
|
|
321
|
+
|
|
304
322
|
private _content: string = '';
|
|
305
323
|
private _state: LayerState = 'cold';
|
|
306
324
|
private _hash: string = '';
|
|
@@ -338,6 +356,7 @@ export class ContextLayer {
|
|
|
338
356
|
if (segment !== undefined && !isLayerSegment(segment))
|
|
339
357
|
throw new Error(`Unknown layer segment "${String(segment)}" for layer "${config.id}"`);
|
|
340
358
|
this.segment = segment;
|
|
359
|
+
this.perTurn = config.perTurn ?? false;
|
|
341
360
|
}
|
|
342
361
|
|
|
343
362
|
// -- Factory --
|
|
@@ -397,6 +416,7 @@ export class ContextLayer {
|
|
|
397
416
|
maxTokens: this._maxTokens,
|
|
398
417
|
prompt: this._prompt,
|
|
399
418
|
segment: this.segment,
|
|
419
|
+
perTurn: this.perTurn,
|
|
400
420
|
});
|
|
401
421
|
copy._focus = this._focus;
|
|
402
422
|
copy._currentMessage = this._currentMessage;
|
|
@@ -485,19 +505,37 @@ export class ContextLayer {
|
|
|
485
505
|
return this._state;
|
|
486
506
|
}
|
|
487
507
|
|
|
488
|
-
/**
|
|
508
|
+
/**
|
|
509
|
+
* Check if this layer has become stale: its staleness window passed, or a source reports that
|
|
510
|
+
* what it read has changed. Triggers the state transition if so.
|
|
511
|
+
*/
|
|
489
512
|
checkStaleness(): LayerState {
|
|
490
513
|
if (
|
|
491
514
|
this._state === 'warm' &&
|
|
492
|
-
this._staleness !== undefined &&
|
|
493
|
-
|
|
494
|
-
|
|
515
|
+
((this._staleness !== undefined &&
|
|
516
|
+
this._lastWarmed !== null &&
|
|
517
|
+
Date.now() - this._lastWarmed > this._staleness) ||
|
|
518
|
+
this._sources.some((source) => source.changed?.()))
|
|
495
519
|
) {
|
|
496
520
|
this._setState('stale');
|
|
497
521
|
}
|
|
498
522
|
return this._state;
|
|
499
523
|
}
|
|
500
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Whether assembly includes this layer: warm, or stale or re-warming with content that does not
|
|
527
|
+
* depend on the message (no focusable source). A standing layer keeps its last content until a
|
|
528
|
+
* re-warm completes, so it never drops out of context while one is slow or in flight.
|
|
529
|
+
*/
|
|
530
|
+
get assemblable(): boolean {
|
|
531
|
+
return (
|
|
532
|
+
this._state === 'warm' ||
|
|
533
|
+
((this._state === 'stale' || this._state === 'warming') &&
|
|
534
|
+
this._lastWarmed !== null &&
|
|
535
|
+
!this._sources.some((source) => source.focusable))
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
501
539
|
get hash(): string {
|
|
502
540
|
return this._hash;
|
|
503
541
|
}
|
package/src/context-stack.ts
CHANGED
|
@@ -36,6 +36,13 @@ export interface PromptBlock {
|
|
|
36
36
|
readonly segment?: string;
|
|
37
37
|
/** Who produced this block (a domain, the router, a layer id). */
|
|
38
38
|
readonly source?: string;
|
|
39
|
+
/**
|
|
40
|
+
* "after-message": a per-turn note appended to the end of the user's message rather than placed
|
|
41
|
+
* in the context (a Warden chiming in). Default: in the context.
|
|
42
|
+
*/
|
|
43
|
+
readonly placement?: 'after-message';
|
|
44
|
+
/** From a per-turn layer: sent with each turn, never held as standing context. */
|
|
45
|
+
readonly perTurn?: true;
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
/** Structured prompt assembled from agent prompt + layer prompts + layer content. */
|
|
@@ -141,7 +148,7 @@ export class ContextStack {
|
|
|
141
148
|
merge(filter?: LayerFilter): string {
|
|
142
149
|
const layers = filter ? this._layers.filter(filter) : this._layers;
|
|
143
150
|
return layers
|
|
144
|
-
.filter((l) => l.
|
|
151
|
+
.filter((l) => l.assemblable && l.content.length > 0)
|
|
145
152
|
.map((l) => l.content)
|
|
146
153
|
.join('\n\n');
|
|
147
154
|
}
|
|
@@ -171,11 +178,12 @@ export class ContextStack {
|
|
|
171
178
|
const layers = filter ? this._layers.filter(filter) : this._layers;
|
|
172
179
|
|
|
173
180
|
for (const layer of layers) {
|
|
174
|
-
if (!layer.
|
|
181
|
+
if (!layer.assemblable || layer.content.length === 0) continue;
|
|
182
|
+
const perTurn = layer.perTurn ? { perTurn: true as const } : {};
|
|
175
183
|
|
|
176
184
|
if (layer.prompt) {
|
|
177
185
|
// A layer prompt is always an instruction; it never inherits the content segment.
|
|
178
|
-
blocks.push({ role: 'layer', id: layer.id, text: layer.prompt });
|
|
186
|
+
blocks.push({ role: 'layer', id: layer.id, text: layer.prompt, ...perTurn });
|
|
179
187
|
}
|
|
180
188
|
// The content block carries the layer's declared segment when it has one; legacy
|
|
181
189
|
// layers leave it unset and the artifact builder falls back to its id heuristic.
|
|
@@ -184,6 +192,7 @@ export class ContextStack {
|
|
|
184
192
|
id: layer.id,
|
|
185
193
|
text: layer.content,
|
|
186
194
|
...(layer.segment ? { segment: layer.segment } : {}),
|
|
195
|
+
...perTurn,
|
|
187
196
|
});
|
|
188
197
|
}
|
|
189
198
|
|
|
@@ -197,7 +206,7 @@ export class ContextStack {
|
|
|
197
206
|
const content = this.merge();
|
|
198
207
|
const layerHashes: Record<string, string> = {};
|
|
199
208
|
for (const layer of this._layers) {
|
|
200
|
-
if (layer.
|
|
209
|
+
if (layer.assemblable) {
|
|
201
210
|
layerHashes[layer.id] = layer.hash;
|
|
202
211
|
}
|
|
203
212
|
}
|
package/src/executor.ts
CHANGED
|
@@ -3,6 +3,11 @@ import { computeHash, REQUIRED_CONTEXT_BLOCKED } from './context-layer';
|
|
|
3
3
|
import type { AssembledContext, ContextStack, LayerFilter } from './context-stack';
|
|
4
4
|
import { buildInjectionArtifact, type InjectionArtifact, type MessageDecoration } from './messages';
|
|
5
5
|
import type { NativeEvidence, NativeObservation } from './native-evidence';
|
|
6
|
+
import {
|
|
7
|
+
type SessionDelivery,
|
|
8
|
+
type SessionDeliveryReport,
|
|
9
|
+
standingParts,
|
|
10
|
+
} from './session-delivery';
|
|
6
11
|
import type { ToolCallObservation } from './tools';
|
|
7
12
|
import type { LLMMessage } from './types';
|
|
8
13
|
|
|
@@ -39,6 +44,10 @@ export interface ExecuteMeta {
|
|
|
39
44
|
recordProviderInput?: (messages: LLMMessage[]) => void;
|
|
40
45
|
/** Capture prepared input even on failure; this is not delivery acknowledgment. */
|
|
41
46
|
recordInjection?: (artifact: InjectionArtifact) => void;
|
|
47
|
+
/** The context split for a session provider; set by the executor for its handler. */
|
|
48
|
+
delivery?: SessionDelivery;
|
|
49
|
+
/** The handler reports what a session provider delivered (CompletionResult.delivery). */
|
|
50
|
+
recordDelivery?: (report: SessionDeliveryReport) => void;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
export type ExecuteHandler<TPayload, TResult> = (
|
|
@@ -77,8 +86,17 @@ export class Executor<TPayload = unknown, TResult = unknown> extends BaseAgent<T
|
|
|
77
86
|
const decoration = meta?.annotations?.decoration as MessageDecoration | undefined;
|
|
78
87
|
const plan = meta?.annotations?.injectionPlan;
|
|
79
88
|
const base = this.assembleContext(filterOverride);
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
// Notes placed after the message (Wardens chiming in) end the user's turn; the rest is context.
|
|
90
|
+
// A payload that is not text has no end to append to; its notes stay in the context.
|
|
91
|
+
const appendable = typeof payload === 'string';
|
|
92
|
+
const contextual = (decoration?.blocks ?? []).filter(
|
|
93
|
+
(b) => !appendable || b.placement !== 'after-message',
|
|
94
|
+
);
|
|
95
|
+
const notes = appendable
|
|
96
|
+
? (decoration?.blocks ?? []).filter((b) => b.placement === 'after-message')
|
|
97
|
+
: [];
|
|
98
|
+
const blocks = contextual.length ? [...base.blocks, ...contextual] : base.blocks;
|
|
99
|
+
const assembled: AssembledContext = contextual.length
|
|
82
100
|
? { blocks, text: blocks.map((block) => block.text).join('\n\n') }
|
|
83
101
|
: base;
|
|
84
102
|
const context = assembled.text;
|
|
@@ -107,7 +125,18 @@ export class Executor<TPayload = unknown, TResult = unknown> extends BaseAgent<T
|
|
|
107
125
|
]),
|
|
108
126
|
),
|
|
109
127
|
});
|
|
128
|
+
// The same context split for session providers: standing parts (the assembled layers) and the
|
|
129
|
+
// per-turn text (per-turn layers, then the decoration). `heldLayers` is the thread's delivery
|
|
130
|
+
// ledger, set by the flow middleware.
|
|
131
|
+
const delivery: SessionDelivery = {
|
|
132
|
+
standing: standingParts(base.blocks.filter((block) => !block.perTurn)),
|
|
133
|
+
turn: [...base.blocks.filter((block) => block.perTurn), ...contextual]
|
|
134
|
+
.map((block) => block.text)
|
|
135
|
+
.join('\n\n'),
|
|
136
|
+
held: { ...((meta?.annotations?.heldLayers as Record<string, string> | undefined) ?? {}) },
|
|
137
|
+
};
|
|
110
138
|
let providerMessages: LLMMessage[] | undefined;
|
|
139
|
+
let sessionDelivery: SessionDeliveryReport | undefined;
|
|
111
140
|
let providerBoundary: ProviderBoundaryReceipt | undefined;
|
|
112
141
|
const capture = (): InjectionArtifact => ({
|
|
113
142
|
...injection,
|
|
@@ -118,6 +147,8 @@ export class Executor<TPayload = unknown, TResult = unknown> extends BaseAgent<T
|
|
|
118
147
|
providerBoundary,
|
|
119
148
|
executorContext: context,
|
|
120
149
|
providerMessages,
|
|
150
|
+
...(sessionDelivery ? { sessionDelivery } : {}),
|
|
151
|
+
standingParts: delivery.standing.map(({ id, hash }) => ({ id, hash })),
|
|
121
152
|
layers,
|
|
122
153
|
...(plan !== undefined ? { plan } : {}),
|
|
123
154
|
});
|
|
@@ -137,6 +168,9 @@ export class Executor<TPayload = unknown, TResult = unknown> extends BaseAgent<T
|
|
|
137
168
|
// selection. No retry/replay is implied by refusing this provider call.
|
|
138
169
|
const mismatched = layers.filter((layer) => {
|
|
139
170
|
if (!layer.included || !layer.selection) return false;
|
|
171
|
+
// A composition report (instruction files) is not a per-message selection.
|
|
172
|
+
if (layer.selection.sources.every((source) => source.report.subject === 'instructions'))
|
|
173
|
+
return false;
|
|
140
174
|
const selectedFor = layer.selection.currentMessage;
|
|
141
175
|
// Standalone legacy sources have no request-identity contract. A flow
|
|
142
176
|
// plan does: even its unidentified loads must not replace identified work.
|
|
@@ -177,8 +211,17 @@ export class Executor<TPayload = unknown, TResult = unknown> extends BaseAgent<T
|
|
|
177
211
|
let native: NativeEvidence | undefined;
|
|
178
212
|
let recording = true;
|
|
179
213
|
try {
|
|
180
|
-
|
|
214
|
+
const sent = (
|
|
215
|
+
notes.length && typeof payload === 'string'
|
|
216
|
+
? `${payload}\n\n---\n\n${notes.map((note) => note.text).join('\n\n')}`
|
|
217
|
+
: payload
|
|
218
|
+
) as TPayload;
|
|
219
|
+
output = await this._handler(context, sent, {
|
|
181
220
|
...meta,
|
|
221
|
+
delivery,
|
|
222
|
+
recordDelivery: (report) => {
|
|
223
|
+
sessionDelivery = structuredClone(report);
|
|
224
|
+
},
|
|
182
225
|
recordNative: (evidence) => {
|
|
183
226
|
if (recording) {
|
|
184
227
|
native = evidence;
|