@alisio/core 0.5.1 → 0.6.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.
Files changed (169) hide show
  1. package/dist/analysis/janitor.d.ts +48 -0
  2. package/dist/analysis/janitor.js +170 -18
  3. package/dist/analysis/jobs.d.ts +11 -2
  4. package/dist/analysis/jobs.js +13 -0
  5. package/dist/analysis/oci.d.ts +11 -2
  6. package/dist/analysis/oci.js +20 -4
  7. package/dist/analysis/python/sources.js +2 -1
  8. package/dist/analysis/rerun.d.ts +1 -1
  9. package/dist/analysis/status.d.ts +3 -1
  10. package/dist/analysis/status.js +3 -0
  11. package/dist/application.d.ts +38 -2
  12. package/dist/application.js +213 -4
  13. package/dist/changelog/data.js +64 -0
  14. package/dist/config.d.ts +110 -2
  15. package/dist/config.js +188 -6
  16. package/dist/core/call-internals.d.ts +22 -0
  17. package/dist/core/call-internals.js +9 -0
  18. package/dist/core/contracts.d.ts +2 -2
  19. package/dist/core/runner.js +111 -38
  20. package/dist/forge/broker.d.ts +51 -0
  21. package/dist/forge/broker.js +244 -0
  22. package/dist/forge/budget.d.ts +27 -0
  23. package/dist/forge/budget.js +60 -0
  24. package/dist/forge/catalog-view.d.ts +24 -0
  25. package/dist/forge/catalog-view.js +43 -0
  26. package/dist/forge/errors.d.ts +26 -0
  27. package/dist/forge/errors.js +49 -0
  28. package/dist/forge/guides/javascript.d.ts +25 -0
  29. package/dist/forge/guides/javascript.js +90 -0
  30. package/dist/forge/guides/provider.d.ts +42 -0
  31. package/dist/forge/guides/provider.js +96 -0
  32. package/dist/forge/guides/python-environment.d.ts +15 -0
  33. package/dist/forge/guides/python-environment.js +81 -0
  34. package/dist/forge/guides/python.d.ts +38 -0
  35. package/dist/forge/guides/python.js +170 -0
  36. package/dist/forge/javascript/engine.d.ts +44 -0
  37. package/dist/forge/javascript/engine.js +229 -0
  38. package/dist/forge/javascript/hosts/factory.d.ts +25 -0
  39. package/dist/forge/javascript/hosts/factory.js +21 -0
  40. package/dist/forge/javascript/hosts/framing.d.ts +17 -0
  41. package/dist/forge/javascript/hosts/framing.js +49 -0
  42. package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
  43. package/dist/forge/javascript/hosts/isolate-main.js +63 -0
  44. package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
  45. package/dist/forge/javascript/hosts/process-entry.js +3 -0
  46. package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
  47. package/dist/forge/javascript/hosts/process-host.js +160 -0
  48. package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
  49. package/dist/forge/javascript/hosts/worker-entry.js +3 -0
  50. package/dist/forge/javascript/hosts/worker-host.d.ts +18 -0
  51. package/dist/forge/javascript/hosts/worker-host.js +101 -0
  52. package/dist/forge/javascript/prelude.d.ts +2 -0
  53. package/dist/forge/javascript/prelude.js +333 -0
  54. package/dist/forge/javascript/probe.d.ts +2 -0
  55. package/dist/forge/javascript/probe.js +53 -0
  56. package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
  57. package/dist/forge/javascript/runtime-manifest.js +14 -0
  58. package/dist/forge/javascript/vm-runner.d.ts +21 -0
  59. package/dist/forge/javascript/vm-runner.js +301 -0
  60. package/dist/forge/javascript/wasm-asset.d.ts +8 -0
  61. package/dist/forge/javascript/wasm-asset.js +8 -0
  62. package/dist/forge/javascript/wasm.d.ts +15 -0
  63. package/dist/forge/javascript/wasm.js +54 -0
  64. package/dist/forge/journal.d.ts +105 -0
  65. package/dist/forge/journal.js +174 -0
  66. package/dist/forge/kernel.d.ts +76 -0
  67. package/dist/forge/kernel.js +338 -0
  68. package/dist/forge/limits.d.ts +38 -0
  69. package/dist/forge/limits.js +73 -0
  70. package/dist/forge/nested.d.ts +96 -0
  71. package/dist/forge/nested.js +214 -0
  72. package/dist/forge/ports.d.ts +120 -0
  73. package/dist/forge/ports.js +1 -0
  74. package/dist/forge/progress.d.ts +24 -0
  75. package/dist/forge/progress.js +76 -0
  76. package/dist/forge/protocol.d.ts +110 -0
  77. package/dist/forge/protocol.js +171 -0
  78. package/dist/forge/python/backend-selector.d.ts +38 -0
  79. package/dist/forge/python/backend-selector.js +111 -0
  80. package/dist/forge/python/backend.d.ts +89 -0
  81. package/dist/forge/python/backend.js +1 -0
  82. package/dist/forge/python/engine.d.ts +84 -0
  83. package/dist/forge/python/engine.js +279 -0
  84. package/dist/forge/python/frame-parser.d.ts +24 -0
  85. package/dist/forge/python/frame-parser.js +107 -0
  86. package/dist/forge/python/frame-transport.d.ts +40 -0
  87. package/dist/forge/python/frame-transport.js +101 -0
  88. package/dist/forge/python/import-scan.d.ts +22 -0
  89. package/dist/forge/python/import-scan.js +133 -0
  90. package/dist/forge/python/managed-backend.d.ts +30 -0
  91. package/dist/forge/python/managed-backend.js +111 -0
  92. package/dist/forge/python/oci-backend.d.ts +27 -0
  93. package/dist/forge/python/oci-backend.js +84 -0
  94. package/dist/forge/python/pyodide-backend.d.ts +40 -0
  95. package/dist/forge/python/pyodide-backend.js +289 -0
  96. package/dist/forge/python/pyodide-install.d.ts +41 -0
  97. package/dist/forge/python/pyodide-install.js +167 -0
  98. package/dist/forge/python/pyodide-manifest.d.ts +42 -0
  99. package/dist/forge/python/pyodide-manifest.js +376 -0
  100. package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
  101. package/dist/forge/python/pyodide-runner-source.js +3 -0
  102. package/dist/forge/python/rss.d.ts +2 -0
  103. package/dist/forge/python/rss.js +33 -0
  104. package/dist/forge/python/scratch-copy.d.ts +6 -0
  105. package/dist/forge/python/scratch-copy.js +61 -0
  106. package/dist/forge/python/settle.d.ts +6 -0
  107. package/dist/forge/python/settle.js +21 -0
  108. package/dist/forge/python/status.d.ts +8 -0
  109. package/dist/forge/python/status.js +15 -0
  110. package/dist/forge/python/wasm-node.d.ts +59 -0
  111. package/dist/forge/python/wasm-node.js +220 -0
  112. package/dist/forge/python/wiring.d.ts +39 -0
  113. package/dist/forge/python/wiring.js +47 -0
  114. package/dist/forge/registry.d.ts +26 -0
  115. package/dist/forge/registry.js +83 -0
  116. package/dist/forge/result.d.ts +58 -0
  117. package/dist/forge/result.js +86 -0
  118. package/dist/forge/routing-rule.d.ts +19 -0
  119. package/dist/forge/routing-rule.js +31 -0
  120. package/dist/forge/runtime.d.ts +64 -0
  121. package/dist/forge/runtime.js +88 -0
  122. package/dist/forge/scratch-files.d.ts +36 -0
  123. package/dist/forge/scratch-files.js +94 -0
  124. package/dist/forge/scratch-provider.d.ts +41 -0
  125. package/dist/forge/scratch-provider.js +79 -0
  126. package/dist/forge/scratch-quota.d.ts +8 -0
  127. package/dist/forge/scratch-quota.js +25 -0
  128. package/dist/forge/selector.d.ts +35 -0
  129. package/dist/forge/selector.js +51 -0
  130. package/dist/forge/session-dir.d.ts +59 -0
  131. package/dist/forge/session-dir.js +239 -0
  132. package/dist/forge/session-path.d.ts +31 -0
  133. package/dist/forge/session-path.js +61 -0
  134. package/dist/forge/slots.d.ts +16 -0
  135. package/dist/forge/slots.js +47 -0
  136. package/dist/forge/state-delta.d.ts +54 -0
  137. package/dist/forge/state-delta.js +99 -0
  138. package/dist/forge/state-machine.d.ts +20 -0
  139. package/dist/forge/state-machine.js +40 -0
  140. package/dist/forge/state-store.d.ts +25 -0
  141. package/dist/forge/state-store.js +50 -0
  142. package/dist/forge/task-log.d.ts +9 -0
  143. package/dist/forge/task-log.js +42 -0
  144. package/dist/forge/verdict.d.ts +20 -0
  145. package/dist/forge/verdict.js +28 -0
  146. package/dist/forge-isolate.d.ts +6 -0
  147. package/dist/forge-isolate.js +6 -0
  148. package/dist/index.d.ts +7 -0
  149. package/dist/index.js +7 -0
  150. package/dist/permissions/decide.d.ts +42 -0
  151. package/dist/permissions/decide.js +24 -0
  152. package/dist/resources/context.d.ts +2 -1
  153. package/dist/resources/context.js +2 -1
  154. package/dist/runtime/access.d.ts +34 -0
  155. package/dist/runtime/access.js +80 -0
  156. package/dist/runtime/process.d.ts +2 -1
  157. package/dist/runtime/process.js +5 -5
  158. package/dist/runtime/store.d.ts +11 -0
  159. package/dist/runtime/store.js +103 -0
  160. package/dist/tools/analysis.d.ts +25 -2
  161. package/dist/tools/analysis.js +315 -267
  162. package/dist/tools/execute.js +6 -7
  163. package/dist/tools/forge-guide.d.ts +6 -0
  164. package/dist/tools/forge-guide.js +60 -0
  165. package/dist/tools/js-run.d.ts +19 -0
  166. package/dist/tools/js-run.js +126 -0
  167. package/dist/tools/standard.d.ts +7 -1
  168. package/dist/tools/standard.js +44 -43
  169. package/package.json +7 -2
@@ -1,4 +1,8 @@
1
1
  import { isEphemeralRunEventType, textProjection, textResult, } from "@alisio/sdk";
2
+ import { createNestedDispatcher } from "../forge/nested.js";
3
+ import { hasScratchEstimator, scratchIncomingBytes } from "../forge/scratch-quota.js";
4
+ import { decideEffect, effectAskable } from "../permissions/decide.js";
5
+ import { bindCallInternals } from "./call-internals.js";
2
6
  import { checkpointInstructions, estimateTokens, MAX_TRUSTED_WINDOW, parseCheckpointOutput, planCompaction, reduceMessagesToBudget, shouldCompactContext, summarize, summaryMessage, } from "./compaction.js";
3
7
  import { HumanWaits } from "./human-wait.js";
4
8
  import { EXIT_PLAN_TOOL, optInAllows } from "./opt-in.js";
@@ -48,11 +52,6 @@ const MIN_MESSAGE_BUDGET_FLOOR = 4_000;
48
52
  * would pay for a summarizer call. The hard-limit last-chance compaction ignores it.
49
53
  */
50
54
  const RECOMPACT_GROWTH_MARGIN = 0.1;
51
- const allowed = (policy, effect) => effect === "read" ||
52
- effect === "internal" ||
53
- ((effect === "write" || effect === "process" || effect === "external") && !!policy[effect]);
54
- /** A tool's capability is pre-allowed by the policy alone (`--allow-analysis`). */
55
- const capabilityAllowed = (policy, tool) => tool.capability === "analysis.run" && !!policy.analysis;
56
55
  /** First 40 lines of a script input, for capability approvals. */
57
56
  const scriptPreview = (input) => typeof input.code === "string" ? input.code.split(/\r?\n/).slice(0, 40).join("\n") : undefined;
58
57
  /** A short id for a tool call the user started from a UI (alphanumeric, 9 characters). */
@@ -164,16 +163,13 @@ export class AgentRunner {
164
163
  return false;
165
164
  if (!optInAllows(t.name, run.optInTools))
166
165
  return false;
167
- const effect = t.effect ?? "external";
168
- if (allowed(policy, effect))
169
- return true;
170
- if (t.capability) {
171
- if (capabilityAllowed(policy, t))
172
- return true;
173
- if (sessionId && o.capabilities?.granted(t.capability, sessionId))
174
- return true;
175
- }
176
- return approvals && (effect === "write" || effect === "process" || effect === "external");
166
+ // Offered when the call would run or could be asked: anything but a denial.
167
+ return (decideEffect(policy, t, {
168
+ approvals,
169
+ capabilityGranted: !!(t.capability &&
170
+ sessionId &&
171
+ o.capabilities?.granted(t.capability, sessionId)),
172
+ }).kind !== "deny");
177
173
  });
178
174
  }
179
175
  /** Abort signal of the session's active run, if any (used to cascade cancellation). */
@@ -415,6 +411,11 @@ export class AgentRunner {
415
411
  const { call } = p;
416
412
  let result;
417
413
  const started = Date.now();
414
+ /** Set by the call itself (see `CallInternals`): it may have left effects. */
415
+ let effectful = false;
416
+ let dispatcher;
417
+ /** Held from the scratchpad quota check until the write is done (L1). */
418
+ let releaseScratch;
418
419
  // `effect` rides along so the TUI can group consecutive calls by capability kind
419
420
  // (read batches, write batches, commands…) without a name heuristic. Additive field.
420
421
  emit("tool_started", {
@@ -436,6 +437,7 @@ export class AgentRunner {
436
437
  // resolved map keeps "allow once" scoped to this one call, with no shared mutable
437
438
  // state that parallel read batches could race on.
438
439
  const resolved = new Map();
440
+ const targets = [];
439
441
  if (pathAccess)
440
442
  for (const declared of p.tool.paths?.(p.input) ?? [])
441
443
  if (!resolved.has(declared)) {
@@ -450,6 +452,7 @@ export class AgentRunner {
450
452
  // ask a second time for an "allow once" decision.
451
453
  resolved.set(declared, target);
452
454
  resolved.set(target, target);
455
+ targets.push(target);
453
456
  }
454
457
  scope.guard?.(effect);
455
458
  if ((options.toolFilter && !options.toolFilter(p.tool)) ||
@@ -457,26 +460,62 @@ export class AgentRunner {
457
460
  throw new Error(`Tool ${call.name} is not available in this session`);
458
461
  const capability = p.tool.capability;
459
462
  const gate = o.capabilities;
460
- if (capability && (allowed(policy, effect) || capabilityAllowed(policy, p.tool))) {
461
- // A broad process grant or --allow-analysis: allowed, audited as a flag decision.
462
- gate?.record({
463
- capability,
464
- sessionId,
465
- scope: "once",
466
- decision: "allow",
467
- source: "flag",
468
- callId: call.id,
469
- runId,
470
- ...(options.correlationId !== undefined ? { correlationId: options.correlationId } : {}),
471
- });
463
+ let decision = decideEffect(policy, p.tool, {
464
+ approvals: !!o.approve && approvals,
465
+ capabilityGranted: !!capability && !!gate?.granted(capability, sessionId),
466
+ });
467
+ // The session's scratchpad is an authorized write root (Forja, spec §9.4): a write confined
468
+ // to it needs no approval in any mode, headless runs included. What is lifted is a call that
469
+ // WOULD ask a person; a run locked down on purpose (`--read-only`, which has no scratchpad
470
+ // root, or a child run with approvals turned off) keeps its denial.
471
+ if (decision.kind !== "allow" &&
472
+ effect === "write" &&
473
+ !capability &&
474
+ options.approvals !== false &&
475
+ hasScratchEstimator(p.tool.name) &&
476
+ pathAccess &&
477
+ targets.length > 0 &&
478
+ decideEffect(policy, p.tool, { approvals: true, capabilityGranted: false }).kind ===
479
+ "ask" &&
480
+ (await Promise.all(targets.map((t) => pathAccess.isScratchPath(t, sessionId)))).every(Boolean))
481
+ decision = { kind: "allow", via: "effect" };
482
+ // A write into the scratchpad also has a quota (FJ-AC-34), whatever allowed it: a full
483
+ // scratchpad refuses new writes with a plain error and deletes nothing.
484
+ if (decision.kind === "allow" &&
485
+ effect === "write" &&
486
+ !capability &&
487
+ pathAccess &&
488
+ targets.length > 0 &&
489
+ (await Promise.all(targets.map((t) => pathAccess.isScratchPath(t, sessionId)))).every(Boolean)) {
490
+ releaseScratch = await pathAccess.lockScratch(sessionId);
491
+ const problem = await pathAccess.scratchQuotaProblem(sessionId, await scratchIncomingBytes(p.tool.name, p.input, targets), targets);
492
+ if (problem)
493
+ throw new Error(problem);
494
+ }
495
+ if (decision.kind === "allow") {
496
+ // A broad process grant or --allow-analysis is audited as a flag decision; a plain effect
497
+ // and a persisted "allow for this session" grant of the root session leave no record here.
498
+ if (capability && decision.via === "flag")
499
+ gate?.record({
500
+ capability,
501
+ sessionId,
502
+ scope: "once",
503
+ decision: "allow",
504
+ source: "flag",
505
+ callId: call.id,
506
+ runId,
507
+ ...(options.correlationId !== undefined
508
+ ? { correlationId: options.correlationId }
509
+ : {}),
510
+ });
472
511
  }
473
- else if (capability && gate?.granted(capability, sessionId)) {
474
- // A persisted "allow for this session" grant of the root session.
512
+ else if (decision.kind === "deny") {
513
+ throw new Error(`Capability denied: ${capability ?? effect}`);
475
514
  }
476
- else if (!allowed(policy, effect)) {
477
- if (!o.approve ||
478
- !approvals ||
479
- (effect !== "write" && effect !== "process" && effect !== "external"))
515
+ else {
516
+ const approve = o.approve;
517
+ // `ask` is only ever decided for an askable effect with a handler (see `decideEffect`).
518
+ if (!approve || !effectAskable(effect))
480
519
  throw new Error(`Capability denied: ${capability ?? effect}`);
481
520
  const preview = capability
482
521
  ? (scriptPreview(p.input) ?? (await gate?.preview?.(p.input, sessionId)))
@@ -488,7 +527,6 @@ export class AgentRunner {
488
527
  ...(options.label ? { label: options.label } : {}),
489
528
  ...(capability ? { capability } : {}),
490
529
  });
491
- const approve = o.approve;
492
530
  const callInput = p.input;
493
531
  // Waiting for the person is not work: the run's time limit does not count it.
494
532
  const decision = await this.humanWaits.track(sessionId, () => approve({
@@ -593,7 +631,7 @@ export class AgentRunner {
593
631
  });
594
632
  return allowedNow ? "once" : "deny";
595
633
  };
596
- result = await p.tool.execute(p.input, {
634
+ const toolContext = {
597
635
  signal: combined,
598
636
  workspace,
599
637
  session: sessionId,
@@ -625,7 +663,35 @@ export class AgentRunner {
625
663
  },
626
664
  }
627
665
  : {}),
666
+ };
667
+ // Only the core's own tools can find these (see `CallInternals`); the SDK context is unchanged.
668
+ bindCallInternals(toolContext, {
669
+ markEffectful: () => {
670
+ effectful = true;
671
+ },
672
+ nested: () => (dispatcher ??= createNestedDispatcher({
673
+ registry: o.registry,
674
+ policy,
675
+ approvals: !!o.approve && approvals,
676
+ scratchWrites: options.approvals !== false,
677
+ ...(options.toolFilter ? { toolFilter: options.toolFilter } : {}),
678
+ ...(options.optInTools ? { optInTools: options.optInTools } : {}),
679
+ workspace,
680
+ session: sessionId,
681
+ ...(options.label ? { label: options.label } : {}),
682
+ runId,
683
+ parentCallId: call.id,
684
+ signal: combined,
685
+ ...(pathAccess ? { pathAccess } : {}),
686
+ ...(publisher ? { artifacts: publisher } : {}),
687
+ ...(decisions ? { decisions } : {}),
688
+ ...(scope.guard ? { guard: scope.guard } : {}),
689
+ markEffectful: () => {
690
+ effectful = true;
691
+ },
692
+ })),
628
693
  });
694
+ result = await p.tool.execute(p.input, toolContext);
629
695
  // The bound applies to what the model sees (the text projection): display-only
630
696
  // ui/image parts are bounded by their producers and must not push text over it.
631
697
  const projected = textProjection(result);
@@ -640,10 +706,14 @@ export class AgentRunner {
640
706
  // the user to inspect. A `read` call cannot have left any (and `exit_plan`, which is one,
641
707
  // is exactly what a stop or the run limit interrupts while a person decides): it is closed
642
708
  // with the cancellation, so the next message continues normally instead of asking to
643
- // "recover" a call nobody needs to inspect.
644
- if (!combined.aborted || p.tool?.effect === "read")
709
+ // "recover" a call nobody needs to inspect. A read that flagged itself `effectful` (js_run
710
+ // after a nested write) is the exception: it stays pending like any other effectful call.
711
+ if (!combined.aborted || (p.tool?.effect === "read" && !effectful))
645
712
  o.store.endCall(sessionId, call, result);
646
713
  }
714
+ finally {
715
+ releaseScratch?.();
716
+ }
647
717
  emit("tool_completed", {
648
718
  id: call.id,
649
719
  name: call.name,
@@ -1262,7 +1332,10 @@ export class AgentRunner {
1262
1332
  const execute = (p) => this.executeCall(scope, p);
1263
1333
  const results = [];
1264
1334
  // Bounded parallel batches for consecutive read or explicitly concurrent tools.
1265
- const parallel = (p) => !!p?.tool && (p.tool.effect === "read" || p.tool.concurrent === true);
1335
+ // `concurrent: false` keeps a tool out of the batch even when it is a read (js_run).
1336
+ const parallel = (p) => !!p?.tool &&
1337
+ p.tool.concurrent !== false &&
1338
+ (p.tool.effect === "read" || p.tool.concurrent === true);
1266
1339
  for (let i = 0; i < prepared.length;) {
1267
1340
  const current = prepared[i];
1268
1341
  if (!current)
@@ -0,0 +1,51 @@
1
+ import type { ForgeProgress } from "@alisio/sdk";
2
+ import type { BudgetLedger } from "./budget.ts";
3
+ import type { SqliteExecutionJournal } from "./journal.ts";
4
+ import type { ForgeLimits } from "./limits.ts";
5
+ import type { NestedCallDispatcher } from "./nested.ts";
6
+ import type { HostReply } from "./ports.ts";
7
+ import type { ForgeOutputItem, GuestMessage } from "./protocol.ts";
8
+ import type { StateDelta } from "./state-delta.ts";
9
+ /** Maximum catalog lookups per execution; they are cheap but not free. */
10
+ export declare const MAX_CATALOG_REQUESTS = 200;
11
+ /** Progress messages one execution may report; the rest is dropped (counted, not stored). */
12
+ export declare const MAX_PROGRESS_MESSAGES = 10000;
13
+ /** Characters of one progress message the host keeps. */
14
+ export declare const MAX_PROGRESS_CHARS = 500;
15
+ /** Bytes of state operations one execution may queue (the commit enforces the stored total). */
16
+ export declare const MAX_STATE_DELTA_BYTES: number;
17
+ export interface BrokerDeps {
18
+ dispatcher: NestedCallDispatcher;
19
+ ledger: BudgetLedger;
20
+ limits: ForgeLimits;
21
+ journal: Pick<SqliteExecutionJournal, "startCall" | "endCall">;
22
+ executionId: string;
23
+ /** Aborts every nested call when the execution is stopped. */
24
+ signal: AbortSignal;
25
+ /** `undefined` when the stored state is switched off. */
26
+ state: StateDelta | undefined;
27
+ /** Coalesced, droppable progress (script progress, a call starting). */
28
+ update(progress: ForgeProgress): void;
29
+ /** Never dropped (a call ended). */
30
+ terminal(progress: ForgeProgress): void;
31
+ engine: ForgeProgress["engine"];
32
+ log(line: string): void;
33
+ now?: () => number;
34
+ }
35
+ export interface CallStats {
36
+ total: number;
37
+ failed: number;
38
+ denied: number;
39
+ }
40
+ export declare class CapabilityBroker {
41
+ #private;
42
+ readonly output: ForgeOutputItem[];
43
+ readonly stats: CallStats;
44
+ /** Progress messages dropped past `MAX_PROGRESS_MESSAGES` (reported in the journal metrics). */
45
+ progressDropped: number;
46
+ constructor(deps: BrokerDeps);
47
+ bind(send: (reply: HostReply) => void): void;
48
+ /** Resolves when every call the guest started has finished (or the timeout passed). */
49
+ drain(timeoutMs: number): Promise<void>;
50
+ receive(message: GuestMessage): Promise<HostReply | undefined>;
51
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The capability broker (spec §4.2, §6.4): the kernel's side of the guest's messages. A script sees
3
+ * proxy functions; every real decision happens here, with the numbers the HOST keeps:
4
+ *
5
+ * - nested calls go through the run's nested-call dispatcher (the same gate as a direct call, no
6
+ * new approvals, D7/D-24), paced by the `BudgetLedger` (total, concurrent, credits);
7
+ * - sizes, output and state are bounded again no matter what the prelude checked;
8
+ * - every nested call is journaled and reported as `ForgeProgress`.
9
+ *
10
+ * Errors that mean "the guest broke a limit" are thrown as `ForgeError`: the engine ends the
11
+ * execution with that code. Errors of one tool call are answered to the guest as `ok: false`.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { describeTool, searchCatalog } from "./catalog-view.js";
15
+ import { ForgeError } from "./errors.js";
16
+ import { clipText } from "./result.js";
17
+ /** Maximum catalog lookups per execution; they are cheap but not free. */
18
+ export const MAX_CATALOG_REQUESTS = 200;
19
+ /** Progress messages one execution may report; the rest is dropped (counted, not stored). */
20
+ export const MAX_PROGRESS_MESSAGES = 10_000;
21
+ /** Characters of one progress message the host keeps. */
22
+ export const MAX_PROGRESS_CHARS = 500;
23
+ /** Bytes of state operations one execution may queue (the commit enforces the stored total). */
24
+ export const MAX_STATE_DELTA_BYTES = 1024 * 1024;
25
+ const DENIED = new Set(["PERMISSION_DENIED", "APPROVAL_REQUIRED"]);
26
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
27
+ const positionOf = (nestedCallId) => Number(nestedCallId.slice(nestedCallId.lastIndexOf(":") + 1));
28
+ export class CapabilityBroker {
29
+ #deps;
30
+ #now;
31
+ output = [];
32
+ stats = { total: 0, failed: 0, denied: 0 };
33
+ #inflight = new Set();
34
+ #send = () => { };
35
+ /** Progress messages dropped past `MAX_PROGRESS_MESSAGES` (reported in the journal metrics). */
36
+ progressDropped = 0;
37
+ #progressSeen = 0;
38
+ #catalogRequests = 0;
39
+ #stateBytes = 0;
40
+ constructor(deps) {
41
+ this.#deps = deps;
42
+ this.#now = deps.now ?? Date.now;
43
+ }
44
+ bind(send) {
45
+ this.#send = send;
46
+ }
47
+ /** Resolves when every call the guest started has finished (or the timeout passed). */
48
+ async drain(timeoutMs) {
49
+ if (this.#inflight.size === 0)
50
+ return;
51
+ await Promise.race([
52
+ Promise.allSettled([...this.#inflight]),
53
+ new Promise((resolve) => setTimeout(resolve, timeoutMs).unref?.()),
54
+ ]);
55
+ }
56
+ async receive(message) {
57
+ switch (message.type) {
58
+ case "ready":
59
+ return { type: "grant", payload: this.#deps.ledger.grant() };
60
+ case "progress": {
61
+ if (++this.#progressSeen > MAX_PROGRESS_MESSAGES) {
62
+ if (this.progressDropped++ === 0)
63
+ this.#deps.log(`progress: more than ${MAX_PROGRESS_MESSAGES} messages; the rest is dropped`);
64
+ return undefined;
65
+ }
66
+ const text = clipText(message.payload.message, MAX_PROGRESS_CHARS).text;
67
+ this.#deps.log(`progress: ${text}`);
68
+ this.#deps.update({
69
+ kind: "forge",
70
+ executionId: this.#deps.executionId,
71
+ engine: this.#deps.engine,
72
+ phase: "running",
73
+ message: text,
74
+ ...(message.payload.completed !== undefined
75
+ ? { completed: message.payload.completed }
76
+ : {}),
77
+ ...(message.payload.total !== undefined ? { total: message.payload.total } : {}),
78
+ });
79
+ return undefined;
80
+ }
81
+ case "output":
82
+ this.#recordOutput(message.payload);
83
+ return undefined;
84
+ case "state.set":
85
+ this.#state().set(message.payload.key, message.payload.value);
86
+ this.#countState(message.payload.key, message.payload.value);
87
+ return undefined;
88
+ case "state.delete":
89
+ this.#state().delete(message.payload.key);
90
+ this.#countState(message.payload.key, null);
91
+ return undefined;
92
+ case "catalog":
93
+ return this.#catalog(message.payload);
94
+ case "call": {
95
+ // Not awaited by the engine's dispatch: calls of one script run concurrently.
96
+ const job = this.#call(message.payload);
97
+ this.#inflight.add(job);
98
+ const settled = () => this.#inflight.delete(job);
99
+ job.then(settled, settled);
100
+ await job;
101
+ return undefined;
102
+ }
103
+ default:
104
+ return undefined;
105
+ }
106
+ }
107
+ #state() {
108
+ const state = this.#deps.state;
109
+ if (!state)
110
+ throw new ForgeError("INVALID_INPUT", "stored state is switched off (forge.state.enabled)");
111
+ return state;
112
+ }
113
+ #countState(key, value) {
114
+ this.#stateBytes += key.length + JSON.stringify(value).length;
115
+ if (this.#stateBytes > MAX_STATE_DELTA_BYTES)
116
+ throw new ForgeError("BUDGET_EXCEEDED", "this execution changed too much stored state");
117
+ }
118
+ #recordOutput(item) {
119
+ const bytes = Buffer.byteLength(JSON.stringify(item), "utf8");
120
+ this.#deps.ledger.recordOutput(bytes);
121
+ this.output.push(item);
122
+ if (item.type === "text")
123
+ this.#deps.log(item.text.replace(/\n$/, ""));
124
+ }
125
+ #catalog(payload) {
126
+ if (++this.#catalogRequests > MAX_CATALOG_REQUESTS)
127
+ throw new ForgeError("BUDGET_EXCEEDED", `catalog lookups are limited to ${MAX_CATALOG_REQUESTS} per execution`);
128
+ const tools = this.#deps.dispatcher.catalog();
129
+ const entries = payload.op === "search"
130
+ ? searchCatalog(tools, payload.query, payload.limit)
131
+ : [describeTool(tools, payload.query)].filter((entry) => entry !== undefined);
132
+ return { type: "catalog.result", payload: { requestId: payload.requestId, entries } };
133
+ }
134
+ async #call(payload) {
135
+ const { ledger, journal, executionId, limits } = this.#deps;
136
+ // Only names the registry has are ever recorded or broadcast; anything else is a placeholder.
137
+ const tool = this.#deps.dispatcher.knows(payload.tool)
138
+ ? payload.tool
139
+ : `unknown:${sha256(payload.tool).slice(0, 12)}`;
140
+ // The host's own count: a call without a credit, or past the total, ends the execution.
141
+ ledger.startCall();
142
+ this.stats.total++;
143
+ const startedAt = this.#now();
144
+ const progress = (status, extra = {}) => ({
145
+ kind: "forge",
146
+ executionId,
147
+ engine: this.#deps.engine,
148
+ phase: "call",
149
+ call: { callId: payload.callId, tool, status, ...extra },
150
+ });
151
+ let journalId;
152
+ let ended = false;
153
+ const end = (status, errorCode) => {
154
+ if (journalId && !ended) {
155
+ ended = true;
156
+ journal.endCall(executionId, journalId, {
157
+ status,
158
+ durationMs: this.#now() - startedAt,
159
+ ...(errorCode ? { errorCode } : {}),
160
+ });
161
+ }
162
+ };
163
+ let reply;
164
+ try {
165
+ const inputJson = JSON.stringify(payload.input ?? {});
166
+ if (Buffer.byteLength(inputJson, "utf8") > limits.maxCallInputBytes) {
167
+ reply = this.#error(payload.callId, "TOOL_INPUT_INVALID", `tool input is larger than ${limits.maxCallInputBytes} bytes`);
168
+ this.stats.failed++;
169
+ this.#deps.terminal(progress("failed"));
170
+ }
171
+ else {
172
+ this.#deps.log(`call ${tool}`);
173
+ this.#deps.update(progress("started"));
174
+ const outcome = await this.#deps.dispatcher.call({
175
+ name: payload.tool,
176
+ input: payload.input,
177
+ signal: this.#deps.signal,
178
+ onStart: (start) => {
179
+ journalId = journal.startCall({
180
+ executionId,
181
+ n: positionOf(start.nestedCallId),
182
+ toolName: start.name,
183
+ effect: start.effect,
184
+ inputSha256: sha256(inputJson),
185
+ paths: start.paths,
186
+ });
187
+ },
188
+ });
189
+ if (outcome.ok) {
190
+ const valueJson = JSON.stringify(outcome.value) ?? "null";
191
+ if (Buffer.byteLength(valueJson, "utf8") > limits.maxCallResponseBytes) {
192
+ reply = this.#error(payload.callId, "OUTPUT_LIMIT", `${payload.tool} returned ${Buffer.byteLength(valueJson, "utf8")} bytes, over the ${limits.maxCallResponseBytes} byte limit; ask for less (a smaller range, a filter, a page)`);
193
+ this.stats.failed++;
194
+ end("failed", "OUTPUT_LIMIT");
195
+ this.#deps.terminal(progress("failed", { durationMs: this.#now() - startedAt }));
196
+ }
197
+ else {
198
+ reply = {
199
+ type: "result",
200
+ payload: { callId: payload.callId, ok: true, value: outcome.value ?? null },
201
+ };
202
+ end("succeeded");
203
+ this.#deps.terminal(progress("succeeded", { durationMs: this.#now() - startedAt }));
204
+ }
205
+ }
206
+ else {
207
+ if (!journalId) {
208
+ // Refused before it started (denied, unknown, invalid): still on the record.
209
+ journalId = journal.startCall({
210
+ executionId,
211
+ n: positionOf(outcome.nestedCallId),
212
+ toolName: tool,
213
+ effect: (outcome.effect ?? "external"),
214
+ inputSha256: sha256(inputJson),
215
+ });
216
+ }
217
+ const denied = DENIED.has(outcome.code);
218
+ if (denied)
219
+ this.stats.denied++;
220
+ else
221
+ this.stats.failed++;
222
+ end("failed", outcome.code);
223
+ this.#deps.log(`call ${tool} failed: ${outcome.code}`);
224
+ this.#deps.terminal(progress(denied ? "denied" : "failed", { durationMs: this.#now() - startedAt }));
225
+ reply = this.#error(payload.callId, outcome.code, outcome.message);
226
+ }
227
+ }
228
+ }
229
+ catch (error) {
230
+ end("failed", "TOOL_FAILED");
231
+ this.stats.failed++;
232
+ this.#deps.terminal(progress("failed", { durationMs: this.#now() - startedAt }));
233
+ reply = this.#error(payload.callId, "TOOL_FAILED", error instanceof Error ? error.message : String(error));
234
+ }
235
+ finally {
236
+ ledger.finishCall();
237
+ }
238
+ this.#send(reply);
239
+ this.#send({ type: "grant", payload: ledger.grant() });
240
+ }
241
+ #error(callId, code, message) {
242
+ return { type: "result", payload: { callId, ok: false, error: { code, message } } };
243
+ }
244
+ }
@@ -0,0 +1,27 @@
1
+ export interface BudgetLimits {
2
+ maxToolCalls: number;
3
+ maxConcurrency: number;
4
+ maxRetainedOutputBytes: number;
5
+ }
6
+ export declare class BudgetLedger {
7
+ #private;
8
+ constructor(limits: BudgetLimits);
9
+ get stats(): {
10
+ started: number;
11
+ active: number;
12
+ outputBytes: number;
13
+ };
14
+ /**
15
+ * Tops the guest's credits up to what it may still use and returns the increment to send in a
16
+ * `grant` message. Call credits never exceed the free concurrency slots or the remaining total.
17
+ */
18
+ grant(): {
19
+ calls: number;
20
+ bytes: number;
21
+ };
22
+ /** Spends a credit for a new nested call or throws `BUDGET_EXCEEDED` without changing state. */
23
+ startCall(): void;
24
+ finishCall(): void;
25
+ /** Accounts output bytes, or throws `OUTPUT_LIMIT` without partial accounting. */
26
+ recordOutput(bytes: number): void;
27
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Per-execution budget: total and concurrent nested calls, the credits granted to the guest
3
+ * (backpressure, spec §5.4) and retained output bytes. The host enforces these numbers even when
4
+ * the guest prelude checks them too. Pure: no I/O.
5
+ */
6
+ import { ForgeError } from "./errors.js";
7
+ export class BudgetLedger {
8
+ #limits;
9
+ #started = 0;
10
+ #active = 0;
11
+ #outputBytes = 0;
12
+ #callCredits = 0;
13
+ #byteCredits = 0;
14
+ constructor(limits) {
15
+ this.#limits = limits;
16
+ }
17
+ get stats() {
18
+ return { started: this.#started, active: this.#active, outputBytes: this.#outputBytes };
19
+ }
20
+ /**
21
+ * Tops the guest's credits up to what it may still use and returns the increment to send in a
22
+ * `grant` message. Call credits never exceed the free concurrency slots or the remaining total.
23
+ */
24
+ grant() {
25
+ const callTarget = Math.max(0, Math.min(this.#limits.maxConcurrency - this.#active, this.#limits.maxToolCalls - this.#started));
26
+ const calls = Math.max(0, callTarget - this.#callCredits);
27
+ this.#callCredits += calls;
28
+ const bytes = this.#limits.maxRetainedOutputBytes - this.#outputBytes - this.#byteCredits;
29
+ this.#byteCredits += bytes;
30
+ return { calls, bytes };
31
+ }
32
+ /** Spends a credit for a new nested call or throws `BUDGET_EXCEEDED` without changing state. */
33
+ startCall() {
34
+ if (this.#started >= this.#limits.maxToolCalls) {
35
+ throw new ForgeError("BUDGET_EXCEEDED", `Tool call limit reached (${this.#limits.maxToolCalls})`);
36
+ }
37
+ if (this.#active >= this.#limits.maxConcurrency) {
38
+ throw new ForgeError("BUDGET_EXCEEDED", `Concurrent tool call limit reached (${this.#limits.maxConcurrency})`);
39
+ }
40
+ if (this.#callCredits <= 0) {
41
+ throw new ForgeError("BUDGET_EXCEEDED", "Tool call without a granted credit");
42
+ }
43
+ this.#callCredits--;
44
+ this.#started++;
45
+ this.#active++;
46
+ }
47
+ finishCall() {
48
+ if (this.#active <= 0)
49
+ throw new Error("finishCall without an active call");
50
+ this.#active--;
51
+ }
52
+ /** Accounts output bytes, or throws `OUTPUT_LIMIT` without partial accounting. */
53
+ recordOutput(bytes) {
54
+ if (this.#outputBytes + bytes > this.#limits.maxRetainedOutputBytes) {
55
+ throw new ForgeError("OUTPUT_LIMIT", `Retained output limit reached (${this.#limits.maxRetainedOutputBytes} bytes)`);
56
+ }
57
+ this.#outputBytes += bytes;
58
+ this.#byteCredits = Math.max(0, this.#byteCredits - bytes);
59
+ }
60
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * What `catalog.search` and `catalog.describe` show a script (spec §6.2, D13): the tools the run
3
+ * lets it call right now, found by name or description, never the whole prompt-sized catalog.
4
+ * Pure over the tool definitions.
5
+ */
6
+ import type { ToolDefinition } from "@alisio/sdk";
7
+ export declare const CATALOG_DEFAULT_LIMIT = 10;
8
+ export declare const CATALOG_MAX_LIMIT = 25;
9
+ export interface CatalogEntry {
10
+ name: string;
11
+ effect: string;
12
+ summary: string;
13
+ }
14
+ export interface CatalogDetail {
15
+ name: string;
16
+ effect: string;
17
+ description: string;
18
+ inputSchema: unknown;
19
+ }
20
+ /** The first sentence of a description, bounded. */
21
+ export declare function summarize(description: string): string;
22
+ /** Tools whose name or description holds every term of the query (all of them for an empty query). */
23
+ export declare function searchCatalog(tools: readonly ToolDefinition[], query: string, limit?: number): CatalogEntry[];
24
+ export declare function describeTool(tools: readonly ToolDefinition[], name: string): CatalogDetail | undefined;