@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,42 @@
1
+ /**
2
+ * A set with bounded capacity that evicts oldest entries when full.
3
+ * Used for message deduplication at harness ingress.
4
+ */
5
+ export class BoundedSet<T> {
6
+ private _items: T[] = [];
7
+ private _set: Set<T> = new Set();
8
+ private _capacity: number;
9
+
10
+ constructor(capacity: number = 1000) {
11
+ this._capacity = capacity;
12
+ }
13
+
14
+ /** Check if item exists in the set. */
15
+ has(item: T): boolean {
16
+ return this._set.has(item);
17
+ }
18
+
19
+ /** Add item. Returns true if newly added, false if already present. */
20
+ add(item: T): boolean {
21
+ if (this._set.has(item)) return false;
22
+
23
+ this._set.add(item);
24
+ this._items.push(item);
25
+
26
+ if (this._items.length > this._capacity) {
27
+ const evicted = this._items.shift()!;
28
+ this._set.delete(evicted);
29
+ }
30
+
31
+ return true;
32
+ }
33
+
34
+ get size(): number {
35
+ return this._set.size;
36
+ }
37
+
38
+ clear(): void {
39
+ this._items = [];
40
+ this._set.clear();
41
+ }
42
+ }
@@ -0,0 +1,187 @@
1
+ import { ContextLayer, type LayerState } from "./context-layer";
2
+ import { ContextStack } from "./context-stack";
3
+
4
+ export interface LifecycleEvent {
5
+ readonly type: string;
6
+ readonly layerId: string;
7
+ readonly timestamp: number;
8
+ readonly meta?: Record<string, unknown>;
9
+ }
10
+
11
+ export type LifecycleHandler = (
12
+ event: LifecycleEvent
13
+ ) => void | false | Promise<void | false>;
14
+
15
+ export interface LifecycleRule {
16
+ readonly id: string;
17
+ readonly triggers: LayerState[];
18
+ readonly layerIds?: string[];
19
+ action(
20
+ layer: ContextLayer,
21
+ state: LayerState,
22
+ stack: ContextStack
23
+ ): Promise<void>;
24
+ }
25
+
26
+ /**
27
+ * Manages the lifecycle of layers within a stack.
28
+ *
29
+ * This is the "when things happen" primitive. It doesn't decide
30
+ * what triggers matter — you register rules that do.
31
+ *
32
+ * Events are queued and processed sequentially to prevent
33
+ * re-entrant recursion from state changes triggering more state changes.
34
+ */
35
+ export class CacheLifecycle {
36
+ private _stack: ContextStack;
37
+ private _rules: LifecycleRule[] = [];
38
+ private _handlers: Map<string, LifecycleHandler[]> = new Map();
39
+ private _unsubscribes: Array<() => void> = [];
40
+ private _running = false;
41
+
42
+ // Event queue to prevent re-entrant recursion
43
+ private _queue: Array<{ state: LayerState; layer: ContextLayer }> = [];
44
+ private _processing = false;
45
+
46
+ constructor(stack: ContextStack) {
47
+ this._stack = stack;
48
+ }
49
+
50
+ // -- Rules --
51
+
52
+ addRule(rule: LifecycleRule): void {
53
+ this._rules.push(rule);
54
+ }
55
+
56
+ removeRule(id: string): boolean {
57
+ const idx = this._rules.findIndex((r) => r.id === id);
58
+ if (idx === -1) return false;
59
+ this._rules.splice(idx, 1);
60
+ return true;
61
+ }
62
+
63
+ // -- Event handlers --
64
+
65
+ on(eventType: string, handler: LifecycleHandler): () => void {
66
+ if (!this._handlers.has(eventType)) {
67
+ this._handlers.set(eventType, []);
68
+ }
69
+ this._handlers.get(eventType)!.push(handler);
70
+
71
+ return () => {
72
+ const handlers = this._handlers.get(eventType);
73
+ if (handlers) {
74
+ const idx = handlers.indexOf(handler);
75
+ if (idx !== -1) handlers.splice(idx, 1);
76
+ }
77
+ };
78
+ }
79
+
80
+ async emit(event: LifecycleEvent): Promise<void> {
81
+ const handlers = this._handlers.get(event.type) ?? [];
82
+ for (const handler of handlers) {
83
+ const result = await handler(event);
84
+ if (result === false) break;
85
+ }
86
+ }
87
+
88
+ // -- Activation --
89
+
90
+ start(): void {
91
+ if (this._running) return;
92
+ this._running = true;
93
+
94
+ for (const layer of this._stack.layers) {
95
+ this._observe(layer);
96
+ }
97
+
98
+ // Auto-observe layers added after start
99
+ const unsub = this._stack.onLayerAdded((layer) => {
100
+ if (this._running) this._observe(layer);
101
+ });
102
+ this._unsubscribes.push(unsub);
103
+ }
104
+
105
+ stop(): void {
106
+ this._running = false;
107
+ for (const unsub of this._unsubscribes) {
108
+ unsub();
109
+ }
110
+ this._unsubscribes = [];
111
+ }
112
+
113
+ observe(layer: ContextLayer): void {
114
+ if (this._running) {
115
+ this._observe(layer);
116
+ }
117
+ }
118
+
119
+ // -- Internal --
120
+
121
+ private _observe(layer: ContextLayer): void {
122
+ const unsub = layer.onStateChange((state, l) => {
123
+ // Enqueue instead of processing inline — prevents re-entrant recursion
124
+ this._queue.push({ state, layer: l });
125
+ this._drain();
126
+ });
127
+
128
+ this._unsubscribes.push(unsub);
129
+ }
130
+
131
+ private _drain(): void {
132
+ if (this._processing) return;
133
+ this._processing = true;
134
+
135
+ // Use queueMicrotask so the drain runs after the current synchronous
136
+ // state mutation completes, but before the next macrotask.
137
+ queueMicrotask(async () => {
138
+ try {
139
+ while (this._queue.length > 0) {
140
+ const { state, layer } = this._queue.shift()!;
141
+
142
+ try {
143
+ await this.emit({
144
+ type: `layer:${state}`,
145
+ layerId: layer.id,
146
+ timestamp: Date.now(),
147
+ });
148
+
149
+ for (const rule of this._rules) {
150
+ if (!rule.triggers.includes(state)) continue;
151
+ if (rule.layerIds && !rule.layerIds.includes(layer.id)) continue;
152
+
153
+ try {
154
+ await rule.action(layer, state, this._stack);
155
+ } catch (err) {
156
+ // Best-effort error event — if this also throws, outer catch handles it
157
+ try {
158
+ await this.emit({
159
+ type: "rule:error",
160
+ layerId: layer.id,
161
+ timestamp: Date.now(),
162
+ meta: { ruleId: rule.id, error: err },
163
+ });
164
+ } catch (emitErr) {
165
+ console.warn(`[CacheLifecycle] failed to emit rule:error for ${layer.id}:`, (emitErr as Error).message);
166
+ }
167
+ }
168
+ }
169
+ } catch (err) {
170
+ try {
171
+ await this.emit({
172
+ type: "lifecycle:error",
173
+ layerId: layer.id,
174
+ timestamp: Date.now(),
175
+ meta: { error: err },
176
+ });
177
+ } catch (emitErr) {
178
+ console.warn(`[CacheLifecycle] failed to emit lifecycle:error for ${layer.id}:`, (emitErr as Error).message);
179
+ }
180
+ }
181
+ }
182
+ } finally {
183
+ this._processing = false;
184
+ }
185
+ });
186
+ }
187
+ }
@@ -0,0 +1,252 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Capability — permission flags and gating for dangerous operations
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // Every operation that could be dangerous (file writes, network calls,
6
+ // expensive LLM calls, data deletion, shell execution) is a "capability."
7
+ //
8
+ // A PermissionPolicy maps capabilities to permission levels:
9
+ // - "allow" → proceed silently
10
+ // - "prompt" → emit an ActionPrompt and wait for approval
11
+ // - "deny" → reject immediately
12
+ //
13
+ // CapabilityGate checks permissions before operations. When a check
14
+ // results in "prompt", the gate uses the ActionQueue to block until
15
+ // a human (or policy hook) approves.
16
+ // ---------------------------------------------------------------------------
17
+
18
+ import { ActionQueue, type ActionResolution } from "./action-prompt";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Built-in capabilities
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Known capability categories. Adapters and providers use these
26
+ * to declare what they need. Custom capabilities use string literals.
27
+ */
28
+ export type BuiltinCapability =
29
+ // File system
30
+ | "file:read"
31
+ | "file:write"
32
+ | "file:delete"
33
+ // Network
34
+ | "net:fetch"
35
+ | "net:api"
36
+ // LLM
37
+ | "llm:call"
38
+ | "llm:expensive"
39
+ // Data stores
40
+ | "data:read"
41
+ | "data:write"
42
+ | "data:delete"
43
+ // Execution
44
+ | "exec:shell"
45
+ | "exec:process"
46
+ // Browser
47
+ | "browser:navigate"
48
+ | "browser:interact"
49
+ | "browser:execute"
50
+ | "browser:screenshot"
51
+ // Meta
52
+ | "prompt:auto-resolve";
53
+
54
+ /** Any string is a valid capability. Builtins are just conventions. */
55
+ export type Capability = BuiltinCapability | (string & {});
56
+
57
+ export type PermissionLevel = "allow" | "prompt" | "deny";
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // PermissionPolicy
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export interface PermissionPolicy {
64
+ /** Default for capabilities not explicitly listed. */
65
+ defaults: PermissionLevel;
66
+ /** Per-capability overrides. */
67
+ capabilities: Partial<Record<string, PermissionLevel>>;
68
+ /** Auto-prompt for LLM calls estimated above this dollar amount. */
69
+ costThreshold?: number;
70
+ /** Auto-prompt for operations touching these path patterns. */
71
+ protectedPaths?: string[];
72
+ }
73
+
74
+ /** Convenience: everything allowed, no prompts. */
75
+ export const UNATTENDED_POLICY: PermissionPolicy = {
76
+ defaults: "allow",
77
+ capabilities: {},
78
+ };
79
+
80
+ /** Convenience: prompt for writes/deletes/exec/expensive, allow reads. */
81
+ export const SUPERVISED_POLICY: PermissionPolicy = {
82
+ defaults: "prompt",
83
+ capabilities: {
84
+ "file:read": "allow",
85
+ "data:read": "allow",
86
+ "net:fetch": "allow",
87
+ "llm:call": "allow",
88
+ },
89
+ };
90
+
91
+ /** Convenience: deny dangerous ops, allow reads and cheap LLM. */
92
+ export const RESTRICTED_POLICY: PermissionPolicy = {
93
+ defaults: "deny",
94
+ capabilities: {
95
+ "file:read": "allow",
96
+ "data:read": "allow",
97
+ "net:fetch": "allow",
98
+ "llm:call": "allow",
99
+ "llm:expensive": "deny",
100
+ "exec:shell": "deny",
101
+ "exec:process": "deny",
102
+ "file:delete": "deny",
103
+ "data:delete": "deny",
104
+ "browser:navigate": "deny",
105
+ "browser:interact": "deny",
106
+ "browser:execute": "deny",
107
+ "browser:screenshot": "deny",
108
+ },
109
+ };
110
+
111
+ /** Convenience: browser-capable agent — allows navigation and interaction, prompts for JS execution. */
112
+ export const BROWSER_POLICY: PermissionPolicy = {
113
+ defaults: "prompt",
114
+ capabilities: {
115
+ "file:read": "allow",
116
+ "data:read": "allow",
117
+ "net:fetch": "allow",
118
+ "llm:call": "allow",
119
+ "browser:navigate": "allow",
120
+ "browser:interact": "allow",
121
+ "browser:execute": "prompt",
122
+ "browser:screenshot": "allow",
123
+ "exec:shell": "deny",
124
+ "exec:process": "deny",
125
+ "file:delete": "deny",
126
+ "data:delete": "deny",
127
+ },
128
+ };
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // CapabilityGate
132
+ // ---------------------------------------------------------------------------
133
+
134
+ export class CapabilityDeniedError extends Error {
135
+ readonly capability: string;
136
+ readonly level: PermissionLevel;
137
+ constructor(capability: string, level: PermissionLevel) {
138
+ super(`Capability "${capability}" is ${level}`);
139
+ this.name = "CapabilityDeniedError";
140
+ this.capability = capability;
141
+ this.level = level;
142
+ }
143
+ }
144
+
145
+ export interface GateContext {
146
+ agentId: string;
147
+ threadId: string;
148
+ /** Extra info for the prompt message (path, cost, model, etc.). */
149
+ detail?: string;
150
+ meta?: Record<string, unknown>;
151
+ }
152
+
153
+ export class CapabilityGate {
154
+ private _policy: PermissionPolicy;
155
+ private _queue: ActionQueue;
156
+
157
+ constructor(policy: PermissionPolicy, queue: ActionQueue) {
158
+ this._policy = policy;
159
+ this._queue = queue;
160
+ }
161
+
162
+ get policy(): PermissionPolicy { return this._policy; }
163
+
164
+ /** Update policy at runtime (e.g. operator toggles supervised mode). */
165
+ setPolicy(policy: PermissionPolicy): void {
166
+ this._policy = policy;
167
+ }
168
+
169
+ /**
170
+ * Check a capability. Returns the resolution if prompted, or
171
+ * a synthetic resolution for allow/deny.
172
+ */
173
+ async check(capability: Capability, ctx: GateContext): Promise<ActionResolution> {
174
+ const level = this._resolve(capability, ctx);
175
+
176
+ if (level === "allow") {
177
+ return { by: "policy", action: "approved", timestamp: Date.now() };
178
+ }
179
+
180
+ if (level === "deny") {
181
+ return { by: "policy", action: "rejected", timestamp: Date.now() };
182
+ }
183
+
184
+ // level === "prompt"
185
+ const message = this._buildMessage(capability, ctx);
186
+ return this._queue.prompt({
187
+ kind: "approval",
188
+ message,
189
+ agentId: ctx.agentId,
190
+ threadId: ctx.threadId,
191
+ capability,
192
+ urgency: this._urgencyFor(capability),
193
+ meta: ctx.meta,
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Check and throw if denied or rejected.
199
+ * Use this as a guard before dangerous operations.
200
+ */
201
+ async require(capability: Capability, ctx: GateContext): Promise<void> {
202
+ const resolution = await this.check(capability, ctx);
203
+ if (resolution.action !== "approved") {
204
+ throw new CapabilityDeniedError(capability, "deny");
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Synchronous check — returns the permission level without prompting.
210
+ * Use this when you need to know the level but can't block.
211
+ */
212
+ levelFor(capability: Capability, ctx?: Partial<GateContext>): PermissionLevel {
213
+ return this._resolve(capability, ctx as GateContext);
214
+ }
215
+
216
+ // -- Internal --
217
+
218
+ private _resolve(capability: Capability, ctx?: GateContext): PermissionLevel {
219
+ // Explicit capability override
220
+ const explicit = this._policy.capabilities[capability];
221
+ if (explicit) return explicit;
222
+
223
+ // Cost threshold check for LLM calls
224
+ if (capability === "llm:call" && this._policy.costThreshold != null && ctx?.meta?.estimatedCost != null) {
225
+ if ((ctx.meta.estimatedCost as number) > this._policy.costThreshold) {
226
+ return "prompt";
227
+ }
228
+ }
229
+
230
+ // Protected paths check for file ops
231
+ if ((capability === "file:write" || capability === "file:delete") && this._policy.protectedPaths?.length && ctx?.meta?.path) {
232
+ const path = ctx.meta.path as string;
233
+ if (this._policy.protectedPaths.some((p) => path.includes(p))) {
234
+ return "prompt";
235
+ }
236
+ }
237
+
238
+ return this._policy.defaults;
239
+ }
240
+
241
+ private _buildMessage(capability: Capability, ctx: GateContext): string {
242
+ const detail = ctx.detail ? ` — ${ctx.detail}` : "";
243
+ return `Agent "${ctx.agentId}" requires capability "${capability}"${detail}`;
244
+ }
245
+
246
+ private _urgencyFor(capability: Capability): "low" | "normal" | "high" | "critical" {
247
+ if (capability === "exec:shell" || capability === "exec:process") return "high";
248
+ if (capability.endsWith(":delete")) return "high";
249
+ if (capability === "llm:expensive") return "normal";
250
+ return "normal";
251
+ }
252
+ }
@@ -0,0 +1,43 @@
1
+ import { Decider, type DeciderConfig, type Decision } from "./decider";
2
+ import type { AgentConfig } from "./base-agent";
3
+ import type { ContextStack } from "./context-stack";
4
+ import type { ExecuteMeta } from "./executor";
5
+
6
+ /**
7
+ * A classification — "what is this thing?"
8
+ */
9
+ export interface Classification {
10
+ readonly category: string;
11
+ readonly subcategory?: string;
12
+ readonly tags?: string[];
13
+ }
14
+
15
+ export type ClassifyHandler<TPayload> = (
16
+ context: string,
17
+ payload: TPayload,
18
+ meta?: ExecuteMeta
19
+ ) => Promise<Decision<Classification>>;
20
+
21
+ export interface ClassifierConfig<TPayload = unknown> extends AgentConfig {
22
+ handler: ClassifyHandler<TPayload>;
23
+ }
24
+
25
+ /**
26
+ * A Classifier is a Decider that returns a category.
27
+ *
28
+ * "Given everything you know about the taxonomy, what IS this?"
29
+ * The Classifier has the full taxonomy loaded in its context layers.
30
+ * The caller sends a payload and gets back a category — not the taxonomy.
31
+ */
32
+ export class Classifier<TPayload = unknown> extends Decider<
33
+ TPayload,
34
+ Classification
35
+ > {
36
+ constructor(config: ClassifierConfig<TPayload>) {
37
+ super(config as DeciderConfig<TPayload, Classification>);
38
+ }
39
+
40
+ override withStack(stack: ContextStack): Classifier<TPayload> {
41
+ return new Classifier<TPayload>({ ...this.agentConfig(stack), handler: this._handler });
42
+ }
43
+ }