operon-agents 0.1.0-alpha.29 → 0.1.0-alpha.30

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/README.md CHANGED
@@ -19,7 +19,7 @@ import { OTelTracingProcessor } from "operon-agents/tracing";
19
19
 
20
20
  ## Tracing
21
21
 
22
- Every session drives the `TracingProcessor` the `harness` hook returns as `tracing`: one
22
+ Every session drives the `TracingProcessor` the `infrastructure` hook returns as `tracing`: one
23
23
  trace per run (a prompt and everything it triggers), spans for agents, turns, model generations,
24
24
  tool calls and sub-agents, the session id on every span as `gen_ai.conversation.id`. By default
25
25
  spans are metadata only. Pass `content: "delta"` to also record the system prompt, the messages
@@ -44,7 +44,7 @@ const tracing = new OTelTracingProcessor({ tracer: provider.getTracer("my-agent"
44
44
 
45
45
  const harness = await createLocalHarness({
46
46
  model,
47
- harness: () => ({ tracing }),
47
+ infrastructure: () => ({ tracing }),
48
48
  });
49
49
  ```
50
50
 
@@ -9,7 +9,7 @@
9
9
  * redirect console.log/info/debug to stderr so a stray log can never corrupt the
10
10
  * protocol stream.
11
11
  */
12
- import { defineModel, DiskSessionRepository } from "operon-agents-core";
12
+ import { DiskSessionRepository } from "operon-agents-core";
13
13
  import { createHarness } from "../harness.js";
14
14
  import { nodeStreamTransport } from "./codec.js";
15
15
  import { AppServer } from "./server.js";
@@ -41,14 +41,6 @@ function parseArgs(argv) {
41
41
  }
42
42
  return out;
43
43
  }
44
- /** Resolve a `provider/model` id into a ChatModel (API keys come from the env). */
45
- function resolveModel(id) {
46
- const slash = id.indexOf("/");
47
- if (slash <= 0 || slash === id.length - 1) {
48
- throw new Error(`invalid --model "${id}": expected "provider/model" (e.g. anthropic/claude-opus-4-...)`);
49
- }
50
- return defineModel({ provider: id.slice(0, slash), model: id.slice(slash + 1) });
51
- }
52
44
  function redirectStdoutLogsToStderr() {
53
45
  const toStderr = (...parts) => {
54
46
  process.stderr.write(`${parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")}\n`);
@@ -62,8 +54,7 @@ async function main() {
62
54
  const args = parseArgs(process.argv.slice(2));
63
55
  const harness = createHarness({
64
56
  model: args.model ?? "anthropic/claude-opus-4-8",
65
- resolveModel,
66
- ...(args.home !== undefined ? { harness: () => ({ sessionRepository: new DiskSessionRepository(args.home) }) } : {}),
57
+ ...(args.home !== undefined ? { infrastructure: () => ({ sessionRepository: new DiskSessionRepository(args.home) }) } : {}),
67
58
  ...(args.workdir !== undefined ? { workDir: args.workdir } : {}),
68
59
  permission: { mode: args.permission ?? "yolo" },
69
60
  });
@@ -24,7 +24,7 @@
24
24
  * `ExtensionRuntime` is the façade that hides which physical channel a decision point arrives on;
25
25
  * extension authors only ever see one `api.on(...)` table.
26
26
  */
27
- import type { AgentEvent, AssistantMessage, ChatModel, AgentInput, ContextBreakdown, ConversationContext, Injector, LlmRequest, Environment, Message, ModelRuntime, RunResult, SessionSummary, PendingApprovalInterrupt, SessionStore, SteerContent, SteerOptions, SteerOrigin, SteerReceipt, StepStopReason, TerminalStepStopReason, ThinkingLevel, Tool, ToolPlan, ToolResult, ToolResultContent, ToolRunContext, Usage } from "operon-agents-core";
27
+ import type { AgentEvent, AssistantMessage, ChatModel, AgentInput, ContextBreakdown, ConversationContext, Injector, LlmRequest, Environment, Message, ModelManager, RunResult, SessionSummary, PendingApprovalInterrupt, SessionStore, SteerContent, SteerOptions, SteerOrigin, SteerReceipt, StepStopReason, TerminalStepStopReason, ThinkingLevel, Tool, ToolPlan, ToolResult, ToolResultContent, ToolRunContext, Usage } from "operon-agents-core";
28
28
  /** Per-extension key/value state, namespaced into the session store (memory without one). */
29
29
  /** A slash command an extension carries (see `ExtensionAPI.registerCommand`). The run
30
30
  * receives only the raw argument string — an extension command closes over what it needs. */
@@ -50,11 +50,11 @@ export interface ExtensionState {
50
50
  delete(key: string): Promise<void>;
51
51
  }
52
52
  /**
53
- * A model provider runtime object, as the registry accepts it. Derived from `ModelRuntime`
53
+ * A model provider runtime object, as the registry accepts it. Derived from `ModelManager`
54
54
  * rather than imported by name: core's public `Provider` is the provider-ID STRING (a historical
55
55
  * name), while the registry wants pi's provider object.
56
56
  */
57
- export type ModelProvider = Parameters<ModelRuntime["models"]["setProvider"]>[0];
57
+ export type ModelProvider = Parameters<ModelManager["models"]["setProvider"]>[0];
58
58
  /**
59
59
  * Operations that exist above the agent loop: other sessions, and the model provider registry.
60
60
  * Supplied by `Harness` when it builds an extension's capability. This is exactly why the
@@ -80,8 +80,7 @@ export interface ExtensionHost {
80
80
  listSessions(): Promise<readonly SessionSummary[]>;
81
81
  /**
82
82
  * Register (or replace) a model provider. HARNESS-GLOBAL: the model runtime is shared by
83
- * every session on this harness, so this affects them all. No-op + warning when the host
84
- * was built without a `modelRuntime`.
83
+ * every session on this harness, so this affects them all.
85
84
  */
86
85
  registerProvider(provider: ModelProvider): void;
87
86
  unregisterProvider(id: string): void;
package/dist/harness.d.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  * primitives for the "assemble it yourself / stateless server" path are exported from
12
12
  * `operon-agents` too.
13
13
  */
14
- import { type EventPublicationMode, type TelemetryService, type AgentEventListener, type AgentInput, SessionProjection, type SessionSnapshot, type SnapshotOptions, type DirectoryEntry, type ProjectionObservation, type ProjectionListener, type ApprovalRequest, type ApprovalResponse, type Capability, type InterruptAnswer, type ChatModel, ListenerSink, type PermissionManagerOptions, type PermissionMode, type PendingRunInterrupt, type QuestionRequest, type QuestionResult, type Responder, type RunHandle, type RunResult, type SessionHandle, type SessionSummary, type ListSessionsFilter, type OpenSessionOptions, type DeleteSessionOptions, type ThinkingLevel, type Tool, type SteerMessage, type SteerOptions, type SteerOrigin, type SteerReceipt, Agent, Runner, Session, McpOAuthService, SkillRegistry, type HookDef, type McpServerConfig, type McpTransportKind, type McpCredentialProvider, type PluginManager, type ApprovalRequestOptions, type Environment, type EnvironmentFactory, type SessionRepository, type ResolvedAgentProfile, type SubagentProvider } from "operon-agents-core";
15
- import type { SkillsService, CompactionService, BackgroundManager, McpServersHandle, GoalStore, PlanMode, Logger, ModelRuntime, TracingProcessor } from "operon-agents-core";
14
+ import { type EventPublicationMode, type TelemetryService, type AgentEventListener, type AgentInput, SessionProjection, type SessionSnapshot, type SnapshotOptions, type DirectoryEntry, type ProjectionObservation, type ProjectionListener, type ApprovalRequest, type ApprovalResponse, type Capability, type InterruptAnswer, type ChatModel, type ModelSettings, type RunOptions, ListenerSink, type PermissionManagerOptions, type PermissionMode, type PendingRunInterrupt, type QuestionRequest, type QuestionResult, type Responder, type RunHandle, type RunResult, type SessionHandle, type SessionSummary, type ListSessionsFilter, type OpenSessionOptions, type DeleteSessionOptions, type ThinkingLevel, type Tool, type SteerMessage, type SteerOptions, type SteerOrigin, type SteerReceipt, Agent, Runner, Session, McpOAuthService, SkillRegistry, type HookDef, type McpServerConfig, type McpTransportKind, type McpCredentialProvider, type PluginManager, type ApprovalRequestOptions, type Environment, type EnvironmentFactory, type SessionRepository, type ResolvedAgentProfile, type SubagentProvider } from "operon-agents-core";
15
+ import type { SkillsService, CompactionService, BackgroundManager, McpServersHandle, GoalStore, PlanMode, Logger, ModelManager, TracingProcessor } from "operon-agents-core";
16
16
  import { type WorkspaceMcpPool } from "./workspace-mcp.ts";
17
17
  import { HarnessExtensionManager, ServiceRegistry, type ExtensionDefinition, type ServiceOptions } from "./extensions/index.ts";
18
18
  import { type CommandResult, type TodoItem } from "operon-agents-core";
@@ -42,7 +42,7 @@ export interface DefaultCapabilitiesOptions {
42
42
  * an OAuth service, the bundle uses THOSE instead of building per-session ones. Without it
43
43
  * everything here is built per session.
44
44
  */
45
- readonly shared?: HarnessParts;
45
+ readonly shared?: HarnessInfrastructure;
46
46
  /**
47
47
  * The session brought its own environment (`createSession({ environment })`). The shared
48
48
  * `skillRegistry` was scanned through the HARNESS's environment and does not describe this one,
@@ -57,7 +57,7 @@ export interface DefaultCapabilitiesOptions {
57
57
  /**
58
58
  * MCP servers private to the session being built — `SessionCapabilityContext.mcpServers`, i.e.
59
59
  * what the caller passed to `createSession({ mcpServers })`. They are layered OVER the
60
- * harness's shared servers (`HarnessParts.mcpServers`) and over `mcpServers` above: same name, the
60
+ * harness's shared servers (`HarnessInfrastructure.mcpServers`) and over `mcpServers` above: same name, the
61
61
  * session's wins for this session only. Built, connected and shut down with the session.
62
62
  */
63
63
  readonly sessionMcpServers?: Record<string, McpServerConfig>;
@@ -132,21 +132,18 @@ export interface DefaultCapabilitiesOptions {
132
132
  */
133
133
  export declare function defaultCapabilities(options?: DefaultCapabilitiesOptions): Capability[];
134
134
  /**
135
- * The process-lived objects a harness is built on — what `HarnessOptions.harness` returns.
135
+ * The process-lived objects a harness is built on — what `HarnessOptions.infrastructure` returns.
136
136
  * Each one is optional; what is absent simply has no provider, and the harness falls back to
137
137
  * its own default (an in-memory repository, a silent logger, a `LocalEnvironment` per session).
138
138
  */
139
- export interface HarnessParts {
139
+ export interface HarnessInfrastructure {
140
140
  readonly logger?: Logger;
141
141
  readonly sessionRepository?: SessionRepository;
142
- readonly modelRuntime?: ModelRuntime;
143
142
  /** The default environment for sessions that bring none: an instance shared by all of them, or
144
143
  * a factory called once per session. */
145
144
  readonly environment?: Environment | EnvironmentFactory;
146
145
  readonly tracing?: TracingProcessor;
147
- readonly telemetry?: TelemetryService;
148
146
  readonly pluginManager?: PluginManager;
149
- readonly eventPublication?: EventPublicationMode;
150
147
  /** One set of MCP connections for every session in this process. */
151
148
  readonly mcpServers?: McpServersHandle;
152
149
  /** One skill scan for every session in this process. */
@@ -159,63 +156,127 @@ export interface HarnessParts {
159
156
  */
160
157
  close?(): void | Promise<void>;
161
158
  }
162
- export interface HarnessOptions<TContext = unknown> {
163
- /** Model for the default agent. Either a constructed `ChatModel` or an id resolved via `resolveModel`. */
164
- readonly model: string | ChatModel;
165
- /** Resolve string model ids (needed only if `model`/agent models are strings). */
166
- readonly resolveModel?: (modelId: string) => ChatModel | Promise<ChatModel>;
167
- /**
168
- * The process-lived parts this harness runs on, built once. Everything a session needs that is
169
- * NOT per-session lives here: the repository (disk locally, Pg/Redis on a server; in-memory
170
- * when absent), the logger, the model runtime, the default environment, one set of MCP
171
- * connections, one skill scan. Runs before the by-value extensions' `harness` halves, so they
172
- * can consume what it returns.
173
- *
174
- * There is no tier between this and a session. A shared object is shared by the WHOLE process
175
- * or it is built per session — nothing is shared by "the sessions in one directory".
159
+ /** The settings every run on one session carries. */
160
+ type SessionRunOverrides<TContext> = Pick<RunOptions<TContext>, "maxTurns" | "subagentProvider" | "workflowTool" | "maxConcurrentSubagents">;
161
+ /**
162
+ * What a harness sets for every session and a session may set for itself. A value given when a
163
+ * session is opened replaces the harness's, whole; nothing merges.
164
+ */
165
+ export interface SessionSettings<TContext = unknown> {
166
+ /**
167
+ * This session's model: a `provider/model` id, or a built `ChatModel`, which is how one session
168
+ * gets its own API key. Applied like `setModel` at open, so it also beats `agent`'s own model.
169
+ * Not persisted: a resume passes it again or runs on the harness's model.
176
170
  */
177
- readonly harness?: () => HarnessParts | Promise<HarnessParts>;
171
+ readonly model?: string | ChatModel;
178
172
  /**
179
- * Session-tier composition: called once per session being opened. Return its capabilities.
180
- * Defaults to `defaultCapabilities()`. Always called fresh per session, so per-session state
181
- * (goal/plan/todo/background/skills/mcp) is isolated by construction.
173
+ * How the default agent's model should behave: `maxTokens`, `temperature`, `reasoning` and so on.
174
+ * Ignored when `agent` is given, whose own `modelSettings` apply. Timeouts and retries are
175
+ * `connection` on the provider's registration.
182
176
  */
183
- readonly session?: (ctx: SessionCapabilityContext) => readonly Capability[] | Promise<readonly Capability[]>;
184
- /** Default working directory for new sessions. Defaults to `process.cwd()`. */
185
- readonly workDir?: string;
177
+ readonly modelSettings?: ModelSettings;
178
+ /**
179
+ * The agent for new sessions. Provide a complete `Agent`, or omit it to run the builtin default
180
+ * profile (`DEFAULT_AGENT_PROFILES.agent`) rendered as an agent — there is no piecemeal middle
181
+ * ground.
182
+ */
183
+ readonly agent?: Agent<TContext>;
184
+ /**
185
+ * Host application context for sessions. It is runtime-only: never serialized into the log or
186
+ * SessionStore, so a process reopening a session provides it again with
187
+ * `resumeSession(id, { context })`.
188
+ */
189
+ readonly context?: TContext;
190
+ /**
191
+ * Concrete tool palette the default profile's tool NAMES resolve against (and the subagent palette).
192
+ * Defaults to `filesystemTools()` plus `askUserQuestionTool`; capability tools (goal/plan/todo/…) are
193
+ * injected at runtime, so they need not appear here. Ignored when `agent` is given — a complete agent
194
+ * carries its own tools.
195
+ */
196
+ readonly tools?: readonly Tool[];
197
+ /**
198
+ * Extra guidance appended to the default agent's system prompt (the profile's `roleAdditional` slot,
199
+ * rendered after its own prompt). This LAYERS on the default profile — it does not replace it; for a
200
+ * wholly different prompt, pass a complete `agent`. Ignored when `agent` is given.
201
+ */
202
+ readonly appendSystemPrompt?: string;
203
+ /**
204
+ * Subagent fleet the `Agent` / `Workflow` tools spawn by type. Defaults to the builtin
205
+ * `coder` / `explore` / `plan` profiles (each inherits the session model; their tools resolve
206
+ * against the default agent's toolset), so orchestration works out of the box. Pass your own
207
+ * provider to override, or `null` to disable subagents entirely — which also hides the
208
+ * Agent/Workflow tools, since there is nothing for them to spawn.
209
+ */
210
+ readonly subagentProvider?: SubagentProvider<TContext> | null;
211
+ /**
212
+ * Extra subagent profiles merged into the builtin `coder`/`explore`/`plan` fleet. The SOURCE is
213
+ * the composition root's concern — disk (`loadAgentProfiles`) locally, external on a server —
214
+ * but the merge/tool-wiring is identical. Ignored when `subagentProvider` is given.
215
+ */
216
+ readonly extraSubagentProfiles?: Record<string, ResolvedAgentProfile>;
217
+ /**
218
+ * Expose the builtin `Workflow` tool (default true).
219
+ *
220
+ * The narrow version of the switch above: use it when the host has its own
221
+ * workflow orchestration and two overlapping tools would leave the model
222
+ * guessing. Subagents and the `Agent` tool are unaffected — unlike
223
+ * `subagentProvider: null`, which necessarily takes both tools with it.
224
+ */
225
+ readonly workflowTool?: boolean;
186
226
  /**
187
227
  * Cap on subagents running concurrently from one session's root frame. Defaults to the same
188
228
  * bound the `Workflow` tool uses, so a batch of `Agent` spawns cannot fan out further than a
189
229
  * workflow would. Nested spawns are exempt by design (they would deadlock a shared pool).
190
230
  */
191
231
  readonly maxConcurrentSubagents?: number;
232
+ /** Permission config. Defaults to `{ mode: "yolo" }`. */
233
+ readonly permission?: PermissionManagerOptions;
192
234
  /** Event durability policy. Defaults to `immediate` for low-latency local applications.
193
235
  * Managed servers override each opened session to `committed`. */
194
236
  readonly eventPublication?: EventPublicationMode;
237
+ /** Turn cap for each run. */
238
+ readonly maxTurns?: number;
195
239
  /**
196
- * Product telemetry (docs/telemetry.md). Process-lifetime: create ONE service, attach the
197
- * appenders once, pass the same instance to every harness. Absent = nothing is counted.
198
- * The harness never shuts it down; the product does, before exit.
240
+ * Step-per-turn cap. Carried on the default agent (the Runner reads the agent's value first),
241
+ * so it is ignored when `agent` is given.
199
242
  */
200
- readonly telemetry?: TelemetryService;
201
- /**
202
- * Extra subagent profiles merged into the builtin `coder`/`explore`/`plan` fleet. The SOURCE is
203
- * the composition root's concern — disk (`loadAgentProfiles`) locally, external on a server —
204
- * but the merge/tool-wiring is identical. Ignored when `subagentProvider` is given.
243
+ readonly maxStepsPerTurn?: number;
244
+ }
245
+ export interface HarnessOptions<TContext = unknown> extends SessionSettings<TContext> {
246
+ /** Model for sessions that name none: a `provider/model` id looked up in {@link modelManager}. */
247
+ readonly model: string;
248
+ /**
249
+ * The one model manager this harness runs on: what every string model id resolves against.
250
+ * Defaults to a fresh `createModelManager()`. Pass one only for what cannot be changed later,
251
+ * such as a custom credential store. Add providers in place: `harness.modelManager.register()` for
252
+ * plain configuration, `harness.modelManager.models.setProvider()` for one built with `createProvider`.
253
+ */
254
+ readonly modelManager?: ModelManager;
255
+ /**
256
+ * The process-lived infrastructure this harness runs on, built once. Everything a session needs
257
+ * that is NOT per-session lives here: the repository (disk locally, Pg/Redis on a server;
258
+ * in-memory when absent), the logger, the default environment, one set of MCP connections, one
259
+ * skill scan. Runs before the by-value extensions' `harness` halves, so they can consume what it
260
+ * returns.
261
+ *
262
+ * There is no tier between this and a session. A shared object is shared by the WHOLE process
263
+ * or it is built per session — nothing is shared by "the sessions in one directory".
205
264
  */
206
- readonly extraSubagentProfiles?: Record<string, ResolvedAgentProfile>;
265
+ readonly infrastructure?: () => HarnessInfrastructure | Promise<HarnessInfrastructure>;
207
266
  /**
208
- * The agent for new sessions. Provide a complete `Agent`, or omit it to run the builtin default
209
- * profile (`DEFAULT_AGENT_PROFILES.agent`) rendered as an agent — there is no piecemeal middle
210
- * ground. Per-session overrides go through `createSession({ agent })`.
267
+ * Session-tier composition: called once per session being opened. Return its capabilities.
268
+ * Defaults to `defaultCapabilities()`. Always called fresh per session, so per-session state
269
+ * (goal/plan/todo/background/skills/mcp) is isolated by construction.
211
270
  */
212
- readonly agent?: Agent<TContext>;
271
+ readonly sessionCapabilities?: (ctx: SessionCapabilityContext) => readonly Capability[] | Promise<readonly Capability[]>;
272
+ /** Default working directory for new sessions. Defaults to `process.cwd()`. */
273
+ readonly workDir?: string;
213
274
  /**
214
- * Default host application context for sessions. It is runtime-only: never serialized into
215
- * the log or SessionStore. Override per session with `createSession({ context })` or provide it
216
- * again when reopening a process with `resumeSession(id, { context })`.
275
+ * Product telemetry (docs/telemetry.md). Process-lifetime: create ONE service, attach the
276
+ * appenders once, pass the same instance to every harness. Absent = nothing is counted.
277
+ * The harness never shuts it down; the product does, before exit.
217
278
  */
218
- readonly context?: TContext;
279
+ readonly telemetry?: TelemetryService;
219
280
  /**
220
281
  * Process-level shared services BY NAME, registered into the harness scope at construction
221
282
  * (extension services are the one string-keyed corner: an extension is loaded by its id and
@@ -244,36 +305,6 @@ export interface HarnessOptions<TContext = unknown> {
244
305
  * `undefined`). Kept outside the code folders so an update never touches state.
245
306
  */
246
307
  readonly extensionDataDir?: string;
247
- /**
248
- * Concrete tool palette the default profile's tool NAMES resolve against (and the subagent palette).
249
- * Defaults to `filesystemTools()` plus `askUserQuestionTool`; capability tools (goal/plan/todo/…) are
250
- * injected at runtime, so they need not appear here. Ignored when `agent` is given — a complete agent
251
- * carries its own tools.
252
- */
253
- readonly tools?: readonly Tool[];
254
- /**
255
- * Extra guidance appended to the default agent's system prompt (the profile's `roleAdditional` slot,
256
- * rendered after its own prompt). This LAYERS on the default profile — it does not replace it; for a
257
- * wholly different prompt, pass a complete `agent`. Ignored when `agent` is given.
258
- */
259
- readonly appendSystemPrompt?: string;
260
- /**
261
- * Subagent fleet the `Agent` / `Workflow` tools spawn by type. Defaults to the builtin
262
- * `coder` / `explore` / `plan` profiles (each inherits the session model; their tools resolve
263
- * against the default agent's toolset), so orchestration works out of the box. Pass your own
264
- * provider to override, or `null` to disable subagents entirely — which also hides the
265
- * Agent/Workflow tools, since there is nothing for them to spawn.
266
- */
267
- readonly subagentProvider?: SubagentProvider<TContext> | null;
268
- /**
269
- * Expose the builtin `Workflow` tool (default true).
270
- *
271
- * The narrow version of the switch above: use it when the host has its own
272
- * workflow orchestration and two overlapping tools would leave the model
273
- * guessing. Subagents and the `Agent` tool are unaffected — unlike
274
- * `subagentProvider: null`, which necessarily takes both tools with it.
275
- */
276
- readonly workflowTool?: boolean;
277
308
  /**
278
309
  * Programmatic runtime extensions, registered once here — the by-value twin of `extensionDir`.
279
310
  * Every definition's `session` runs in every session; a session varies that with
@@ -287,10 +318,6 @@ export interface HarnessOptions<TContext = unknown> {
287
318
  * are skills/MCP/hooks metadata, extensions are trusted host code.
288
319
  */
289
320
  readonly extensions?: readonly ExtensionDefinition[];
290
- /** Permission config. Defaults to `{ mode: "yolo" }`. */
291
- readonly permission?: PermissionManagerOptions;
292
- readonly maxTurns?: number;
293
- readonly maxStepsPerTurn?: number;
294
321
  }
295
322
  /**
296
323
  * What a capability factory is told about the session it is building for.
@@ -328,13 +355,9 @@ export interface SessionCapabilityContext {
328
355
  * Each one is already a session-level input inside the engine; the harness was
329
356
  * simply passing its own value straight through.
330
357
  */
331
- interface OpenSessionOptionsBase<TContext = unknown> {
332
- /** Override the agent for this session only. */
333
- readonly agent?: Agent<TContext>;
358
+ interface OpenSessionOptionsBase<TContext = unknown> extends SessionSettings<TContext> {
334
359
  /** Override the execution environment for this session only. Lifecycle remains host-owned. */
335
360
  readonly environment?: Environment | EnvironmentFactory;
336
- /** Runtime-only application context for this session. Overrides `HarnessOptions.context`. */
337
- readonly context?: TContext;
338
361
  /**
339
362
  * MCP servers scoped to this session, surfaced to the capability factory via
340
363
  * {@link SessionCapabilityContext}. Merge policy is the factory's call — the
@@ -361,24 +384,6 @@ interface OpenSessionOptionsBase<TContext = unknown> {
361
384
  * This is the per-session knob: definitions themselves are registered once, on the harness.
362
385
  */
363
386
  readonly params?: Record<string, unknown>;
364
- /** Permission config for this session. Overrides `HarnessOptions.permission`. */
365
- readonly permission?: PermissionManagerOptions;
366
- /** Override this session's event durability policy. */
367
- readonly eventPublication?: EventPublicationMode;
368
- /**
369
- * Extra system-prompt text for this session's default agent. Overrides
370
- * `HarnessOptions.appendSystemPrompt`. Ignored when `agent` is given, since that
371
- * replaces the default agent outright.
372
- */
373
- readonly appendSystemPrompt?: string;
374
- /** Turn cap for this session's runs. Overrides `HarnessOptions.maxTurns`. */
375
- readonly maxTurns?: number;
376
- /**
377
- * Step-per-turn cap for this session. Overrides `HarnessOptions.maxStepsPerTurn`.
378
- * Carried on the session's default agent (the Runner reads the agent's value
379
- * first), so it is ignored when `agent` is given.
380
- */
381
- readonly maxStepsPerTurn?: number;
382
387
  }
383
388
  export interface CreateSessionOptions<TContext = unknown> extends OpenSessionOptionsBase<TContext> {
384
389
  readonly id?: string;
@@ -457,8 +462,7 @@ export declare class HarnessSession<TContext = unknown> {
457
462
  private readonly closingNotified;
458
463
  /** A wake is already scheduled on the microtask queue; more enqueues fold into it. */
459
464
  private wakeScheduled;
460
- /** Per-session turn cap; the Runner's own config is the fallback. */
461
- private readonly maxTurns;
465
+ private readonly runOverrides;
462
466
  /** Builds the capability an extension attached to THIS session runs as — bound to the
463
467
  * session's harness reach and its per-session params. */
464
468
  private readonly mountExtension;
@@ -471,7 +475,7 @@ export declare class HarnessSession<TContext = unknown> {
471
475
  projection: SessionProjection;
472
476
  workDir: string;
473
477
  context?: TContext;
474
- maxTurns?: number;
478
+ runOverrides: SessionRunOverrides<TContext>;
475
479
  interrupted?: boolean;
476
480
  mountExtension: (definition: ExtensionDefinition) => Capability;
477
481
  onClosed: (id: string) => void | Promise<void>;
@@ -493,8 +497,6 @@ export declare class HarnessSession<TContext = unknown> {
493
497
  * Re-checked in a loop: a new hold can land between a release and this task resuming.
494
498
  */
495
499
  private parkAtGate;
496
- /** Run options every run on this session carries — currently the session's turn cap. */
497
- private get runOverrides();
498
500
  /** Current runtime-only application context supplied to Agent callbacks. */
499
501
  get context(): TContext | undefined;
500
502
  get status(): HarnessSessionStatus;
@@ -683,14 +685,19 @@ export declare class Harness<TContext = unknown> {
683
685
  private readonly runner;
684
686
  /** Concrete tools the default profile's tool NAMES resolve against (keyed by schema name). */
685
687
  private readonly toolPalette;
688
+ private readonly agentTools;
689
+ private readonly defaultSubagents;
686
690
  private readonly activeSessions;
687
691
  /** Opens in flight by session id (`resumeSession`): concurrent callers share ONE open — the
688
692
  * whole of it, reconcile and failure cleanup included — instead of each building an instance. */
689
693
  private readonly opening;
690
694
  /** The one close in flight (or finished): every `close()` call returns THIS promise. */
691
695
  private closing;
692
- /** The process-lived objects this harness runs on (`HarnessOptions.harness`), plus defaults. */
693
- private parts;
696
+ /** The one model manager of this harness. Edit its providers in place; it is never swapped. */
697
+ readonly modelManager: ModelManager;
698
+ private readonly resolveModel;
699
+ /** The process-lived objects this harness runs on (`HarnessOptions.infrastructure`), plus defaults. */
700
+ private infrastructure;
694
701
  /** Process-level extension services by name (a facade over `scope`). The host replaces
695
702
  * providers here (`services.replace`); sessions only ever see handles (`ctx.shared`, `ctx.services`). */
696
703
  readonly services: ServiceRegistry;
@@ -717,12 +724,12 @@ export declare class Harness<TContext = unknown> {
717
724
  private runningCreate;
718
725
  constructor(options: HarnessOptions<TContext>);
719
726
  /**
720
- * Which session-facing parts the `harness` hook built for the whole process — what a
727
+ * Which session-facing parts the `infrastructure` hook built for the whole process — what a
721
728
  * multi-tenant host must not have, since every session on the process would share them.
722
729
  * Infrastructure (repository, logger, model runtime) is not listed: sharing it is the point.
723
730
  */
724
731
  processSharedParts(): Promise<readonly ("mcpServers" | "oauthService" | "skillRegistry")[]>;
725
- /** The session repository, once the `harness` hook has had its say. */
732
+ /** The session repository, once the `infrastructure` hook has had its say. */
726
733
  private repository;
727
734
  /** The fallback repository, built once so two opens share one in-memory store. */
728
735
  private memoryRepository;
@@ -757,6 +764,13 @@ export declare class Harness<TContext = unknown> {
757
764
  * prompts stay isolated without coupling Agent lifetime to environment lifetime.
758
765
  */
759
766
  private buildDefaultAgent;
767
+ /**
768
+ * The Agent/Workflow tools only appear when a run has subagents to spawn. The fleet defaults to
769
+ * the builtin coder/explore/plan profiles so orchestration works out of the box; an explicit
770
+ * provider overrides it, and `null` opts out (hiding those tools).
771
+ */
772
+ private subagentProviderFor;
773
+ private runOverridesFor;
760
774
  get sessions(): ReadonlyMap<string, HarnessSession<TContext>>;
761
775
  createSession(opts?: CreateSessionOptions<TContext>): Promise<HarnessSession<TContext>>;
762
776
  /**
package/dist/harness.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * primitives for the "assemble it yourself / stateless server" path are exported from
12
12
  * `operon-agents` too.
13
13
  */
14
- import { readSessionTails, SessionProjection, ListenerSink, recoverOrphanedTurns, Agent, buildAgentFromProfile, INTERRUPTION_STATE_KEY, flattenPendingInterrupts, parseInterruptionState, Runner, Session, emptyUsage, isGuardrailTripwireError, askUserQuestionTool, backgroundCapability, compactionCapability, filesystemTools, goalCapability, LocalEnvironment, McpOAuthService, MemorySessionRepository, SessionRepositoryNotFoundError, planCapability, skillsCapability, SkillRegistry, mcpServersCapability, pluginsCapability, userHooksCapability, taskCapability, workflowCapability, DEFAULT_AGENT_PROFILES, DEFAULT_AGENT_PROFILE_NAME, DEFAULT_SUBAGENT_NAMES, profileSubagentProvider, DEFAULT_ADDRESS, envLogger, noopLogger, } from "operon-agents-core";
14
+ import { readSessionTails, SessionProjection, ListenerSink, recoverOrphanedTurns, Agent, buildAgentFromProfile, INTERRUPTION_STATE_KEY, flattenPendingInterrupts, parseInterruptionState, Runner, Session, emptyUsage, isGuardrailTripwireError, askUserQuestionTool, createModelManager, backgroundCapability, compactionCapability, filesystemTools, goalCapability, LocalEnvironment, McpOAuthService, MemorySessionRepository, SessionRepositoryNotFoundError, planCapability, skillsCapability, SkillRegistry, mcpServersCapability, pluginsCapability, userHooksCapability, taskCapability, workflowCapability, DEFAULT_AGENT_PROFILES, DEFAULT_AGENT_PROFILE_NAME, DEFAULT_SUBAGENT_NAMES, profileSubagentProvider, DEFAULT_ADDRESS, envLogger, noopLogger, } from "operon-agents-core";
15
15
  import { mkdirSync } from "node:fs";
16
16
  import { workspaceMcpCapability } from "./workspace-mcp.js";
17
17
  import { join } from "node:path";
@@ -138,9 +138,12 @@ function defaultSubagentProvider(agentTools, resolveModel, extraProfiles = {}) {
138
138
  return profileSubagentProvider(profiles, {
139
139
  only,
140
140
  tools,
141
- ...(resolveModel !== undefined ? { resolveModel } : {}),
141
+ resolveModel,
142
142
  });
143
143
  }
144
+ function toolPaletteOf(tools) {
145
+ return Object.fromEntries(tools.map((tool) => [tool.schema.name, tool]));
146
+ }
144
147
  /** A `Responder` whose approval/question handlers can be swapped at runtime (last-wins). */
145
148
  class MutableResponder {
146
149
  approvalHandler;
@@ -215,8 +218,7 @@ export class HarnessSession {
215
218
  });
216
219
  /** A wake is already scheduled on the microtask queue; more enqueues fold into it. */
217
220
  wakeScheduled = false;
218
- /** Per-session turn cap; the Runner's own config is the fallback. */
219
- maxTurns;
221
+ runOverrides;
220
222
  /** Builds the capability an extension attached to THIS session runs as — bound to the
221
223
  * session's harness reach and its per-session params. */
222
224
  mountExtension;
@@ -230,7 +232,7 @@ export class HarnessSession {
230
232
  this.events = args.events;
231
233
  this.responder = args.responder;
232
234
  this.runContext = args.context;
233
- this.maxTurns = args.maxTurns;
235
+ this.runOverrides = args.runOverrides;
234
236
  this.lastRunInterrupted = args.interrupted ?? false;
235
237
  this.onClosed = args.onClosed;
236
238
  this.mountExtension = args.mountExtension;
@@ -291,10 +293,6 @@ export class HarnessSession {
291
293
  throw new Error(`session "${this.id}" is closed`);
292
294
  }
293
295
  }
294
- /** Run options every run on this session carries — currently the session's turn cap. */
295
- get runOverrides() {
296
- return this.maxTurns !== undefined ? { maxTurns: this.maxTurns } : {};
297
- }
298
296
  /** Current runtime-only application context supplied to Agent callbacks. */
299
297
  get context() {
300
298
  return this.runContext;
@@ -836,14 +834,19 @@ export class Harness {
836
834
  runner;
837
835
  /** Concrete tools the default profile's tool NAMES resolve against (keyed by schema name). */
838
836
  toolPalette;
837
+ agentTools;
838
+ defaultSubagents;
839
839
  activeSessions = new Map();
840
840
  /** Opens in flight by session id (`resumeSession`): concurrent callers share ONE open — the
841
841
  * whole of it, reconcile and failure cleanup included — instead of each building an instance. */
842
842
  opening = new Map();
843
843
  /** The one close in flight (or finished): every `close()` call returns THIS promise. */
844
844
  closing;
845
- /** The process-lived objects this harness runs on (`HarnessOptions.harness`), plus defaults. */
846
- parts = {};
845
+ /** The one model manager of this harness. Edit its providers in place; it is never swapped. */
846
+ modelManager;
847
+ resolveModel;
848
+ /** The process-lived objects this harness runs on (`HarnessOptions.infrastructure`), plus defaults. */
849
+ infrastructure = {};
847
850
  /** Process-level extension services by name (a facade over `scope`). The host replaces
848
851
  * providers here (`services.replace`); sessions only ever see handles (`ctx.shared`, `ctx.services`). */
849
852
  services;
@@ -870,22 +873,16 @@ export class Harness {
870
873
  runningCreate;
871
874
  constructor(options) {
872
875
  this.options = options;
876
+ this.modelManager = options.modelManager ?? createModelManager();
877
+ this.resolveModel = (id) => this.modelManager.resolve(id);
873
878
  this.services = new ServiceRegistry();
874
- const agentTools = options.tools ?? [...filesystemTools(), askUserQuestionTool];
875
- this.toolPalette = Object.fromEntries(agentTools.map((tool) => [tool.schema.name, tool]));
876
- // The Agent/Workflow tools only appear when the run has subagents to spawn. Default the fleet
877
- // to the builtin coder/explore/plan profiles so orchestration works out of the box; an explicit
878
- // provider overrides it, and `null` opts out (hiding those tools).
879
- const subagentProvider = options.subagentProvider === null
880
- ? undefined
881
- : options.subagentProvider ?? defaultSubagentProvider(agentTools, options.resolveModel, options.extraSubagentProfiles);
879
+ this.agentTools = options.tools ?? [...filesystemTools(), askUserQuestionTool];
880
+ this.toolPalette = toolPaletteOf(this.agentTools);
881
+ this.defaultSubagents = defaultSubagentProvider(this.agentTools, this.resolveModel, options.extraSubagentProfiles);
882
882
  this.runner = new Runner({
883
- resolveModel: options.resolveModel,
884
- subagentProvider,
885
- ...(options.workflowTool !== undefined ? { workflowTool: options.workflowTool } : {}),
883
+ resolveModel: this.resolveModel,
886
884
  maxTurns: options.maxTurns,
887
885
  maxStepsPerTurn: options.maxStepsPerTurn,
888
- ...(options.maxConcurrentSubagents !== undefined ? { maxConcurrentSubagents: options.maxConcurrentSubagents } : {}),
889
886
  });
890
887
  this.dataRoot = options.extensionDataDir ?? (options.extensionDir !== undefined ? join(options.extensionDir, ".data") : undefined);
891
888
  this.extensions = options.extensionDir === undefined
@@ -920,14 +917,9 @@ export class Harness {
920
917
  // The host's process-tier registrations come first (extension `harness` halves may consume
921
918
  // them); a synchronous hook keeps the whole chain synchronous, so a synchronous extension's
922
919
  // service exists the moment `createHarness` returns.
923
- const composed = options.harness?.();
920
+ const composed = options.infrastructure?.();
924
921
  const absorb = (parts) => {
925
- this.parts = {
926
- ...(parts ?? {}),
927
- // Explicit options win over what the preset built, and both win over the defaults below.
928
- ...(options.telemetry !== undefined ? { telemetry: options.telemetry } : {}),
929
- ...(options.eventPublication !== undefined ? { eventPublication: options.eventPublication } : {}),
930
- };
922
+ this.infrastructure = parts ?? {};
931
923
  };
932
924
  this.sharedReady = composed instanceof Promise
933
925
  ? composed.then((parts) => {
@@ -939,33 +931,33 @@ export class Harness {
939
931
  this.sharedReady.catch(() => undefined);
940
932
  }
941
933
  /**
942
- * Which session-facing parts the `harness` hook built for the whole process — what a
934
+ * Which session-facing parts the `infrastructure` hook built for the whole process — what a
943
935
  * multi-tenant host must not have, since every session on the process would share them.
944
936
  * Infrastructure (repository, logger, model runtime) is not listed: sharing it is the point.
945
937
  */
946
938
  async processSharedParts() {
947
939
  await this.sharedReady;
948
940
  const parts = [];
949
- if (this.parts.mcpServers !== undefined)
941
+ if (this.infrastructure.mcpServers !== undefined)
950
942
  parts.push("mcpServers");
951
- if (this.parts.oauthService !== undefined)
943
+ if (this.infrastructure.oauthService !== undefined)
952
944
  parts.push("oauthService");
953
- if (this.parts.skillRegistry !== undefined)
945
+ if (this.infrastructure.skillRegistry !== undefined)
954
946
  parts.push("skillRegistry");
955
947
  return parts;
956
948
  }
957
- /** The session repository, once the `harness` hook has had its say. */
949
+ /** The session repository, once the `infrastructure` hook has had its say. */
958
950
  async repository() {
959
951
  await this.sharedReady;
960
952
  this.memoryRepository ??= new MemorySessionRepository();
961
- return this.parts.sessionRepository ?? this.memoryRepository;
953
+ return this.infrastructure.sessionRepository ?? this.memoryRepository;
962
954
  }
963
955
  /** The fallback repository, built once so two opens share one in-memory store. */
964
956
  memoryRepository;
965
957
  /** The harness logger: the preset's, else the `AGENTS_LOG` env logger, else silent. */
966
958
  get logger() {
967
959
  this.defaultLogger ??= envLogger() ?? noopLogger;
968
- return this.parts.logger ?? this.defaultLogger;
960
+ return this.infrastructure.logger ?? this.defaultLogger;
969
961
  }
970
962
  defaultLogger;
971
963
  /**
@@ -1061,17 +1053,43 @@ export class Harness {
1061
1053
  * resolves the current frame's environment + cwd through the Session cache, so root/subagent/worktree
1062
1054
  * prompts stay isolated without coupling Agent lifetime to environment lifetime.
1063
1055
  */
1064
- buildDefaultAgent(appendSystemPrompt, maxStepsPerTurn) {
1065
- const additional = appendSystemPrompt ?? this.options.appendSystemPrompt;
1066
- const steps = maxStepsPerTurn ?? this.options.maxStepsPerTurn;
1056
+ buildDefaultAgent(opts) {
1057
+ const additional = opts.appendSystemPrompt ?? this.options.appendSystemPrompt;
1058
+ const steps = opts.maxStepsPerTurn ?? this.options.maxStepsPerTurn;
1059
+ const modelSettings = opts.modelSettings ?? this.options.modelSettings;
1067
1060
  return buildAgentFromProfile(DEFAULT_AGENT_PROFILES[DEFAULT_AGENT_PROFILE_NAME], {
1068
- tools: this.toolPalette,
1061
+ tools: opts.tools === undefined ? this.toolPalette : toolPaletteOf(opts.tools),
1069
1062
  model: this.options.model,
1070
1063
  ...(additional !== undefined ? { roleAdditional: additional } : {}),
1071
1064
  ...(steps !== undefined ? { maxStepsPerTurn: steps } : {}),
1072
- ...(this.options.resolveModel !== undefined ? { resolveModel: this.options.resolveModel } : {}),
1065
+ ...(modelSettings !== undefined ? { modelSettings } : {}),
1066
+ resolveModel: this.resolveModel,
1073
1067
  });
1074
1068
  }
1069
+ /**
1070
+ * The Agent/Workflow tools only appear when a run has subagents to spawn. The fleet defaults to
1071
+ * the builtin coder/explore/plan profiles so orchestration works out of the box; an explicit
1072
+ * provider overrides it, and `null` opts out (hiding those tools).
1073
+ */
1074
+ subagentProviderFor(opts) {
1075
+ const chosen = opts.subagentProvider !== undefined ? opts.subagentProvider : this.options.subagentProvider;
1076
+ if (chosen !== undefined)
1077
+ return chosen;
1078
+ if (opts.tools === undefined && opts.extraSubagentProfiles === undefined)
1079
+ return this.defaultSubagents;
1080
+ return defaultSubagentProvider(opts.tools ?? this.agentTools, this.resolveModel, opts.extraSubagentProfiles ?? this.options.extraSubagentProfiles);
1081
+ }
1082
+ runOverridesFor(opts) {
1083
+ const maxTurns = opts.maxTurns ?? this.options.maxTurns;
1084
+ const workflowTool = opts.workflowTool ?? this.options.workflowTool;
1085
+ const maxConcurrentSubagents = opts.maxConcurrentSubagents ?? this.options.maxConcurrentSubagents;
1086
+ return {
1087
+ subagentProvider: this.subagentProviderFor(opts),
1088
+ ...(maxTurns !== undefined ? { maxTurns } : {}),
1089
+ ...(workflowTool !== undefined ? { workflowTool } : {}),
1090
+ ...(maxConcurrentSubagents !== undefined ? { maxConcurrentSubagents } : {}),
1091
+ };
1092
+ }
1075
1093
  get sessions() {
1076
1094
  return this.activeSessions;
1077
1095
  }
@@ -1243,7 +1261,7 @@ export class Harness {
1243
1261
  // Last: the process-shared objects the preset opened (MCP connections, handles). Every
1244
1262
  // session is closed by now, so nothing is still reading them.
1245
1263
  try {
1246
- await this.parts.close?.();
1264
+ await this.infrastructure.close?.();
1247
1265
  }
1248
1266
  catch (error) {
1249
1267
  this.logger.log("warn", "harness parts close failed", { error: error instanceof Error ? error.message : String(error) });
@@ -1415,10 +1433,10 @@ export class Harness {
1415
1433
  async resolveCapabilities(ctx, params = {}) {
1416
1434
  // A session on its own environment (a sandbox) does not get the process's MCP connections or
1417
1435
  // skill scan: both describe the HARNESS's machine, not the one this session's tools run on.
1418
- const base = this.options.session !== undefined
1419
- ? await this.options.session(ctx)
1436
+ const base = this.options.sessionCapabilities !== undefined
1437
+ ? await this.options.sessionCapabilities(ctx)
1420
1438
  : defaultCapabilities({
1421
- shared: this.parts,
1439
+ shared: this.infrastructure,
1422
1440
  ownEnvironment: ctx.ownEnvironment,
1423
1441
  strictIsolation: ctx.strictIsolation,
1424
1442
  ...(ctx.mcpServers !== undefined ? { sessionMcpServers: ctx.mcpServers } : {}),
@@ -1449,7 +1467,7 @@ export class Harness {
1449
1467
  */
1450
1468
  extensionHost(sessionId) {
1451
1469
  const self = this;
1452
- const runtime = this.parts.modelRuntime;
1470
+ const runtime = this.modelManager;
1453
1471
  return {
1454
1472
  sessionId,
1455
1473
  newSession: (options) => self.createSession({ ...(options?.title !== undefined ? { title: options.title } : {}) }),
@@ -1457,13 +1475,9 @@ export class Harness {
1457
1475
  openSession: (id) => self.resumeSession(id),
1458
1476
  listSessions: () => self.listSessions(),
1459
1477
  registerProvider: (provider) => {
1460
- if (!runtime)
1461
- throw new Error("registerProvider() requires the harness to be built with a `modelRuntime`");
1462
1478
  runtime.models.setProvider(provider);
1463
1479
  },
1464
1480
  unregisterProvider: (id) => {
1465
- if (!runtime)
1466
- throw new Error("unregisterProvider() requires the harness to be built with a `modelRuntime`");
1467
1481
  runtime.models.deleteProvider(id);
1468
1482
  },
1469
1483
  services: {
@@ -1502,7 +1516,7 @@ export class Harness {
1502
1516
  const responder = new MutableResponder();
1503
1517
  // Environment: this call's override → the harness's default → a LocalEnvironment at the
1504
1518
  // session's own workDir. The session only OPERATES a caller-supplied environment.
1505
- const environment = opts.environment ?? this.parts.environment ?? new LocalEnvironment(workDir);
1519
+ const environment = opts.environment ?? this.infrastructure.environment ?? new LocalEnvironment(workDir);
1506
1520
  // ONE open-time log read, shared: every shard from its newest checkpoint on (each
1507
1521
  // checkpoint carries the history and lifecycle before it). The projection seeds from it,
1508
1522
  // and Session.open receives it as `preloadedLog` so its capability restore + context
@@ -1541,16 +1555,18 @@ export class Harness {
1541
1555
  responder,
1542
1556
  environment,
1543
1557
  permissionOptions: opts.permission ?? this.options.permission ?? { mode: "yolo" },
1544
- ...(this.parts.logger !== undefined ? { logger: this.parts.logger } : {}),
1545
- ...(this.parts.tracing !== undefined ? { tracing: this.parts.tracing } : {}),
1546
- ...(this.parts.telemetry !== undefined ? { telemetry: this.parts.telemetry } : {}),
1547
- ...(opts.eventPublication ?? this.parts.eventPublication) !== undefined
1548
- ? { eventPublication: (opts.eventPublication ?? this.parts.eventPublication) }
1558
+ ...(this.infrastructure.logger !== undefined ? { logger: this.infrastructure.logger } : {}),
1559
+ ...(this.infrastructure.tracing !== undefined ? { tracing: this.infrastructure.tracing } : {}),
1560
+ ...(this.options.telemetry !== undefined ? { telemetry: this.options.telemetry } : {}),
1561
+ ...(opts.eventPublication ?? this.options.eventPublication) !== undefined
1562
+ ? { eventPublication: (opts.eventPublication ?? this.options.eventPublication) }
1549
1563
  : {},
1550
1564
  });
1551
1565
  const agent = opts.agent ??
1552
1566
  this.options.agent ??
1553
- (await this.buildDefaultAgent(opts.appendSystemPrompt, opts.maxStepsPerTurn));
1567
+ (await this.buildDefaultAgent(opts));
1568
+ if (opts.model !== undefined)
1569
+ core.setModel(opts.model);
1554
1570
  const session = new HarnessSession({
1555
1571
  core,
1556
1572
  // The two caps land in different places: maxStepsPerTurn is an Agent property
@@ -1562,9 +1578,7 @@ export class Harness {
1562
1578
  projection,
1563
1579
  workDir,
1564
1580
  context: this.contextFor(opts),
1565
- ...(opts.maxTurns ?? this.options.maxTurns) !== undefined
1566
- ? { maxTurns: opts.maxTurns ?? this.options.maxTurns }
1567
- : {},
1581
+ runOverrides: this.runOverridesFor(opts),
1568
1582
  interrupted,
1569
1583
  mountExtension: (definition) => {
1570
1584
  if (params?.[definition.id] === false) {
@@ -1597,7 +1611,7 @@ export class Harness {
1597
1611
  * throws (a cleanup failure is logged, never allowed to mask the open's own error).
1598
1612
  */
1599
1613
  async rollbackOpen(id, built, cause) {
1600
- const logger = this.parts.logger;
1614
+ const logger = this.infrastructure.logger;
1601
1615
  const attempt = async (step, run) => {
1602
1616
  try {
1603
1617
  await run();
package/dist/local.d.ts CHANGED
@@ -1,16 +1,16 @@
1
- import type { ModelRuntime } from "operon-agents-core";
1
+ import type { ModelManager } from "operon-agents-core";
2
2
  import { type HookDef, type Logger, type McpServerConfig, type PluginManager } from "operon-agents-core";
3
3
  import { type Harness, type HarnessOptions } from "./harness.ts";
4
4
  export interface LocalDeploymentOptions<TContext = unknown> extends HarnessOptions<TContext> {
5
5
  /**
6
6
  * App home root — sessions (`<homeDir>/sessions`), MCP creds, logs, and disk-discovered agent
7
7
  * profiles all live under it. Defaults to `~/.agents`. (`homeDir` is a local-deployment concept;
8
- * the harness itself only knows `HarnessParts.sessionRepository`.)
8
+ * the harness itself only knows `HarnessInfrastructure.sessionRepository`.)
9
9
  */
10
10
  readonly homeDir?: string;
11
11
  /** MCP servers to expose, shared by every session of the harness. */
12
12
  readonly mcpServers?: Record<string, McpServerConfig>;
13
- /** Installed-plugin manager, if any. Loaded once here, then handed out as `HarnessParts.pluginManager`. */
13
+ /** Installed-plugin manager, if any. Loaded once here, then handed out as `HarnessInfrastructure.pluginManager`. */
14
14
  readonly pluginManager?: PluginManager;
15
15
  /** Shell hooks from config (`config.hooks`), projected as HookDefs. */
16
16
  readonly hooks?: readonly HookDef[];
@@ -20,14 +20,9 @@ export interface LocalDeploymentOptions<TContext = unknown> extends HarnessOptio
20
20
  readonly logger?: Logger;
21
21
  /** Context budget for the default compaction capability. */
22
22
  readonly maxContextTokens?: number;
23
- /**
24
- * The model runtime this harness resolves providers through. Pass the same one the host used to
25
- * build its `ChatModel` (see `createModelRuntimeFromConfig`) when a model lives on a configured
26
- * endpoint — otherwise the two registries disagree about what exists. Omitted, the harness builds
27
- * one from `<homeDir>/providers.toml` itself.
28
- */
29
- readonly modelRuntime?: ModelRuntime;
30
- /** Skip reading `<homeDir>/providers.toml` entirely. Ignored when `modelRuntime` is given. */
23
+ /** {@link HarnessOptions.modelManager}. Omitted, it is built from `<homeDir>/providers.toml`. */
24
+ readonly modelManager?: ModelManager;
25
+ /** Skip reading `<homeDir>/providers.toml` entirely. Ignored when `modelManager` is given. */
31
26
  readonly loadConfiguredProviders?: boolean;
32
27
  }
33
28
  /** Build `HarnessOptions` wired for a local, single-environment deployment. */
package/dist/local.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * file log, file-backed MCP credentials, disk-discovered agent profiles, and the cron extension.
5
5
  *
6
6
  * It is a PRESET: pure data in (`LocalDeploymentOptions`), the three composition hooks out.
7
- * The `harness` hook registers the process-lived objects on the harness scope; the `session`
7
+ * The `harness` hook registers the process-lived objects on the harness scope; the `sessionCapabilities`
8
8
  * hook builds each session's capability set. A hosted deployment writes its own preset the same
9
9
  * way (see `examples/managed-agents`) — there is still no "mode" inside the engine
10
10
  * (Architecture Invariant 7): this file only picks backends, and returns plain `HarnessOptions`
@@ -12,14 +12,14 @@
12
12
  */
13
13
  import { homedir } from "node:os";
14
14
  import { cronExtension } from "./cron/index.js";
15
- import { createModelRuntimeFromConfig } from "./providers.js";
15
+ import { createModelManagerFromConfig } from "./providers.js";
16
16
  import { join, resolve } from "node:path";
17
17
  import { DiskSessionRepository, LocalEnvironment, McpOAuthService, RotatingFileSink, SkillRegistry, createMcpServers, loadSkillRoots, loadAgentProfiles, resolveGlobalLogPath, sinkLogger, } from "operon-agents-core";
18
18
  import { createHarness, defaultCapabilities, } from "./harness.js";
19
19
  /** Build `HarnessOptions` wired for a local, single-environment deployment. */
20
20
  export async function localHarnessOptions(options) {
21
- const { homeDir: home, mcpServers, pluginManager, hooks, loadDiskProfiles, logger, maxContextTokens, modelRuntime, loadConfiguredProviders, harness, session, extensions, ...engine } = options;
22
- // Filled by the `harness` half below, read by the `session` half — one process, one set.
21
+ const { homeDir: home, mcpServers, pluginManager, hooks, loadDiskProfiles, logger, maxContextTokens, modelManager, loadConfiguredProviders, infrastructure, sessionCapabilities, extensions, ...engine } = options;
22
+ // Filled by the `infrastructure` half below, read by the `sessionCapabilities` half — one process, one set.
23
23
  let sharedParts = {};
24
24
  // Set when the shared skill scan ran through a LocalEnvironment at the default workDir (the host
25
25
  // named no environment): that catalog only describes sessions rooted there.
@@ -36,14 +36,15 @@ export async function localHarnessOptions(options) {
36
36
  await pluginManager.load();
37
37
  // Configured endpoints are resolved once per harness, not per session: the registry is a
38
38
  // harness-tier object, and a session only ever reads it.
39
- const runtime = modelRuntime ??
39
+ const runtime = modelManager ??
40
40
  ((loadConfiguredProviders ?? true)
41
- ? await createModelRuntimeFromConfig({ homeDir })
41
+ ? await createModelManagerFromConfig({ homeDir })
42
42
  : undefined);
43
43
  return {
44
44
  ...engine,
45
45
  ...(extraSubagentProfiles !== undefined ? { extraSubagentProfiles } : {}),
46
- harness: async () => {
46
+ ...(runtime !== undefined ? { modelManager: runtime } : {}),
47
+ infrastructure: async () => {
47
48
  // Disk sessions under <homeDir>/sessions; diagnostics roll on disk under <homeDir>/logs.
48
49
  // The MCP connections, the skill scan and the OAuth store are process-wide: one set for
49
50
  // every session this harness opens, built once, here.
@@ -56,7 +57,7 @@ export async function localHarnessOptions(options) {
56
57
  // `onStatusChange`, which each session subscribes to when it views it.
57
58
  await servers.connect({ sessionId: "" });
58
59
  }
59
- const fromHost = await harness?.();
60
+ const fromHost = await infrastructure?.();
60
61
  // Skills follow the EXECUTION environment, not the host's disk: the catalog the model sees
61
62
  // must be the one whose scripts its Bash can reach. A host that named an environment above
62
63
  // is scanned through it; an environment FACTORY (one per session) has no single filesystem
@@ -85,8 +86,6 @@ export async function localHarnessOptions(options) {
85
86
  ...(servers !== undefined ? { mcpServers: servers } : {}),
86
87
  ...(skillRegistry !== undefined ? { skillRegistry } : {}),
87
88
  ...(pluginManager !== undefined ? { pluginManager } : {}),
88
- // What `ExtensionHost.registerProvider` mutates, and where a session's model is looked up.
89
- ...(runtime !== undefined ? { modelRuntime: runtime } : {}),
90
89
  ...fromHost,
91
90
  close: async () => {
92
91
  await fromHost?.close?.();
@@ -95,7 +94,7 @@ export async function localHarnessOptions(options) {
95
94
  };
96
95
  return sharedParts;
97
96
  },
98
- session: session ??
97
+ sessionCapabilities: sessionCapabilities ??
99
98
  ((ctx) => defaultCapabilities({
100
99
  shared: sessionSharedParts(sharedParts, sharedSkillsWorkDir, ctx.workDir),
101
100
  ownEnvironment: ctx.ownEnvironment,
@@ -35,11 +35,7 @@
35
35
  * ```
36
36
  */
37
37
  import { z } from "zod";
38
- import { DecisionModel, type Api, type ChatModel, type Model, type ModelRuntime } from "operon-agents-core";
39
- import type { Provider } from "@earendil-works/pi-ai";
40
- /** The wire dialects a configured endpoint can speak. Almost everything speaks the first. */
41
- export declare const PROVIDER_APIS: readonly ["openai-completions", "openai-responses", "anthropic-messages"];
42
- export type ProviderApi = (typeof PROVIDER_APIS)[number];
38
+ import { type Api, type ModelManager } from "operon-agents-core";
43
39
  declare const ProviderEntrySchema: z.ZodObject<{
44
40
  base_url: z.ZodString;
45
41
  name: z.ZodOptional<z.ZodString>;
@@ -120,35 +116,8 @@ export declare function parseModelsResponse(body: unknown): readonly string[];
120
116
  * Turn one config entry into a provider the engine can stream through. Its model list starts at
121
117
  * whatever the config declared and is replaced by the endpoint's on the first refresh.
122
118
  */
123
- export declare function providerFromConfig(providerId: string, entry: ProviderEntry): Provider<Api>;
124
- /**
125
- * One model, named by a host that already knows which it wants.
126
- *
127
- * The picked fields are pi's own. `baseUrl` is used exactly as given; without it, pi's catalog says
128
- * where each model lives. The others describe a model the catalog lacks: a catalogued one keeps its own.
129
- */
130
- export interface ChatModelConfig extends Readonly<Partial<Pick<Model<Api>, "baseUrl" | "contextWindow" | "maxTokens" | "input" | "reasoning">>> {
131
- /** pi's provider id to use its catalog; any other name needs `api` and `baseUrl`. */
132
- readonly provider: string;
133
- readonly model: string;
134
- readonly apiKey?: string;
135
- /** Marks a custom endpoint, built with `createProvider`. Only the dialects it can load. */
136
- readonly api?: ProviderApi;
137
- }
138
- /** Build the `ChatModel` a host picked: from pi's catalog, or from `api` and `baseUrl` for a custom endpoint. */
139
- export declare function resolveChatModel(config: ChatModelConfig): ChatModel;
140
- export interface DecisionModelConfig {
141
- /** pi's provider id: decision models only exist in its catalog. */
142
- readonly provider: string;
143
- readonly model: string;
144
- readonly apiKey: string;
145
- }
146
- /**
147
- * The decision model a config names, or undefined when it is an LLM. An id catalogued as both
148
- * (OpenAI's `gpt-6-luna`) counts as an LLM, and an id the catalog doesn't know can only be one.
149
- */
150
- export declare function resolveDecisionModel(config: DecisionModelConfig): Promise<DecisionModel | undefined>;
151
- export interface ModelRuntimeFromConfigOptions {
119
+ export declare function providerFromConfig(providerId: string, entry: ProviderEntry): import("@earendil-works/pi-ai").Provider<Api>;
120
+ export interface ModelManagerFromConfigOptions {
152
121
  readonly homeDir: string;
153
122
  /**
154
123
  * Ask each configured endpoint for its models. Default true. With it off the runtime carries
@@ -163,10 +132,8 @@ export interface ModelRuntimeFromConfigOptions {
163
132
  * One model runtime carrying the engine's built-in providers plus everything in
164
133
  * `<homeDir>/providers.toml`, with each configured endpoint asked what it serves.
165
134
  *
166
- * Hand the same runtime to `defineModel({ provider, model, runtime })` and to
167
- * `createLocalHarness({ modelRuntime })`, so the model a host resolves and the models its sessions
168
- * can switch to come from one registry.
135
+ * Hand it to `createLocalHarness({ modelManager })`, so every model a session runs on comes from it.
169
136
  */
170
- export declare function createModelRuntimeFromConfig(options: ModelRuntimeFromConfigOptions): Promise<ModelRuntime>;
137
+ export declare function createModelManagerFromConfig(options: ModelManagerFromConfigOptions): Promise<ModelManager>;
171
138
  export {};
172
139
  //# sourceMappingURL=providers.d.ts.map
package/dist/providers.js CHANGED
@@ -38,9 +38,7 @@ import { readFile } from "node:fs/promises";
38
38
  import { join } from "node:path";
39
39
  import { parse as parseToml } from "smol-toml";
40
40
  import { z } from "zod";
41
- import { DecisionModel, createModelRuntime, createProvider, defineModel, lazyApi, } from "operon-agents-core";
42
- /** The wire dialects a configured endpoint can speak. Almost everything speaks the first. */
43
- export const PROVIDER_APIS = ["openai-completions", "openai-responses", "anthropic-messages"];
41
+ import { NO_KEY_PLACEHOLDER, PROVIDER_APIS, createModelManager, createProvider, dialectStreams, endpointModel, } from "operon-agents-core";
44
42
  const ModelOverrideSchema = z.object({
45
43
  name: z.string().optional(),
46
44
  context_window: z.number().int().positive().optional(),
@@ -99,12 +97,6 @@ export async function loadProviderConfigs(homeDir) {
99
97
  };
100
98
  }
101
99
  }
102
- /**
103
- * What a keyless endpoint sends as its bearer token. The engine's OpenAI dialect refuses to build a
104
- * client without one, and a local server that wants no key ignores whatever arrives; pi uses the
105
- * same literal for the same reason.
106
- */
107
- const NO_KEY_PLACEHOLDER = "unused";
108
100
  /** The key this provider authenticates with, or undefined when its env var is declared but unset. */
109
101
  function configuredKey(entry) {
110
102
  if (entry.api_key !== undefined)
@@ -143,11 +135,9 @@ function configAuth(providerId, entry) {
143
135
  },
144
136
  };
145
137
  }
146
- /** Zero cost: a self-hosted endpoint has no per-token price, and a wrong number is worse than none. */
147
- const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
148
138
  function modelFrom(providerId, entry, modelId) {
149
139
  const override = entry.models[modelId];
150
- return {
140
+ return endpointModel({
151
141
  id: modelId,
152
142
  name: override?.name ?? modelId,
153
143
  api: (override?.api ?? entry.api),
@@ -155,11 +145,10 @@ function modelFrom(providerId, entry, modelId) {
155
145
  baseUrl: entry.base_url,
156
146
  reasoning: override?.reasoning ?? entry.reasoning,
157
147
  input: (override?.vision ?? entry.vision) ? ["text", "image"] : ["text"],
158
- cost: NO_COST,
159
148
  contextWindow: override?.context_window ?? entry.context_window,
160
149
  maxTokens: override?.max_output_tokens ?? entry.max_output_tokens,
161
- ...(entry.headers !== undefined ? { headers: entry.headers } : {}),
162
- };
150
+ headers: entry.headers,
151
+ });
163
152
  }
164
153
  /** The model ids an OpenAI-compatible `/models` response lists, in the order the endpoint gave. */
165
154
  export function parseModelsResponse(body) {
@@ -215,95 +204,14 @@ export function providerFromConfig(providerId, entry) {
215
204
  api: dialectStreams(entry.api),
216
205
  });
217
206
  }
218
- /** pi's API implementations load on first use, so an unused dialect is never imported. */
219
- function dialectStreams(api) {
220
- switch (api) {
221
- case "anthropic-messages":
222
- return lazyApi(() => import("@earendil-works/pi-ai/api/anthropic-messages"));
223
- case "openai-responses":
224
- return lazyApi(() => import("@earendil-works/pi-ai/api/openai-responses"));
225
- case "openai-completions":
226
- return lazyApi(() => import("@earendil-works/pi-ai/api/openai-completions"));
227
- }
228
- }
229
- /** An id newer than a catalog provider's list runs on the API of that provider's default endpoint. */
230
- function modelNewerThanCatalog(provider, config) {
231
- const models = provider.getModels();
232
- const template = models.find((model) => model.baseUrl === provider.baseUrl) ?? models[0];
233
- if (template === undefined)
234
- throw new Error(`Provider "${provider.id}" lists no models to run "${config.model}" like.`);
235
- return {
236
- id: config.model,
237
- name: config.model,
238
- api: template.api,
239
- provider: provider.id,
240
- baseUrl: template.baseUrl,
241
- reasoning: config.reasoning ?? false,
242
- input: [...(config.input ?? ["text"])],
243
- cost: NO_COST,
244
- contextWindow: config.contextWindow ?? 128_000,
245
- maxTokens: config.maxTokens ?? 8_192,
246
- };
247
- }
248
- function catalogChatModel(config) {
249
- const runtime = createModelRuntime();
250
- const provider = runtime.models.getProvider(config.provider);
251
- if (provider === undefined) {
252
- throw new Error(`Provider "${config.provider}" is not in pi's catalog. Set \`api\` and \`baseUrl\` for a custom endpoint.`);
253
- }
254
- const descriptor = runtime.models.getModel(config.provider, config.model) ?? modelNewerThanCatalog(provider, config);
255
- return defineModel({
256
- runtime,
257
- descriptor: config.baseUrl === undefined ? descriptor : { ...descriptor, baseUrl: config.baseUrl },
258
- ...(config.apiKey !== undefined ? { apiKey: config.apiKey } : {}),
259
- });
260
- }
261
- function customChatModel(config, api) {
262
- if (config.baseUrl === undefined)
263
- throw new Error(`Custom provider "${config.provider}" needs a \`baseUrl\`.`);
264
- const entry = ProviderEntrySchema.parse({
265
- base_url: config.baseUrl,
266
- api,
267
- api_key: config.apiKey,
268
- fetch_models: false,
269
- context_window: config.contextWindow,
270
- max_output_tokens: config.maxTokens,
271
- reasoning: config.reasoning,
272
- vision: config.input?.includes("image"),
273
- models: { [config.model]: {} },
274
- });
275
- const runtime = createModelRuntime({ builtins: false });
276
- runtime.models.setProvider(providerFromConfig(config.provider, entry));
277
- return defineModel({ runtime, descriptor: modelFrom(config.provider, entry, config.model) });
278
- }
279
- /** Build the `ChatModel` a host picked: from pi's catalog, or from `api` and `baseUrl` for a custom endpoint. */
280
- export function resolveChatModel(config) {
281
- return config.api === undefined ? catalogChatModel(config) : customChatModel(config, config.api);
282
- }
283
- /**
284
- * The decision model a config names, or undefined when it is an LLM. An id catalogued as both
285
- * (OpenAI's `gpt-6-luna`) counts as an LLM, and an id the catalog doesn't know can only be one.
286
- */
287
- export async function resolveDecisionModel(config) {
288
- const runtime = createModelRuntime();
289
- if (runtime.models.getModel(config.provider, config.model) !== undefined)
290
- return undefined;
291
- const descriptor = runtime.models.getModelOfType("classifier", config.provider, config.model);
292
- if (descriptor === undefined)
293
- return undefined;
294
- await runtime.credentials.modify(config.provider, async () => ({ type: "api_key", key: config.apiKey }));
295
- return new DecisionModel({ runtime, descriptor });
296
- }
297
207
  /**
298
208
  * One model runtime carrying the engine's built-in providers plus everything in
299
209
  * `<homeDir>/providers.toml`, with each configured endpoint asked what it serves.
300
210
  *
301
- * Hand the same runtime to `defineModel({ provider, model, runtime })` and to
302
- * `createLocalHarness({ modelRuntime })`, so the model a host resolves and the models its sessions
303
- * can switch to come from one registry.
211
+ * Hand it to `createLocalHarness({ modelManager })`, so every model a session runs on comes from it.
304
212
  */
305
- export async function createModelRuntimeFromConfig(options) {
306
- const runtime = createModelRuntime();
213
+ export async function createModelManagerFromConfig(options) {
214
+ const runtime = createModelManager();
307
215
  const { providers, warnings } = await loadProviderConfigs(options.homeDir);
308
216
  for (const warning of warnings)
309
217
  options.onWarning?.(warning);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "operon-agents",
3
- "version": "0.1.0-alpha.29",
3
+ "version": "0.1.0-alpha.30",
4
4
  "description": "Public SDK entrypoint for building agents.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -70,7 +70,7 @@
70
70
  "zod": "^4.4.3",
71
71
  "smol-toml": "^1.6.1",
72
72
  "@earendil-works/pi-ai": "1.5.0",
73
- "operon-agents-core": "0.1.0-alpha.29"
73
+ "operon-agents-core": "0.1.0-alpha.30"
74
74
  },
75
75
  "devDependencies": {
76
76
  "@earendil-works/pi-ai": "1.5.0"