@inixiative/foundry-core 0.3.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.
@@ -23,6 +23,30 @@ export type ProviderType =
23
23
  | 'custom';
24
24
  export type RuntimeKind = 'api' | 'native-harness' | 'typed-decision';
25
25
 
26
+ /** Who trains the models a provider serves. Gateways and local hosts serve many labs and carry none. */
27
+ export type ModelLab =
28
+ | 'anthropic'
29
+ | 'openai'
30
+ | 'google'
31
+ | 'meta'
32
+ | 'xai'
33
+ | 'deepseek'
34
+ | 'moonshot'
35
+ | 'zhipu'
36
+ | 'alibaba'
37
+ | 'mistral'
38
+ | 'typesafe';
39
+
40
+ /** Labs whose models may run a main thread (the Artificer), on whatever transport reaches them. */
41
+ export const MAIN_THREAD_LABS = [
42
+ 'anthropic',
43
+ 'openai',
44
+ 'google',
45
+ 'meta',
46
+ 'xai',
47
+ ] as const satisfies readonly ModelLab[];
48
+ export type MainThreadLab = (typeof MAIN_THREAD_LABS)[number];
49
+
26
50
  /**
27
51
  * What a model is worth using for. Every model is a judgment client — that is
28
52
  * not a provider category, it is a tag every entry carries. The other tags name
@@ -175,6 +199,8 @@ const MUSE_REASONING: ModelReasoning = {
175
199
  export interface FoundryProviderInfo {
176
200
  id: string;
177
201
  type: ProviderType;
202
+ /** Absent for gateways and local hosts. */
203
+ lab?: ModelLab;
178
204
  label: string;
179
205
  description: string;
180
206
  envKey?: string;
@@ -199,11 +225,17 @@ export interface ModelSweepOption {
199
225
  export const MODEL_REGISTRY_UPDATED_AT = '2026-09-24';
200
226
 
201
227
  /**
202
- * Decision default: the cheapest current model tagged `judgment`. Held here
203
- * rather than in an adapter so one map answers "what runs decisions".
228
+ * Decision default: Claude Haiku 5.5, the cheapest current Claude model tagged `judgment`. Held here
229
+ * rather than in an adapter so one map answers "what runs decisions". DECISION_PROVIDER is the
230
+ * API-token path; subscription decisions pick their model per runtime from SUBSCRIPTION_DECISION_MODELS.
204
231
  */
205
- export const DECISION_PROVIDER = 'openai';
206
- export const DECISION_MODEL = 'gpt-6-luna';
232
+ export const DECISION_PROVIDER = 'anthropic';
233
+ export const DECISION_MODEL = 'claude-haiku-5-5';
234
+ /** Subscription decision default per native runtime; settings choose the runtime. */
235
+ export const SUBSCRIPTION_DECISION_MODELS = {
236
+ claude: DECISION_MODEL,
237
+ codex: 'gpt-6-luna',
238
+ } as const;
207
239
 
208
240
  /** Tag rule: every model judges. Cheap models review and route; capable models execute and advise. */
209
241
  const FAST = ['judgment', 'learning-review', 'tool-use'] as ModelCapability[];
@@ -222,6 +254,7 @@ const POWERFUL_REASONING = [...POWERFUL, 'reasoning'] as ModelCapability[];
222
254
  export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
223
255
  'claude-code': {
224
256
  id: 'claude-code',
257
+ lab: 'anthropic',
225
258
  type: 'claude-code',
226
259
  label: 'Claude Code (CLI subscription)',
227
260
  description: "Native Claude Code harness using the user's authenticated subscription.",
@@ -243,7 +276,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
243
276
  },
244
277
  {
245
278
  id: 'opus',
246
- label: 'Opus 5',
279
+ label: 'Opus 5.5',
247
280
  tier: 'powerful',
248
281
  costTier: 'high',
249
282
  contextWindow: 1_000_000,
@@ -251,10 +284,11 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
251
284
  nativeAlias: true,
252
285
  capabilities: POWERFUL_REASONING,
253
286
  reasoning: CLAUDE_REASONING,
287
+ notes: 'Claude Code alias for claude-opus-5-5.',
254
288
  },
255
289
  {
256
290
  id: 'sonnet',
257
- label: 'Sonnet 5',
291
+ label: 'Sonnet 5.5',
258
292
  tier: 'standard',
259
293
  costTier: 'medium',
260
294
  contextWindow: 1_000_000,
@@ -262,13 +296,46 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
262
296
  nativeAlias: true,
263
297
  capabilities: STANDARD_REASONING,
264
298
  reasoning: CLAUDE_REASONING,
299
+ notes: 'Claude Code alias for claude-sonnet-5-5.',
300
+ },
301
+ {
302
+ id: 'claude-haiku-5-5',
303
+ label: 'Haiku 5.5',
304
+ tier: 'fast',
305
+ costTier: 'low',
306
+ contextWindow: 1_000_000,
307
+ runtimeKind: 'native-harness',
308
+ capabilities: FAST_REASONING,
309
+ reasoning: { ...CLAUDE_REASONING, efforts: ['low', 'medium', 'high'] },
310
+ notes: "Foundry's decision model; the CLI reports this id as the served model.",
311
+ },
312
+ {
313
+ id: 'claude-opus-5-5',
314
+ label: 'Opus 5.5',
315
+ tier: 'powerful',
316
+ costTier: 'high',
317
+ contextWindow: 1_000_000,
318
+ runtimeKind: 'native-harness',
319
+ capabilities: POWERFUL_REASONING,
320
+ reasoning: CLAUDE_REASONING,
321
+ },
322
+ {
323
+ id: 'claude-fable-5-1',
324
+ label: 'Fable 5.1',
325
+ tier: 'powerful',
326
+ costTier: 'high',
327
+ contextWindow: 1_000_000,
328
+ maxOutputTokens: 128_000,
329
+ runtimeKind: 'native-harness',
330
+ capabilities: POWERFUL_REASONING,
331
+ reasoning: CLAUDE_REASONING,
265
332
  },
266
333
  {
267
334
  id: 'haiku',
268
- label: 'Haiku 4.5',
335
+ label: 'Haiku 5.5',
269
336
  tier: 'fast',
270
337
  costTier: 'low',
271
- contextWindow: 200_000,
338
+ contextWindow: 1_000_000,
272
339
  runtimeKind: 'native-harness',
273
340
  nativeAlias: true,
274
341
  capabilities: FAST_REASONING,
@@ -278,6 +345,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
278
345
  },
279
346
  anthropic: {
280
347
  id: 'anthropic',
348
+ lab: 'anthropic',
281
349
  type: 'anthropic',
282
350
  label: 'Anthropic (API key)',
283
351
  description: 'Direct Anthropic API access without the Claude Code native harness.',
@@ -297,6 +365,17 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
297
365
  reasoning: CLAUDE_REASONING,
298
366
  notes: 'Thinking is always on; steer with effort.',
299
367
  },
368
+ {
369
+ id: 'claude-opus-5-5',
370
+ label: 'Claude Opus 5.5',
371
+ tier: 'powerful',
372
+ costTier: 'high',
373
+ contextWindow: 1_000_000,
374
+ maxOutputTokens: 128_000,
375
+ runtimeKind: 'api',
376
+ capabilities: POWERFUL_REASONING,
377
+ reasoning: CLAUDE_REASONING,
378
+ },
300
379
  {
301
380
  id: 'claude-opus-5',
302
381
  label: 'Claude Opus 5',
@@ -319,6 +398,18 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
319
398
  capabilities: STANDARD_REASONING,
320
399
  reasoning: CLAUDE_REASONING,
321
400
  },
401
+ {
402
+ id: 'claude-haiku-5-5',
403
+ label: 'Claude Haiku 5.5',
404
+ tier: 'fast',
405
+ costTier: 'low',
406
+ contextWindow: 1_000_000,
407
+ maxOutputTokens: 128_000,
408
+ runtimeKind: 'api',
409
+ capabilities: FAST_REASONING,
410
+ reasoning: { ...CLAUDE_REASONING, efforts: ['low', 'medium', 'high'] },
411
+ },
412
+ // Previous generation, still served.
322
413
  {
323
414
  id: 'claude-haiku-4-5',
324
415
  label: 'Claude Haiku 4.5',
@@ -330,7 +421,6 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
330
421
  reasoning: { ...CLAUDE_REASONING, efforts: ['low', 'medium', 'high'] },
331
422
  notes: 'Takes a thinking budget, not an effort ladder.',
332
423
  },
333
- // Previous generation, still served.
334
424
  {
335
425
  id: 'claude-fable-5',
336
426
  label: 'Claude Fable 5',
@@ -390,6 +480,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
390
480
  },
391
481
  codex: {
392
482
  id: 'codex',
483
+ lab: 'openai',
393
484
  type: 'codex',
394
485
  label: 'Codex CLI (OpenAI subscription)',
395
486
  description: "Native Codex harness using the user's authenticated OpenAI/Codex subscription.",
@@ -480,6 +571,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
480
571
  },
481
572
  openai: {
482
573
  id: 'openai',
574
+ lab: 'openai',
483
575
  type: 'openai',
484
576
  label: 'OpenAI',
485
577
  description: 'OpenAI API models for classification, routing, and execution.',
@@ -575,6 +667,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
575
667
  },
576
668
  gemini: {
577
669
  id: 'gemini',
670
+ lab: 'google',
578
671
  type: 'gemini',
579
672
  label: 'Google Gemini',
580
673
  description: 'Gemini API models for high-volume agent work.',
@@ -641,6 +734,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
641
734
  },
642
735
  meta: {
643
736
  id: 'meta',
737
+ lab: 'meta',
644
738
  type: 'openai-compatible',
645
739
  // Llama is not Meta's frontier line any more; Muse Spark replaced it in April 2026
646
740
  // and there is no "Llama 5". The open-weight Llama entries under openrouter,
@@ -721,6 +815,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
721
815
  },
722
816
  xai: {
723
817
  id: 'xai',
818
+ lab: 'xai',
724
819
  type: 'xai',
725
820
  label: 'xAI (Grok)',
726
821
  description:
@@ -803,6 +898,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
803
898
  },
804
899
  deepseek: {
805
900
  id: 'deepseek',
901
+ lab: 'deepseek',
806
902
  type: 'openai-compatible',
807
903
  label: 'DeepSeek',
808
904
  description:
@@ -839,6 +935,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
839
935
  },
840
936
  kimi: {
841
937
  id: 'kimi',
938
+ lab: 'moonshot',
842
939
  type: 'openai-compatible',
843
940
  label: 'Kimi (Moonshot AI)',
844
941
  description: 'Moonshot Kimi models. OpenAI-compatible; temperature range is 0-1, not 0-2.',
@@ -893,6 +990,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
893
990
  },
894
991
  glm: {
895
992
  id: 'glm',
993
+ lab: 'zhipu',
896
994
  type: 'openai-compatible',
897
995
  label: 'GLM (Z.ai / Zhipu)',
898
996
  description:
@@ -1030,6 +1128,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
1030
1128
  },
1031
1129
  qwen: {
1032
1130
  id: 'qwen',
1131
+ lab: 'alibaba',
1033
1132
  type: 'openai-compatible',
1034
1133
  label: 'Qwen (Alibaba Model Studio)',
1035
1134
  // The legacy static domain still works; Alibaba now recommends the per-workspace
@@ -1439,6 +1538,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
1439
1538
  },
1440
1539
  mistral: {
1441
1540
  id: 'mistral',
1541
+ lab: 'mistral',
1442
1542
  type: 'openai-compatible',
1443
1543
  label: 'Mistral AI',
1444
1544
  description:
@@ -1507,6 +1607,7 @@ export const MODEL_REGISTRY: Record<string, FoundryProviderInfo> = {
1507
1607
  },
1508
1608
  typesafe: {
1509
1609
  id: 'typesafe',
1610
+ lab: 'typesafe',
1510
1611
  type: 'typesafe',
1511
1612
  label: 'TypeSafe (Jev)',
1512
1613
  description:
@@ -1565,6 +1666,14 @@ export function providersWithCapability(capability: ModelCapability): FoundryPro
1565
1666
  );
1566
1667
  }
1567
1668
 
1669
+ /** The main-thread lab a provider serves, or undefined when it serves none (or many). */
1670
+ export function mainThreadLab(providerId: string): MainThreadLab | undefined {
1671
+ const lab = MODEL_REGISTRY[providerId]?.lab;
1672
+ return (MAIN_THREAD_LABS as readonly string[]).includes(lab ?? '')
1673
+ ? (lab as MainThreadLab)
1674
+ : undefined;
1675
+ }
1676
+
1568
1677
  /** The part of a Foundry configuration `resolveDecisionModel` reads. */
1569
1678
  export interface DecisionModelDefaults {
1570
1679
  defaults: {
@@ -167,7 +167,7 @@ export interface NativeBridgeLease {
167
167
  readonly toolPolicy?: NativeToolPolicy;
168
168
  /** Private, empty native cwd owned by a restricted fixture lease. */
169
169
  readonly fixtureCwd?: string;
170
- readonly launch: { readonly claudeJson: string; readonly codexOverrides: readonly string[] };
170
+ readonly launch: { readonly claudeJson: string };
171
171
  check(): void;
172
172
  register(evidence: NativeEvidence): void;
173
173
  observe(evidence: NativeEvidence): void;
@@ -194,7 +194,17 @@ export interface NativeObservation {
194
194
  register(evidence: NativeEvidence): void | Promise<void>;
195
195
  /** Fallible observer; never vetoes executor completion. */
196
196
  observe(evidence: NativeEvidence): void | Promise<void>;
197
+ /** Receives the stop for this owner's registered admission, where the runtime acknowledges stops. */
198
+ interruptible?(interrupt: NativeInterrupt): void;
199
+ /**
200
+ * The executor's public thinking text, for live display only. It never becomes evidence:
201
+ * nothing here is journalled, traced or projected (reasoning stays out of NativeEvidence).
202
+ */
203
+ thought?(text: string): void;
197
204
  }
205
+ /** `acknowledged`: the runtime confirmed the stop and the turn reached its native terminal. */
206
+ export type NativeInterruptOutcome = 'acknowledged' | 'no-turn' | 'unacknowledged';
207
+ export type NativeInterrupt = (opts?: { timeoutMs?: number }) => Promise<NativeInterruptOutcome>;
198
208
  export function freezeEvidence<T>(value: T): T {
199
209
  const copy = structuredClone(value);
200
210
  const freeze = (v: unknown) => {
package/src/scope.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  export interface OwnershipScope {
10
10
  readonly threadId?: string;
11
11
  readonly projectId?: string;
12
+ /** Where the thread's native session runs. Not ownership: sources that read the workspace resolve against it. */
13
+ readonly cwd?: string;
12
14
  }
13
15
 
14
16
  /** Copy a scope, dropping undefined fields so equality checks are stable. */
@@ -0,0 +1,76 @@
1
+ import { computeHash } from './context-layer';
2
+ import type { PromptBlock } from './context-stack';
3
+
4
+ /** One standing part of a thread's context (a layer with its prompt, or the agent's own prompt), keyed by content hash. */
5
+ export interface StandingPart {
6
+ readonly id: string;
7
+ readonly hash: string;
8
+ readonly text: string;
9
+ }
10
+
11
+ /**
12
+ * A turn's context split for a provider that keeps a native session. Standing parts go into the
13
+ * session's system prompt once, when the session starts; afterwards only parts whose hash differs
14
+ * from what the session holds are sent with the turn. The per-turn text is sent every turn.
15
+ */
16
+ export interface SessionDelivery {
17
+ /** Every standing part, in context order. */
18
+ readonly standing: readonly StandingPart[];
19
+ /** Per-turn context (routing, Warden advice); sent with every turn. */
20
+ readonly turn: string;
21
+ /**
22
+ * The thread's delivery ledger: part id → hash the session holds from an earlier turn's message.
23
+ * Cleared on compaction, reset when a session starts.
24
+ */
25
+ readonly held: Readonly<Record<string, string>>;
26
+ }
27
+
28
+ export interface DeliveredPart {
29
+ readonly id: string;
30
+ readonly hash: string;
31
+ }
32
+
33
+ /** What a session provider actually delivered for one turn. */
34
+ export interface SessionDeliveryReport {
35
+ /** "started": this turn ran on a session that started with `base` as its system prompt. */
36
+ readonly session: 'started' | 'held';
37
+ /** The parts the session's system prompt carries. */
38
+ readonly base: readonly DeliveredPart[];
39
+ /** Standing parts sent in this turn's message because the session did not hold their hash. */
40
+ readonly sent: readonly DeliveredPart[];
41
+ /**
42
+ * A started session that resumed the conversation the thread's ledger describes (a relaunch in
43
+ * this process): the ledger still holds, so it is kept and extended rather than reset.
44
+ */
45
+ readonly ledgerKept?: true;
46
+ }
47
+
48
+ /** Group assembled layer blocks (a layer's prompt block, then its content block) into standing parts. */
49
+ export function standingParts(blocks: readonly PromptBlock[]): StandingPart[] {
50
+ const parts: Array<{ id: string; texts: string[] }> = [];
51
+ for (const block of blocks) {
52
+ const id = block.id ?? `${block.role}:${parts.length}`;
53
+ const last = parts.at(-1);
54
+ if (last && last.id === id) last.texts.push(block.text);
55
+ else parts.push({ id, texts: [block.text] });
56
+ }
57
+ // Two layers may share an id (a configured layer and a runtime-owned one); each is its own part.
58
+ const seen = new Map<string, number>();
59
+ return parts.map(({ id, texts }) => {
60
+ const n = (seen.get(id) ?? 0) + 1;
61
+ seen.set(id, n);
62
+ return standingPart(n === 1 ? id : `${id}#${n}`, texts.join('\n\n'));
63
+ });
64
+ }
65
+
66
+ /** A warm layer as the standing part the executor assembles: its prompt, then its content. */
67
+ export function layerPart(layer: { id: string; prompt?: string; content: string }): StandingPart {
68
+ return standingPart(
69
+ layer.id,
70
+ layer.prompt ? `${layer.prompt}\n\n${layer.content}` : layer.content,
71
+ );
72
+ }
73
+
74
+ export function standingPart(id: string, text: string): StandingPart {
75
+ return { id, hash: computeHash(text), text };
76
+ }
package/src/thread.ts CHANGED
@@ -125,6 +125,14 @@ export interface DispatchOptions {
125
125
  messageId?: string;
126
126
  /** How the caller invoked the agent: always, conditional, on-demand, background. */
127
127
  invocation?: string;
128
+ /**
129
+ * What the agent itself receives when it differs from the payload that middleware and the
130
+ * dispatch observation read: a host's instruction wrappers go to the agent verbatim, while
131
+ * everything that learns from the message sees only the user's words.
132
+ */
133
+ agentPayload?: unknown;
134
+ /** Lets this dispatch's middleware include an on-demand layer in its assembly (`RequestContext.requestLayer`). */
135
+ requestLayer?: (layerId: string) => void;
128
136
  }
129
137
 
130
138
  /**
@@ -355,6 +363,7 @@ export class Thread {
355
363
  stack: this._createStackView(),
356
364
  projectId: this.meta.projectId,
357
365
  messageId: opts?.messageId,
366
+ ...(opts?.requestLayer ? { requestLayer: opts.requestLayer } : {}),
358
367
  };
359
368
 
360
369
  const start = performance.now();
@@ -398,6 +407,8 @@ export class Thread {
398
407
  bridge: opts.nativeObservation.bridge,
399
408
  register: opts.nativeObservation.register,
400
409
  observe: opts.nativeObservation.observe,
410
+ interruptible: opts.nativeObservation.interruptible,
411
+ thought: opts.nativeObservation.thought,
401
412
  owner: Object.freeze({
402
413
  threadId: this.id,
403
414
  projectId: this.meta.projectId,
@@ -411,7 +422,11 @@ export class Thread {
411
422
 
412
423
  try {
413
424
  const result = await this.middleware.execute(ctx, () =>
414
- agent.run(payload, filterOverride, meta),
425
+ agent.run(
426
+ opts?.agentPayload !== undefined ? (opts.agentPayload as TPayload) : payload,
427
+ filterOverride,
428
+ meta,
429
+ ),
415
430
  );
416
431
 
417
432
  const durationMs = performance.now() - start;
@@ -548,11 +563,11 @@ export class Thread {
548
563
  return {
549
564
  hasLayer: (id) => {
550
565
  const l = stack.getLayer(id);
551
- return l ? l.isWarm : false;
566
+ return l ? l.assemblable : false;
552
567
  },
553
568
  getContent: (id) => {
554
569
  const l = stack.getLayer(id);
555
- return l?.isWarm ? l.content : '';
570
+ return l?.assemblable ? l.content : '';
556
571
  },
557
572
  getState: (id) => stack.getLayer(id)?.state,
558
573
  get layerIds() {
package/src/types.ts CHANGED
@@ -114,6 +114,12 @@ export interface CompletionOpts {
114
114
  * cached prefix; others ignore it. Messages are sent unchanged either way.
115
115
  */
116
116
  stablePrefix?: string;
117
+ /**
118
+ * The executor's context split into standing parts and per-turn text. A provider that keeps a
119
+ * native session delivers standing parts once and then only what changed (session-delivery.ts);
120
+ * others ignore it and send the messages unchanged.
121
+ */
122
+ delivery?: import('./session-delivery').SessionDelivery;
117
123
  }
118
124
 
119
125
  export interface CompletionResult {
@@ -125,6 +131,8 @@ export interface CompletionResult {
125
131
  /** Tool calls requested by the LLM (present when finishReason is "tool_use"). */
126
132
  readonly toolCalls?: ToolCall[];
127
133
  readonly raw?: unknown;
134
+ /** What a session provider delivered when it used `CompletionOpts.delivery`. */
135
+ readonly delivery?: import('./session-delivery').SessionDeliveryReport;
128
136
  }
129
137
 
130
138
  export interface EmbeddingResult {
@@ -1,80 +0,0 @@
1
- import { type Capability, CapabilityDeniedError, type CapabilityGate } from './capability';
2
- import type { DispatchContext, Middleware, MiddlewareNext } from './middleware';
3
-
4
- export interface PermissionCheckResult {
5
- action: 'allow' | 'deny' | 'ask';
6
- /** Optional modified payload (like Claude Code's updatedInput). */
7
- modifiedPayload?: unknown;
8
- /** Reason for denial. */
9
- reason?: string;
10
- }
11
-
12
- export interface PermissionCheck {
13
- /** Which capability this check relates to. */
14
- capability: Capability;
15
- /** Narrow the check to specific agents. If omitted, applies to all. */
16
- agentIds?: string[];
17
- /** Custom check logic beyond the gate. */
18
- check: (ctx: DispatchContext) => PermissionCheckResult | Promise<PermissionCheckResult>;
19
- }
20
-
21
- export interface PermissionMiddlewareConfig {
22
- /** The capability gate to check against. */
23
- gate: CapabilityGate;
24
- /** Thread ID for prompt context. */
25
- threadId: string;
26
- /** Default capability to check for all dispatches. Default: "llm:call". */
27
- defaultCapability?: Capability;
28
- /** Additional custom permission checks. */
29
- checks?: PermissionCheck[];
30
- }
31
-
32
- /**
33
- * Creates a middleware that checks permissions before dispatch.
34
- *
35
- * Integrates the existing CapabilityGate into the dispatch pipeline.
36
- * Records permission decisions in ctx.annotations.
37
- */
38
- export function permissionMiddleware(config: PermissionMiddlewareConfig): Middleware {
39
- const { gate, threadId, defaultCapability = 'llm:call', checks } = config;
40
-
41
- return async (ctx: DispatchContext, next: MiddlewareNext) => {
42
- // Check default capability via gate
43
- const resolution = await gate.check(defaultCapability, {
44
- agentId: ctx.agentId,
45
- threadId,
46
- detail: `Dispatch to agent ${ctx.agentId}`,
47
- });
48
-
49
- if (resolution.action === 'rejected') {
50
- ctx.annotations['permission:denied'] = true;
51
- ctx.annotations['permission:capability'] = defaultCapability;
52
- throw new CapabilityDeniedError(defaultCapability, 'deny');
53
- }
54
-
55
- ctx.annotations['permission:approved'] = true;
56
- ctx.annotations['permission:by'] = resolution.by;
57
-
58
- // Run custom checks
59
- if (checks) {
60
- for (const check of checks) {
61
- if (check.agentIds && !check.agentIds.includes(ctx.agentId)) continue;
62
-
63
- const result = await check.check(ctx);
64
-
65
- if (result.action === 'deny') {
66
- ctx.annotations['permission:denied'] = true;
67
- ctx.annotations['permission:capability'] = check.capability;
68
- ctx.annotations['permission:reason'] = result.reason;
69
- throw new CapabilityDeniedError(check.capability, 'deny');
70
- }
71
-
72
- if (result.modifiedPayload !== undefined) {
73
- ctx.annotations['permission:modifiedPayload'] = result.modifiedPayload;
74
- }
75
- }
76
- }
77
-
78
- return next();
79
- };
80
- }