@roj-ai/sdk 0.1.33 → 0.1.35

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.
Files changed (92) hide show
  1. package/dist/bootstrap.d.ts +39 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/bootstrap.js +5 -0
  4. package/dist/bootstrap.js.map +1 -1
  5. package/dist/core/agents/agent.d.ts +11 -0
  6. package/dist/core/agents/agent.d.ts.map +1 -1
  7. package/dist/core/agents/agent.js +19 -2
  8. package/dist/core/agents/agent.js.map +1 -1
  9. package/dist/core/preset/index.d.ts +1 -0
  10. package/dist/core/preset/index.d.ts.map +1 -1
  11. package/dist/core/preset/index.js +2 -0
  12. package/dist/core/preset/index.js.map +1 -1
  13. package/dist/core/preset/overrides.d.ts +14 -0
  14. package/dist/core/preset/overrides.d.ts.map +1 -0
  15. package/dist/core/preset/overrides.js +20 -0
  16. package/dist/core/preset/overrides.js.map +1 -0
  17. package/dist/core/sessions/session-manager.d.ts +5 -0
  18. package/dist/core/sessions/session-manager.d.ts.map +1 -1
  19. package/dist/core/sessions/session-manager.js +20 -6
  20. package/dist/core/sessions/session-manager.js.map +1 -1
  21. package/dist/core/sessions/session-overrides.integration.test.d.ts +2 -0
  22. package/dist/core/sessions/session-overrides.integration.test.d.ts.map +1 -0
  23. package/dist/core/sessions/session-overrides.integration.test.js +154 -0
  24. package/dist/core/sessions/session-overrides.integration.test.js.map +1 -0
  25. package/dist/core/sessions/session.d.ts +10 -1
  26. package/dist/core/sessions/session.d.ts.map +1 -1
  27. package/dist/core/sessions/session.js +15 -0
  28. package/dist/core/sessions/session.js.map +1 -1
  29. package/dist/core/sessions/state.d.ts +57 -0
  30. package/dist/core/sessions/state.d.ts.map +1 -1
  31. package/dist/core/sessions/state.js +48 -0
  32. package/dist/core/sessions/state.js.map +1 -1
  33. package/dist/core/system.d.ts +2 -0
  34. package/dist/core/system.d.ts.map +1 -1
  35. package/dist/core/system.js +2 -1
  36. package/dist/core/system.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/plugins/services/pid-registry.d.ts +72 -0
  42. package/dist/plugins/services/pid-registry.d.ts.map +1 -0
  43. package/dist/plugins/services/pid-registry.js +171 -0
  44. package/dist/plugins/services/pid-registry.js.map +1 -0
  45. package/dist/plugins/services/pid-registry.test.d.ts +2 -0
  46. package/dist/plugins/services/pid-registry.test.d.ts.map +1 -0
  47. package/dist/plugins/services/pid-registry.test.js +161 -0
  48. package/dist/plugins/services/pid-registry.test.js.map +1 -0
  49. package/dist/plugins/services/plugin.d.ts +6 -0
  50. package/dist/plugins/services/plugin.d.ts.map +1 -1
  51. package/dist/plugins/services/plugin.js +55 -15
  52. package/dist/plugins/services/plugin.js.map +1 -1
  53. package/dist/plugins/services/prompt.d.ts.map +1 -1
  54. package/dist/plugins/services/prompt.js +8 -0
  55. package/dist/plugins/services/prompt.js.map +1 -1
  56. package/dist/plugins/services/schema.d.ts +33 -0
  57. package/dist/plugins/services/schema.d.ts.map +1 -1
  58. package/dist/plugins/services/service.d.ts +24 -0
  59. package/dist/plugins/services/service.d.ts.map +1 -1
  60. package/dist/plugins/services/service.js +96 -1
  61. package/dist/plugins/services/service.js.map +1 -1
  62. package/dist/plugins/services/services.integration.test.js +254 -0
  63. package/dist/plugins/services/services.integration.test.js.map +1 -1
  64. package/dist/plugins/session-lifecycle/plugin.d.ts +36 -0
  65. package/dist/plugins/session-lifecycle/plugin.d.ts.map +1 -1
  66. package/dist/plugins/session-lifecycle/plugin.js +53 -1
  67. package/dist/plugins/session-lifecycle/plugin.js.map +1 -1
  68. package/dist/testing/test-harness.d.ts +12 -2
  69. package/dist/testing/test-harness.d.ts.map +1 -1
  70. package/dist/testing/test-harness.js +13 -2
  71. package/dist/testing/test-harness.js.map +1 -1
  72. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  73. package/package.json +2 -2
  74. package/src/bootstrap.ts +7 -0
  75. package/src/core/agents/agent.ts +19 -2
  76. package/src/core/preset/index.ts +3 -0
  77. package/src/core/preset/overrides.ts +24 -0
  78. package/src/core/sessions/session-manager.ts +27 -4
  79. package/src/core/sessions/session-overrides.integration.test.ts +210 -0
  80. package/src/core/sessions/session.ts +19 -1
  81. package/src/core/sessions/state.ts +77 -0
  82. package/src/core/system.ts +4 -0
  83. package/src/index.ts +8 -1
  84. package/src/plugins/services/pid-registry.test.ts +180 -0
  85. package/src/plugins/services/pid-registry.ts +191 -0
  86. package/src/plugins/services/plugin.ts +58 -15
  87. package/src/plugins/services/prompt.ts +8 -0
  88. package/src/plugins/services/schema.ts +33 -0
  89. package/src/plugins/services/service.ts +125 -1
  90. package/src/plugins/services/services.integration.test.ts +270 -0
  91. package/src/plugins/session-lifecycle/plugin.ts +59 -1
  92. package/src/testing/test-harness.ts +22 -3
@@ -51,6 +51,7 @@ import type { Logger } from '../../lib/logger/logger.js'
51
51
  import type { SessionContext } from '../sessions/context.js'
52
52
  import type { SessionStore } from '../sessions/session-store.js'
53
53
  import type { SessionState } from '../sessions/state.js'
54
+ import { resolveAgentOverrides } from '../sessions/state.js'
54
55
  import type { SessionEnvironment, ToolExecutor } from '../tools/index.js'
55
56
  import type { AgentContext } from './context.js'
56
57
  import { sanitizeLLMResponse } from './response-sanitizer.js'
@@ -200,6 +201,22 @@ export class Agent {
200
201
  return this.store.getAgentState(this.id)
201
202
  }
202
203
 
204
+ /**
205
+ * Model for the next inference: the session override if one is set, else the
206
+ * model from the preset definition.
207
+ *
208
+ * Resolved per inference rather than frozen at construction so that changing
209
+ * the override applies to agents that are already running. Applying it in
210
+ * `Session.getAgentConfig()` instead would only reach agents created after the
211
+ * change, and catching up the rest would mean rebuilding live Agent instances
212
+ * mid-turn — abort controllers, debounce timers and mailbox state included.
213
+ */
214
+ private resolveModel(): ModelId {
215
+ const definitionName = this.state?.definitionName
216
+ if (definitionName === undefined) return this.config.model
217
+ return resolveAgentOverrides(this.store.getState(), definitionName).model ?? this.config.model
218
+ }
219
+
203
220
  // ============================================================================
204
221
  // Public API
205
222
  // ============================================================================
@@ -449,7 +466,7 @@ export class Agent {
449
466
  const cachedMessages = applyCacheBreakpoint(messages, extraMessages.length, this.config.cacheTtl, agentState.preamble.length)
450
467
 
451
468
  const request: InferenceRequest = {
452
- model: this.config.model,
469
+ model: this.resolveModel(),
453
470
  systemPrompt: this.buildSystemPrompt(),
454
471
  messages: cachedMessages,
455
472
  tools: this.tools.size > 0 ? [...this.tools.values()] : undefined,
@@ -628,7 +645,7 @@ export class Agent {
628
645
 
629
646
  // 5. LLM inference (with retry)
630
647
  const request: InferenceRequest = {
631
- model: this.config.model,
648
+ model: this.resolveModel(),
632
649
  systemPrompt: this.buildSystemPrompt(),
633
650
  messages: cachedMessages,
634
651
  tools: this.tools.size > 0 ? [...this.tools.values()] : undefined,
@@ -9,3 +9,6 @@ export { createPreset, validatePreset } from './config.js'
9
9
  // Re-export from preset-builder
10
10
  export type { CreateOrchestratorInput, DefineAgentInput } from './preset-builder.js'
11
11
  export { createOrchestrator, defineAgent } from './preset-builder.js'
12
+
13
+ // Re-export from overrides
14
+ export { knownDefinitionNames, unknownOverrideTargets } from './overrides.js'
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Validation of session overrides against the preset they target.
3
+ *
4
+ * Overrides are keyed by agent definition name, which makes a typo silent: the
5
+ * entry simply never matches and the agent quietly keeps the preset's model.
6
+ * Every entry point that accepts an override patch rejects unknown names instead.
7
+ */
8
+
9
+ import { COMMUNICATOR_ROLE, ORCHESTRATOR_ROLE } from '~/core/agents/agent-roles.js'
10
+ import type { SessionOverridesPatch } from '~/core/sessions/state.js'
11
+ import type { PresetDefinition } from './config.js'
12
+
13
+ /** Every definition name an override may target, in preset order. */
14
+ export const knownDefinitionNames = (preset: PresetDefinition): string[] => [
15
+ ORCHESTRATOR_ROLE,
16
+ ...(preset.communicator ? [COMMUNICATOR_ROLE] : []),
17
+ ...preset.agents.map(agent => agent.name),
18
+ ]
19
+
20
+ /** Names in the patch that the preset does not define. Empty when the patch is valid. */
21
+ export const unknownOverrideTargets = (preset: PresetDefinition, patch: SessionOverridesPatch): string[] => {
22
+ const known = new Set(knownDefinitionNames(preset))
23
+ return Object.keys(patch.agents ?? {}).filter(name => !known.has(name))
24
+ }
@@ -21,9 +21,10 @@ import type { LLMLogger } from '~/core/llm/logger.js'
21
21
  import type { LLMProvider } from '~/core/llm/provider.js'
22
22
  import type { CallerContext, ConfiguredPlugin, ManagerMethodContext, PluginDefinition } from '~/core/plugins/plugin-builder.js'
23
23
  import type { Preset } from '~/core/preset/index.js'
24
+ import { knownDefinitionNames, unknownOverrideTargets } from '~/core/preset/overrides.js'
24
25
  import type { SessionId } from '~/core/sessions/schema.js'
25
26
  import { generateSessionId } from '~/core/sessions/schema.js'
26
- import type { SessionCreatedEvent } from '~/core/sessions/state.js'
27
+ import type { SessionCreatedEvent, SessionOverridesPatch } from '~/core/sessions/state.js'
27
28
  import { checkRecoveryNeeded, isSessionCreatedEvent, reconstructSessionState, sessionEvents } from '~/core/sessions/state.js'
28
29
  import type { ToolExecutor } from '~/core/tools'
29
30
  import { FileLogger } from '~/lib/logger/file.js'
@@ -34,6 +35,7 @@ import type { Result } from '~/lib/utils/result.js'
34
35
  import { Err, Ok } from '~/lib/utils/result.js'
35
36
  import type { SpawnableAgentInfo } from '~/plugins/agents/index.js'
36
37
  import { getAgentUnconsumedMailbox, selectMailboxState } from '~/plugins/mailbox/query.js'
38
+ import type { ServicePidRegistry } from '~/plugins/services/pid-registry.js'
37
39
  import type { PortPool } from '~/plugins/services/port-pool.js'
38
40
  import type { ServiceConfig } from '~/plugins/services/schema.js'
39
41
  import { selectSessionStats, type SessionStatsState } from '~/plugins/session-stats/index.js'
@@ -77,6 +79,7 @@ export interface SessionManagerOptions {
77
79
  preprocessorRegistry?: PreprocessorRegistry
78
80
  llmLogger?: LLMLogger
79
81
  portPool?: PortPool
82
+ pidRegistry?: ServicePidRegistry
80
83
  /** Host-environment adapters (filesystem, process). */
81
84
  platform: Platform
82
85
  systemPlugins?: readonly PluginDefinition<string, any, any, any, any>[]
@@ -108,6 +111,7 @@ export class SessionManager {
108
111
  private readonly preprocessorRegistry?: PreprocessorRegistry
109
112
  private readonly llmLogger?: LLMLogger
110
113
  private readonly portPool?: PortPool
114
+ private readonly pidRegistry?: ServicePidRegistry
111
115
  private readonly platform: Platform
112
116
  private readonly systemPlugins: readonly PluginDefinition<string, any, any, any, any>[]
113
117
 
@@ -124,6 +128,7 @@ export class SessionManager {
124
128
  this.preprocessorRegistry = options.preprocessorRegistry
125
129
  this.llmLogger = options.llmLogger
126
130
  this.portPool = options.portPool
131
+ this.pidRegistry = options.pidRegistry
127
132
  this.platform = options.platform
128
133
  this.systemPlugins = options.systemPlugins ?? []
129
134
  this.managerMethods = this.collectManagerMethods()
@@ -139,13 +144,22 @@ export class SessionManager {
139
144
  */
140
145
  async createSession(
141
146
  presetId: string,
142
- options?: { workspaceDir?: string; sessionId?: string },
147
+ options?: { workspaceDir?: string; sessionId?: string; overrides?: SessionOverridesPatch },
143
148
  ): Promise<Result<Session, DomainError>> {
144
149
  const preset = this.presets.get(presetId)
145
150
  if (!preset) {
146
151
  return Err(PresetErrors.notFound(presetId))
147
152
  }
148
153
 
154
+ if (options?.overrides) {
155
+ const unknown = unknownOverrideTargets(preset, options.overrides)
156
+ if (unknown.length > 0) {
157
+ return Err(ValidationErrors.invalid(
158
+ `Unknown agent definitions in overrides: ${unknown.join(', ')}. Preset '${presetId}' defines: ${knownDefinitionNames(preset).join(', ')}`,
159
+ ))
160
+ }
161
+ }
162
+
149
163
  const sessionId = options?.sessionId ? (options.sessionId as any) : generateSessionId()
150
164
  // First orchestrator gets seq 1
151
165
  const orchestratorId = generateAgentId(ORCHESTRATOR_ROLE, 1)
@@ -171,6 +185,15 @@ export class SessionManager {
171
185
  ...(workspaceDir ? { workspaceDir } : {}),
172
186
  }),
173
187
  ),
188
+ ]
189
+
190
+ // Seeded through the same event a runtime change uses, so there is one code
191
+ // path and a forked/replayed session reconstructs the overrides for free.
192
+ if (options?.overrides) {
193
+ events.push(withSessionId(sessionId, sessionEvents.create('session_overrides_set', options.overrides)))
194
+ }
195
+
196
+ events.push(
174
197
  withSessionId(
175
198
  sessionId,
176
199
  agentEvents.create('agent_spawned', {
@@ -179,7 +202,7 @@ export class SessionManager {
179
202
  parentId: null,
180
203
  }),
181
204
  ),
182
- ]
205
+ )
183
206
 
184
207
  // Spawn communicator if configured
185
208
  const hasCommunicator = !!preset.communicator
@@ -774,7 +797,7 @@ export class SessionManager {
774
797
  }
775
798
  }
776
799
  if (servicesByType.size > 0 && this.portPool) {
777
- configs.set('services', { services: [...servicesByType.values()], portPool: this.portPool })
800
+ configs.set('services', { services: [...servicesByType.values()], portPool: this.portPool, pidRegistry: this.pidRegistry })
778
801
  }
779
802
 
780
803
  return configs
@@ -0,0 +1,210 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { ORCHESTRATOR_ROLE } from '~/core/agents/agent-roles.js'
3
+ import { MockLLMProvider } from '~/core/llm/mock.js'
4
+ import { ModelId } from '~/core/llm/schema.js'
5
+ import { createTestPreset, TestHarness } from '~/testing/index.js'
6
+
7
+ const LUNA = ModelId('openai/gpt-5.6-luna')
8
+ const KIMI = ModelId('moonshotai/kimi-k3')
9
+
10
+ /** Models the mock provider was actually asked for, in call order. */
11
+ const modelsUsed = (llmProvider: MockLLMProvider): string[] => llmProvider.getCallHistory().map(request => String(request.model))
12
+
13
+ const newHarness = () =>
14
+ new TestHarness({
15
+ presets: [createTestPreset({ agents: [{ name: 'worker', system: 'worker' }] })],
16
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'ok', toolCalls: [] }),
17
+ })
18
+
19
+ describe('session overrides', () => {
20
+ it('uses the preset model when no override is set', async () => {
21
+ const harness = newHarness()
22
+ const session = await harness.createSession('test')
23
+
24
+ await session.sendAndWaitForIdle('hello')
25
+
26
+ expect(modelsUsed(harness.llmProvider)).toEqual(['mock'])
27
+
28
+ await harness.shutdown()
29
+ })
30
+
31
+ it('applies an override seeded at session creation', async () => {
32
+ const harness = newHarness()
33
+ const session = await harness.createSession('test', {
34
+ overrides: { agents: { [ORCHESTRATOR_ROLE]: { model: LUNA } } },
35
+ })
36
+
37
+ await session.sendAndWaitForIdle('hello')
38
+
39
+ expect(modelsUsed(harness.llmProvider)).toEqual([String(LUNA)])
40
+
41
+ await harness.shutdown()
42
+ })
43
+
44
+ // The point of resolving the model per inference instead of freezing it on the
45
+ // Agent at construction: this agent is already built and has already run a turn.
46
+ it('applies an override set at runtime to an already-running agent', async () => {
47
+ const harness = newHarness()
48
+ const session = await harness.createSession('test')
49
+
50
+ await session.sendAndWaitForIdle('before')
51
+ await session.setOverrides({ agents: { [ORCHESTRATOR_ROLE]: { model: LUNA } } })
52
+ await session.sendAndWaitForIdle('after')
53
+
54
+ expect(modelsUsed(harness.llmProvider)).toEqual(['mock', String(LUNA)])
55
+
56
+ await harness.shutdown()
57
+ })
58
+
59
+ it('reverts to the preset model when an override is cleared with null', async () => {
60
+ const harness = newHarness()
61
+ const session = await harness.createSession('test', {
62
+ overrides: { agents: { [ORCHESTRATOR_ROLE]: { model: LUNA } } },
63
+ })
64
+
65
+ await session.sendAndWaitForIdle('before')
66
+ await session.setOverrides({ agents: { [ORCHESTRATOR_ROLE]: null } })
67
+ await session.sendAndWaitForIdle('after')
68
+
69
+ expect(modelsUsed(harness.llmProvider)).toEqual([String(LUNA), 'mock'])
70
+
71
+ await harness.shutdown()
72
+ })
73
+
74
+ it('falls back to defaults for an agent with no entry of its own', async () => {
75
+ const harness = newHarness()
76
+ const session = await harness.createSession('test', { overrides: { defaults: { model: KIMI } } })
77
+
78
+ await session.sendAndWaitForIdle('hello')
79
+
80
+ expect(modelsUsed(harness.llmProvider)).toEqual([String(KIMI)])
81
+
82
+ await harness.shutdown()
83
+ })
84
+
85
+ it('lets a per-agent entry win over defaults', async () => {
86
+ const harness = newHarness()
87
+ const session = await harness.createSession('test', {
88
+ overrides: { defaults: { model: KIMI }, agents: { [ORCHESTRATOR_ROLE]: { model: LUNA } } },
89
+ })
90
+
91
+ await session.sendAndWaitForIdle('hello')
92
+
93
+ expect(modelsUsed(harness.llmProvider)).toEqual([String(LUNA)])
94
+
95
+ await harness.shutdown()
96
+ })
97
+
98
+ it('leaves defaults alone when a patch only touches agents', async () => {
99
+ const harness = newHarness()
100
+ const session = await harness.createSession('test', { overrides: { defaults: { model: KIMI } } })
101
+
102
+ await session.setOverrides({ agents: { worker: { model: LUNA } } })
103
+ await session.sendAndWaitForIdle('hello')
104
+
105
+ // The orchestrator still resolves through the untouched defaults.
106
+ expect(modelsUsed(harness.llmProvider)).toEqual([String(KIMI)])
107
+
108
+ await harness.shutdown()
109
+ })
110
+
111
+ it('rejects an override targeting an agent the preset does not define', async () => {
112
+ const harness = newHarness()
113
+
114
+ const result = await harness.createSessionResult('test', {
115
+ overrides: { agents: { 'no-such-agent': { model: LUNA } } },
116
+ })
117
+
118
+ expect(result.ok).toBe(false)
119
+ if (!result.ok) {
120
+ expect(result.error.message).toContain('no-such-agent')
121
+ }
122
+
123
+ await harness.shutdown()
124
+ })
125
+
126
+ // The RPC surface the worker actually calls.
127
+ describe('sessions.setOverrides', () => {
128
+ it('sets the override and echoes back the resolved state', async () => {
129
+ const harness = newHarness()
130
+ const session = await harness.createSession('test')
131
+
132
+ const result = await harness.sessionManager.callManagerMethod('sessions.setOverrides', {
133
+ sessionId: String(session.sessionId),
134
+ overrides: { agents: { worker: { model: LUNA } }, defaults: { model: KIMI } },
135
+ })
136
+
137
+ expect(result.ok).toBe(true)
138
+ if (result.ok) {
139
+ expect(result.value).toEqual({
140
+ agents: { worker: { model: LUNA } },
141
+ defaults: { model: KIMI },
142
+ })
143
+ }
144
+
145
+ await harness.shutdown()
146
+ })
147
+
148
+ it('rejects an unknown agent name and names what the preset defines', async () => {
149
+ const harness = newHarness()
150
+ const session = await harness.createSession('test')
151
+
152
+ const result = await harness.sessionManager.callManagerMethod('sessions.setOverrides', {
153
+ sessionId: String(session.sessionId),
154
+ overrides: { agents: { wrker: { model: LUNA } } },
155
+ })
156
+
157
+ expect(result.ok).toBe(false)
158
+ if (!result.ok) {
159
+ expect(result.error.message).toContain('wrker')
160
+ expect(result.error.message).toContain('worker')
161
+ }
162
+
163
+ await harness.shutdown()
164
+ })
165
+
166
+ it('reports the effective overrides back through sessions.get', async () => {
167
+ const harness = newHarness()
168
+ const session = await harness.createSession('test', {
169
+ overrides: { agents: { [ORCHESTRATOR_ROLE]: { model: LUNA } } },
170
+ })
171
+
172
+ const result = await harness.sessionManager.callPluginMethod(
173
+ session.sessionId,
174
+ 'sessions.get',
175
+ { sessionId: String(session.sessionId) },
176
+ )
177
+
178
+ expect(result.ok).toBe(true)
179
+ if (result.ok) {
180
+ expect(result.value).toMatchObject({
181
+ overrides: { agents: { [ORCHESTRATOR_ROLE]: { model: LUNA } } },
182
+ })
183
+ }
184
+
185
+ await harness.shutdown()
186
+ })
187
+ })
188
+
189
+ // Overrides are state like any other, so they must survive a replay rather than
190
+ // living only on the in-memory Session that happened to accept the patch.
191
+ it('reconstructs overrides when the session is reopened from the event store', async () => {
192
+ const harness = newHarness()
193
+ const session = await harness.createSession('test')
194
+ await session.setOverrides({ agents: { [ORCHESTRATOR_ROLE]: { model: LUNA } } })
195
+ await harness.shutdown()
196
+
197
+ const restarted = new TestHarness({
198
+ presets: [createTestPreset({ agents: [{ name: 'worker', system: 'worker' }] })],
199
+ llmProvider: MockLLMProvider.withFixedResponse({ content: 'ok', toolCalls: [] }),
200
+ eventStore: harness.eventStore,
201
+ })
202
+ const reopened = await restarted.openSession(session.sessionId)
203
+
204
+ await reopened.sendAndWaitForIdle('hello')
205
+
206
+ expect(modelsUsed(restarted.llmProvider)).toEqual([String(LUNA)])
207
+
208
+ await restarted.shutdown()
209
+ })
210
+ })
@@ -22,7 +22,7 @@ import type { AgentPluginConfig, BaseSessionHookContext, CallerContext, Configur
22
22
  import { AGENT_CALLER, DEFAULT_CALLER, buildPluginDeps, type PluginNotification } from '~/core/plugins/plugin-builder.js'
23
23
  import type { Preset } from '~/core/preset/index.js'
24
24
  import type { SessionId } from '~/core/sessions/schema.js'
25
- import type { SessionState } from '~/core/sessions/state.js'
25
+ import type { SessionOverridesPatch, SessionState } from '~/core/sessions/state.js'
26
26
  import { getEntryAgentId, getNextAgentSeq, sessionEvents } from '~/core/sessions/state.js'
27
27
  import type { Logger } from '~/lib/logger/logger.js'
28
28
  import type { Platform } from '~/platform/index.js'
@@ -214,6 +214,24 @@ export class Session {
214
214
  return Ok(undefined)
215
215
  }
216
216
 
217
+ /**
218
+ * Patch the session's overrides (see {@link SessionOverrides}).
219
+ *
220
+ * Takes effect on the next inference each affected agent runs — running agents
221
+ * are not rebuilt and a request already in flight completes on the model it
222
+ * started with. Callers are expected to have validated the patch against the
223
+ * preset; see `unknownOverrideTargets`.
224
+ */
225
+ async setOverrides(patch: SessionOverridesPatch): Promise<Result<void, DomainError>> {
226
+ if (this.store.isClosed()) {
227
+ return Err(SessionErrors.closed(String(this.id)))
228
+ }
229
+
230
+ await this.store.emit(withSessionId(this.id, sessionEvents.create('session_overrides_set', patch)))
231
+
232
+ return Ok(undefined)
233
+ }
234
+
217
235
  /**
218
236
  * Get an agent by ID.
219
237
  */
@@ -6,6 +6,7 @@ import { agentEvents } from '~/core/agents/state.js'
6
6
  import { contextEvents } from '~/core/context/state.js'
7
7
  import { createEventsFactory } from '~/core/events/types'
8
8
  import type { DomainEvent } from '~/core/events/types.js'
9
+ import { modelIdSchema } from '~/core/llm/schema.js'
9
10
  import { llmEvents } from '~/core/llm/state.js'
10
11
  import { SessionId, sessionIdSchema } from '~/core/sessions/schema.js'
11
12
  import type { PendingToolResult } from '~/core/tools/schema.js'
@@ -24,6 +25,65 @@ import { createTypedReducer } from './reducer.js'
24
25
  */
25
26
  export type SessionHandlerName = 'onSessionReady' | 'onSessionClose'
26
27
 
28
+ // ============================================================================
29
+ // Session overrides
30
+ // ============================================================================
31
+
32
+ /**
33
+ * Per-agent settings layered over the preset definition.
34
+ *
35
+ * Deliberately an object rather than a bare model id: the same layering is where
36
+ * `cacheTtl`, token caps or reasoning effort belong once they need to be
37
+ * session-scoped, and adding a field here stays backwards compatible.
38
+ */
39
+ export const agentOverridesSchema = z4.object({
40
+ /** Replaces the model declared on the agent definition. */
41
+ model: modelIdSchema.optional(),
42
+ })
43
+
44
+ export type AgentOverrides = z4.infer<typeof agentOverridesSchema>
45
+
46
+ /**
47
+ * A patch against {@link SessionOverrides}. An absent key is left alone, `null`
48
+ * clears it, and an object replaces that entry wholesale (it is not merged into
49
+ * the previous value — the entry *is* the override).
50
+ */
51
+ export const sessionOverridesPatchSchema = z4.object({
52
+ defaults: agentOverridesSchema.nullable().optional(),
53
+ agents: z4.record(z4.string(), agentOverridesSchema.nullable()).optional(),
54
+ })
55
+
56
+ export type SessionOverridesPatch = z4.infer<typeof sessionOverridesPatchSchema>
57
+
58
+ /**
59
+ * Session-scoped configuration layered over the preset, patchable at any time.
60
+ *
61
+ * Two levels because they answer different questions. `defaults` is "run this
62
+ * whole session on X"; `agents` is "…except this one". A preset that pins a
63
+ * cheap model to specific sub-agents (redo's `fastModel`) would have that intent
64
+ * flattened by `defaults` alone, so the per-definition level is what makes the
65
+ * override surgical.
66
+ */
67
+ export interface SessionOverrides {
68
+ /** Applied to every agent that has no `agents` entry. */
69
+ defaults?: AgentOverrides
70
+ /** Keyed by definitionName, including ORCHESTRATOR_ROLE / COMMUNICATOR_ROLE. */
71
+ agents: Map<string, AgentOverrides>
72
+ }
73
+
74
+ /**
75
+ * Effective overrides for one agent definition: `agents` layered over `defaults`
76
+ * field by field, so `defaults: { model }` plus a per-agent entry that only sets
77
+ * some other field keeps the default model.
78
+ */
79
+ export const resolveAgentOverrides = (
80
+ state: SessionState,
81
+ definitionName: string,
82
+ ): AgentOverrides => ({
83
+ ...state.overrides.defaults,
84
+ ...state.overrides.agents.get(definitionName),
85
+ })
86
+
27
87
  export const sessionEvents = createEventsFactory({
28
88
  events: {
29
89
  session_created: z4.object({
@@ -44,6 +104,7 @@ export const sessionEvents = createEventsFactory({
44
104
  key: z4.string(),
45
105
  value: z4.unknown(),
46
106
  }),
107
+ session_overrides_set: sessionOverridesPatchSchema,
47
108
  session_handler_started: z4.object({
48
109
  handlerName: z4.enum(['onSessionReady', 'onSessionClose']),
49
110
  pluginName: z4.string(),
@@ -62,6 +123,7 @@ export type SessionClosedEvent = (typeof sessionEvents)['Events']['session_close
62
123
  export type SessionReopenedEvent = (typeof sessionEvents)['Events']['session_reopened']
63
124
  export type SessionRestartedEvent = (typeof sessionEvents)['Events']['session_restarted']
64
125
  export type SessionMetadataSetEvent = (typeof sessionEvents)['Events']['session_metadata_set']
126
+ export type SessionOverridesSetEvent = (typeof sessionEvents)['Events']['session_overrides_set']
65
127
  export type SessionHandlerStartedEvent = (typeof sessionEvents)['Events']['session_handler_started']
66
128
  export type SessionHandlerCompletedEvent = (typeof sessionEvents)['Events']['session_handler_completed']
67
129
 
@@ -89,6 +151,8 @@ export interface SessionState {
89
151
  workspaceDir?: string
90
152
  /** Generic key-value metadata store (tool sets use their own keys) */
91
153
  metadata: Map<string, unknown>
154
+ /** Session-scoped settings layered over the preset. See {@link SessionOverrides}. */
155
+ overrides: SessionOverrides
92
156
  /** If this session was forked from another session */
93
157
  forkedFrom?: { sessionId: SessionId; eventIndex: number }
94
158
  }
@@ -109,6 +173,7 @@ export const createSessionState = (
109
173
  createdAt: timestamp,
110
174
  agentCounters: new Map(),
111
175
  metadata: new Map(),
176
+ overrides: { agents: new Map() },
112
177
  })
113
178
 
114
179
  // ============================================================================
@@ -429,6 +494,18 @@ export const coreReducer = createTypedReducer(
429
494
  return { ...state, metadata: newMetadata }
430
495
  }
431
496
 
497
+ case 'session_overrides_set': {
498
+ const agents = new Map(state.overrides.agents)
499
+ for (const [definitionName, override] of Object.entries(event.agents ?? {})) {
500
+ if (override === null) agents.delete(definitionName)
501
+ else agents.set(definitionName, override)
502
+ }
503
+ // undefined = untouched, null = cleared. `?? undefined` collapses the
504
+ // explicit null, which the state shape does not carry.
505
+ const defaults = event.defaults === undefined ? state.overrides.defaults : (event.defaults ?? undefined)
506
+ return { ...state, overrides: { defaults, agents } }
507
+ }
508
+
432
509
  case 'agent_paused':
433
510
  return updateAgent(state, event.agentId, (agent) => ({
434
511
  ...agent,
@@ -10,6 +10,7 @@
10
10
  import type z4 from 'zod/v4'
11
11
  import type { Logger } from '~/lib/logger/logger.js'
12
12
  import type { Platform } from '~/platform/index.js'
13
+ import type { ServicePidRegistry } from '~/plugins/services/pid-registry.js'
13
14
  import type { PortPool } from '~/plugins/services/port-pool.js'
14
15
  import type { PreprocessorRegistry } from '~/plugins/uploads/preprocessor.js'
15
16
  import type { EventStore } from './events/event-store.js'
@@ -120,6 +121,7 @@ export interface CreateSystemOptions<TPlugins extends readonly PluginDefinition<
120
121
  preprocessorRegistry?: PreprocessorRegistry
121
122
  llmLogger?: LLMLogger
122
123
  portPool?: PortPool
124
+ pidRegistry?: ServicePidRegistry
123
125
  /** Host-environment adapters (filesystem, process). */
124
126
  platform: Platform
125
127
  }
@@ -145,6 +147,7 @@ export function createSystem<const TPlugins extends readonly PluginDefinition<st
145
147
  preprocessorRegistry,
146
148
  llmLogger,
147
149
  portPool,
150
+ pidRegistry,
148
151
  platform,
149
152
  } = options
150
153
 
@@ -167,6 +170,7 @@ export function createSystem<const TPlugins extends readonly PluginDefinition<st
167
170
  preprocessorRegistry,
168
171
  llmLogger,
169
172
  portPool,
173
+ pidRegistry,
170
174
  platform,
171
175
  systemPlugins: [...plugins],
172
176
  })
package/src/index.ts CHANGED
@@ -66,7 +66,14 @@ export { applyEvent } from '~/core/sessions/apply-event.js'
66
66
  export { selectPluginState } from '~/core/sessions/reducer.js'
67
67
  export { SessionId } from '~/core/sessions/schema.js'
68
68
  export type { SessionMetadata } from '~/core/sessions/schema.js'
69
- export { createSessionState, reconstructSessionState } from '~/core/sessions/state.js'
69
+ export type { AgentOverrides, SessionOverrides, SessionOverridesPatch } from '~/core/sessions/state.js'
70
+ export {
71
+ agentOverridesSchema,
72
+ createSessionState,
73
+ reconstructSessionState,
74
+ resolveAgentOverrides,
75
+ sessionOverridesPatchSchema,
76
+ } from '~/core/sessions/state.js'
70
77
  export type { SessionState } from '~/core/sessions/state.js'
71
78
  export type { ToolCallId } from '~/core/tools/schema.js'
72
79
  export type { ToolStartedEvent } from '~/core/tools/state.js'