@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.
- package/dist/analysis/janitor.d.ts +48 -0
- package/dist/analysis/janitor.js +170 -18
- package/dist/analysis/jobs.d.ts +11 -2
- package/dist/analysis/jobs.js +13 -0
- package/dist/analysis/oci.d.ts +11 -2
- package/dist/analysis/oci.js +20 -4
- package/dist/analysis/python/sources.js +2 -1
- package/dist/analysis/rerun.d.ts +1 -1
- package/dist/analysis/status.d.ts +3 -1
- package/dist/analysis/status.js +3 -0
- package/dist/application.d.ts +38 -2
- package/dist/application.js +213 -4
- package/dist/changelog/data.js +64 -0
- package/dist/config.d.ts +110 -2
- package/dist/config.js +188 -6
- package/dist/core/call-internals.d.ts +22 -0
- package/dist/core/call-internals.js +9 -0
- package/dist/core/contracts.d.ts +2 -2
- package/dist/core/runner.js +111 -38
- package/dist/forge/broker.d.ts +51 -0
- package/dist/forge/broker.js +244 -0
- package/dist/forge/budget.d.ts +27 -0
- package/dist/forge/budget.js +60 -0
- package/dist/forge/catalog-view.d.ts +24 -0
- package/dist/forge/catalog-view.js +43 -0
- package/dist/forge/errors.d.ts +26 -0
- package/dist/forge/errors.js +49 -0
- package/dist/forge/guides/javascript.d.ts +25 -0
- package/dist/forge/guides/javascript.js +90 -0
- package/dist/forge/guides/provider.d.ts +42 -0
- package/dist/forge/guides/provider.js +96 -0
- package/dist/forge/guides/python-environment.d.ts +15 -0
- package/dist/forge/guides/python-environment.js +81 -0
- package/dist/forge/guides/python.d.ts +38 -0
- package/dist/forge/guides/python.js +170 -0
- package/dist/forge/javascript/engine.d.ts +44 -0
- package/dist/forge/javascript/engine.js +229 -0
- package/dist/forge/javascript/hosts/factory.d.ts +25 -0
- package/dist/forge/javascript/hosts/factory.js +21 -0
- package/dist/forge/javascript/hosts/framing.d.ts +17 -0
- package/dist/forge/javascript/hosts/framing.js +49 -0
- package/dist/forge/javascript/hosts/isolate-main.d.ts +9 -0
- package/dist/forge/javascript/hosts/isolate-main.js +63 -0
- package/dist/forge/javascript/hosts/process-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/process-entry.js +3 -0
- package/dist/forge/javascript/hosts/process-host.d.ts +33 -0
- package/dist/forge/javascript/hosts/process-host.js +160 -0
- package/dist/forge/javascript/hosts/worker-entry.d.ts +1 -0
- package/dist/forge/javascript/hosts/worker-entry.js +3 -0
- package/dist/forge/javascript/hosts/worker-host.d.ts +18 -0
- package/dist/forge/javascript/hosts/worker-host.js +101 -0
- package/dist/forge/javascript/prelude.d.ts +2 -0
- package/dist/forge/javascript/prelude.js +333 -0
- package/dist/forge/javascript/probe.d.ts +2 -0
- package/dist/forge/javascript/probe.js +53 -0
- package/dist/forge/javascript/runtime-manifest.d.ts +14 -0
- package/dist/forge/javascript/runtime-manifest.js +14 -0
- package/dist/forge/javascript/vm-runner.d.ts +21 -0
- package/dist/forge/javascript/vm-runner.js +301 -0
- package/dist/forge/javascript/wasm-asset.d.ts +8 -0
- package/dist/forge/javascript/wasm-asset.js +8 -0
- package/dist/forge/javascript/wasm.d.ts +15 -0
- package/dist/forge/javascript/wasm.js +54 -0
- package/dist/forge/journal.d.ts +105 -0
- package/dist/forge/journal.js +174 -0
- package/dist/forge/kernel.d.ts +76 -0
- package/dist/forge/kernel.js +338 -0
- package/dist/forge/limits.d.ts +38 -0
- package/dist/forge/limits.js +73 -0
- package/dist/forge/nested.d.ts +96 -0
- package/dist/forge/nested.js +214 -0
- package/dist/forge/ports.d.ts +120 -0
- package/dist/forge/ports.js +1 -0
- package/dist/forge/progress.d.ts +24 -0
- package/dist/forge/progress.js +76 -0
- package/dist/forge/protocol.d.ts +110 -0
- package/dist/forge/protocol.js +171 -0
- package/dist/forge/python/backend-selector.d.ts +38 -0
- package/dist/forge/python/backend-selector.js +111 -0
- package/dist/forge/python/backend.d.ts +89 -0
- package/dist/forge/python/backend.js +1 -0
- package/dist/forge/python/engine.d.ts +84 -0
- package/dist/forge/python/engine.js +279 -0
- package/dist/forge/python/frame-parser.d.ts +24 -0
- package/dist/forge/python/frame-parser.js +107 -0
- package/dist/forge/python/frame-transport.d.ts +40 -0
- package/dist/forge/python/frame-transport.js +101 -0
- package/dist/forge/python/import-scan.d.ts +22 -0
- package/dist/forge/python/import-scan.js +133 -0
- package/dist/forge/python/managed-backend.d.ts +30 -0
- package/dist/forge/python/managed-backend.js +111 -0
- package/dist/forge/python/oci-backend.d.ts +27 -0
- package/dist/forge/python/oci-backend.js +84 -0
- package/dist/forge/python/pyodide-backend.d.ts +40 -0
- package/dist/forge/python/pyodide-backend.js +289 -0
- package/dist/forge/python/pyodide-install.d.ts +41 -0
- package/dist/forge/python/pyodide-install.js +167 -0
- package/dist/forge/python/pyodide-manifest.d.ts +42 -0
- package/dist/forge/python/pyodide-manifest.js +376 -0
- package/dist/forge/python/pyodide-runner-source.d.ts +2 -0
- package/dist/forge/python/pyodide-runner-source.js +3 -0
- package/dist/forge/python/rss.d.ts +2 -0
- package/dist/forge/python/rss.js +33 -0
- package/dist/forge/python/scratch-copy.d.ts +6 -0
- package/dist/forge/python/scratch-copy.js +61 -0
- package/dist/forge/python/settle.d.ts +6 -0
- package/dist/forge/python/settle.js +21 -0
- package/dist/forge/python/status.d.ts +8 -0
- package/dist/forge/python/status.js +15 -0
- package/dist/forge/python/wasm-node.d.ts +59 -0
- package/dist/forge/python/wasm-node.js +220 -0
- package/dist/forge/python/wiring.d.ts +39 -0
- package/dist/forge/python/wiring.js +47 -0
- package/dist/forge/registry.d.ts +26 -0
- package/dist/forge/registry.js +83 -0
- package/dist/forge/result.d.ts +58 -0
- package/dist/forge/result.js +86 -0
- package/dist/forge/routing-rule.d.ts +19 -0
- package/dist/forge/routing-rule.js +31 -0
- package/dist/forge/runtime.d.ts +64 -0
- package/dist/forge/runtime.js +88 -0
- package/dist/forge/scratch-files.d.ts +36 -0
- package/dist/forge/scratch-files.js +94 -0
- package/dist/forge/scratch-provider.d.ts +41 -0
- package/dist/forge/scratch-provider.js +79 -0
- package/dist/forge/scratch-quota.d.ts +8 -0
- package/dist/forge/scratch-quota.js +25 -0
- package/dist/forge/selector.d.ts +35 -0
- package/dist/forge/selector.js +51 -0
- package/dist/forge/session-dir.d.ts +59 -0
- package/dist/forge/session-dir.js +239 -0
- package/dist/forge/session-path.d.ts +31 -0
- package/dist/forge/session-path.js +61 -0
- package/dist/forge/slots.d.ts +16 -0
- package/dist/forge/slots.js +47 -0
- package/dist/forge/state-delta.d.ts +54 -0
- package/dist/forge/state-delta.js +99 -0
- package/dist/forge/state-machine.d.ts +20 -0
- package/dist/forge/state-machine.js +40 -0
- package/dist/forge/state-store.d.ts +25 -0
- package/dist/forge/state-store.js +50 -0
- package/dist/forge/task-log.d.ts +9 -0
- package/dist/forge/task-log.js +42 -0
- package/dist/forge/verdict.d.ts +20 -0
- package/dist/forge/verdict.js +28 -0
- package/dist/forge-isolate.d.ts +6 -0
- package/dist/forge-isolate.js +6 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/permissions/decide.d.ts +42 -0
- package/dist/permissions/decide.js +24 -0
- package/dist/resources/context.d.ts +2 -1
- package/dist/resources/context.js +2 -1
- package/dist/runtime/access.d.ts +34 -0
- package/dist/runtime/access.js +80 -0
- package/dist/runtime/process.d.ts +2 -1
- package/dist/runtime/process.js +5 -5
- package/dist/runtime/store.d.ts +11 -0
- package/dist/runtime/store.js +103 -0
- package/dist/tools/analysis.d.ts +25 -2
- package/dist/tools/analysis.js +315 -267
- package/dist/tools/execute.js +6 -7
- package/dist/tools/forge-guide.d.ts +6 -0
- package/dist/tools/forge-guide.js +60 -0
- package/dist/tools/js-run.d.ts +19 -0
- package/dist/tools/js-run.js +126 -0
- package/dist/tools/standard.d.ts +7 -1
- package/dist/tools/standard.js +44 -43
- package/package.json +7 -2
package/dist/core/runner.js
CHANGED
|
@@ -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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
gate?.
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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 (
|
|
474
|
-
|
|
512
|
+
else if (decision.kind === "deny") {
|
|
513
|
+
throw new Error(`Capability denied: ${capability ?? effect}`);
|
|
475
514
|
}
|
|
476
|
-
else
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|