operon-agents 0.1.0-alpha.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/LICENSE +55 -0
- package/README.md +18 -0
- package/dist/app-server/client.d.ts +80 -0
- package/dist/app-server/client.js +187 -0
- package/dist/app-server/codec.d.ts +52 -0
- package/dist/app-server/codec.js +252 -0
- package/dist/app-server/index.d.ts +22 -0
- package/dist/app-server/index.js +22 -0
- package/dist/app-server/main.d.ts +3 -0
- package/dist/app-server/main.js +85 -0
- package/dist/app-server/protocol.d.ts +217 -0
- package/dist/app-server/protocol.js +100 -0
- package/dist/app-server/server.d.ts +51 -0
- package/dist/app-server/server.js +274 -0
- package/dist/core.d.ts +12 -0
- package/dist/core.js +12 -0
- package/dist/cron/clock.d.ts +11 -0
- package/dist/cron/clock.js +19 -0
- package/dist/cron/cron-expr.d.ts +15 -0
- package/dist/cron/cron-expr.js +267 -0
- package/dist/cron/extension.d.ts +25 -0
- package/dist/cron/extension.js +141 -0
- package/dist/cron/index.d.ts +20 -0
- package/dist/cron/index.js +11 -0
- package/dist/cron/jitter.d.ts +18 -0
- package/dist/cron/jitter.js +46 -0
- package/dist/cron/manager.d.ts +55 -0
- package/dist/cron/manager.js +123 -0
- package/dist/cron/persist.d.ts +10 -0
- package/dist/cron/persist.js +22 -0
- package/dist/cron/scheduler.d.ts +25 -0
- package/dist/cron/scheduler.js +192 -0
- package/dist/cron/session-store.d.ts +14 -0
- package/dist/cron/session-store.js +50 -0
- package/dist/cron/time-format.d.ts +2 -0
- package/dist/cron/time-format.js +12 -0
- package/dist/cron/tools.d.ts +7 -0
- package/dist/cron/tools.js +161 -0
- package/dist/cron/types.d.ts +9 -0
- package/dist/cron/types.js +2 -0
- package/dist/extensions/index.d.ts +23 -0
- package/dist/extensions/index.js +41 -0
- package/dist/extensions/loader.d.ts +121 -0
- package/dist/extensions/loader.js +285 -0
- package/dist/extensions/manager.d.ts +103 -0
- package/dist/extensions/manager.js +190 -0
- package/dist/extensions/runtime.d.ts +188 -0
- package/dist/extensions/runtime.js +1127 -0
- package/dist/extensions/services.d.ts +85 -0
- package/dist/extensions/services.js +265 -0
- package/dist/extensions/types.d.ts +594 -0
- package/dist/extensions/types.js +2 -0
- package/dist/harness.d.ts +746 -0
- package/dist/harness.js +1483 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +29 -0
- package/dist/internal.d.ts +9 -0
- package/dist/internal.js +9 -0
- package/dist/llm.d.ts +2 -0
- package/dist/llm.js +2 -0
- package/dist/local.d.ts +24 -0
- package/dist/local.js +58 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +2 -0
- package/dist/protocol.d.ts +2 -0
- package/dist/protocol.js +2 -0
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +2 -0
- package/dist/tracing.d.ts +2 -0
- package/dist/tracing.js +2 -0
- package/package.json +78 -0
package/dist/harness.js
ADDED
|
@@ -0,0 +1,1483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mode 2 — batteries-included, in-process Harness facade.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the `operon-agents-core` engine into the kind of ergonomic interface a CLI
|
|
5
|
+
* backend wants: `createHarness()` manages sessions, and each session exposes
|
|
6
|
+
* `prompt()/steer()/followUp()/cancel()/onEvent()/setApprovalHandler()` plus the full
|
|
7
|
+
* control plane (goals, plan mode, compaction, skills, plugins, …) delegated to
|
|
8
|
+
* the core `Session`.
|
|
9
|
+
*
|
|
10
|
+
* It is a pure composition of core primitives — no new engine behavior. For the
|
|
11
|
+
* low-level "assemble it yourself / stateless server" path, import from
|
|
12
|
+
* `operon-agents/core` instead.
|
|
13
|
+
*/
|
|
14
|
+
import { readLog, SessionProjection, ListenerSink, agentEventFromRecord, newAgentEventId, Agent, buildAgentFromProfile, INTERRUPTION_STATE_KEY, flattenPendingInterrupts, parseInterruptionState, Runner, Session, emptyUsage, renderSteerText, isGuardrailTripwireError, askUserQuestionTool, backgroundCapability, compactionCapability, filesystemTools, goalCapability, LocalMachine, McpOAuthService, MemorySessionRepository, SessionRepositoryNotFoundError, planCapability, skillsCapability, SkillRegistry, mcpServersCapability, pluginsCapability, userHooksCapability, taskCapability, workflowCapability, DEFAULT_AGENT_PROFILES, DEFAULT_AGENT_PROFILE_NAME, DEFAULT_SUBAGENT_NAMES, profileSubagentProvider, DEFAULT_ADDRESS, } from "operon-agents-core";
|
|
15
|
+
import { mkdirSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { extensionsCapability, ExtensionRuntime, HarnessExtensionManager, ServiceRegistry, stageDefinition } from "./extensions/index.js";
|
|
18
|
+
import { createExtensionCommandRegistry } from "operon-agents-core";
|
|
19
|
+
/** Rendezvous budget for `replaceExtension` when the caller names none. */
|
|
20
|
+
const DEFAULT_REPLACE_TIMEOUT_MS = 30_000;
|
|
21
|
+
/** Session-store key holding the per-session extension params map (see `createSession({ params })`). */
|
|
22
|
+
const EXTENSION_PARAMS_STATE_KEY = "extensions:params";
|
|
23
|
+
/**
|
|
24
|
+
* The standard coding-agent capability bundle (goal / plan / todo / skills /
|
|
25
|
+
* cron / background / compaction). Filesystem access is NOT here — it is a tool
|
|
26
|
+
* on the agent (`filesystemTools()`), not a capability; so is AskUserQuestion
|
|
27
|
+
* (`askUserQuestionTool`, wired through ToolRunContext.responder).
|
|
28
|
+
*
|
|
29
|
+
* With `mcpServers` and/or `pluginManager`, an MCP capability is added; with `pluginManager`, the
|
|
30
|
+
* plugin's skills/MCP/session-start are wired in too. Intended to be called fresh per session (via
|
|
31
|
+
* a harness capability factory) so per-session state (goal/plan/todo/…) and the plugin set are
|
|
32
|
+
* isolated per session, mirroring a fresh-Session-per-create model.
|
|
33
|
+
*/
|
|
34
|
+
export function defaultCapabilities(options = {}) {
|
|
35
|
+
const manager = options.pluginManager;
|
|
36
|
+
// Shared registry so the plugin session-start injector can render a skill the skills capability
|
|
37
|
+
// loaded (its roots include the plugin skill dirs below).
|
|
38
|
+
const registry = manager !== undefined ? new SkillRegistry() : undefined;
|
|
39
|
+
// `includeDefaultRoots` because the plugin dirs ADD to the project/user `.agents/skills` roots —
|
|
40
|
+
// without it, enabling a single skill-bearing plugin would hide every local skill.
|
|
41
|
+
const skills = manager !== undefined
|
|
42
|
+
? skillsCapability({ registry, roots: manager.skillRoots(), includeDefaultRoots: true })
|
|
43
|
+
: skillsCapability();
|
|
44
|
+
const capabilities = [
|
|
45
|
+
goalCapability(),
|
|
46
|
+
workflowCapability(),
|
|
47
|
+
planCapability(),
|
|
48
|
+
taskCapability(),
|
|
49
|
+
skills,
|
|
50
|
+
backgroundCapability(),
|
|
51
|
+
compactionCapability({ maxContextTokens: options.maxContextTokens ?? 200_000 }),
|
|
52
|
+
];
|
|
53
|
+
// Cron is a local-only capability (Invariant 7): the server host doesn't install it.
|
|
54
|
+
// MCP: workspace servers + enabled plugin servers (namespaced, so they can't collide).
|
|
55
|
+
if (options.mcpServers !== undefined || manager !== undefined) {
|
|
56
|
+
capabilities.push(mcpServersCapability({ ...(options.mcpServers ?? {}), ...(manager?.mcpServerConfigs() ?? {}) }, options.oauthService !== undefined ? { oauthService: options.oauthService } : {}));
|
|
57
|
+
}
|
|
58
|
+
// Plugins: load the manager + render the plugin's session-start skill from the shared registry
|
|
59
|
+
// (skills opens before this, so by injection time the registry holds the plugin skills).
|
|
60
|
+
if (manager !== undefined && registry !== undefined) {
|
|
61
|
+
capabilities.push(pluginsCapability(manager, (_pluginId, skillName) => registry.getSkill(skillName)?.content));
|
|
62
|
+
}
|
|
63
|
+
// Shell hooks: config first, then enabled plugin hooks (single HookEngine / SessionStart injector).
|
|
64
|
+
const hookDefs = [
|
|
65
|
+
...(options.hooks ?? []),
|
|
66
|
+
...(manager !== undefined ? manager.hookDefs() : []),
|
|
67
|
+
];
|
|
68
|
+
if (hookDefs.length > 0) {
|
|
69
|
+
capabilities.push(userHooksCapability(hookDefs));
|
|
70
|
+
}
|
|
71
|
+
return capabilities;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The builtin subagents (`coder` / `explore` / `plan`) the `Agent` and `Workflow` tools spawn by
|
|
75
|
+
* default. The profiles declare no model, so each subagent inherits the session's model; their tool
|
|
76
|
+
* names resolve against the default agent's toolset (sharing read/write/edit/grep/glob/bash).
|
|
77
|
+
*/
|
|
78
|
+
function defaultSubagentProvider(agentTools, resolveModel, extraProfiles = {}) {
|
|
79
|
+
const tools = {};
|
|
80
|
+
for (const tool of agentTools)
|
|
81
|
+
tools[tool.schema.name] = tool;
|
|
82
|
+
// Deployment-supplied profiles (disk locally, external on a server) extend the builtin fleet;
|
|
83
|
+
// the SOURCE differs by composition root, the wiring here does not.
|
|
84
|
+
const profiles = { ...DEFAULT_AGENT_PROFILES, ...extraProfiles };
|
|
85
|
+
const only = [...new Set([...DEFAULT_SUBAGENT_NAMES, ...Object.keys(extraProfiles)])];
|
|
86
|
+
return profileSubagentProvider(profiles, {
|
|
87
|
+
only,
|
|
88
|
+
tools,
|
|
89
|
+
...(resolveModel !== undefined ? { resolveModel } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** A `Responder` whose approval/question handlers can be swapped at runtime (last-wins). */
|
|
93
|
+
class MutableResponder {
|
|
94
|
+
approvalHandler;
|
|
95
|
+
questionHandler;
|
|
96
|
+
/** No handler ⇒ not a live approver ⇒ the run interrupts durably (resume later via
|
|
97
|
+
* `HarnessSession.resume`) instead of auto-rejecting the tool. */
|
|
98
|
+
isLiveApprover() {
|
|
99
|
+
return this.approvalHandler !== undefined;
|
|
100
|
+
}
|
|
101
|
+
async requestApproval(request, options) {
|
|
102
|
+
if (this.approvalHandler)
|
|
103
|
+
return this.approvalHandler(request, options);
|
|
104
|
+
return { decision: "rejected", feedback: "No approval handler registered on this session." };
|
|
105
|
+
}
|
|
106
|
+
async requestQuestion(request, options) {
|
|
107
|
+
if (this.questionHandler)
|
|
108
|
+
return this.questionHandler(request, options);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Ergonomic per-session facade. Run-driving methods (`prompt`/`steer`/`followUp`/`cancel`/
|
|
114
|
+
* `onEvent`/approval handlers) live here; everything else delegates to the core
|
|
115
|
+
* `Session` (reachable via `.core` for anything not surfaced).
|
|
116
|
+
*/
|
|
117
|
+
export class HarnessSession {
|
|
118
|
+
id;
|
|
119
|
+
workDir;
|
|
120
|
+
/** The underlying core session — escape hatch for the full control plane. */
|
|
121
|
+
core;
|
|
122
|
+
/**
|
|
123
|
+
* The session's event stream folded into state. The consumer split: `onEvent` serves
|
|
124
|
+
* events-as-events (tracing, telemetry); the projection serves state — a late-joining
|
|
125
|
+
* consumer calls `observeProjection()` and gets an exact history/live seam (no loss,
|
|
126
|
+
* no duplicates, no dedup).
|
|
127
|
+
* Attached before the session's first run — see docs/architecture.md §2.3.
|
|
128
|
+
*/
|
|
129
|
+
projection;
|
|
130
|
+
agent;
|
|
131
|
+
runner;
|
|
132
|
+
events;
|
|
133
|
+
responder;
|
|
134
|
+
onClosed;
|
|
135
|
+
runContext;
|
|
136
|
+
currentRun;
|
|
137
|
+
/** Barrier gate (docs/architecture.md §5.5): while set, no NEW run starts on this
|
|
138
|
+
* session — prompt/resume park at it, promptStream starts lazily behind it. The in-flight
|
|
139
|
+
* run is never touched. */
|
|
140
|
+
runGate;
|
|
141
|
+
/** Resolves when the latest run settles — the "arrived at the boundary" signal a barrier
|
|
142
|
+
* coordinator awaits. Undefined while idle. */
|
|
143
|
+
runSettled;
|
|
144
|
+
runSettles = new Map();
|
|
145
|
+
lastRunInterrupted = false;
|
|
146
|
+
closed = false;
|
|
147
|
+
deliveryCounter = 0;
|
|
148
|
+
/** A wake is already scheduled on the microtask queue; more enqueues fold into it. */
|
|
149
|
+
wakeScheduled = false;
|
|
150
|
+
/** Per-session turn cap; the Runner's own config is the fallback. */
|
|
151
|
+
maxTurns;
|
|
152
|
+
constructor(args) {
|
|
153
|
+
this.core = args.core;
|
|
154
|
+
this.id = args.core.id;
|
|
155
|
+
this.projection = args.projection;
|
|
156
|
+
this.workDir = args.workDir;
|
|
157
|
+
this.agent = args.agent;
|
|
158
|
+
this.runner = args.runner;
|
|
159
|
+
this.events = args.events;
|
|
160
|
+
this.responder = args.responder;
|
|
161
|
+
this.runContext = args.context;
|
|
162
|
+
this.maxTurns = args.maxTurns;
|
|
163
|
+
this.lastRunInterrupted = args.interrupted ?? false;
|
|
164
|
+
this.onClosed = args.onClosed;
|
|
165
|
+
// Nothing drains a queue that fills while the session sits idle — the run loop's
|
|
166
|
+
// turn-boundary drain only exists while a run is in flight. Without this, a background
|
|
167
|
+
// task settling minutes after its spawning turn ended (the normal case: spawning to the
|
|
168
|
+
// background is precisely how a turn stops waiting) would leave its result queued until
|
|
169
|
+
// the user happened to prompt again.
|
|
170
|
+
this.core.steer.setIdleWakeListener(() => this.scheduleIdleWake());
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Start a turn to consume queued follow-ups, once the current synchronous work is done.
|
|
174
|
+
*
|
|
175
|
+
* Deferred to a microtask for two reasons: several enqueues in one stack fold into a single
|
|
176
|
+
* wake, and a settle that lands in a finishing run's own stack sees `currentRun` already
|
|
177
|
+
* cleared. Re-checked at fire time because either can change in between — and re-checked
|
|
178
|
+
* again after every run, since the queue may have filled during one whose drain had passed.
|
|
179
|
+
*/
|
|
180
|
+
scheduleIdleWake() {
|
|
181
|
+
if (this.wakeScheduled)
|
|
182
|
+
return;
|
|
183
|
+
this.wakeScheduled = true;
|
|
184
|
+
queueMicrotask(() => {
|
|
185
|
+
this.wakeScheduled = false;
|
|
186
|
+
if (this.closed || this.lastRunInterrupted)
|
|
187
|
+
return;
|
|
188
|
+
// Held at a barrier: stand down instead of parking a drain run at the gate (stacked
|
|
189
|
+
// parked drains would race on release). `release()` re-schedules this wake.
|
|
190
|
+
if (this.runGate !== undefined)
|
|
191
|
+
return;
|
|
192
|
+
// A run in flight drains at its own turn boundary; an interrupted one must be resumed,
|
|
193
|
+
// not talked over.
|
|
194
|
+
if (this.currentRun !== undefined)
|
|
195
|
+
return;
|
|
196
|
+
// Either channel counts. A "steering" item is an interruption of work in flight, but with
|
|
197
|
+
// nothing in flight there is nothing to interrupt — it is simply the next input, and the
|
|
198
|
+
// turn picks it up at the top of its first step (`runTurn`'s drain runs before the model
|
|
199
|
+
// call), just as the follow-up queue is drained before the turn starts.
|
|
200
|
+
if (!this.core.steer.hasItems())
|
|
201
|
+
return;
|
|
202
|
+
// No input: the queue IS the prompt for this turn.
|
|
203
|
+
void this.runPrompt([]).catch(() => undefined);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
/** Run options every run on this session carries — currently the session's turn cap. */
|
|
207
|
+
get runOverrides() {
|
|
208
|
+
return this.maxTurns !== undefined ? { maxTurns: this.maxTurns } : {};
|
|
209
|
+
}
|
|
210
|
+
/** Current runtime-only application context supplied to Agent callbacks. */
|
|
211
|
+
get context() {
|
|
212
|
+
return this.runContext;
|
|
213
|
+
}
|
|
214
|
+
get status() {
|
|
215
|
+
return {
|
|
216
|
+
state: this.closed
|
|
217
|
+
? "closed"
|
|
218
|
+
: this.currentRun !== undefined
|
|
219
|
+
? "running"
|
|
220
|
+
: this.lastRunInterrupted
|
|
221
|
+
? "interrupted"
|
|
222
|
+
: "idle",
|
|
223
|
+
activeTurnId: this.core.steer.activeTurn,
|
|
224
|
+
hasQueuedMessages: this.core.steer.hasItems(),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/** Durable approvals / tool inputs awaiting answers, including after a reopen where the
|
|
228
|
+
* historical `turn.paused` event itself is no longer live in Projection. */
|
|
229
|
+
async pendingInterruptions() {
|
|
230
|
+
const raw = await this.core.store?.getState(INTERRUPTION_STATE_KEY);
|
|
231
|
+
if (raw === null || raw === undefined)
|
|
232
|
+
return [];
|
|
233
|
+
return flattenPendingInterrupts(parseInterruptionState(raw));
|
|
234
|
+
}
|
|
235
|
+
/** Replace the runtime-only context used by subsequent prompt/resume calls. */
|
|
236
|
+
setContext(context) {
|
|
237
|
+
this.runContext = context;
|
|
238
|
+
}
|
|
239
|
+
// ── Run driving (the methods core Session does not have) ──
|
|
240
|
+
beginRun(controller) {
|
|
241
|
+
this.currentRun = controller;
|
|
242
|
+
this.runSettled = new Promise((resolve) => {
|
|
243
|
+
this.runSettles.set(controller, resolve);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
endRun(controller) {
|
|
247
|
+
if (this.currentRun === controller)
|
|
248
|
+
this.currentRun = undefined;
|
|
249
|
+
this.runSettles.get(controller)?.();
|
|
250
|
+
this.runSettles.delete(controller);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Barrier support (docs/architecture.md §5.5): refuse NEW runs until released and
|
|
254
|
+
* report when this session reaches its run boundary. Installing the gate is synchronous — a
|
|
255
|
+
* coordinator gates every affected session FIRST, then awaits quiescence, so the set of
|
|
256
|
+
* running sessions only shrinks. The in-flight run (if any) is never signaled or aborted;
|
|
257
|
+
* `quiescent` settles when it finishes on its own. `release` is idempotent and always safe —
|
|
258
|
+
* a coordinator calls it in a finally so a failed barrier leaves nothing held.
|
|
259
|
+
*/
|
|
260
|
+
holdAtBoundary() {
|
|
261
|
+
let releaseGate;
|
|
262
|
+
const released = new Promise((resolve) => {
|
|
263
|
+
releaseGate = resolve;
|
|
264
|
+
});
|
|
265
|
+
const gate = { released, release: releaseGate };
|
|
266
|
+
this.runGate = gate;
|
|
267
|
+
const quiescent = this.currentRun !== undefined ? (this.runSettled ?? Promise.resolve()) : Promise.resolve();
|
|
268
|
+
return {
|
|
269
|
+
quiescent,
|
|
270
|
+
release: () => {
|
|
271
|
+
if (this.runGate === gate)
|
|
272
|
+
this.runGate = undefined;
|
|
273
|
+
gate.release();
|
|
274
|
+
// Anything queued while held drains on the normal idle-wake path.
|
|
275
|
+
this.scheduleIdleWake();
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** Run a turn to completion. Serialized with other runs on this session. */
|
|
280
|
+
async prompt(input) {
|
|
281
|
+
return this.runPrompt(input);
|
|
282
|
+
}
|
|
283
|
+
async runPrompt(input, inputOrigin) {
|
|
284
|
+
if (this.closed)
|
|
285
|
+
throw new Error(`session "${this.id}" is closed`);
|
|
286
|
+
// Barrier gate: park (never fail) until released. A loop, not an if — a new hold can land
|
|
287
|
+
// between the release and this task resuming.
|
|
288
|
+
while (this.runGate !== undefined)
|
|
289
|
+
await this.runGate.released;
|
|
290
|
+
const controller = new AbortController();
|
|
291
|
+
this.beginRun(controller);
|
|
292
|
+
try {
|
|
293
|
+
const result = await this.runner.run(this.agent, input, {
|
|
294
|
+
session: this.core,
|
|
295
|
+
signal: controller.signal,
|
|
296
|
+
context: this.runContext,
|
|
297
|
+
...(inputOrigin !== undefined ? { inputOrigin } : {}),
|
|
298
|
+
...this.runOverrides,
|
|
299
|
+
});
|
|
300
|
+
this.lastRunInterrupted = result.status === "interrupted";
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
// Facade contract: a guardrail tripwire resolves as a terminal RunResult (the low-level
|
|
305
|
+
// Runner still throws — OpenAI parity). Callers branch on `result.status`, no try/catch.
|
|
306
|
+
if (isGuardrailTripwireError(error))
|
|
307
|
+
return this.guardrailBlocked(error);
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
this.endRun(controller);
|
|
312
|
+
// A follow-up can land after this run's last drain but before it settles — that enqueue
|
|
313
|
+
// saw `currentRun` set and stood down, so the run itself has to look once on the way out.
|
|
314
|
+
this.scheduleIdleWake();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Hand a message to the frame running at `address` in this session (`main` for the root agent,
|
|
319
|
+
* `main/<agentId>` for a subagent); `false` when nobody is there. The seam an out-of-process
|
|
320
|
+
* coordinator uses to address a specific subagent.
|
|
321
|
+
*/
|
|
322
|
+
steerTo(address, content, origin) {
|
|
323
|
+
if (this.closed || this.lastRunInterrupted)
|
|
324
|
+
return false;
|
|
325
|
+
// A run in flight drains its own queues, so handing the message to the frame is enough.
|
|
326
|
+
if (this.currentRun !== undefined)
|
|
327
|
+
return this.core.steerTo(address, content, origin);
|
|
328
|
+
// Idle: only the root frame can be woken. A subagent whose frame has ended is not a teammate
|
|
329
|
+
// waiting for mail — it is a finished delegation, and only the parent that delegated it can
|
|
330
|
+
// decide to continue it (`Agent(resume=...)`). Its store, capabilities, permissions and
|
|
331
|
+
// lifetime are all the parent's; there is no independent agent here to start.
|
|
332
|
+
if (address !== DEFAULT_ADDRESS)
|
|
333
|
+
return false;
|
|
334
|
+
// Queue it and let the idle-wake listener start the turn. Going through the bus (rather
|
|
335
|
+
// than synthesizing a prompt here) is what gives the message a `steerId` on its journal
|
|
336
|
+
// record, same as every other producer — the enqueue→consume trail stays unbroken.
|
|
337
|
+
this.core.steer.steer(content, origin);
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Deliver an externally-originated message. A running session receives it through the
|
|
342
|
+
* requested SteerBus channel; an idle session starts a new turn immediately.
|
|
343
|
+
*
|
|
344
|
+
* ACCEPTANCE IS DURABLE BEFORE THIS RESOLVES. The input is journaled as `inbox.received`
|
|
345
|
+
* and that write is awaited before a receipt exists, so the receipt means "this survives a
|
|
346
|
+
* crash", not merely "this reached a process". Previously the message lived only in memory
|
|
347
|
+
* (SteerBus, or an argument to `runPrompt`) until the run that consumed it journaled it —
|
|
348
|
+
* a crash in that window lost a delivery the caller had already been told was accepted.
|
|
349
|
+
*
|
|
350
|
+
* The provenance of what the model ultimately sees is still persisted on the eventual
|
|
351
|
+
* `message.appended` record: acceptance and processing are journaled separately because
|
|
352
|
+
* they can legitimately differ (capability rewrite, guardrail rejection, or a capability
|
|
353
|
+
* answering the prompt outright).
|
|
354
|
+
*/
|
|
355
|
+
async deliver(input, options) {
|
|
356
|
+
if (this.closed)
|
|
357
|
+
throw new Error(`session "${this.id}" is closed`);
|
|
358
|
+
if (this.lastRunInterrupted && this.currentRun === undefined) {
|
|
359
|
+
throw new Error(`session "${this.id}" is interrupted; resume it before delivering new work`);
|
|
360
|
+
}
|
|
361
|
+
if (!options.source.trim())
|
|
362
|
+
throw new Error("delivery source must not be empty");
|
|
363
|
+
this.deliveryCounter += 1;
|
|
364
|
+
const deliveryId = `delivery_${Date.now().toString(36)}_${this.deliveryCounter.toString(36)}`;
|
|
365
|
+
const mode = options.mode ?? "auto";
|
|
366
|
+
const channel = mode === "follow_up" ? "follow_up" : "steering";
|
|
367
|
+
const external = {
|
|
368
|
+
kind: "external",
|
|
369
|
+
source: options.source,
|
|
370
|
+
deliveryId,
|
|
371
|
+
actor: options.actor,
|
|
372
|
+
metadata: options.metadata,
|
|
373
|
+
channel,
|
|
374
|
+
};
|
|
375
|
+
const acceptedAt = Date.now();
|
|
376
|
+
// Durable acceptance, before anything is dispatched. A stored session gets its receipt from
|
|
377
|
+
// this record (the store is publication-aware, so the append also produces the
|
|
378
|
+
// `delivery.accepted` event); a storeless one has nothing to be durable about and emits the
|
|
379
|
+
// event directly.
|
|
380
|
+
if (this.core.store !== undefined) {
|
|
381
|
+
await this.core.store.appendRecord({
|
|
382
|
+
type: "inbox.received",
|
|
383
|
+
time: acceptedAt,
|
|
384
|
+
address: "main",
|
|
385
|
+
input,
|
|
386
|
+
origin: external,
|
|
387
|
+
mode,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
void this.core.events.emit({
|
|
392
|
+
type: "delivery.accepted",
|
|
393
|
+
deliveryId,
|
|
394
|
+
source: options.source,
|
|
395
|
+
channel,
|
|
396
|
+
address: "main",
|
|
397
|
+
sessionId: this.id,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
return this.dispatchAccepted(input, external, acceptedAt);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Dispatch an input whose acceptance is ALREADY journaled.
|
|
404
|
+
*
|
|
405
|
+
* A worker draining a session's inbox calls this directly: the `inbox.received` record exists,
|
|
406
|
+
* so going back through `deliver` would write a second one and count the delivery twice. The
|
|
407
|
+
* split is the whole reason acceptance and dispatch are separable — accepting is a durable
|
|
408
|
+
* write that must happen before a receipt exists, dispatching is what someone holding the
|
|
409
|
+
* session's lease does with it afterwards, possibly on another machine, possibly much later.
|
|
410
|
+
*
|
|
411
|
+
* Run state is read HERE, not at acceptance: by now a turn may have started or ended, so
|
|
412
|
+
* whether this steers work in flight or starts a fresh turn can only be decided now.
|
|
413
|
+
*/
|
|
414
|
+
dispatchAccepted(input, origin, acceptedAt = Date.now()) {
|
|
415
|
+
if (this.closed)
|
|
416
|
+
throw new Error(`session "${this.id}" is closed`);
|
|
417
|
+
const deliveryId = origin.deliveryId;
|
|
418
|
+
const channel = origin.channel ?? "steering";
|
|
419
|
+
if (this.currentRun !== undefined) {
|
|
420
|
+
const receipt = this.core.steer.steer(input, origin);
|
|
421
|
+
return {
|
|
422
|
+
deliveryId,
|
|
423
|
+
sessionId: this.id,
|
|
424
|
+
acceptedAt,
|
|
425
|
+
status: "queued",
|
|
426
|
+
channel,
|
|
427
|
+
steerId: receipt.steerId,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
const promptOrigin = {
|
|
431
|
+
kind: "external",
|
|
432
|
+
source: origin.source,
|
|
433
|
+
deliveryId,
|
|
434
|
+
...(origin.actor !== undefined ? { actor: origin.actor } : {}),
|
|
435
|
+
...(origin.metadata !== undefined ? { metadata: origin.metadata } : {}),
|
|
436
|
+
};
|
|
437
|
+
const message = {
|
|
438
|
+
role: "user",
|
|
439
|
+
content: [{ type: "text", text: renderSteerText(origin, input) }],
|
|
440
|
+
timestamp: acceptedAt,
|
|
441
|
+
};
|
|
442
|
+
const completion = this.runPrompt([message], promptOrigin);
|
|
443
|
+
// Callers may only need acceptance. Keep a fire-and-observe delivery from becoming an
|
|
444
|
+
// unhandled rejection; awaiting `completion` still receives the original rejection.
|
|
445
|
+
completion.catch(() => undefined);
|
|
446
|
+
return { deliveryId, sessionId: this.id, acceptedAt, status: "started", channel: "turn", completion };
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Resume a durably-interrupted run. When a `prompt` returns `status:"interrupted"` — which
|
|
450
|
+
* happens when no live approval handler is registered (`no live responder ⇒ durable`) — the run's
|
|
451
|
+
* interruption control tree was persisted to the session store. This loads it, applies the
|
|
452
|
+
* caller's `answers` (prefer `approvalId`; a unique `toolCallId` is also accepted), and continues the
|
|
453
|
+
* run to its next stop. Works across processes as long as the reopened session shares the same
|
|
454
|
+
* durable store. Same guardrail-facade contract as `prompt` (a tripwire resolves as a terminal
|
|
455
|
+
* `RunResult`, not a throw).
|
|
456
|
+
*
|
|
457
|
+
* An answer is either a bare `ApprovalResponse` (shorthand for the common approval case) or a
|
|
458
|
+
* discriminated `InterruptAnswer` — pass `{ kind: "input", data }` to answer a durable input
|
|
459
|
+
* suspension (`ctx.suspend`), which the approval-only shorthand cannot express.
|
|
460
|
+
*
|
|
461
|
+
* Recovery: if a previous resume threw mid-flight, the persisted state is left at
|
|
462
|
+
* `phase: "recovery_required"` with its revision bumped. Calling this again handles it —
|
|
463
|
+
* the state is re-read from the store here, so it is never stale — and continues from the
|
|
464
|
+
* last durable pause. Only callers driving `Runner.resume` directly with a kept-around
|
|
465
|
+
* copy see a "stale interruption state" error; re-fetching the persisted state clears it.
|
|
466
|
+
*/
|
|
467
|
+
async resume(answers) {
|
|
468
|
+
// Barrier gate first (a coordinator holding the barrier should not be resumed against —
|
|
469
|
+
// the call parks here until release rather than failing).
|
|
470
|
+
while (this.runGate !== undefined)
|
|
471
|
+
await this.runGate.released;
|
|
472
|
+
if (this.currentRun !== undefined)
|
|
473
|
+
throw new Error(`session "${this.id}" already has an active run`);
|
|
474
|
+
const controller = new AbortController();
|
|
475
|
+
// Set this BEFORE the first await (past the gate). A host returning 202 from resume can
|
|
476
|
+
// immediately observe `running`, and a concurrent delivery joins this resumed turn instead
|
|
477
|
+
// of seeing stale interrupted state while the control record is being read.
|
|
478
|
+
this.beginRun(controller);
|
|
479
|
+
try {
|
|
480
|
+
const raw = await this.core.store?.getState(INTERRUPTION_STATE_KEY);
|
|
481
|
+
if (raw === null || raw === undefined) {
|
|
482
|
+
throw new Error("No interrupted run to resume on this session (no persisted interruption state).");
|
|
483
|
+
}
|
|
484
|
+
const interruption = parseInterruptionState(raw);
|
|
485
|
+
// Bare ApprovalResponses (no `kind` field — the common case) are wrapped into the
|
|
486
|
+
// discriminated InterruptAnswer shape; already-discriminated answers (including
|
|
487
|
+
// `kind: "input"` for durable input suspensions) pass through untouched.
|
|
488
|
+
const wrappedAnswers = Object.fromEntries(Object.entries(answers).map(([id, answer]) => [
|
|
489
|
+
id,
|
|
490
|
+
isInterruptAnswer(answer) ? answer : { kind: "approval", ...answer },
|
|
491
|
+
]));
|
|
492
|
+
const result = await this.runner.resume(this.agent, { interruption, answers: wrappedAnswers }, {
|
|
493
|
+
session: this.core,
|
|
494
|
+
signal: controller.signal,
|
|
495
|
+
context: this.runContext,
|
|
496
|
+
...this.runOverrides,
|
|
497
|
+
});
|
|
498
|
+
this.lastRunInterrupted = result.status === "interrupted";
|
|
499
|
+
return result;
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
if (isGuardrailTripwireError(error))
|
|
503
|
+
return this.guardrailBlocked(error);
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
this.endRun(controller);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/** Streaming variant: iterate events, await `.completed` for the result. */
|
|
511
|
+
promptStream(input) {
|
|
512
|
+
const controller = new AbortController();
|
|
513
|
+
// Ungated: start synchronously, byte-identical to the pre-barrier behavior. Gated: the
|
|
514
|
+
// handle returns immediately but the underlying run starts only after release (lazy start —
|
|
515
|
+
// promptStream has no callers that depend on first-event timing).
|
|
516
|
+
const gated = this.runGate !== undefined;
|
|
517
|
+
if (!gated)
|
|
518
|
+
this.beginRun(controller);
|
|
519
|
+
const start = (async () => {
|
|
520
|
+
if (gated) {
|
|
521
|
+
while (this.runGate !== undefined)
|
|
522
|
+
await this.runGate.released;
|
|
523
|
+
this.beginRun(controller);
|
|
524
|
+
}
|
|
525
|
+
return this.runner.runStream(this.agent, input, {
|
|
526
|
+
session: this.core,
|
|
527
|
+
signal: controller.signal,
|
|
528
|
+
context: this.runContext,
|
|
529
|
+
...this.runOverrides,
|
|
530
|
+
});
|
|
531
|
+
})();
|
|
532
|
+
// Same facade contract as `prompt`: `completed` resolves with a "guardrail_blocked" result
|
|
533
|
+
// instead of rejecting. Events (including the `guardrail.blocked` frame) stream through the
|
|
534
|
+
// original handle unchanged.
|
|
535
|
+
const completed = start
|
|
536
|
+
.then((handle) => handle.completed)
|
|
537
|
+
.then((result) => {
|
|
538
|
+
this.lastRunInterrupted = result.status === "interrupted";
|
|
539
|
+
return result;
|
|
540
|
+
})
|
|
541
|
+
.catch((error) => {
|
|
542
|
+
if (isGuardrailTripwireError(error))
|
|
543
|
+
return this.guardrailBlocked(error);
|
|
544
|
+
throw error;
|
|
545
|
+
})
|
|
546
|
+
.finally(() => {
|
|
547
|
+
this.endRun(controller);
|
|
548
|
+
});
|
|
549
|
+
// Guard the floating promise so iterate-only callers don't trip an unhandled rejection
|
|
550
|
+
// (mirrors runStream's own internal guard); awaiters still receive a real rejection.
|
|
551
|
+
completed.catch(() => undefined);
|
|
552
|
+
const iterate = async function* () {
|
|
553
|
+
const handle = await start;
|
|
554
|
+
for await (const event of handle)
|
|
555
|
+
yield event;
|
|
556
|
+
};
|
|
557
|
+
return {
|
|
558
|
+
[Symbol.asyncIterator]: () => iterate()[Symbol.asyncIterator](),
|
|
559
|
+
completed,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
/** Synthesize the terminal RunResult for a guardrail tripwire (facade contract). */
|
|
563
|
+
guardrailBlocked(error) {
|
|
564
|
+
// Terminal, like any other completed result: without this, a tripwire during resume()
|
|
565
|
+
// would leave the flag stuck true (the success path at the call sites never runs) and
|
|
566
|
+
// wedge the session in "interrupted" with its durable state already consumed.
|
|
567
|
+
this.lastRunInterrupted = false;
|
|
568
|
+
return {
|
|
569
|
+
status: "guardrail_blocked",
|
|
570
|
+
finalAgent: error.agentName ?? this.agent.name,
|
|
571
|
+
activeAddress: error.address ?? this.core.conversationHead()?.address ?? "main",
|
|
572
|
+
output: "",
|
|
573
|
+
messages: [],
|
|
574
|
+
usage: emptyUsage(),
|
|
575
|
+
guardrail: {
|
|
576
|
+
stage: error.stage,
|
|
577
|
+
guardrail: error.guardrailName,
|
|
578
|
+
...(error.agentName !== undefined ? { agent: error.agentName } : {}),
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Inject a user message into the in-flight (or next) turn. Returns the steer's correlation
|
|
584
|
+
* id: a `steer.queued` event carries it now, and the consuming `message.appended` event's
|
|
585
|
+
* `origin.steerId` matches it when the model actually sees the message.
|
|
586
|
+
*/
|
|
587
|
+
steer(input) {
|
|
588
|
+
return this.core.steer.steer(input, { kind: "user" }).steerId;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Queue a user message for a new turn after the current run's active turn finishes.
|
|
592
|
+
* Returns the steer's correlation id (see `steer`), or null when the session has no
|
|
593
|
+
* active run — use `prompt` instead in that case.
|
|
594
|
+
*/
|
|
595
|
+
followUp(input) {
|
|
596
|
+
if (this.currentRun === undefined)
|
|
597
|
+
return null;
|
|
598
|
+
return this.core.steer.followUp(input).steerId;
|
|
599
|
+
}
|
|
600
|
+
/** Abort the current run. */
|
|
601
|
+
cancel() {
|
|
602
|
+
this.currentRun?.abort();
|
|
603
|
+
}
|
|
604
|
+
/** Subscribe to this session's event stream. Returns an unsubscribe fn. */
|
|
605
|
+
onEvent(listener) {
|
|
606
|
+
return this.events.subscribe(listener);
|
|
607
|
+
}
|
|
608
|
+
// ── Projection (state-shaped consumers; see the `projection` field doc) ──
|
|
609
|
+
/** Current folded state. Consumers that also need subsequent events should use
|
|
610
|
+
* `observeProjection()` for an atomic history/live seam. */
|
|
611
|
+
snapshot(options) {
|
|
612
|
+
return this.projection.snapshot(options);
|
|
613
|
+
}
|
|
614
|
+
/** Post-apply event feed: every delivered event's effect is already in `snapshot()`.
|
|
615
|
+
* Listeners must be synchronous — backpressure belongs to the transport. */
|
|
616
|
+
subscribeProjection(listener) {
|
|
617
|
+
return this.projection.subscribe(listener);
|
|
618
|
+
}
|
|
619
|
+
/** Atomically subscribe to post-fold AgentEvents and capture the authoritative state at
|
|
620
|
+
* that boundary. Prefer this over coordinating `snapshot()` and `subscribeProjection()`
|
|
621
|
+
* manually in transports and UIs. */
|
|
622
|
+
observeProjection(listener, options) {
|
|
623
|
+
return this.projection.observe(listener, options);
|
|
624
|
+
}
|
|
625
|
+
/** Lightweight per-address summary (which agents exist, who is live/running). */
|
|
626
|
+
directory() {
|
|
627
|
+
return this.projection.directory();
|
|
628
|
+
}
|
|
629
|
+
setApprovalHandler(handler) {
|
|
630
|
+
this.responder.approvalHandler = handler;
|
|
631
|
+
}
|
|
632
|
+
setQuestionHandler(handler) {
|
|
633
|
+
this.responder.questionHandler = handler;
|
|
634
|
+
}
|
|
635
|
+
// ── Control plane (delegated to core Session) ──
|
|
636
|
+
setModel(model) {
|
|
637
|
+
this.core.setModel(model);
|
|
638
|
+
}
|
|
639
|
+
setThinking(level) {
|
|
640
|
+
this.core.setThinking(level);
|
|
641
|
+
}
|
|
642
|
+
/** In-memory switch is immediate; await the returned promise to confirm it was journaled. */
|
|
643
|
+
setPermissionMode(mode) {
|
|
644
|
+
return this.core.setPermissionMode(mode);
|
|
645
|
+
}
|
|
646
|
+
createGoal(...args) {
|
|
647
|
+
return this.core.createGoal(...args);
|
|
648
|
+
}
|
|
649
|
+
getGoal() {
|
|
650
|
+
return this.core.getGoal();
|
|
651
|
+
}
|
|
652
|
+
setPlanMode(...args) {
|
|
653
|
+
return this.core.setPlanMode(...args);
|
|
654
|
+
}
|
|
655
|
+
getPlan() {
|
|
656
|
+
return this.core.getPlan();
|
|
657
|
+
}
|
|
658
|
+
compact(...args) {
|
|
659
|
+
return this.core.compact(...args);
|
|
660
|
+
}
|
|
661
|
+
listSkills() {
|
|
662
|
+
return this.core.listSkills();
|
|
663
|
+
}
|
|
664
|
+
activateSkill(...args) {
|
|
665
|
+
return this.core.activateSkill(...args);
|
|
666
|
+
}
|
|
667
|
+
listPlugins() {
|
|
668
|
+
return this.core.listPlugins();
|
|
669
|
+
}
|
|
670
|
+
installPlugin(...args) {
|
|
671
|
+
return this.core.installPlugin(...args);
|
|
672
|
+
}
|
|
673
|
+
listBackgroundTasks(...args) {
|
|
674
|
+
return this.core.listBackgroundTasks(...args);
|
|
675
|
+
}
|
|
676
|
+
// ── Conversation log (flat, linear) — read the record stream for transcript rendering ──
|
|
677
|
+
getRecords(...args) {
|
|
678
|
+
return this.core.getRecords(...args);
|
|
679
|
+
}
|
|
680
|
+
listSubagents(...args) {
|
|
681
|
+
return this.core.listSubagents(...args);
|
|
682
|
+
}
|
|
683
|
+
reconcileSubagents(...args) {
|
|
684
|
+
return this.core.reconcileSubagents(...args);
|
|
685
|
+
}
|
|
686
|
+
// ── context introspection — where the model's window is spent, as of the last turn ──
|
|
687
|
+
getContextBreakdown() {
|
|
688
|
+
return this.core.getContextBreakdown();
|
|
689
|
+
}
|
|
690
|
+
// ── extensions — hot attach/detach on the live session (host API; never a model tool) ──
|
|
691
|
+
/**
|
|
692
|
+
* Add an extension mid-session. Applied at the next quiet point: immediately when idle,
|
|
693
|
+
* at the run's stop boundary when one is in flight — its tools appear at the next run's
|
|
694
|
+
* assembly. The change is journaled; it does NOT survive `resumeSession` (re-attach, or
|
|
695
|
+
* register it in `createHarness({ extensions })`, which every open re-evaluates). A definition
|
|
696
|
+
* with a `create` half is accepted only once registered — its service must already exist.
|
|
697
|
+
*/
|
|
698
|
+
attachExtension(definition) {
|
|
699
|
+
return this.extensionRuntime().attach(definition);
|
|
700
|
+
}
|
|
701
|
+
/** Remove an extension mid-session: `session.end("detach")` fires, then its scope unwinds. */
|
|
702
|
+
detachExtension(extensionId) {
|
|
703
|
+
return this.extensionRuntime().detach(extensionId);
|
|
704
|
+
}
|
|
705
|
+
/** Ids of currently attached extensions; empty when the session has no extensions capability. */
|
|
706
|
+
attachedExtensionIds() {
|
|
707
|
+
try {
|
|
708
|
+
return this.extensionRuntime().attachedIds();
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
return [];
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
/** Currently attached extensions with what each `uses`; empty without an extensions capability. */
|
|
715
|
+
attachedExtensions() {
|
|
716
|
+
try {
|
|
717
|
+
return this.extensionRuntime().attachedExtensions();
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
return [];
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/** Run a slash command against this session: the static registry plus capability-contributed
|
|
724
|
+
* dynamic commands (extensions' `registerCommand`). Commands are the SERIALIZABLE control
|
|
725
|
+
* surface — what an out-of-process host (app-server RPC) uses where an in-process host
|
|
726
|
+
* reaches `extensionHandle()`. */
|
|
727
|
+
runCommand(input) {
|
|
728
|
+
return sharedCommands().run(input, { session: this.core });
|
|
729
|
+
}
|
|
730
|
+
/** The control surface an extension published via `api.expose`; undefined when the extension
|
|
731
|
+
* is absent, detached, or exposed nothing. The generic replacement for per-feature session
|
|
732
|
+
* facade methods (e.g. cron's — see `cronExtension`). */
|
|
733
|
+
extensionHandle(extensionId) {
|
|
734
|
+
try {
|
|
735
|
+
return this.extensionRuntime().exposedHandle(extensionId);
|
|
736
|
+
}
|
|
737
|
+
catch {
|
|
738
|
+
return undefined;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
extensionRuntime() {
|
|
742
|
+
const runtime = this.core.service("extensions");
|
|
743
|
+
if (!runtime)
|
|
744
|
+
throw new Error("this session has no extensions capability");
|
|
745
|
+
return runtime;
|
|
746
|
+
}
|
|
747
|
+
// ── goal lifecycle ──
|
|
748
|
+
pauseGoal(...args) {
|
|
749
|
+
return this.core.pauseGoal(...args);
|
|
750
|
+
}
|
|
751
|
+
resumeGoal(...args) {
|
|
752
|
+
return this.core.resumeGoal(...args);
|
|
753
|
+
}
|
|
754
|
+
cancelGoal(...args) {
|
|
755
|
+
return this.core.cancelGoal(...args);
|
|
756
|
+
}
|
|
757
|
+
setGoalBudget(...args) {
|
|
758
|
+
return this.core.setGoalBudget(...args);
|
|
759
|
+
}
|
|
760
|
+
// ── plan + compaction ──
|
|
761
|
+
clearPlan(...args) {
|
|
762
|
+
return this.core.clearPlan(...args);
|
|
763
|
+
}
|
|
764
|
+
pendingCompaction(...args) {
|
|
765
|
+
return this.core.pendingCompaction(...args);
|
|
766
|
+
}
|
|
767
|
+
cancelCompaction(...args) {
|
|
768
|
+
return this.core.cancelCompaction(...args);
|
|
769
|
+
}
|
|
770
|
+
// ── background tasks ──
|
|
771
|
+
readBackgroundTaskOutput(...args) {
|
|
772
|
+
return this.core.readBackgroundTaskOutput(...args);
|
|
773
|
+
}
|
|
774
|
+
readBackgroundTaskOutputDelta(...args) {
|
|
775
|
+
return this.core.readBackgroundTaskOutputDelta(...args);
|
|
776
|
+
}
|
|
777
|
+
stopBackgroundTask(...args) {
|
|
778
|
+
return this.core.stopBackgroundTask(...args);
|
|
779
|
+
}
|
|
780
|
+
/** Move a running detachable tool call (bash/subagent/workflow) into a background task. */
|
|
781
|
+
detachTool(...args) {
|
|
782
|
+
return this.core.detachTool(...args);
|
|
783
|
+
}
|
|
784
|
+
// ── plugins (full lifecycle) ──
|
|
785
|
+
getPluginInfo(...args) {
|
|
786
|
+
return this.core.getPluginInfo(...args);
|
|
787
|
+
}
|
|
788
|
+
setPluginEnabled(...args) {
|
|
789
|
+
return this.core.setPluginEnabled(...args);
|
|
790
|
+
}
|
|
791
|
+
setPluginMcpServerEnabled(...args) {
|
|
792
|
+
return this.core.setPluginMcpServerEnabled(...args);
|
|
793
|
+
}
|
|
794
|
+
removePlugin(...args) {
|
|
795
|
+
return this.core.removePlugin(...args);
|
|
796
|
+
}
|
|
797
|
+
reloadPlugins(...args) {
|
|
798
|
+
return this.core.reloadPlugins(...args);
|
|
799
|
+
}
|
|
800
|
+
// ── MCP ──
|
|
801
|
+
listMcpServers(...args) {
|
|
802
|
+
return this.core.listMcpServers(...args);
|
|
803
|
+
}
|
|
804
|
+
reconnectMcpServer(...args) {
|
|
805
|
+
return this.core.reconnectMcpServer(...args);
|
|
806
|
+
}
|
|
807
|
+
/** Close the session and release its resources. */
|
|
808
|
+
async close() {
|
|
809
|
+
if (this.closed)
|
|
810
|
+
return;
|
|
811
|
+
this.closed = true;
|
|
812
|
+
this.cancel();
|
|
813
|
+
this.projection.detach();
|
|
814
|
+
await this.core.close();
|
|
815
|
+
this.onClosed(this.id);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
/** Manages a fleet of in-process sessions over a shared engine config. */
|
|
819
|
+
export class Harness {
|
|
820
|
+
options;
|
|
821
|
+
runner;
|
|
822
|
+
repo;
|
|
823
|
+
/** Concrete tools the default profile's tool NAMES resolve against (keyed by schema name). */
|
|
824
|
+
toolPalette;
|
|
825
|
+
activeSessions = new Map();
|
|
826
|
+
/** Process-level shared services. The host replaces providers here (`services.replace`);
|
|
827
|
+
* sessions only ever see handles (`ctx.shared`, `ctx.services`). */
|
|
828
|
+
services = new ServiceRegistry();
|
|
829
|
+
/** The one in-flight reshape barrier (mutex: a second barrier operation rejects). Sessions
|
|
830
|
+
* appearing while it holds are gated at birth — see the tail of `openFromStore`. */
|
|
831
|
+
activeBarrier;
|
|
832
|
+
/** File-extension manager (both tiers) — present only when the host opted in with
|
|
833
|
+
* `extensionDir`. NOT the Codex-compatible skill/MCP plugin system (`pluginManager`). */
|
|
834
|
+
extensions;
|
|
835
|
+
/** Service ids published by by-value `create` halves, in creation order — unregistered (and
|
|
836
|
+
* disposed) by `close()`, newest first. */
|
|
837
|
+
createdServices = [];
|
|
838
|
+
/** The by-value definitions this harness currently mounts into newborn sessions, in
|
|
839
|
+
* registration order. Seeded from `createHarness({ extensions })` and rewritten in place by
|
|
840
|
+
* `replaceExtension`, so a swap reaches sessions born after it — reading `options.extensions`
|
|
841
|
+
* directly would hand new sessions the OLD code, which is exactly the skew the barrier exists
|
|
842
|
+
* to prevent. */
|
|
843
|
+
valueDefs = new Map();
|
|
844
|
+
/** Root of extensions' data folders (`host.dataDir` = `<root>/<id>`); absent ⇒ none handed out. */
|
|
845
|
+
dataRoot;
|
|
846
|
+
/** Settles once every by-value `create` half has published its service; sessions open after it. */
|
|
847
|
+
sharedReady;
|
|
848
|
+
/** The extension whose `create` is running right now — its `createSession` must refuse. */
|
|
849
|
+
runningCreate;
|
|
850
|
+
constructor(options) {
|
|
851
|
+
this.options = options;
|
|
852
|
+
const agentTools = options.tools ?? [...filesystemTools(), askUserQuestionTool];
|
|
853
|
+
this.toolPalette = Object.fromEntries(agentTools.map((tool) => [tool.schema.name, tool]));
|
|
854
|
+
// The Agent/Workflow tools only appear when the run has subagents to spawn. Default the fleet
|
|
855
|
+
// to the builtin coder/explore/plan profiles so orchestration works out of the box; an explicit
|
|
856
|
+
// provider overrides it, and `null` opts out (hiding those tools).
|
|
857
|
+
const subagentProvider = options.subagentProvider === null
|
|
858
|
+
? undefined
|
|
859
|
+
: options.subagentProvider ?? defaultSubagentProvider(agentTools, options.resolveModel, options.extraSubagentProfiles);
|
|
860
|
+
this.runner = new Runner({
|
|
861
|
+
resolveModel: options.resolveModel,
|
|
862
|
+
subagentProvider,
|
|
863
|
+
...(options.workflowTool !== undefined ? { workflowTool: options.workflowTool } : {}),
|
|
864
|
+
maxTurns: options.maxTurns,
|
|
865
|
+
maxStepsPerTurn: options.maxStepsPerTurn,
|
|
866
|
+
...(options.maxConcurrentSubagents !== undefined ? { maxConcurrentSubagents: options.maxConcurrentSubagents } : {}),
|
|
867
|
+
});
|
|
868
|
+
// Injected repository (disk locally, Pg/Redis on a server); in-memory when omitted.
|
|
869
|
+
this.repo = options.repository ?? new MemorySessionRepository();
|
|
870
|
+
this.dataRoot = options.extensionDataDir ?? (options.extensionDir !== undefined ? join(options.extensionDir, ".data") : undefined);
|
|
871
|
+
this.extensions = options.extensionDir === undefined
|
|
872
|
+
? undefined
|
|
873
|
+
: new HarnessExtensionManager({
|
|
874
|
+
directory: options.extensionDir,
|
|
875
|
+
dataDir: this.dataRoot ?? join(options.extensionDir, ".data"),
|
|
876
|
+
bridge: {
|
|
877
|
+
services: this.services,
|
|
878
|
+
createSession: (sessionOptions) => this.createSession(sessionOptions),
|
|
879
|
+
sessions: () => [...this.activeSessions.values()],
|
|
880
|
+
withBarrier: (extensionIds, timeoutMs, fn) => this.withBarrier(extensionIds, timeoutMs, fn),
|
|
881
|
+
warn: (message) => console.warn(`[extensions] ${message}`),
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
for (const [name, value] of Object.entries(options.services ?? {})) {
|
|
885
|
+
// A plain object carrying an `instance` key is read as a registration descriptor;
|
|
886
|
+
// anything else registers as the service itself (methods-only, not replaceable).
|
|
887
|
+
if (isServiceDescriptor(value)) {
|
|
888
|
+
const { instance, ...serviceOptions } = value;
|
|
889
|
+
this.services.register(name, instance, serviceOptions);
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
this.services.register(name, value);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
for (const definition of options.extensions ?? []) {
|
|
896
|
+
if (this.valueDefs.has(definition.id))
|
|
897
|
+
throw new Error(`duplicate by-value extension id "${definition.id}" in createHarness({ extensions })`);
|
|
898
|
+
this.valueDefs.set(definition.id, definition);
|
|
899
|
+
}
|
|
900
|
+
this.sharedReady = this.registerDefinitions(options.extensions);
|
|
901
|
+
// Surfaced to whoever opens a session; never an unhandled rejection on its own.
|
|
902
|
+
this.sharedReady.catch(() => undefined);
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Run one by-value definition's `create` half against a staging host: the service is COLLECTED,
|
|
906
|
+
* not registered, so a throwing `create` publishes nothing. Stays synchronous when `create` is
|
|
907
|
+
* — what lets a synchronous extension's service exist the moment `createHarness` returns.
|
|
908
|
+
* Shared by construction and `replaceExtension`.
|
|
909
|
+
*/
|
|
910
|
+
stageValue(definition) {
|
|
911
|
+
// The data folder exists before `create` runs (synchronously: `create` may be too).
|
|
912
|
+
const dataDir = this.dataRoot !== undefined ? join(this.dataRoot, definition.id) : undefined;
|
|
913
|
+
if (dataDir !== undefined)
|
|
914
|
+
mkdirSync(dataDir, { recursive: true });
|
|
915
|
+
this.runningCreate = definition.id;
|
|
916
|
+
const done = (staged) => {
|
|
917
|
+
this.runningCreate = undefined;
|
|
918
|
+
return staged;
|
|
919
|
+
};
|
|
920
|
+
const failed = (error) => {
|
|
921
|
+
this.runningCreate = undefined;
|
|
922
|
+
throw error;
|
|
923
|
+
};
|
|
924
|
+
try {
|
|
925
|
+
const staged = stageDefinition(definition, {
|
|
926
|
+
defaultReplaceable: false,
|
|
927
|
+
...(dataDir !== undefined ? { dataDir } : {}),
|
|
928
|
+
createSession: (sessionOptions) => {
|
|
929
|
+
if (this.runningCreate !== undefined) {
|
|
930
|
+
return Promise.reject(new Error(`extension "${this.runningCreate}": createSession is not available inside create() — open sessions later, from setup, a tool, or an event`));
|
|
931
|
+
}
|
|
932
|
+
return this.createSession(sessionOptions);
|
|
933
|
+
},
|
|
934
|
+
warn: (message) => console.warn(`[extensions] ${message}`),
|
|
935
|
+
});
|
|
936
|
+
return staged instanceof Promise ? staged.then(done, failed) : done(staged);
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
return failed(error);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Register the by-value definitions, in order: check each one's `uses` against the registry
|
|
944
|
+
* (a consumer must come after its provider), then run its `create` half if it has one and
|
|
945
|
+
* register the result as a service under the extension's `id`. Stays synchronous as long as
|
|
946
|
+
* every `create` is — so `createHarness` returns with a synchronous extension's service
|
|
947
|
+
* already registered; the first async `create` turns the rest of the sequence into a promise
|
|
948
|
+
* chain that `openFromStore` awaits. Staging (`stageDefinition`) collects before registering,
|
|
949
|
+
* so a throwing `create` publishes nothing.
|
|
950
|
+
*/
|
|
951
|
+
registerDefinitions(extensions) {
|
|
952
|
+
if (extensions === undefined)
|
|
953
|
+
return Promise.resolve();
|
|
954
|
+
const run = (definition) => {
|
|
955
|
+
for (const name of definition.uses ?? []) {
|
|
956
|
+
if (!this.services.has(name)) {
|
|
957
|
+
throw new Error(`extension "${definition.id}" uses service "${name}", which is not registered — list its provider earlier in createHarness({ extensions }) (or register it in services)`);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if (definition.create === undefined)
|
|
961
|
+
return;
|
|
962
|
+
const staged = this.stageValue(definition);
|
|
963
|
+
const publish = (result) => {
|
|
964
|
+
if (result.service !== undefined) {
|
|
965
|
+
this.services.register(result.service.name, result.service.instance, result.service.options);
|
|
966
|
+
this.createdServices.push(result.service.name);
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
if (staged instanceof Promise)
|
|
970
|
+
return staged.then(publish);
|
|
971
|
+
publish(staged);
|
|
972
|
+
};
|
|
973
|
+
let chain;
|
|
974
|
+
for (const definition of extensions) {
|
|
975
|
+
if (chain === undefined) {
|
|
976
|
+
const result = run(definition);
|
|
977
|
+
if (result !== undefined)
|
|
978
|
+
chain = result;
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
chain = chain.then(() => run(definition));
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return chain ?? Promise.resolve();
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* The default agent for sessions with no explicit `agent`: the builtin `agent` profile rendered as
|
|
988
|
+
* an Agent — its tool names resolved against {@link toolPalette}, its model forced to `options.model`
|
|
989
|
+
* (the profile declares none). Capability tools (goal/plan/todo/…) still join at runtime via the
|
|
990
|
+
* session's capabilities.
|
|
991
|
+
*
|
|
992
|
+
* Built fresh per session. Runtime-specific prompt data is not captured here: `instructions`
|
|
993
|
+
* resolves the current frame's machine + cwd through the Session cache, so root/subagent/worktree
|
|
994
|
+
* prompts stay isolated without coupling Agent lifetime to machine lifetime.
|
|
995
|
+
*/
|
|
996
|
+
buildDefaultAgent(appendSystemPrompt, maxStepsPerTurn) {
|
|
997
|
+
const additional = appendSystemPrompt ?? this.options.appendSystemPrompt;
|
|
998
|
+
const steps = maxStepsPerTurn ?? this.options.maxStepsPerTurn;
|
|
999
|
+
return buildAgentFromProfile(DEFAULT_AGENT_PROFILES[DEFAULT_AGENT_PROFILE_NAME], {
|
|
1000
|
+
tools: this.toolPalette,
|
|
1001
|
+
model: this.options.model,
|
|
1002
|
+
...(additional !== undefined ? { roleAdditional: additional } : {}),
|
|
1003
|
+
...(steps !== undefined ? { maxStepsPerTurn: steps } : {}),
|
|
1004
|
+
...(this.options.resolveModel !== undefined ? { resolveModel: this.options.resolveModel } : {}),
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
get sessions() {
|
|
1008
|
+
return this.activeSessions;
|
|
1009
|
+
}
|
|
1010
|
+
async createSession(opts = {}) {
|
|
1011
|
+
const workDir = opts.workDir ?? this.options.workDir ?? process.cwd();
|
|
1012
|
+
const handle = await this.repo.create({ id: opts.id, workDir, ownerKey: opts.ownerKey, title: opts.title });
|
|
1013
|
+
return this.openFromStore(handle.id, handle.workDir, handle.store, opts);
|
|
1014
|
+
}
|
|
1015
|
+
async resumeSession(id, opts = {}) {
|
|
1016
|
+
const existing = this.activeSessions.get(id);
|
|
1017
|
+
if (existing) {
|
|
1018
|
+
if (hasOwnContext(opts))
|
|
1019
|
+
existing.setContext(opts.context);
|
|
1020
|
+
return existing;
|
|
1021
|
+
}
|
|
1022
|
+
const handle = await this.repo.open(id);
|
|
1023
|
+
if (handle === undefined)
|
|
1024
|
+
throw new SessionRepositoryNotFoundError(id);
|
|
1025
|
+
const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
|
|
1026
|
+
// Reopening: orphaned background subagents (running from a dead process) → lost.
|
|
1027
|
+
await session.reconcileSubagents();
|
|
1028
|
+
return session;
|
|
1029
|
+
}
|
|
1030
|
+
/** Fork an existing session into a new one (copies its log + state) and open it. */
|
|
1031
|
+
async forkSession(sourceId, opts = {}) {
|
|
1032
|
+
const handle = await this.repo.fork(sourceId, {
|
|
1033
|
+
...(opts.id !== undefined ? { id: opts.id } : {}),
|
|
1034
|
+
...(opts.ownerKey !== undefined ? { ownerKey: opts.ownerKey } : {}),
|
|
1035
|
+
...(opts.title !== undefined ? { title: opts.title } : {}),
|
|
1036
|
+
});
|
|
1037
|
+
const session = await this.openFromStore(handle.id, handle.workDir, handle.store, opts);
|
|
1038
|
+
// A fork copies the subagent ledger; its background "running" rows have no live task → lost.
|
|
1039
|
+
await session.reconcileSubagents();
|
|
1040
|
+
return session;
|
|
1041
|
+
}
|
|
1042
|
+
/** The currently-open session with this id, or undefined (does not reopen — use resumeSession). */
|
|
1043
|
+
getSession(id) {
|
|
1044
|
+
return this.activeSessions.get(id);
|
|
1045
|
+
}
|
|
1046
|
+
/** Read a session's durable handle without constructing a Session, opening capabilities,
|
|
1047
|
+
* reconciling background tasks, or subscribing Projection. Intended for read-only servers.
|
|
1048
|
+
* Soft-deleted sessions resolve only with `{ includeDeleted: true }` — audit/purge tooling. */
|
|
1049
|
+
inspectSession(id, options) {
|
|
1050
|
+
return this.repo.open(id, options);
|
|
1051
|
+
}
|
|
1052
|
+
/** Read one catalog row without the O(N) `listSessions()` scan. */
|
|
1053
|
+
getSessionSummary(id) {
|
|
1054
|
+
return this.repo.get(id);
|
|
1055
|
+
}
|
|
1056
|
+
/** Close one open session by id (no-op if it isn't open). */
|
|
1057
|
+
async closeSession(id) {
|
|
1058
|
+
await this.activeSessions.get(id)?.close();
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Delete a session after releasing any open in-process handle. Soft by default — the durable
|
|
1062
|
+
* record is retained and `resumeSession` stops resolving it. `{ purge: true }` destroys it.
|
|
1063
|
+
*/
|
|
1064
|
+
async deleteSession(id, options) {
|
|
1065
|
+
await this.closeSession(id);
|
|
1066
|
+
await this.repo.delete(id, options);
|
|
1067
|
+
}
|
|
1068
|
+
/** Undo a soft delete. No-op when the session is absent or was never deleted. */
|
|
1069
|
+
restoreSession(id) {
|
|
1070
|
+
return this.repo.restore(id);
|
|
1071
|
+
}
|
|
1072
|
+
async listSessions(filter) {
|
|
1073
|
+
return this.repo.list(filter);
|
|
1074
|
+
}
|
|
1075
|
+
async close() {
|
|
1076
|
+
for (const session of [...this.activeSessions.values()])
|
|
1077
|
+
await session.close();
|
|
1078
|
+
// By-value `create` halves die with the harness that ran them: unregister each service,
|
|
1079
|
+
// newest first, which drains it and runs its dispose hook (default: the instance's close()).
|
|
1080
|
+
await this.sharedReady.catch(() => undefined);
|
|
1081
|
+
for (const name of [...this.createdServices].reverse())
|
|
1082
|
+
await this.services.unregister(name);
|
|
1083
|
+
this.createdServices.length = 0;
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Replace by-value extensions with new versions, as one coordinated act — the by-value twin of
|
|
1087
|
+
* `harness.extensions.reload()` (docs/architecture.md §5.5). Every session holding
|
|
1088
|
+
* one of them rendezvous at its run boundary (in-flight runs finish untouched — nothing is
|
|
1089
|
+
* aborted); in that global quiet moment the old halves detach, each `create` half re-runs and
|
|
1090
|
+
* its service is swapped, and the new halves attach; then everyone resumes. Sessions born from
|
|
1091
|
+
* the quiet moment on are born with the new definitions. On rendezvous timeout NOTHING changes:
|
|
1092
|
+
* the barrier lifts, `BarrierTimeout` names the stuck sessions, and the caller retries later
|
|
1093
|
+
* (or closes the straggler).
|
|
1094
|
+
*
|
|
1095
|
+
* Pass EVERY definition a shape change affects in ONE call — the provider and the consumers
|
|
1096
|
+
* written against it — so they cross the barrier together; a consumer left out resumes against
|
|
1097
|
+
* the new shape running old code, which is the skew this whole mechanism exists to prevent.
|
|
1098
|
+
*
|
|
1099
|
+
* `services` swaps HOST-registered services (`createHarness({ services })`) in the same quiet
|
|
1100
|
+
* moment, for when the shape that changed is the host's rather than an extension's. Those obey
|
|
1101
|
+
* their `replaceable` flag; an extension's own `create` service swaps unconditionally, since
|
|
1102
|
+
* its owner is being replaced along with it.
|
|
1103
|
+
*
|
|
1104
|
+
* Implementation-only swaps need none of this — `harness.services.replace` (layer 1) lands on
|
|
1105
|
+
* the next call and touches no session at all.
|
|
1106
|
+
*/
|
|
1107
|
+
async replaceExtension(next, options = {}) {
|
|
1108
|
+
const definitions = Array.isArray(next) ? [...next] : [next];
|
|
1109
|
+
if (definitions.length === 0) {
|
|
1110
|
+
throw new Error("replaceExtension needs at least one definition — to swap a service alone, use harness.services.replace");
|
|
1111
|
+
}
|
|
1112
|
+
for (const definition of definitions) {
|
|
1113
|
+
if (!this.valueDefs.has(definition.id)) {
|
|
1114
|
+
throw new Error(`cannot replace extension "${definition.id}": it is not registered on this harness by value — pass it in createHarness({ extensions }), or use harness.extensions.reload("${definition.id}") for a file extension`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
// A `create` half still publishing would race the swap.
|
|
1118
|
+
await this.sharedReady;
|
|
1119
|
+
// Every new `create` runs BEFORE the barrier: staging COLLECTS, so one that throws leaves the
|
|
1120
|
+
// world completely untouched (no session detached, no service swapped) — the same discipline
|
|
1121
|
+
// construction and the file manager use. Whatever a failed act staged is disposed below.
|
|
1122
|
+
const staged = new Map();
|
|
1123
|
+
for (const definition of definitions) {
|
|
1124
|
+
if (definition.create !== undefined)
|
|
1125
|
+
staged.set(definition.id, await this.stageValue(definition));
|
|
1126
|
+
}
|
|
1127
|
+
const published = new Set();
|
|
1128
|
+
const ids = definitions.map((definition) => definition.id);
|
|
1129
|
+
try {
|
|
1130
|
+
await this.withBarrier(ids, options.timeoutMs ?? DEFAULT_REPLACE_TIMEOUT_MS, async (held) => {
|
|
1131
|
+
// Globally quiet. These two statements are ONE synchronous act: from here on newborn
|
|
1132
|
+
// sessions are born with the new definitions, and `targets` is exactly the set still
|
|
1133
|
+
// holding an old one — so nobody is swapped twice and nobody is missed.
|
|
1134
|
+
const targets = held()
|
|
1135
|
+
.map((session) => ({ session, held: ids.filter((id) => session.attachedExtensionIds().includes(id)) }))
|
|
1136
|
+
.filter((target) => target.held.length > 0);
|
|
1137
|
+
for (const definition of definitions)
|
|
1138
|
+
this.valueDefs.set(definition.id, definition);
|
|
1139
|
+
// Old halves out first: their `session.end` runs against the old shape.
|
|
1140
|
+
for (const target of targets) {
|
|
1141
|
+
for (const id of target.held)
|
|
1142
|
+
await target.session.detachExtension(id);
|
|
1143
|
+
}
|
|
1144
|
+
for (const [name, instance] of Object.entries(options.services ?? {})) {
|
|
1145
|
+
await this.services.replace(name, instance, options.drainTimeoutMs !== undefined ? { drainTimeoutMs: options.drainTimeoutMs } : {});
|
|
1146
|
+
}
|
|
1147
|
+
for (const definition of definitions) {
|
|
1148
|
+
await this.publishValueService(definition, staged.get(definition.id), options.drainTimeoutMs);
|
|
1149
|
+
published.add(definition.id);
|
|
1150
|
+
}
|
|
1151
|
+
// New halves in: their `session.start` runs against the new shape. The pending queue
|
|
1152
|
+
// applies immediately on idle sessions.
|
|
1153
|
+
for (const target of targets) {
|
|
1154
|
+
if (!this.activeSessions.has(target.session.id))
|
|
1155
|
+
continue; // closed while the barrier held
|
|
1156
|
+
for (const id of target.held)
|
|
1157
|
+
await target.session.attachExtension(this.valueDefs.get(id));
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
catch (error) {
|
|
1162
|
+
// Rendezvous timed out, or a step failed: nothing staged was published, so dispose it —
|
|
1163
|
+
// a `create` that opened a pool must not leak it because the barrier never converged.
|
|
1164
|
+
for (const [id, entry] of staged) {
|
|
1165
|
+
if (!published.has(id) && entry.service !== undefined)
|
|
1166
|
+
await this.disposeStaged(id, entry.service.instance);
|
|
1167
|
+
}
|
|
1168
|
+
throw error;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* The service half of one replaced extension: publish what its `create` staged. `replace`
|
|
1173
|
+
* rather than unregister + register, so consumers this barrier never gated — other extensions
|
|
1174
|
+
* holding a `uses` handle — see no gap; `force` because the owner is being replaced along with
|
|
1175
|
+
* it (see `ServiceRegistry.replace`).
|
|
1176
|
+
*/
|
|
1177
|
+
async publishValueService(definition, staged, drainTimeoutMs) {
|
|
1178
|
+
const id = definition.id;
|
|
1179
|
+
const previous = this.createdServices.indexOf(id);
|
|
1180
|
+
const drain = drainTimeoutMs !== undefined ? { drainTimeoutMs } : {};
|
|
1181
|
+
if (staged?.service !== undefined) {
|
|
1182
|
+
if (previous >= 0) {
|
|
1183
|
+
await this.services.replace(id, staged.service.instance, { force: true, ...drain });
|
|
1184
|
+
}
|
|
1185
|
+
else {
|
|
1186
|
+
this.services.register(id, staged.service.instance, staged.service.options);
|
|
1187
|
+
this.createdServices.push(id);
|
|
1188
|
+
}
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
// The new version dropped its `create` half: its service goes away with it.
|
|
1192
|
+
if (previous >= 0) {
|
|
1193
|
+
await this.services.unregister(id, drain);
|
|
1194
|
+
this.createdServices.splice(previous, 1);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
/** Dispose a `create` result that was staged but never published (default rule: its `close()`). */
|
|
1198
|
+
async disposeStaged(id, instance) {
|
|
1199
|
+
try {
|
|
1200
|
+
const close = instance?.close;
|
|
1201
|
+
if (typeof close === "function")
|
|
1202
|
+
await close.call(instance);
|
|
1203
|
+
}
|
|
1204
|
+
catch (error) {
|
|
1205
|
+
console.warn(`[extensions] extension "${id}": disposing the unpublished create result failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* The barrier itself (docs/architecture.md §5.5), shared by `replaceExtension` and
|
|
1210
|
+
* the plugin manager: gate every session holding one of `extensionIds` (synchronously, FIRST
|
|
1211
|
+
* — the set of running sessions only shrinks from there), rendezvous bounded by `timeoutMs`
|
|
1212
|
+
* (a straggler throws BarrierTimeout and nothing ran), then run `fn` in the globally quiet
|
|
1213
|
+
* moment. `held()` reads live, so sessions born gated during the barrier are included.
|
|
1214
|
+
* Whatever happens, every gate is released.
|
|
1215
|
+
*/
|
|
1216
|
+
async withBarrier(extensionIds, timeoutMs, fn) {
|
|
1217
|
+
if (this.activeBarrier !== undefined)
|
|
1218
|
+
throw new Error("another barrier operation is already in progress");
|
|
1219
|
+
const holds = new Map();
|
|
1220
|
+
this.activeBarrier = { holds };
|
|
1221
|
+
try {
|
|
1222
|
+
for (const session of this.activeSessions.values()) {
|
|
1223
|
+
const ids = session.attachedExtensionIds();
|
|
1224
|
+
if (extensionIds.some((id) => ids.includes(id)))
|
|
1225
|
+
holds.set(session.id, session.holdAtBoundary());
|
|
1226
|
+
}
|
|
1227
|
+
const stuck = await rendezvous(holds, timeoutMs);
|
|
1228
|
+
if (stuck.length > 0)
|
|
1229
|
+
throw new BarrierTimeout(stuck);
|
|
1230
|
+
const held = () => [...holds.keys()]
|
|
1231
|
+
.map((sessionId) => this.activeSessions.get(sessionId))
|
|
1232
|
+
.filter((session) => session !== undefined);
|
|
1233
|
+
await fn(held);
|
|
1234
|
+
}
|
|
1235
|
+
finally {
|
|
1236
|
+
// Whatever happened, nothing stays held — a failed barrier leaves the world running.
|
|
1237
|
+
for (const hold of holds.values())
|
|
1238
|
+
hold.release();
|
|
1239
|
+
this.activeBarrier = undefined;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
/** Resolve the capability set for one session — calling the factory fresh when one was given. */
|
|
1243
|
+
async resolveCapabilities(ctx, params = {}) {
|
|
1244
|
+
const configured = this.options.capabilities;
|
|
1245
|
+
const base = typeof configured === "function"
|
|
1246
|
+
? await configured(ctx)
|
|
1247
|
+
: configured ?? defaultCapabilities({ maxContextTokens: this.options.maxContextTokens });
|
|
1248
|
+
// Every definition registered on this harness: by value (`extensions`, create halves
|
|
1249
|
+
// included — their per-session `setup` is mounted here like any other), then from files.
|
|
1250
|
+
const extensions = [...this.valueDefs.values(), ...(this.extensions?.sessionDefinitions() ?? [])];
|
|
1251
|
+
// Put extension transforms first. Shell PreToolUse remains in the staged permission policy,
|
|
1252
|
+
// so it evaluates the final rewritten args before normal authorization.
|
|
1253
|
+
// Installed even when empty: the runtime must exist for `attachExtension` to have
|
|
1254
|
+
// somewhere to land on sessions born without extensions.
|
|
1255
|
+
return [extensionsCapability(extensions, { host: this.extensionHost(ctx.sessionId), params }), ...base];
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* The harness-tier reach handed to a session's extensions: other sessions, and the model
|
|
1259
|
+
* provider registry. Bound to `sessionId` so `fork()` forks THAT session, not whichever one
|
|
1260
|
+
* happens to be active.
|
|
1261
|
+
*
|
|
1262
|
+
* `isIdle`/`waitForIdle` resolve the HarnessSession lazily — at capability-build time the
|
|
1263
|
+
* session object does not exist yet (this runs while it is being constructed).
|
|
1264
|
+
*/
|
|
1265
|
+
extensionHost(sessionId) {
|
|
1266
|
+
const self = this;
|
|
1267
|
+
const runtime = this.options.modelRuntime;
|
|
1268
|
+
return {
|
|
1269
|
+
sessionId,
|
|
1270
|
+
newSession: (options) => self.createSession({ ...(options?.title !== undefined ? { title: options.title } : {}) }),
|
|
1271
|
+
fork: (options) => self.forkSession(sessionId, { ...(options?.title !== undefined ? { title: options.title } : {}) }),
|
|
1272
|
+
openSession: (id) => self.resumeSession(id),
|
|
1273
|
+
listSessions: () => self.listSessions(),
|
|
1274
|
+
registerProvider: (provider) => {
|
|
1275
|
+
if (!runtime)
|
|
1276
|
+
throw new Error("registerProvider() requires the harness to be built with a `modelRuntime`");
|
|
1277
|
+
runtime.models.setProvider(provider);
|
|
1278
|
+
},
|
|
1279
|
+
unregisterProvider: (id) => {
|
|
1280
|
+
if (!runtime)
|
|
1281
|
+
throw new Error("unregisterProvider() requires the harness to be built with a `modelRuntime`");
|
|
1282
|
+
runtime.models.deleteProvider(id);
|
|
1283
|
+
},
|
|
1284
|
+
services: self.services,
|
|
1285
|
+
isIdle: () => self.activeSessions.get(sessionId)?.status.state !== "running",
|
|
1286
|
+
waitForIdle: async () => {
|
|
1287
|
+
const session = self.activeSessions.get(sessionId);
|
|
1288
|
+
// Taking and immediately releasing the run lock IS the wait: it can only be acquired
|
|
1289
|
+
// once the in-flight run has finished.
|
|
1290
|
+
if (session)
|
|
1291
|
+
await session.core.withRunLock(async () => undefined);
|
|
1292
|
+
},
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
async openFromStore(id, workDir, store, opts) {
|
|
1296
|
+
// A session's `setup` reaches a create half through its service, so none opens before the
|
|
1297
|
+
// create halves have registered them.
|
|
1298
|
+
await this.sharedReady;
|
|
1299
|
+
// Per-session extension params: given at create, persisted, and read back on every later
|
|
1300
|
+
// open (resume, fork) so a session keeps them without the caller repeating them.
|
|
1301
|
+
let params = opts.params;
|
|
1302
|
+
if (params !== undefined)
|
|
1303
|
+
await store.putState(EXTENSION_PARAMS_STATE_KEY, params);
|
|
1304
|
+
else
|
|
1305
|
+
params = (await store.getState(EXTENSION_PARAMS_STATE_KEY)) ?? undefined;
|
|
1306
|
+
const events = new ListenerSink();
|
|
1307
|
+
const responder = new MutableResponder();
|
|
1308
|
+
// ONE open-time log read, shared: the projection seeds from it, and Session.open
|
|
1309
|
+
// receives it as `preloadedLog` so its capability restore + context pre-build fold the
|
|
1310
|
+
// same records — same IO, and the same `Message` objects (no second parsed copy).
|
|
1311
|
+
// Attach still happens BEFORE Session.open: no events flow yet, so the seed cannot
|
|
1312
|
+
// race the subscription, and anything capabilities emit during open is already folded.
|
|
1313
|
+
let preloadedLog = await readLog(store);
|
|
1314
|
+
const projection = await SessionProjection.attach({ id, store, events }, preloadedLog);
|
|
1315
|
+
const interrupted = await store.getState(INTERRUPTION_STATE_KEY) !== null;
|
|
1316
|
+
if (!interrupted) {
|
|
1317
|
+
const recoveryRecords = await closeOrphanedProjectionFrames(id, store, projection);
|
|
1318
|
+
if (recoveryRecords.length > 0)
|
|
1319
|
+
preloadedLog = [...preloadedLog, ...recoveryRecords];
|
|
1320
|
+
}
|
|
1321
|
+
const core = await Session.open({
|
|
1322
|
+
sessionId: id,
|
|
1323
|
+
store,
|
|
1324
|
+
preloadedLog,
|
|
1325
|
+
events,
|
|
1326
|
+
responder,
|
|
1327
|
+
machine: opts.machine ?? this.options.machine ?? new LocalMachine(workDir),
|
|
1328
|
+
capabilities: await this.resolveCapabilities({
|
|
1329
|
+
sessionId: id,
|
|
1330
|
+
workDir,
|
|
1331
|
+
...(opts.mcpServers !== undefined ? { mcpServers: opts.mcpServers } : {}),
|
|
1332
|
+
}, params),
|
|
1333
|
+
permission: opts.permission ?? this.options.permission ?? { mode: "yolo" },
|
|
1334
|
+
eventPublication: opts.eventPublication ?? this.options.eventPublication ?? "immediate",
|
|
1335
|
+
// undefined → core resolves the `AGENTS_LOG` env fallback in Session.open.
|
|
1336
|
+
logger: this.options.logger,
|
|
1337
|
+
});
|
|
1338
|
+
const agent = opts.agent ??
|
|
1339
|
+
this.options.agent ??
|
|
1340
|
+
(await this.buildDefaultAgent(opts.appendSystemPrompt, opts.maxStepsPerTurn));
|
|
1341
|
+
const session = new HarnessSession({
|
|
1342
|
+
core,
|
|
1343
|
+
// The two caps land in different places: maxStepsPerTurn is an Agent property
|
|
1344
|
+
// (the Runner reads the agent's value first), maxTurns is a per-run option.
|
|
1345
|
+
agent,
|
|
1346
|
+
runner: this.runner,
|
|
1347
|
+
events,
|
|
1348
|
+
responder,
|
|
1349
|
+
projection,
|
|
1350
|
+
workDir,
|
|
1351
|
+
context: this.contextFor(opts),
|
|
1352
|
+
...(opts.maxTurns ?? this.options.maxTurns) !== undefined
|
|
1353
|
+
? { maxTurns: opts.maxTurns ?? this.options.maxTurns }
|
|
1354
|
+
: {},
|
|
1355
|
+
interrupted,
|
|
1356
|
+
onClosed: (sid) => this.activeSessions.delete(sid),
|
|
1357
|
+
});
|
|
1358
|
+
this.activeSessions.set(id, session);
|
|
1359
|
+
// Born gated: a session appearing while a reshape barrier holds (createSession,
|
|
1360
|
+
// resumeSession and openSession all land here) must not start runs mid-swap. Its
|
|
1361
|
+
// quiescent is trivially settled — it has no run — so it never delays the rendezvous.
|
|
1362
|
+
if (this.activeBarrier !== undefined)
|
|
1363
|
+
this.activeBarrier.holds.set(id, session.holdAtBoundary());
|
|
1364
|
+
return session;
|
|
1365
|
+
}
|
|
1366
|
+
contextFor(options) {
|
|
1367
|
+
return hasOwnContext(options) ? options.context : this.options.context;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* A cold open proves that no in-process run still owns lifecycle frames left open in the
|
|
1372
|
+
* previous journal. Close those frames durably so historical event consumers and Projection
|
|
1373
|
+
* agree with HarnessSession.status. A durable interruption is excluded by the caller: its
|
|
1374
|
+
* open turn is intentional continuation state, not a crash remnant.
|
|
1375
|
+
*/
|
|
1376
|
+
/**
|
|
1377
|
+
* Close the turns a dead process left open, without opening the session.
|
|
1378
|
+
*
|
|
1379
|
+
* A worker that dies mid-turn journals no `turn.ended`; to every reader the turn is still
|
|
1380
|
+
* running. Opening the session repairs that (the same recovery runs inside `resumeSession`),
|
|
1381
|
+
* but opening boots capabilities and a machine just to write two records. This does only the
|
|
1382
|
+
* repair: fold the log, and for every frame still inside a turn append `turn.ended` with
|
|
1383
|
+
* `reason: "failed"` (and `agent.ended` for a live sub-agent). Returns the events those records
|
|
1384
|
+
* project to, so a host that has live subscribers can hand them on — a record appended here
|
|
1385
|
+
* reaches the log, not any broadcast channel.
|
|
1386
|
+
*
|
|
1387
|
+
* A session paused for an answer is left alone: its open turn is waiting, not dead.
|
|
1388
|
+
*/
|
|
1389
|
+
export async function closeOrphanedTurns(sessionId, store) {
|
|
1390
|
+
if ((await store.getState(INTERRUPTION_STATE_KEY)) !== null)
|
|
1391
|
+
return [];
|
|
1392
|
+
const projection = await SessionProjection.attach({ id: sessionId, store, events: new ListenerSink() }, await readLog(store));
|
|
1393
|
+
const records = await closeOrphanedProjectionFrames(sessionId, store, projection);
|
|
1394
|
+
const events = [];
|
|
1395
|
+
for (const record of records) {
|
|
1396
|
+
const event = agentEventFromRecord(record, sessionId);
|
|
1397
|
+
if (event !== undefined)
|
|
1398
|
+
events.push(event);
|
|
1399
|
+
}
|
|
1400
|
+
return events;
|
|
1401
|
+
}
|
|
1402
|
+
async function closeOrphanedProjectionFrames(sessionId, store, projection) {
|
|
1403
|
+
const recovered = [];
|
|
1404
|
+
const snapshot = projection.snapshot({ maxMessages: 0 });
|
|
1405
|
+
for (const agent of snapshot.agents) {
|
|
1406
|
+
if (agent.turn !== undefined) {
|
|
1407
|
+
await appendRecoveryLifecycle({
|
|
1408
|
+
type: "event.lifecycle",
|
|
1409
|
+
eventId: newAgentEventId(),
|
|
1410
|
+
time: Date.now(),
|
|
1411
|
+
address: agent.address,
|
|
1412
|
+
event: { type: "turn.ended", turnId: agent.turn.turnId, reason: "failed" },
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
if (agent.live && agent.agent !== undefined) {
|
|
1416
|
+
await appendRecoveryLifecycle({
|
|
1417
|
+
type: "event.lifecycle",
|
|
1418
|
+
eventId: newAgentEventId(),
|
|
1419
|
+
time: Date.now(),
|
|
1420
|
+
address: agent.address,
|
|
1421
|
+
event: { type: "agent.ended", agent: agent.agent },
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
return recovered;
|
|
1426
|
+
async function appendRecoveryLifecycle(record) {
|
|
1427
|
+
await store.appendRecord(record);
|
|
1428
|
+
recovered.push(record);
|
|
1429
|
+
const event = agentEventFromRecord(record, sessionId);
|
|
1430
|
+
if (event !== undefined)
|
|
1431
|
+
projection.apply(event, record.time ?? Date.now());
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
/** A reshape rendezvous that did not converge in time. Nothing was changed; the sessions that
|
|
1435
|
+
* never reached their run boundary are named so the caller can act on them and retry. */
|
|
1436
|
+
export class BarrierTimeout extends Error {
|
|
1437
|
+
stuckSessionIds;
|
|
1438
|
+
constructor(stuckSessionIds) {
|
|
1439
|
+
super(`reshape barrier timed out; still running: ${stuckSessionIds.join(", ")}`);
|
|
1440
|
+
this.name = "BarrierTimeout";
|
|
1441
|
+
this.stuckSessionIds = stuckSessionIds;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
/** Await every hold's quiescent within the budget; report who is still running on expiry. */
|
|
1445
|
+
async function rendezvous(holds, timeoutMs) {
|
|
1446
|
+
if (holds.size === 0)
|
|
1447
|
+
return [];
|
|
1448
|
+
const remaining = new Set(holds.keys());
|
|
1449
|
+
for (const [id, hold] of holds)
|
|
1450
|
+
void hold.quiescent.then(() => remaining.delete(id));
|
|
1451
|
+
let timer;
|
|
1452
|
+
const timedOut = await Promise.race([
|
|
1453
|
+
Promise.all([...holds.values()].map((hold) => hold.quiescent)).then(() => false),
|
|
1454
|
+
new Promise((resolve) => {
|
|
1455
|
+
timer = setTimeout(() => resolve(true), timeoutMs);
|
|
1456
|
+
}),
|
|
1457
|
+
]);
|
|
1458
|
+
clearTimeout(timer);
|
|
1459
|
+
return timedOut ? [...remaining] : [];
|
|
1460
|
+
}
|
|
1461
|
+
let commandRegistry;
|
|
1462
|
+
function sharedCommands() {
|
|
1463
|
+
commandRegistry ??= createExtensionCommandRegistry();
|
|
1464
|
+
return commandRegistry;
|
|
1465
|
+
}
|
|
1466
|
+
function isServiceDescriptor(value) {
|
|
1467
|
+
return (typeof value === "object" &&
|
|
1468
|
+
value !== null &&
|
|
1469
|
+
Object.getPrototypeOf(value) === Object.prototype &&
|
|
1470
|
+
"instance" in value);
|
|
1471
|
+
}
|
|
1472
|
+
export function createHarness(options) {
|
|
1473
|
+
return new Harness(options);
|
|
1474
|
+
}
|
|
1475
|
+
function hasOwnContext(value) {
|
|
1476
|
+
return Object.prototype.hasOwnProperty.call(value, "context");
|
|
1477
|
+
}
|
|
1478
|
+
/** An already-discriminated resume answer. ApprovalResponse has no `kind` field, so the
|
|
1479
|
+
* presence of a valid discriminant is unambiguous. */
|
|
1480
|
+
function isInterruptAnswer(answer) {
|
|
1481
|
+
return "kind" in answer && (answer.kind === "approval" || answer.kind === "input");
|
|
1482
|
+
}
|
|
1483
|
+
//# sourceMappingURL=harness.js.map
|