@meistrari/agent-core 0.2.0-experimental.7 → 0.2.0-experimental.8

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
@@ -149,3 +149,10 @@ bypass-permissions mode as root, so run them as a regular user.
149
149
  Regenerate the experimental Codex wire types with `bun run generate:codex-protocol`.
150
150
  The generator pins Codex 0.159.0 and verifies any `CODEX_EXECUTABLE` override before
151
151
  using it. Commit generated files with generator changes; CI checks reproducibility.
152
+
153
+
154
+ ## Standalone settings changes
155
+
156
+ `agent.change-settings` carries a full `settings: { model, reasoningEffort }` pair through the supervisor wire and the agent protocol. Claude and Codex apply it only at an idle turn boundary, without submitting input, and emit `session.settings.changed` with the command ID and `appliesFrom: next_turn`. Product catalog, provider compatibility and admission ordering remain the caller's responsibility.
157
+
158
+ The supervisor advertises `capabilities["model-change"] = "v1"` in health. It commits the settings command outcome and inbox status in one SQLite transaction. After a process restart, an interrupted settings command can be reapplied idempotently; prompt commands retain their uncertain-delivery policy and are never retried by this path. Confirmed settings override the immutable bootstrap defaults when resuming the provider. Prompt and user-input-answer commands may carry resolved `settings` so their execution does not rely solely on mutable defaults.
@@ -0,0 +1,60 @@
1
+ # Model and reasoning effort changes
2
+
3
+ agent-core can update a session's model and reasoning effort without sending a
4
+ conversational prompt. Healthy Claude and Codex sessions keep the same CLI process
5
+ and provider session, preserving their warmed runtime state.
6
+
7
+ ![Claude and Codex live model and effort updates](diagrams/model-effort-change.png)
8
+
9
+ ## Standalone command
10
+
11
+ Send `agent.change-settings` with `settings: { model, reasoningEffort }` at an idle
12
+ turn boundary. The caller schedules that boundary; a busy run defers the operation.
13
+
14
+ The supervisor persists the command in its inbox and acknowledges receipt with
15
+ `command.ack`. Receipt does not mean the provider has applied the settings.
16
+
17
+ | Provider | How settings are applied | Acceptance |
18
+ | --- | --- | --- |
19
+ | Claude | `query.setModel(model)` and `query.applyFlagSettings({ effortLevel })`, skipping unchanged known values | The required control responses succeed |
20
+ | Codex | `thread/settings/update` with `{ threadId, model, effort }` | The JSON-RPC request succeeds |
21
+
22
+ After acceptance, the run updates its defaults and emits `session.settings.changed`
23
+ with `model`, `reasoningEffort`, the initiating `commandId`, and
24
+ `appliesFrom: 'next_turn'`. The supervisor commits the accepted settings together
25
+ with command completion, then sends `supervisor.command.applied`.
26
+
27
+ A provider failure does not produce a successful settings-change event or a
28
+ successful command completion. Receipt alone must never update a consumer's
29
+ display of confirmed settings.
30
+
31
+ ## Settings carried by inputs
32
+
33
+ `agent.send-prompt.settings` and `agent.respond-user-input.settings` can carry
34
+ partial settings. Omitted fields retain the currently accepted values. Prompt
35
+ settings retain the provider's `next` and `steer` behavior; the confirmation event
36
+ identifies whether the change applies to the current or next turn. Already-delivered
37
+ answers do not change defaults or emit another settings-change event.
38
+
39
+ For prompts, a supplied `settings` object takes precedence over the entire legacy
40
+ top-level `model` / `reasoningEffort` pair, even when the object is empty. Without
41
+ that object, accepted legacy fields are also persisted as default changes. Missing
42
+ legacy fields do not clear previously accepted defaults.
43
+
44
+ Claude's legacy top-level effort field must match the current accepted effort.
45
+ Use `settings.reasoningEffort` or the standalone command to change it. For example,
46
+ after starting at `medium` and successfully applying standalone `high`, a legacy
47
+ prompt specifying `high` is accepted; one specifying `medium` is rejected.
48
+
49
+ ## Recovery
50
+
51
+ The supervisor restores the latest accepted defaults across standalone commands,
52
+ prompts, and answers. For example, standalone A/high, followed by a legacy prompt
53
+ B/low, followed by `settings: { reasoningEffort: 'medium' }`, recovers as B/medium.
54
+ Failed commands and already-delivered answers do not replace those defaults.
55
+
56
+ The Bedrock wrapper retains its legacy prompt model-rebinding path. Standalone
57
+ changes bypass that path on a healthy connection. If a previous replacement failed
58
+ and left the old run stopped, the wrapper first recovers a connection with its
59
+ previously accepted settings, then applies the requested update. Recovery preserves
60
+ the provider session identity and does not replay the failed prompt.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@meistrari/agent-core",
3
3
  "type": "module",
4
- "version": "0.2.0-experimental.7",
4
+ "version": "0.2.0-experimental.8",
5
5
  "packageManager": "bun@1.3.12",
6
6
  "description": "Shared contracts and runtime modules for Tela coding-agent sandboxes: agent protocol, supervisor wire protocol, resident supervisor, worker runtime client, and Claude/Codex harness adapters.",
7
7
  "license": "UNLICENSED",
@@ -3,6 +3,7 @@ import type {
3
3
  AgentEvent,
4
4
  AgentNotifyCommand,
5
5
  AgentRunMetadata,
6
+ ChangeSettingsAgentCommand,
6
7
  CompactContextAgentCommand,
7
8
  InterruptAgentCommand,
8
9
  RespondUserInputAgentCommand,
@@ -34,6 +35,7 @@ export interface AgentRun {
34
35
  prepareWorkspace: (options?: AgentOperationOptions) => Promise<void>
35
36
  // Providers without root registration accept the root without provider I/O.
36
37
  addWorkspaceRepositoryRoot: (root: string, options?: AgentOperationOptions) => Promise<void>
38
+ changeSettings?: (command: ChangeSettingsAgentCommand, options?: AgentOperationOptions) => Promise<void>
37
39
  sendPrompt: (command: SendPromptCommand, options?: AgentPromptOperationOptions) => Promise<AgentPromptAcceptance>
38
40
  // Optional capabilities: the Claude and Codex runs implement both; other runs may omit them.
39
41
  notify?: (command: AgentNotifyCommand, options?: AgentOperationOptions) => Promise<AgentNotificationAcceptance>
@@ -1,18 +1,6 @@
1
1
  import type { Query, SDKMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
2
2
  import type { UUID } from 'node:crypto'
3
- import type {
4
- AgentContextCompactionRequestResult,
5
- AgentError,
6
- AgentNotifyCommand,
7
- AgentReasoningEffort,
8
- AgentRunMetadata,
9
- AgentSettingsChange,
10
- CompactContextAgentCommand,
11
- InterruptAgentCommand,
12
- RespondUserInputAgentCommand,
13
- SendPromptCommand,
14
- StopAgentCommand,
15
- } from '../../protocol'
3
+ import type { AgentContextCompactionRequestResult, AgentError, AgentNotifyCommand, AgentReasoningEffort, AgentRunMetadata, AgentSettingsChange, ChangeSettingsAgentCommand, CompactContextAgentCommand, InterruptAgentCommand, RespondUserInputAgentCommand, SendPromptCommand, StopAgentCommand } from '../../protocol'
16
4
  import type { AgentEventDraft } from '../agent-event-stream'
17
5
  import type { AgentOperationOptions, AgentPromptOperationOptions } from '../agent-operation'
18
6
  import type { AgentNotificationAcceptance, AgentPromptAcceptance, AgentRun, AgentUserInputSubmission } from '../agent-run'
@@ -297,6 +285,10 @@ export class ClaudeRun implements AgentRun {
297
285
  this.events.push(decorated)
298
286
  if (draft.type === 'turn.started' && draft.payload.commandId)
299
287
  this.emitAcceptedMessage(draft.payload.commandId, draft.turnId)
288
+ // Running may precede the input's started receipt. Consumption identifies the turn
289
+ // after that receipt, before the mapper emits the assistant's message.
290
+ if (draft.type === 'prompt.turn-correlated')
291
+ this.emitAcceptedMessage(draft.payload.commandId, draft.payload.turnId)
300
292
  }
301
293
 
302
294
  // The accepted input becomes a user message of the turn that consumed it, correlated with the
@@ -356,6 +348,28 @@ export class ClaudeRun implements AgentRun {
356
348
  this.skillReloadOwed = false
357
349
  }
358
350
 
351
+ async changeSettings(command: ChangeSettingsAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
352
+ this.assertCommandTargetsRun(command)
353
+ this.assertRunning()
354
+ options.signal?.throwIfAborted()
355
+ if (this.eventMapperState.currentTurnId || this.commandByClientMessageId.size > 0 || this.modelChangeInFlight)
356
+ throw new AgentPromptDeferredError({ message: 'Settings changes require an idle turn boundary.' })
357
+ this.modelChangeInFlight = true
358
+ try {
359
+ const settings = this.settingsWith(command.settings)
360
+ await this.applyProviderSettings(settings)
361
+ this.current = settings
362
+ if (settings.model) {
363
+ this.metadata.model = settings.model
364
+ this.init.requestedModel = settings.model
365
+ }
366
+ this.pushEvent(sessionSettingsChangedEvent({ model: settings.model!, reasoningEffort: settings.effort, commandId: command.commandId, appliesFrom: 'next_turn' }))
367
+ }
368
+ finally {
369
+ this.modelChangeInFlight = false
370
+ }
371
+ }
372
+
359
373
  async sendPrompt(command: SendPromptCommand, options: AgentPromptOperationOptions = {}): Promise<AgentPromptAcceptance> {
360
374
  this.assertCommandTargetsRun(command)
361
375
  this.assertRunning()
@@ -365,7 +379,7 @@ export class ClaudeRun implements AgentRun {
365
379
  message: 'Bypass submission requires a steer prompt.',
366
380
  })
367
381
  }
368
- if (command.reasoningEffort !== undefined && command.reasoningEffort !== this.init.reasoningEffort)
382
+ if (!command.settings && command.reasoningEffort !== undefined && command.reasoningEffort !== this.current.effort)
369
383
  throw new AgentNotAcceptedError({ message: 'Claude reasoning effort changes are not supported by this adapter.', recovery: { kind: 'terminal', reason: 'reasoning_effort_change_not_supported' } })
370
384
  // Claude Code steers by cancelling the running ask, which would abort a manual compaction
371
385
  // (CLI 2.1.280). Like Codex's non-steerable compaction turn, the steer waits for it to end.
@@ -441,6 +455,8 @@ export class ClaudeRun implements AgentRun {
441
455
  private async applyCommandModel(command: SendPromptCommand, options: AgentOperationOptions): Promise<void> {
442
456
  if (this.modelChangeInFlight)
443
457
  throw new AgentPromptDeferredError({ message: 'Claude model configuration is still in progress.' })
458
+ if (command.settings)
459
+ return
444
460
  const currentModel = this.init.requestedModel ?? this.metadata.model
445
461
  if (!command.model || command.model === currentModel)
446
462
  return
@@ -742,7 +758,8 @@ export class ClaudeRun implements AgentRun {
742
758
  return 'already_delivered'
743
759
  // Deliver first: on a prebound resume this is the run's first input, and the resolved
744
760
  // event must not precede the lifecycle events the init barrier emits.
745
- await this.applyProviderSettings(this.current)
761
+ const settings = this.settingsWith(command.settings)
762
+ await this.applyProviderSettings(settings)
746
763
  // The answer opens its turn as the user's input, correlated like a prompt: the turn it
747
764
  // starts carries this command, and the answer is accepted as that turn's user message.
748
765
  const clientMessageId = deriveMessageId(command.commandId)
@@ -762,6 +779,16 @@ export class ClaudeRun implements AgentRun {
762
779
  finally {
763
780
  this.acceptanceWaiters.delete(clientMessageId)
764
781
  }
782
+ if (command.settings) {
783
+ this.current = settings
784
+ this.pushEvent(sessionSettingsChangedEvent({
785
+ model: settings.model ?? await this.initialization,
786
+ reasoningEffort: settings.effort,
787
+ commandId: command.commandId,
788
+ turnId: this.eventMapperState.currentTurnId,
789
+ appliesFrom: 'current_turn',
790
+ }))
791
+ }
765
792
  if (resolved.event)
766
793
  this.pushEvent(resolved.event)
767
794
  this.userInputRequests.markDelivered(command.requestId)
@@ -181,18 +181,19 @@ export class CodexProvider implements AgentProvider<'codex'> {
181
181
  return this.wrapBedrockRun(await this.resumeRawSession(input, options), input)
182
182
  }
183
183
 
184
- // Bedrock model bindings are fixed per app-server session, so a model change resumes the same
185
- // thread under the new binding at an idle turn boundary.
184
+ // Legacy prompt-only model changes rebind at an idle boundary. Explicit settings
185
+ // commands use the native update on the existing thread and keep the CLI warm.
186
186
  private wrapBedrockRun(run: CodexRun, input: AgentSessionOpenInput<'codex'>): AgentRun {
187
187
  if (this.auth.kind !== 'amazon-bedrock')
188
188
  return run
189
189
  const roots = new Set(input.workspaceRepositoryRoots)
190
- return new ModelRebindingRun(run, async (model, signal) => await this.resumeRawSession({
190
+ return new ModelRebindingRun(run, async (model, signal, reasoningEffort) => await this.resumeRawSession({
191
191
  ...input,
192
192
  model: codexModelIdSchema.parse(model),
193
+ reasoningEffort,
193
194
  providerSessionId: run.metadata.providerSessionId,
194
195
  workspaceRepositoryRoots: [...roots],
195
- }, { signal }), root => roots.add(root))
196
+ }, { signal }), root => roots.add(root), input.reasoningEffort)
196
197
  }
197
198
 
198
199
  private async resumeRawSession(input: AgentSessionResumeInput<'codex'>, options: AgentOperationOptions = {}): Promise<CodexRun> {
@@ -1,17 +1,4 @@
1
- import type {
2
- AgentAuthor,
3
- AgentContextCompactionRequestResult,
4
- AgentNotifyCommand,
5
- AgentPrompt,
6
- AgentReasoningEffort,
7
- AgentRunMetadata,
8
- AgentSettingsChange,
9
- CompactContextAgentCommand,
10
- InterruptAgentCommand,
11
- RespondUserInputAgentCommand,
12
- SendPromptCommand,
13
- StopAgentCommand,
14
- } from '../../protocol'
1
+ import type { AgentAuthor, AgentContextCompactionRequestResult, AgentNotifyCommand, AgentPrompt, AgentReasoningEffort, AgentRunMetadata, AgentSettingsChange, ChangeSettingsAgentCommand, CompactContextAgentCommand, InterruptAgentCommand, RespondUserInputAgentCommand, SendPromptCommand, StopAgentCommand } from '../../protocol'
15
2
  import type { AgentEventDraft } from '../agent-event-stream'
16
3
  import type { AgentOperationOptions, AgentPromptOperationOptions } from '../agent-operation'
17
4
  import type { AgentNotificationAcceptance, AgentPromptAcceptance, AgentRun, AgentUserInputSubmission } from '../agent-run'
@@ -274,6 +261,22 @@ export class CodexRun implements AgentRun {
274
261
  await stopping.promise
275
262
  }
276
263
 
264
+ async changeSettings(command: ChangeSettingsAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
265
+ this.assertCommandTargetsRun(command)
266
+ this.assertRunning()
267
+ options.signal?.throwIfAborted()
268
+ this.deferNextWhileTurnActive('next')
269
+ const settings = withSettingsChange(this.current, command.settings)
270
+ try {
271
+ await this.client.request('thread/settings/update', { threadId: this.metadata.providerSessionId, model: this.resolveInferenceModel(settings.model), effort: codexEffort(settings.effort) })
272
+ }
273
+ catch (error) {
274
+ throw mapSettingsTransportLoss(error)
275
+ }
276
+ this.metadata.model = settings.model
277
+ this.settingsAccepted({ settings, commandId: command.commandId, turnId: undefined, placement: { appliesFrom: 'next_turn' } })
278
+ }
279
+
277
280
  async sendPrompt(command: SendPromptCommand, options: AgentPromptOperationOptions = {}): Promise<AgentPromptAcceptance> {
278
281
  this.assertCommandTargetsRun(command)
279
282
  this.assertRunning()
@@ -583,11 +586,14 @@ export class CodexRun implements AgentRun {
583
586
  if (resolved.event)
584
587
  this.pushEvent(resolved.event)
585
588
  const prepared = await this.prepareTurn({ prompt: resolved.prompt, commandId: command.commandId, options })
586
- await this.startTurn({
589
+ const settings = withSettingsChange(this.current, command.settings)
590
+ const { receipt } = await this.startTurn({
587
591
  commandId: prepared.commandId,
588
- settings: this.current,
592
+ settings,
589
593
  content: { kind: 'prompt', input: prepared.input, origin: prepared.origin },
590
594
  }, options)
595
+ if (command.settings)
596
+ this.settingsAccepted({ settings, commandId: command.commandId, turnId: receipt.turnId, placement: { appliesFrom: 'current_turn' } })
591
597
  this.sessionContextDelivered(prepared.sessionContext)
592
598
  this.userInputRequests.markDelivered(command.requestId)
593
599
  return 'submitted'
@@ -1,4 +1,4 @@
1
- import type { AgentEvent, AgentRunMetadata, SendPromptCommand } from '../protocol'
1
+ import type { AgentEvent, AgentReasoningEffort, AgentRunMetadata, ChangeSettingsAgentCommand, SendPromptCommand } from '../protocol'
2
2
  import type { AgentOperationOptions, AgentPromptOperationOptions } from './agent-operation'
3
3
  import type { AgentPromptAcceptance, AgentRun } from './agent-run'
4
4
  import { AgentEventStream } from './agent-event-stream'
@@ -12,7 +12,8 @@ export class ModelRebindingRun implements AgentRun {
12
12
  private pump: Promise<void>
13
13
  private activeTurn: string | undefined
14
14
  private lastEndedTurn: string | undefined
15
- private dispatching: Promise<AgentPromptAcceptance> | undefined
15
+ private dispatching: Promise<unknown> | undefined
16
+ private settingsChanging = false
16
17
  private changing = false
17
18
  private needsRebind = false
18
19
  private stopped = false
@@ -21,7 +22,7 @@ export class ModelRebindingRun implements AgentRun {
21
22
  private readonly lifetime = new AbortController()
22
23
  private readonly controls = new Set<Promise<unknown>>()
23
24
 
24
- constructor(run: AgentRun, private readonly reopen: (model: string, signal: AbortSignal) => Promise<AgentRun>, private readonly rootAdded?: (root: string) => void) {
25
+ constructor(run: AgentRun, private readonly reopen: (model: string, signal: AbortSignal, reasoningEffort?: AgentReasoningEffort) => Promise<AgentRun>, private readonly rootAdded?: (root: string) => void, private reasoningEffort?: AgentReasoningEffort) {
25
26
  this.run = run
26
27
  this.metadata = { ...run.metadata }
27
28
  this.events = new AgentEventStream(this.metadata)
@@ -42,7 +43,13 @@ export class ModelRebindingRun implements AgentRun {
42
43
  })
43
44
  }
44
45
 
45
- respondUserInput: AgentRun['respondUserInput'] = async (command, options) => await this.withControl(signal => this.run.respondUserInput(command, { ...options, signal }), options)
46
+ respondUserInput: AgentRun['respondUserInput'] = async (command, options) => await this.withControl(async (signal) => {
47
+ const result = await this.run.respondUserInput(command, { ...options, signal })
48
+ if (result === 'submitted')
49
+ this.reasoningEffort = command.settings?.reasoningEffort ?? this.reasoningEffort
50
+ return result
51
+ }, options)
52
+
46
53
  interrupt: AgentRun['interrupt'] = async (command, options) => await this.withControl(signal => this.run.interrupt(command, { ...options, signal }), options)
47
54
  notify: NonNullable<AgentRun['notify']> = async (command, options) => await this.withControl(async (signal) => {
48
55
  if (!this.run.notify)
@@ -74,7 +81,56 @@ export class ModelRebindingRun implements AgentRun {
74
81
  throw new AgentRunStateError({ message: 'Prompt does not target the bound run.' })
75
82
  if (this.changing)
76
83
  throw new AgentPromptDeferredError({ message: 'The provider model is being rebound.' })
77
- const model = command.model ?? this.metadata.model
84
+ const settings = command.settings ?? { model: command.model, reasoningEffort: command.reasoningEffort }
85
+ await this.rebind(settings.model ?? this.metadata.model, options)
86
+ this.lifetime.signal.throwIfAborted()
87
+ options.signal?.throwIfAborted()
88
+ const acceptance = await this.run.sendPrompt(command, { ...options, signal: AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])]) })
89
+ this.reasoningEffort = settings.reasoningEffort ?? this.reasoningEffort
90
+ if (acceptance.kind === 'turn' && acceptance.turnId !== this.lastEndedTurn)
91
+ this.activeTurn = acceptance.turnId
92
+ return acceptance
93
+ }
94
+
95
+ async changeSettings(command: ChangeSettingsAgentCommand, options: AgentOperationOptions = {}): Promise<void> {
96
+ if (this.stopped)
97
+ throw new AgentRunStateError({ message: 'The run stopped.' })
98
+ if (command.sessionId !== this.metadata.sessionId || command.providerSessionId !== this.metadata.providerSessionId || command.provider !== this.metadata.provider)
99
+ throw new AgentRunStateError({ message: 'Settings command does not target the bound run.' })
100
+ if (this.dispatching || this.workspaceChanging || this.controls.size || this.changing)
101
+ throw new AgentPromptDeferredError({ message: 'A provider operation is already in progress.' })
102
+ if (this.activeTurn)
103
+ throw new AgentPromptDeferredError({ message: 'Settings changes require an idle turn boundary.' })
104
+ this.settingsChanging = true
105
+ const task = this.dispatchSettings(command, options)
106
+ this.dispatching = task
107
+ try {
108
+ await task
109
+ }
110
+ finally {
111
+ this.dispatching = undefined
112
+ this.settingsChanging = false
113
+ }
114
+ }
115
+
116
+ private async dispatchSettings(command: ChangeSettingsAgentCommand, options: AgentOperationOptions): Promise<void> {
117
+ const signal = AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])])
118
+ signal.throwIfAborted()
119
+ // A failed legacy prompt rebind leaves the old run stopped. Recover its
120
+ // accepted settings before applying the new command on the replacement.
121
+ if (this.needsRebind)
122
+ await this.rebind(this.metadata.model ?? command.settings.model, { signal })
123
+ signal.throwIfAborted()
124
+ // Native settings updates configure the existing app-server thread, including
125
+ // empty threads. Keep the CLI and its warmed state instead of reopening it.
126
+ if (!this.run.changeSettings)
127
+ throw new AgentRunStateError({ message: 'The provider run does not support settings changes.' })
128
+ await this.run.changeSettings(command, { signal })
129
+ this.metadata.model = this.run.metadata.model
130
+ this.reasoningEffort = command.settings.reasoningEffort
131
+ }
132
+
133
+ private async rebind(model: string | undefined, options: AgentOperationOptions): Promise<void> {
78
134
  if (model && (this.needsRebind || model !== this.metadata.model)) {
79
135
  if (this.activeTurn)
80
136
  throw new AgentPromptDeferredError({ message: 'Model changes require an idle turn boundary.' })
@@ -88,7 +144,7 @@ export class ModelRebindingRun implements AgentRun {
88
144
  await this.run.stop({ type: 'agent.stop', ...this.target(), reason: 'model_rebind' }, { signal })
89
145
  await this.pump
90
146
  signal.throwIfAborted()
91
- replacement = await this.reopen(model, signal)
147
+ replacement = await this.reopen(model, signal, this.reasoningEffort)
92
148
  signal.throwIfAborted()
93
149
  if (replacement.metadata.providerSessionId !== this.metadata.providerSessionId || replacement.metadata.sessionId !== this.metadata.sessionId || replacement.metadata.provider !== this.metadata.provider || replacement.metadata.model !== model) {
94
150
  identityMismatch = true
@@ -111,12 +167,6 @@ export class ModelRebindingRun implements AgentRun {
111
167
  this.changing = false
112
168
  }
113
169
  }
114
- this.lifetime.signal.throwIfAborted()
115
- options.signal?.throwIfAborted()
116
- const acceptance = await this.run.sendPrompt(command, { ...options, signal: AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])]) })
117
- if (acceptance.kind === 'turn' && acceptance.turnId !== this.lastEndedTurn)
118
- this.activeTurn = acceptance.turnId
119
- return acceptance
120
170
  }
121
171
 
122
172
  stop: AgentRun['stop'] = async (command, options) => {
@@ -138,7 +188,7 @@ export class ModelRebindingRun implements AgentRun {
138
188
  private async withControl<T>(operation: (signal: AbortSignal) => Promise<T>, options: AgentOperationOptions = {}): Promise<T> {
139
189
  if (this.stopped)
140
190
  throw new AgentRunStateError({ message: 'The run stopped.' })
141
- if (this.changing || this.needsRebind)
191
+ if (this.settingsChanging || this.changing || this.needsRebind)
142
192
  throw new AgentPromptDeferredError({ message: 'The provider model is being rebound.' })
143
193
  const signal = AbortSignal.any([this.lifetime.signal, ...(options.signal ? [options.signal] : [])])
144
194
  const task = Promise.resolve().then(async () => {
@@ -90,10 +90,18 @@ export const respondUserInputAgentCommandSchema = runningCommandBaseSchema.exten
90
90
  commandId: commandIdSchema,
91
91
  requestId: z.string().min(1),
92
92
  answers: agentUserInputAnswersSchema,
93
+ settings: agentSettingsChangeSchema.optional(),
94
+ })
95
+
96
+ export const changeSettingsAgentCommandSchema = runningCommandBaseSchema.extend({
97
+ type: z.literal('agent.change-settings'),
98
+ commandId: commandIdSchema,
99
+ settings: z.strictObject({ model: agentModelIdSchema, reasoningEffort: agentReasoningEffortSchema }),
93
100
  })
94
101
 
95
102
  export const agentCommandSchema = z.union([
96
103
  sendPromptCommandSchema,
104
+ changeSettingsAgentCommandSchema,
97
105
  agentNotifyCommandSchema,
98
106
  interruptAgentCommandSchema,
99
107
  compactContextAgentCommandSchema,
@@ -112,3 +120,5 @@ export type CompactContextAgentCommand = z.infer<typeof compactContextAgentComma
112
120
  export type StopAgentCommand = z.infer<typeof stopAgentCommandSchema>
113
121
  export type RespondUserInputAgentCommand = z.infer<typeof respondUserInputAgentCommandSchema>
114
122
  export type AgentCommand = z.infer<typeof agentCommandSchema>
123
+
124
+ export type ChangeSettingsAgentCommand = z.infer<typeof changeSettingsAgentCommandSchema>
@@ -1,6 +1,6 @@
1
1
  // Generated by scripts/write-provenance.ts. Do not edit by hand.
2
2
  export const generatedProvenance = {
3
- version: "0.2.0-experimental.7",
4
- sha: "c27666274441cd3c81ffc5934ca115dffbe234ee",
5
- buildTime: "2026-10-14T20:20:44.640Z",
3
+ version: "0.2.0-experimental.8",
4
+ sha: "4b63c95e2cc33fde3fbae56b5135006a2afac1e8",
5
+ buildTime: "2026-10-20T14:09:24.027Z",
6
6
  } as const
@@ -176,12 +176,18 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
176
176
 
177
177
  async ready(): Promise<void> { await this.recovered.promise }
178
178
 
179
- async handle(input: Parameters<SupervisorAgentRuntime['handle']>[0]): Promise<void> {
179
+ async handle(input: Parameters<SupervisorAgentRuntime['handle']>[0]): ReturnType<SupervisorAgentRuntime['handle']> {
180
180
  this.attachEmitter(input.emit)
181
181
  await this.ready()
182
182
  if (this.eventFailure)
183
183
  throw this.eventFailure
184
184
  const metadata = this.input.run.metadata
185
+ if (input.body.type === 'agent.change-settings') {
186
+ if (!this.input.run.changeSettings)
187
+ throw new Error('Runtime does not support standalone settings changes.')
188
+ await this.input.run.changeSettings({ ...metadata, type: 'agent.change-settings', commandId: input.commandId, settings: input.body.settings }, { signal: input.signal })
189
+ return { appliedSettings: input.body.settings }
190
+ }
185
191
  if (input.body.type === 'agent.send-prompt') {
186
192
  const inputAttachmentPreparation = await this.input.prepareInputAttachments?.({
187
193
  commandId: input.commandId,
@@ -196,6 +202,7 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
196
202
  commandId: input.commandId,
197
203
  mode: input.body.mode,
198
204
  turnId: input.body.turnId,
205
+ settings: input.body.settings,
199
206
  model: input.body.model,
200
207
  reasoningEffort: input.body.reasoningEffort,
201
208
  origin: 'user',
@@ -212,7 +219,13 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
212
219
  inputAttachmentPreparation,
213
220
  senderContext: input.body.user?.senderContext,
214
221
  })
215
- return
222
+ // Both prompt formats can change later defaults. Omitted legacy fields must
223
+ // not overwrite accepted values, and settings takes whole-object precedence.
224
+ const settings = input.body.settings ?? {
225
+ ...(input.body.model === undefined ? {} : { model: input.body.model }),
226
+ ...(input.body.reasoningEffort === undefined ? {} : { reasoningEffort: input.body.reasoningEffort }),
227
+ }
228
+ return input.body.settings || Object.keys(settings).length ? { appliedSettings: settings } : undefined
216
229
  }
217
230
  if (input.body.type === 'agent.interrupt') {
218
231
  await this.input.run.interrupt({
@@ -225,7 +238,7 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
225
238
  }, { signal: input.signal })
226
239
  return
227
240
  }
228
- await this.input.run.respondUserInput({
241
+ const submission = await this.input.run.respondUserInput({
229
242
  type: 'agent.respond-user-input',
230
243
  provider: metadata.provider,
231
244
  sessionId: metadata.sessionId,
@@ -233,7 +246,10 @@ class AgentSupervisorRuntime implements SupervisorAgentRuntime {
233
246
  commandId: input.commandId,
234
247
  requestId: input.body.requestId,
235
248
  answers: input.body.answers,
249
+ settings: input.body.settings,
236
250
  }, { signal: input.signal })
251
+ if (submission === 'submitted' && input.body.settings)
252
+ return { appliedSettings: input.body.settings }
237
253
  }
238
254
 
239
255
  async refreshSecrets(input: {
@@ -1,3 +1,4 @@
1
+ import type { AgentSettingsChange } from '../../protocol'
1
2
  import type { SessionBootstrapBody } from '../../supervisor-protocol/bootstrap'
2
3
  import type { CommandEnvelope } from '../../supervisor-protocol/envelopes/control-plane-to-supervisor'
3
4
  import type { DurableEventEnvelope } from '../../supervisor-protocol/envelopes/supervisor-to-control-plane'
@@ -6,6 +7,7 @@ import type { SupervisorAgentRunSnapshot } from '../../supervisor-protocol/super
6
7
  import { mkdirSync } from 'node:fs'
7
8
  import { dirname } from 'node:path'
8
9
  import { Database } from 'bun:sqlite'
10
+ import { agentSettingsChangeSchema } from '../../protocol'
9
11
  import { sessionBootstrapBodySchema } from '../../supervisor-protocol/bootstrap'
10
12
  import { wireCommandBodySchema } from '../../supervisor-protocol/command-body'
11
13
  import { wireEventBodySchema } from '../../supervisor-protocol/event-body'
@@ -215,6 +217,27 @@ export class SupervisorStore {
215
217
  })()
216
218
  }
217
219
 
220
+ /** Restore accepted defaults in application order; the bootstrap remains immutable. */
221
+ appliedSettings(): AgentSettingsChange | undefined {
222
+ const row = this.database.query<{ value: string }, []>('SELECT value FROM metadata WHERE key = \'applied_settings\'').get()
223
+ return row ? agentSettingsChangeSchema.parse(JSON.parse(row.value)) : undefined
224
+ }
225
+
226
+ /** Commit settings, the outcome and inbox state together, including partial prompt/answer updates. */
227
+ finishCommand(input: { commandSeq: number, body: WireEventBody, appliedSettings?: AgentSettingsChange }): DurableEventEnvelope {
228
+ return this.database.transaction(() => {
229
+ const event = this.appendDurableEvent({ occurredAt: new Date().toISOString(), body: input.body })
230
+ const applied = input.body.type === 'supervisor.command.applied'
231
+ if (applied && input.appliedSettings) {
232
+ const settings = agentSettingsChangeSchema.parse({ ...this.appliedSettings(), ...input.appliedSettings })
233
+ this.database.query(`INSERT INTO metadata (key, value) VALUES ('applied_settings', ?)
234
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(canonicalJson(settings))
235
+ }
236
+ this.markCommand({ commandSeq: input.commandSeq, status: applied ? 'applied' : 'failed' })
237
+ return event
238
+ })()
239
+ }
240
+
218
241
  /** Commit before calling a provider: a process crash makes the outcome uncertain. */
219
242
  beginCommandDispatch(commandSeq: number): void {
220
243
  this.database.query('INSERT INTO command_dispatches(seq) VALUES (?)').run(commandSeq)
@@ -228,6 +251,12 @@ export class SupervisorStore {
228
251
  JOIN command_dispatches ON command_dispatches.seq=commands.seq ORDER BY commands.seq
229
252
  `).all()
230
253
  for (const row of rows) {
254
+ // Reapplying a full settings pair is idempotent and submits no input. Prompt
255
+ // commands retain the existing uncertain-delivery policy below.
256
+ if (commandBodyFromRow(row).type === 'agent.change-settings') {
257
+ this.database.query('DELETE FROM command_dispatches WHERE seq = ?').run(row.seq)
258
+ continue
259
+ }
231
260
  this.appendDurableEvent({
232
261
  occurredAt: new Date().toISOString(),
233
262
  body: {
@@ -1,3 +1,4 @@
1
+ import type { AgentSettingsChange } from '../protocol'
1
2
  import type { EphemeralCredentials, SessionBootstrapBody, SessionBootstrapGitToken } from '../supervisor-protocol/bootstrap'
2
3
  import type { WireCommandBody } from '../supervisor-protocol/command-body'
3
4
  import type { WireEventBody } from '../supervisor-protocol/event-body'
@@ -13,7 +14,7 @@ export interface SupervisorAgentRuntime {
13
14
  body: WireCommandBody
14
15
  signal: AbortSignal
15
16
  emit: (body: WireEventBody) => void
16
- }) => Promise<void>
17
+ }) => Promise<void | { appliedSettings: AgentSettingsChange }>
17
18
  refreshSecrets?: (input: {
18
19
  credentials?: EphemeralCredentials
19
20
  gitToken?: SessionBootstrapGitToken
@@ -105,6 +105,7 @@ export async function createResidentSupervisor(input: {
105
105
  binding: binding.status,
106
106
  profile: input.profile,
107
107
  agentCore: agentCoreProvenance(),
108
+ capabilities: { 'model-change': 'v1' },
108
109
  }),
109
110
  getBoundSessionId: () => binding.status === 'bound' ? binding.sessionId : undefined,
110
111
  getLocalEventMax: () => binding.status === 'bound' ? binding.store.localEventMax() : 0,
@@ -207,7 +207,7 @@ export class SupervisorRuntimeHandler implements RuntimeConnectionHandler {
207
207
  gitToken: this.dependencies.gitToken,
208
208
  }
209
209
  this.createAgentTask = this.dependencies.providerFactory.create({
210
- bootstrap: binding.body,
210
+ bootstrap: { ...binding.body, agent: { ...binding.body.agent, ...this.dependencies.store.appliedSettings() } },
211
211
  ...initialSecrets,
212
212
  rpc: this.rpc,
213
213
  signal: this.lifecycleController.signal,
@@ -240,42 +240,42 @@ export class SupervisorRuntimeHandler implements RuntimeConnectionHandler {
240
240
  agent: SupervisorAgentRuntime
241
241
  command: CommandEnvelope & { body: WireCommandBody }
242
242
  }): Promise<void> {
243
+ this.dependencies.store.beginCommandDispatch(input.command.commandSeq)
244
+ let result: Awaited<ReturnType<SupervisorAgentRuntime['handle']>>
243
245
  try {
244
- this.dependencies.store.beginCommandDispatch(input.command.commandSeq)
245
- await input.agent.handle({
246
+ result = await input.agent.handle({
246
247
  commandId: input.command.commandId,
247
248
  commandSeq: input.command.commandSeq,
248
249
  body: input.command.body,
249
250
  signal: this.lifecycleController.signal,
250
251
  emit: body => this.emit(body),
251
252
  })
252
- this.dependencies.store.markCommand({ commandSeq: input.command.commandSeq, status: 'applied' })
253
- this.emit({
254
- type: 'supervisor.command.applied',
255
- payload: {
256
- commandId: input.command.commandId,
257
- commandSeq: input.command.commandSeq,
258
- bodyType: input.command.body.type,
259
- },
260
- })
261
253
  }
262
- catch {
254
+ catch (error) {
263
255
  if (this.lifecycleController.signal.aborted)
264
256
  return
265
- this.dependencies.store.markCommand({ commandSeq: input.command.commandSeq, status: 'failed' })
266
- this.emit({
267
- type: 'supervisor.command.failed',
268
- payload: {
257
+ this.dependencies.logger.error({ err: error, commandId: input.command.commandId, bodyType: input.command.body.type }, 'Agent command execution failed')
258
+ this.sendEvent(this.dependencies.store.finishCommand({
259
+ commandSeq: input.command.commandSeq,
260
+ body: { type: 'supervisor.command.failed', payload: {
269
261
  commandId: input.command.commandId,
270
262
  commandSeq: input.command.commandSeq,
271
263
  bodyType: input.command.body.type,
272
- failure: {
273
- type: 'command',
274
- detail: 'Agent command execution failed.',
275
- },
276
- },
277
- })
264
+ failure: { type: 'command', detail: 'Agent command execution failed.' },
265
+ } },
266
+ }))
267
+ return
278
268
  }
269
+ // Persistence and socket errors must not turn an accepted command into a failed one.
270
+ this.sendEvent(this.dependencies.store.finishCommand({
271
+ commandSeq: input.command.commandSeq,
272
+ appliedSettings: result?.appliedSettings,
273
+ body: { type: 'supervisor.command.applied', payload: {
274
+ commandId: input.command.commandId,
275
+ commandSeq: input.command.commandSeq,
276
+ bodyType: input.command.body.type,
277
+ } },
278
+ }))
279
279
  }
280
280
 
281
281
  private emit(body: WireEventBody): void {
@@ -1,5 +1,5 @@
1
1
  import z from 'zod'
2
- import { agentPromptModeSchema, agentPromptSchema, agentReasoningEffortSchema, agentUserInputAnswersSchema, jsonObjectSchema, jsonUtf8ByteLength } from '../protocol'
2
+ import { agentPromptModeSchema, agentPromptSchema, agentReasoningEffortSchema, agentSettingsChangeSchema, agentUserInputAnswersSchema, jsonObjectSchema, jsonUtf8ByteLength } from '../protocol'
3
3
 
4
4
  const wireSenderContextSchema = z.discriminatedUnion('kind', [
5
5
  z.object({ kind: z.literal('application') }).strict(),
@@ -43,6 +43,7 @@ const wireSendPromptCommandSchema = z.object({
43
43
  turnId: z.string().min(1).optional(),
44
44
  model: z.string().trim().min(1).max(256).optional(),
45
45
  reasoningEffort: agentReasoningEffortSchema.optional(),
46
+ settings: agentSettingsChangeSchema.optional(),
46
47
  prompt: agentPromptSchema,
47
48
  attachments: z.array(wireInputAttachmentRefSchema).superRefine((attachments, context) => {
48
49
  const attachmentIds = new Set<string>()
@@ -69,10 +70,12 @@ const wireRespondUserInputCommandSchema = z.object({
69
70
  type: z.literal('agent.respond-user-input'),
70
71
  requestId: z.string().min(1),
71
72
  answers: agentUserInputAnswersSchema,
73
+ settings: agentSettingsChangeSchema.optional(),
72
74
  user: wireCommandUserSchema.optional(),
73
75
  }).strict()
74
76
 
75
77
  export const wireCommandBodySchema = z.discriminatedUnion('type', [
78
+ z.strictObject({ type: z.literal('agent.change-settings'), settings: z.strictObject({ model: z.string().trim().min(1).max(256), reasoningEffort: agentReasoningEffortSchema }) }),
76
79
  wireSendPromptCommandSchema,
77
80
  wireInterruptCommandSchema,
78
81
  wireRespondUserInputCommandSchema,