@skanl/brambo-session 0.1.1
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/LICENSE +21 -0
- package/README.md +199 -0
- package/dist/executors.d.ts +146 -0
- package/dist/executors.js +342 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +73 -0
- package/dist/methods.d.ts +137 -0
- package/dist/methods.js +264 -0
- package/dist/remote-mcp.d.ts +37 -0
- package/dist/remote-mcp.js +76 -0
- package/dist/run-session.d.ts +300 -0
- package/dist/run-session.js +523 -0
- package/dist/tool-executor.d.ts +4 -0
- package/dist/tool-executor.js +159 -0
- package/dist/usage.d.ts +30 -0
- package/dist/usage.js +110 -0
- package/dist/workspaces.d.ts +63 -0
- package/dist/workspaces.js +126 -0
- package/package.json +59 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import { createExecutorPlugin, EXECUTOR_CONFIG_KEY, EXECUTOR_SERVICE } from '@skanl/brambo-adapter-cli';
|
|
2
|
+
import { BramboError, BRAMBO_ERROR_CODES, validateSandboxCapabilities, validateSandboxPolicy, validateToolExecutionContext, validateToolInvocation, } from '@skanl/brambo-contracts';
|
|
3
|
+
import { createKernel, createMemoryLogSink, deepMerge, } from '@skanl/brambo-kernel';
|
|
4
|
+
import { WORKSPACE_CONFIG_KEY, WORKSPACE_CONFIG_WARNING_EVENT, WORKSPACE_SERVICE, } from '@skanl/brambo-workspace-local';
|
|
5
|
+
import { seedExecutorConfig, selectExecutor, } from './executors.js';
|
|
6
|
+
import { DEFAULT_WORKSPACE_PROVIDER_ID, WORKSPACE_PROVIDER_CONFIG_KEY, createSelectedWorkspacePlugin, selectWorkspaceProvider, worktreeStateDir, } from './workspaces.js';
|
|
7
|
+
import { assertMethodMayMount, resolveMethod, selectMethod, swapMethod } from './methods.js';
|
|
8
|
+
/**
|
|
9
|
+
* The PREFIX every session invocation is recorded under. The registered id is
|
|
10
|
+
* `${SESSION_ACTION_ID}#${workspace.id}`, not this constant: a pipeline rejects a
|
|
11
|
+
* duplicate registration, and since Story M3.B the pipeline belongs to the
|
|
12
|
+
* KERNEL rather than to the session, so two sessions sharing one kernel really
|
|
13
|
+
* do register into the same set of ids. Scoping to the workspace is what keeps
|
|
14
|
+
* them distinguishable — and workspace ids are UUIDs on the default provider.
|
|
15
|
+
*
|
|
16
|
+
* Exported because a reader of the record stream needs the same string the
|
|
17
|
+
* pipeline wrote; match with `subject.startsWith(SESSION_ACTION_ID + '#')`.
|
|
18
|
+
*/
|
|
19
|
+
export const SESSION_ACTION_ID = 'session.executor-run';
|
|
20
|
+
/**
|
|
21
|
+
* What one executor run is ADMITTED at, before the vendor says what it spent.
|
|
22
|
+
*
|
|
23
|
+
* The upgrade path the previous note named is now built. Story M3.C gave the
|
|
24
|
+
* pipeline settlement — an action is admitted on this estimate and reconciled
|
|
25
|
+
* against the executor's own reported usage when the run resolves — so within a
|
|
26
|
+
* single session `maxTotalCost` and `maxInvocations` finally refuse on DIFFERENT
|
|
27
|
+
* runs: one run of claude-code settles at tens of thousands of tokens, so a cost
|
|
28
|
+
* cap fires while the invocation count is still 1, and an invocation cap fires
|
|
29
|
+
* while the settled cost is far under its own limit.
|
|
30
|
+
*
|
|
31
|
+
* ponytail: this stays a flat 1 on purpose, and it is the last piece of the old
|
|
32
|
+
* collapse still standing. Brambo may not invent a pre-run token figure — no
|
|
33
|
+
* estimating, no tokenizer, that is the whole point of settling instead — and
|
|
34
|
+
* raising it to a token-scale placeholder would silently redefine every cap
|
|
35
|
+
* already written against "1 = one run". A host that budgets in tokens builds
|
|
36
|
+
* its own kernel (`createSessionKernel`) and can pass its own estimate through
|
|
37
|
+
* `createExecutorPlugin({ cost })`; the settlement corrects it either way. What
|
|
38
|
+
* remains open: a run whose vendor reports nothing is charged this 1, so a
|
|
39
|
+
* session against such an executor is still capped by count rather than by
|
|
40
|
+
* spend (deferred-work.md).
|
|
41
|
+
*/
|
|
42
|
+
export const SESSION_ACTION_COST = 1;
|
|
43
|
+
function sameToolPolicy(left, right) {
|
|
44
|
+
const canonical = (value) => {
|
|
45
|
+
if (value === undefined)
|
|
46
|
+
return '';
|
|
47
|
+
if (value === null || typeof value !== 'object')
|
|
48
|
+
return JSON.stringify(value);
|
|
49
|
+
if (Array.isArray(value))
|
|
50
|
+
return `[${value.map(canonical).join(',')}]`;
|
|
51
|
+
return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(',')}}`;
|
|
52
|
+
};
|
|
53
|
+
return left.version === right.version
|
|
54
|
+
&& left.mode === right.mode
|
|
55
|
+
&& left.workspaceRoot === right.workspaceRoot
|
|
56
|
+
&& left.allowDangerous === right.allowDangerous
|
|
57
|
+
&& canonical(left.requiredCapabilities) === canonical(right.requiredCapabilities)
|
|
58
|
+
&& canonical(left.resourceLimits) === canonical(right.resourceLimits);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Executes one validated tool through the supplied ToolExecutor.
|
|
62
|
+
*
|
|
63
|
+
* This is deliberately separate from `runSession`: executor runs are vendor
|
|
64
|
+
* sessions, while tool calls are host-controlled SDK operations. The helper
|
|
65
|
+
* makes the approval boundary executable without teaching the kernel about
|
|
66
|
+
* tools, processes, filesystems, or sandbox backends.
|
|
67
|
+
*/
|
|
68
|
+
export async function executeTool(options) {
|
|
69
|
+
const invocation = validateToolInvocation(options.invocation);
|
|
70
|
+
const context = validateToolExecutionContext(options.context);
|
|
71
|
+
const executor = options.toolExecutor;
|
|
72
|
+
if (executor === undefined) {
|
|
73
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, 'tool execution requires a ToolExecutor');
|
|
74
|
+
}
|
|
75
|
+
if (options.toolPolicy !== undefined) {
|
|
76
|
+
const policy = validateSandboxPolicy(options.toolPolicy);
|
|
77
|
+
if (!sameToolPolicy(policy, context.policy)) {
|
|
78
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxRequestInvalid, 'tool policy does not match execution context');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (options.sandboxProvider !== undefined) {
|
|
82
|
+
validateSandboxCapabilities(context.policy, options.sandboxProvider.capabilities);
|
|
83
|
+
}
|
|
84
|
+
if (options.approveTool !== undefined && !(await options.approveTool({ invocation, context }))) {
|
|
85
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxDenied, 'tool invocation was denied by the host');
|
|
86
|
+
}
|
|
87
|
+
const result = await executor.execute(invocation, context);
|
|
88
|
+
options.onToolExecution?.({ invocation, context, result });
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Options that only mean something for a kernel this session builds itself.
|
|
93
|
+
*
|
|
94
|
+
* `cwd` and `onSelection` joined the list on review: both were accepted and
|
|
95
|
+
* silently ignored beside a supplied kernel (`onSelection` calls measured at 0),
|
|
96
|
+
* which is the exact behaviour the refusal rule above exists to forbid.
|
|
97
|
+
*/
|
|
98
|
+
const KERNEL_OWNED_OPTIONS = [
|
|
99
|
+
'configLayers',
|
|
100
|
+
'cwd',
|
|
101
|
+
'executorId',
|
|
102
|
+
'adapterOptions',
|
|
103
|
+
'createAdapter',
|
|
104
|
+
'createProvider',
|
|
105
|
+
'onSelection',
|
|
106
|
+
'log',
|
|
107
|
+
'actionPolicy',
|
|
108
|
+
];
|
|
109
|
+
async function contained(action) {
|
|
110
|
+
try {
|
|
111
|
+
await action();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Cleanup must never mask the primary envelope/error or crash the exit path.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Validate only the policy/capability relationship that is knowable without a
|
|
119
|
+
* tool invocation. Provider selection, session creation and execution remain
|
|
120
|
+
* absent until the session receives a tool call to route.
|
|
121
|
+
*/
|
|
122
|
+
function validateToolComposition(options) {
|
|
123
|
+
const { sandboxProvider, toolPolicy } = options;
|
|
124
|
+
if (toolPolicy === undefined)
|
|
125
|
+
return;
|
|
126
|
+
const policy = validateSandboxPolicy(toolPolicy);
|
|
127
|
+
if (sandboxProvider === undefined)
|
|
128
|
+
return;
|
|
129
|
+
const providerId = sandboxProvider.id;
|
|
130
|
+
const capabilities = validateSandboxCapabilities(policy, sandboxProvider.capabilities);
|
|
131
|
+
if (providerId !== capabilities.providerId) {
|
|
132
|
+
throw new BramboError(BRAMBO_ERROR_CODES.sandboxCapabilityUnavailable, `sandbox provider id '${providerId}' does not match its capability facts '${capabilities.providerId}'`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The sink a session-owned kernel records into.
|
|
137
|
+
*
|
|
138
|
+
* `SessionOptions.log` is documented — and pinned by three suites — as the
|
|
139
|
+
* WATERFALL's sink, and the kernel's stream carries lifecycle transitions too.
|
|
140
|
+
* Forwarding the whole stream would redefine a published option; forwarding
|
|
141
|
+
* nothing would delete the trail. So the caller's sink receives exactly the
|
|
142
|
+
* waterfall, the kernel keeps its complete stream, and a caller who wants the
|
|
143
|
+
* complete one supplies the kernel instead.
|
|
144
|
+
*/
|
|
145
|
+
function waterfallSink(caller) {
|
|
146
|
+
const own = createMemoryLogSink();
|
|
147
|
+
if (caller === undefined)
|
|
148
|
+
return own;
|
|
149
|
+
return {
|
|
150
|
+
record(entry) {
|
|
151
|
+
own.record(entry);
|
|
152
|
+
if (entry.event.startsWith('action.'))
|
|
153
|
+
caller.record(entry);
|
|
154
|
+
},
|
|
155
|
+
async drain() {
|
|
156
|
+
await own.drain();
|
|
157
|
+
await caller.drain();
|
|
158
|
+
},
|
|
159
|
+
get state() {
|
|
160
|
+
return caller.state;
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function serviceMissing(service, detail) {
|
|
165
|
+
// AD-5: a consumed service that never activated reads as `{ kind: 'absent' }`
|
|
166
|
+
// and its USE SITE raises a named, coded error. `undefined` reaching a call
|
|
167
|
+
// site is the failure the typed-absent value exists to make impossible, and a
|
|
168
|
+
// bare "cannot read property of undefined" names neither the service nor the
|
|
169
|
+
// plugin that owed it.
|
|
170
|
+
return new BramboError(BRAMBO_ERROR_CODES.kernelServiceNotProvided, `brambo's kernel provides no '${service}' service: ${detail}`);
|
|
171
|
+
}
|
|
172
|
+
/** What a contained start failure has to say for itself, plugin by plugin. */
|
|
173
|
+
function describeFailures(failures) {
|
|
174
|
+
return failures.map((failure) => `${failure.pluginId}: ${failure.error.message}`).join('; ');
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* A kernel with brambo's two plugins mounted, its configuration seeded from
|
|
178
|
+
* brambo's own documents, and its plugins started.
|
|
179
|
+
*
|
|
180
|
+
* This is the ONE composition. `runSession` calls it when no kernel is passed,
|
|
181
|
+
* and a host that wants several sessions to share one pipeline, one budget and
|
|
182
|
+
* one record stream calls it directly and passes the result as
|
|
183
|
+
* `SessionOptions.kernel`.
|
|
184
|
+
*
|
|
185
|
+
* It exists as a single named surface on purpose. `@skanl/brambo-session` briefly
|
|
186
|
+
* re-exported `createKernel` and both plugin FACTORIES so a host could assemble
|
|
187
|
+
* this itself, and that was a hole rather than a convenience: a `PluginFactory`
|
|
188
|
+
* invoked with an `ActivationContext` of the caller's own construction hands
|
|
189
|
+
* back a real vendor adapter wired to the caller's own pipeline, so the bypass
|
|
190
|
+
* surface of a session-only consumer went from nothing to one. Handing back a
|
|
191
|
+
* started kernel gives a host the capability without the factory.
|
|
192
|
+
*
|
|
193
|
+
* Throws before anything is constructed for a selection brambo has no adapter
|
|
194
|
+
* for, and stops the kernel again if any plugin fails to activate.
|
|
195
|
+
*/
|
|
196
|
+
export function createSessionKernel(options = {}) {
|
|
197
|
+
const { cwd, executorId, configLayers, adapterOptions, createAdapter, log, actionPolicy, onSelection, onWarning, } = options;
|
|
198
|
+
validateToolComposition(options);
|
|
199
|
+
// The KERNEL's sink, unfiltered: a host that builds its own kernel is exactly
|
|
200
|
+
// the caller who wants the lifecycle transitions too, and it is where the AD-4
|
|
201
|
+
// ordering proof reads its stream. `runSession` hands this a sink already
|
|
202
|
+
// narrowed to the waterfall, because `SessionOptions.log` means that and is
|
|
203
|
+
// pinned to it by three suites.
|
|
204
|
+
const kernel = createKernel({ log, actionPolicy });
|
|
205
|
+
try {
|
|
206
|
+
// Subscribed BEFORE anything activates: a plugin emits its configuration
|
|
207
|
+
// warnings during activation, synchronously, and a listener attached after
|
|
208
|
+
// `start()` would receive none of them.
|
|
209
|
+
if (onWarning !== undefined) {
|
|
210
|
+
kernel.bus.subscribe('global', (event) => {
|
|
211
|
+
if (event.type !== WORKSPACE_CONFIG_WARNING_EVENT)
|
|
212
|
+
return;
|
|
213
|
+
onWarning(`configuration ignored: '${event.payload.key}' ${event.payload.detail}`);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
// ONE layered configuration, and it is the kernel's: executor selection and
|
|
217
|
+
// every mounted plugin read the same composed document, by layer. The
|
|
218
|
+
// session's own workspace root enters as a LAYER rather than as a plugin
|
|
219
|
+
// option — `invocation` when the caller named a cwd, `defaults` when it did
|
|
220
|
+
// not — so a `workspace.rootDir` in the project document decides in exactly
|
|
221
|
+
// the case a layered configuration says it should.
|
|
222
|
+
const projectRoot = cwd ?? process.cwd();
|
|
223
|
+
const workspaceRoot = { [WORKSPACE_CONFIG_KEY]: { rootDir: worktreeStateDir(projectRoot) } };
|
|
224
|
+
// Brambo's built-in workspace provider is a LAYER too, and the same layer the
|
|
225
|
+
// executor's default lives in — which is what makes "nothing configured" a
|
|
226
|
+
// reportable provenance (`defaults`) rather than an invisible branch, and
|
|
227
|
+
// what lets `selectWorkspaceProvider` take value and layer from one entry.
|
|
228
|
+
// Composed OVER a caller's `defaults` exactly as `seedExecutorConfig` does
|
|
229
|
+
// with the executor default: a caller that wants another provider says so in
|
|
230
|
+
// a narrower layer.
|
|
231
|
+
const workspaceProvider = {
|
|
232
|
+
[WORKSPACE_CONFIG_KEY]: { [WORKSPACE_PROVIDER_CONFIG_KEY]: DEFAULT_WORKSPACE_PROVIDER_ID },
|
|
233
|
+
};
|
|
234
|
+
let invocation = { ...configLayers?.invocation };
|
|
235
|
+
if (executorId !== undefined)
|
|
236
|
+
invocation[EXECUTOR_CONFIG_KEY] = executorId.trim();
|
|
237
|
+
// `deepMerge`, not `Object.assign`. The assign is SHALLOW, so it replaced the
|
|
238
|
+
// caller's whole `workspace` subtree with brambo's `{ rootDir }` — invisible
|
|
239
|
+
// while the subtree had one key, and a silently dropped `workspace.provider`
|
|
240
|
+
// the moment it had two: a host naming both a `cwd` and a provider in the
|
|
241
|
+
// narrowest layer would have run in a workspace it did not ask for. `rootDir`
|
|
242
|
+
// still wins, because a named `cwd` is this invocation's answer.
|
|
243
|
+
if (cwd !== undefined)
|
|
244
|
+
invocation = deepMerge(invocation, workspaceRoot);
|
|
245
|
+
const declined = seedExecutorConfig(kernel.config, {
|
|
246
|
+
...configLayers,
|
|
247
|
+
defaults: deepMerge((cwd === undefined ? workspaceRoot : configLayers?.defaults) ?? {}, workspaceProvider),
|
|
248
|
+
...(Object.keys(invocation).length === 0 ? {} : { invocation }),
|
|
249
|
+
});
|
|
250
|
+
// A METHOD THE PROJECT RECOMMENDED AND BRAMBO DID NOT ADMIT, said out loud on
|
|
251
|
+
// the channel that already exists for exactly this. Its own comment at the
|
|
252
|
+
// CLI end settled the question this reuses: "A configuration key brambo read
|
|
253
|
+
// and could not use. Reported, never fatal: one forward-looking key in
|
|
254
|
+
// `~/.brambo/config.json` used to fail every run on the machine, and silence
|
|
255
|
+
// would have been the other wrong answer."
|
|
256
|
+
//
|
|
257
|
+
// NOT on the bus. `WORKSPACE_CONFIG_WARNING_EVENT` is spelled
|
|
258
|
+
// `workspace.config.ignored` and is declared twice, once in each workspace
|
|
259
|
+
// plugin; routing a METHOD through it would smear that vocabulary across a
|
|
260
|
+
// key no workspace owns, which is the divergence correction-01 exists to
|
|
261
|
+
// remove. `onWarning` is the seam; the bus is one plugin's way of reaching it.
|
|
262
|
+
if (onWarning !== undefined && declined !== undefined) {
|
|
263
|
+
onWarning(`configuration ignored: '${declined.key}' — '${declined.specifier}' in '${declined.filePath}' is a recommendation, not a selection: brambo never mounts a method a project directory names, because running it is running that project's code. ${declined.using === undefined
|
|
264
|
+
? 'Running with no method.'
|
|
265
|
+
: `Using '${declined.using}' instead.`} To adopt it on this machine, run \`brambo swap method ${declined.specifier}\` from that directory`);
|
|
266
|
+
}
|
|
267
|
+
// Resolved BEFORE anything is constructed, beside the prompt check and for
|
|
268
|
+
// the same reason: an invalid request must cost no mkdir. An `executorId`
|
|
269
|
+
// the catalogue does not hold used to be rejected AFTER `provider.create()`,
|
|
270
|
+
// leaving a workspace directory on disk that nothing removes.
|
|
271
|
+
const selection = selectExecutor(kernel.config);
|
|
272
|
+
if (onSelection !== undefined) {
|
|
273
|
+
try {
|
|
274
|
+
onSelection(selection);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// A reporter is an observer; an observer must not fail the run.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const executor = createExecutorPlugin({ createAdapter, adapterOptions, cost: SESSION_ACTION_COST });
|
|
281
|
+
// The MOUNT. Selected from the same composed document the executor was
|
|
282
|
+
// selected from, one entry, value and layer together — so the plugin that
|
|
283
|
+
// gets registered and the provider the document names cannot diverge. The
|
|
284
|
+
// package is otherwise unreachable from the product: this line is the whole
|
|
285
|
+
// of Story 4.2's reachability claim, and `packages/session/test/guard.test.ts`
|
|
286
|
+
// is red until it exists.
|
|
287
|
+
const workspace = createSelectedWorkspacePlugin(selectWorkspaceProvider(kernel.config).providerId, {
|
|
288
|
+
repoPath: projectRoot,
|
|
289
|
+
});
|
|
290
|
+
kernel.register(executor.manifest, executor.factory);
|
|
291
|
+
kernel.register(workspace.manifest, workspace.factory);
|
|
292
|
+
const started = kernel.start();
|
|
293
|
+
if (started.failures.length > 0) {
|
|
294
|
+
// Contained by the kernel — every OTHER plugin still activated — and
|
|
295
|
+
// surfaced here, naming each plugin that failed. Swallowing it would let a
|
|
296
|
+
// `{ kind: 'absent' }` reach a use site with no explanation of why.
|
|
297
|
+
throw new BramboError(BRAMBO_ERROR_CODES.kernelPluginStartFailed, `brambo's kernel could not activate every plugin this session needs (${describeFailures(started.failures)})`);
|
|
298
|
+
}
|
|
299
|
+
return kernel;
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
// The one place a constructed kernel could have been abandoned. Nothing has
|
|
303
|
+
// activated on most of these paths, so nothing leaks — but "most" is not a
|
|
304
|
+
// guarantee, and `stop()` is idempotent.
|
|
305
|
+
void kernel.stop().catch(() => { });
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* One brambo session: compose through a kernel, create a workspace, run the
|
|
311
|
+
* prompt under a cancellation signal through the kernel's interception
|
|
312
|
+
* waterfall, then release and dispose whatever happened.
|
|
313
|
+
*
|
|
314
|
+
* This is the composition `brambo run` performs, and it lives here rather than in
|
|
315
|
+
* `@skanl/brambo-cli` so a third party gets it by importing packages (PRD §2, ROADMAP-01
|
|
316
|
+
* Correction A). The CLI adds argv parsing, JSON formatting and exit codes on top
|
|
317
|
+
* and nothing else.
|
|
318
|
+
*
|
|
319
|
+
* Since Story M3.B the adapter and the provider are MOUNTED, not constructed:
|
|
320
|
+
* `createExecutorPlugin` and `createWorkspacePlugin` are registered on a kernel,
|
|
321
|
+
* their configuration comes from one composed document, and their services are
|
|
322
|
+
* consumed by name. What that buys beyond hygiene: the executor invocation is an
|
|
323
|
+
* action on the KERNEL's pipeline, so a host that shares one kernel across
|
|
324
|
+
* sessions shares one budget — and the observability log exists before any
|
|
325
|
+
* plugin loads, which `loadPlugins`' required sink parameter makes a type error
|
|
326
|
+
* to violate rather than a comment.
|
|
327
|
+
*
|
|
328
|
+
* The honest scope of the no-bypass claim: neither the kernel nor the `executor`
|
|
329
|
+
* service exports a path around the waterfall. Any package may still import
|
|
330
|
+
* `@skanl/brambo-adapter-cli` and drive a vendor adapter itself, and a caller that keeps
|
|
331
|
+
* a reference to the adapter it passed to `createAdapter` can invoke it after a
|
|
332
|
+
* refusal. Both are recorded as open in deferred-work.md.
|
|
333
|
+
*
|
|
334
|
+
* SIDE EFFECT: the mounted provider creates a directory per session under
|
|
335
|
+
* `<cwd>/.brambo/workspaces/<uuid>` and NOTHING removes it. `release()` ends a
|
|
336
|
+
* lease and `dispose()` deliberately leaves the tree in place so work survives —
|
|
337
|
+
* retention is the caller's problem (deferred-work.md).
|
|
338
|
+
*
|
|
339
|
+
* Failure surfaces as a throw (a coded `BramboError` from the workspace port or
|
|
340
|
+
* from a plugin that never activated, a coded `BramboKernelError` from a refusal,
|
|
341
|
+
* or whatever the adapter threw), because an envelope is what an executor RAN
|
|
342
|
+
* produces — returning a synthetic one for a workspace that never existed would
|
|
343
|
+
* make the two indistinguishable.
|
|
344
|
+
*/
|
|
345
|
+
export async function runSession(options) {
|
|
346
|
+
// Every field read ONCE, here, before the first await. `provider.create()` hands
|
|
347
|
+
// control back to the caller's event loop, so a live read afterwards is a TOCTOU
|
|
348
|
+
// hole: an accessor answering a benign prompt now and a hostile one later gets
|
|
349
|
+
// the hostile one executed. The kernel closes exactly this hole at `register`
|
|
350
|
+
// and says so; a session that read `options.prompt` inside the operation closure
|
|
351
|
+
// would hand it straight back.
|
|
352
|
+
const { prompt, cwd, executorId, configLayers, adapterOptions, createAdapter, createProvider, onInterrupt, onSelection, onWarning, log, actionPolicy, kernel: suppliedKernel, sandboxProvider, toolExecutor, toolPolicy, approveTool, onToolExecution, onSandboxEvent, } = options;
|
|
353
|
+
// Before anything is constructed or written: an invalid request must cost no
|
|
354
|
+
// mkdir. The predicate, the code and the message are the contracts package's
|
|
355
|
+
// own (`runRequestIssues` + `throwSchemaViolation`), so this rejects exactly
|
|
356
|
+
// what the adapter would have rejected — earlier, not differently.
|
|
357
|
+
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
|
|
358
|
+
throw new BramboError(BRAMBO_ERROR_CODES.contractEnvelopeInvalid, "schema violation: 'prompt' must be a non-empty string");
|
|
359
|
+
}
|
|
360
|
+
const owned = {
|
|
361
|
+
configLayers,
|
|
362
|
+
cwd,
|
|
363
|
+
executorId,
|
|
364
|
+
adapterOptions,
|
|
365
|
+
createAdapter,
|
|
366
|
+
createProvider,
|
|
367
|
+
onSelection,
|
|
368
|
+
log,
|
|
369
|
+
actionPolicy,
|
|
370
|
+
};
|
|
371
|
+
if (suppliedKernel !== undefined) {
|
|
372
|
+
const conflicting = KERNEL_OWNED_OPTIONS.filter((name) => owned[name] !== undefined);
|
|
373
|
+
if (conflicting.length > 0) {
|
|
374
|
+
throw new BramboError(BRAMBO_ERROR_CODES.contractEnvelopeInvalid, `schema violation: a supplied 'kernel' owns its configuration, its plugins, its pipeline and its sink, so ${conflicting
|
|
375
|
+
.map((name) => `'${name}'`)
|
|
376
|
+
.join(', ')} cannot also be given here`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// A caller-owned kernel bypasses `createSessionKernel`, so the composition
|
|
380
|
+
// boundary still validates its policy/provider pairing here. Tool calls are
|
|
381
|
+
// explicit `executeTool` operations and are not implicit side effects of a
|
|
382
|
+
// vendor session run.
|
|
383
|
+
const toolComposition = {
|
|
384
|
+
...(sandboxProvider === undefined ? {} : { sandboxProvider }),
|
|
385
|
+
...(toolExecutor === undefined ? {} : { toolExecutor }),
|
|
386
|
+
...(toolPolicy === undefined ? {} : { toolPolicy }),
|
|
387
|
+
...(approveTool === undefined ? {} : { approveTool }),
|
|
388
|
+
...(onToolExecution === undefined ? {} : { onToolExecution }),
|
|
389
|
+
...(onSandboxEvent === undefined ? {} : { onSandboxEvent }),
|
|
390
|
+
};
|
|
391
|
+
if (suppliedKernel !== undefined)
|
|
392
|
+
validateToolComposition(toolComposition);
|
|
393
|
+
const kernel = suppliedKernel ??
|
|
394
|
+
createSessionKernel({
|
|
395
|
+
cwd,
|
|
396
|
+
executorId,
|
|
397
|
+
configLayers,
|
|
398
|
+
adapterOptions,
|
|
399
|
+
createAdapter,
|
|
400
|
+
log: waterfallSink(log),
|
|
401
|
+
actionPolicy,
|
|
402
|
+
onSelection,
|
|
403
|
+
onWarning,
|
|
404
|
+
...toolComposition,
|
|
405
|
+
});
|
|
406
|
+
const stopKernel = suppliedKernel === undefined ? () => kernel.stop() : undefined;
|
|
407
|
+
let executor;
|
|
408
|
+
let provider;
|
|
409
|
+
let disposeProvider = false;
|
|
410
|
+
try {
|
|
411
|
+
const resolved = kernel.getService(EXECUTOR_SERVICE);
|
|
412
|
+
if (resolved.kind !== 'provided') {
|
|
413
|
+
throw serviceMissing(EXECUTOR_SERVICE, 'mount an executor plugin (`createExecutorPlugin` from @skanl/brambo-adapter-cli) before running a session on this kernel');
|
|
414
|
+
}
|
|
415
|
+
executor = resolved.value;
|
|
416
|
+
if (createProvider === undefined) {
|
|
417
|
+
const workspace = kernel.getService(WORKSPACE_SERVICE);
|
|
418
|
+
if (workspace.kind !== 'provided') {
|
|
419
|
+
throw serviceMissing(WORKSPACE_SERVICE, 'mount a workspace plugin (`createSessionKernel` mounts one) before running a session on this kernel');
|
|
420
|
+
}
|
|
421
|
+
provider = workspace.value;
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
// The seam, and with it the ownership: a provider this session created is
|
|
425
|
+
// a provider this session disposes. Refused beside a supplied kernel (see
|
|
426
|
+
// `SessionOptions.kernel`), so this only ever runs for a kernel the
|
|
427
|
+
// session built and will stop.
|
|
428
|
+
provider = createProvider();
|
|
429
|
+
disposeProvider = true;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
if (stopKernel !== undefined)
|
|
434
|
+
await contained(stopKernel);
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
437
|
+
// The selected methodology, mounted for this session (FR-28 / UJ-3).
|
|
438
|
+
//
|
|
439
|
+
// Read from the kernel's ALREADY COMPOSED configuration rather than from disk
|
|
440
|
+
// again, so the method and the executor are decided by one layer resolution
|
|
441
|
+
// and cannot disagree about which document won.
|
|
442
|
+
//
|
|
443
|
+
// A failure here FAILS THE RUN. A broken method selection that fell through to
|
|
444
|
+
// "no method" would run a different methodology than the document names,
|
|
445
|
+
// silently — the same failure `selectExecutor` refuses for the executor, and
|
|
446
|
+
// the reason `resolveMethod` is coded rather than optional. No selection at
|
|
447
|
+
// all is the ordinary v1 state and costs nothing.
|
|
448
|
+
let method;
|
|
449
|
+
try {
|
|
450
|
+
const selected = selectMethod(kernel.config);
|
|
451
|
+
if (selected !== undefined) {
|
|
452
|
+
// BEFORE the import, and that ordering is the whole guarantee: after
|
|
453
|
+
// `resolveMethod` the module's top-level code has already run.
|
|
454
|
+
assertMethodMayMount(selected);
|
|
455
|
+
method = await swapMethod(undefined, await resolveMethod(selected.specifier, cwd ?? process.cwd()));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
if (disposeProvider)
|
|
460
|
+
await contained(() => provider.dispose());
|
|
461
|
+
if (stopKernel !== undefined)
|
|
462
|
+
await contained(stopKernel);
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
465
|
+
let handle;
|
|
466
|
+
try {
|
|
467
|
+
handle = await provider.create();
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
if (method !== undefined)
|
|
471
|
+
await contained(() => method.deactivate());
|
|
472
|
+
// Nothing was leased, so there is nothing to release — but the provider was
|
|
473
|
+
// obtained and owns whatever it allocated before it failed.
|
|
474
|
+
if (disposeProvider)
|
|
475
|
+
await contained(() => provider.dispose());
|
|
476
|
+
if (stopKernel !== undefined)
|
|
477
|
+
await contained(stopKernel);
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
const controller = new AbortController();
|
|
481
|
+
// Initialised to the noop and only then replaced, because everything from the
|
|
482
|
+
// lease onwards has to unwind through the `finally`: registering OUTSIDE the
|
|
483
|
+
// try meant a throwing `onInterrupt` leaked the handle and the provider whole.
|
|
484
|
+
let removeSignalHandler = () => { };
|
|
485
|
+
try {
|
|
486
|
+
removeSignalHandler = onInterrupt?.(() => controller.abort()) ?? removeSignalHandler;
|
|
487
|
+
// The ONLY way this package can reach an executor. The service closed over
|
|
488
|
+
// the adapter and hands back no `run` of its own — what it registers is an
|
|
489
|
+
// action on the KERNEL's pipeline, scoped to the workspace so two sessions
|
|
490
|
+
// sharing one kernel stay distinguishable in the record stream. A provider
|
|
491
|
+
// handing back an id a log record rejects fails CLOSED at registration,
|
|
492
|
+
// before anything is charged.
|
|
493
|
+
return await executor.run(`${SESSION_ACTION_ID}#${handle.id}`, {
|
|
494
|
+
prompt,
|
|
495
|
+
workspace: handle,
|
|
496
|
+
signal: controller.signal,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
// Order is load-bearing and matches what `brambo run` has always done:
|
|
501
|
+
// unregister first so a signal arriving during cleanup cannot abort a
|
|
502
|
+
// controller nobody is watching, then release the lease, then dispose. ALL
|
|
503
|
+
// of them are contained: a bare deregistration replaced a successful
|
|
504
|
+
// envelope with its own rejection AND skipped the rest, which is the failure
|
|
505
|
+
// this block exists to prevent.
|
|
506
|
+
await contained(() => removeSignalHandler());
|
|
507
|
+
await contained(() => provider.release(handle));
|
|
508
|
+
if (disposeProvider)
|
|
509
|
+
await contained(() => provider.dispose());
|
|
510
|
+
// The method unmounts BEFORE the kernel stops and AFTER the workspace is
|
|
511
|
+
// released: its onDeactivate may touch what it materialised, and a teardown
|
|
512
|
+
// running against a half-disposed session is the failure this ordering
|
|
513
|
+
// exists to prevent. Contained like every other step here.
|
|
514
|
+
if (method !== undefined)
|
|
515
|
+
await contained(() => method.deactivate());
|
|
516
|
+
// A kernel this session BUILT is a kernel this session stops, which is what
|
|
517
|
+
// runs every mounted plugin's disposer — the mounted provider's included. A
|
|
518
|
+
// kernel the caller supplied is the caller's to stop, or two sessions on one
|
|
519
|
+
// kernel would leave the first one's cleanup disposing the second's provider.
|
|
520
|
+
if (stopKernel !== undefined)
|
|
521
|
+
await contained(stopKernel);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ToolExecutor } from '@skanl/brambo-contracts';
|
|
2
|
+
import type { ResolvedSandboxSession } from '@skanl/brambo-sandbox';
|
|
3
|
+
import type { RemoteMcpClient } from './remote-mcp.ts';
|
|
4
|
+
export declare function createToolExecutor(session: ResolvedSandboxSession, remoteMcpClient?: RemoteMcpClient): ToolExecutor;
|