@roj-ai/sdk 0.1.34 → 0.1.36

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 (121) hide show
  1. package/dist/bootstrap.d.ts +36 -0
  2. package/dist/bootstrap.d.ts.map +1 -1
  3. package/dist/core/agents/agent.d.ts +11 -0
  4. package/dist/core/agents/agent.d.ts.map +1 -1
  5. package/dist/core/agents/agent.js +26 -5
  6. package/dist/core/agents/agent.js.map +1 -1
  7. package/dist/core/agents/state.d.ts +24 -0
  8. package/dist/core/agents/state.d.ts.map +1 -1
  9. package/dist/core/agents/state.js.map +1 -1
  10. package/dist/core/context/state.d.ts.map +1 -1
  11. package/dist/core/context/state.js +4 -0
  12. package/dist/core/context/state.js.map +1 -1
  13. package/dist/core/llm/anthropic.d.ts.map +1 -1
  14. package/dist/core/llm/anthropic.js +49 -8
  15. package/dist/core/llm/anthropic.js.map +1 -1
  16. package/dist/core/llm/model-shortlist-live.test.d.ts +35 -0
  17. package/dist/core/llm/model-shortlist-live.test.d.ts.map +1 -0
  18. package/dist/core/llm/model-shortlist-live.test.js +452 -0
  19. package/dist/core/llm/model-shortlist-live.test.js.map +1 -0
  20. package/dist/core/llm/openrouter-mapping.test.js +18 -0
  21. package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
  22. package/dist/core/llm/openrouter.d.ts.map +1 -1
  23. package/dist/core/llm/openrouter.js +20 -11
  24. package/dist/core/llm/openrouter.js.map +1 -1
  25. package/dist/core/llm/provider.d.ts +34 -3
  26. package/dist/core/llm/provider.d.ts.map +1 -1
  27. package/dist/core/llm/provider.js.map +1 -1
  28. package/dist/core/llm/reasoning-details.test.d.ts +2 -0
  29. package/dist/core/llm/reasoning-details.test.d.ts.map +1 -0
  30. package/dist/core/llm/reasoning-details.test.js +144 -0
  31. package/dist/core/llm/reasoning-details.test.js.map +1 -0
  32. package/dist/core/llm/state.d.ts +2 -0
  33. package/dist/core/llm/state.d.ts.map +1 -1
  34. package/dist/core/llm/state.js +11 -0
  35. package/dist/core/llm/state.js.map +1 -1
  36. package/dist/core/llm/thinking-blocks.test.d.ts +2 -0
  37. package/dist/core/llm/thinking-blocks.test.d.ts.map +1 -0
  38. package/dist/core/llm/thinking-blocks.test.js +291 -0
  39. package/dist/core/llm/thinking-blocks.test.js.map +1 -0
  40. package/dist/core/preset/index.d.ts +1 -0
  41. package/dist/core/preset/index.d.ts.map +1 -1
  42. package/dist/core/preset/index.js +2 -0
  43. package/dist/core/preset/index.js.map +1 -1
  44. package/dist/core/preset/overrides.d.ts +14 -0
  45. package/dist/core/preset/overrides.d.ts.map +1 -0
  46. package/dist/core/preset/overrides.js +20 -0
  47. package/dist/core/preset/overrides.js.map +1 -0
  48. package/dist/core/reasoning-round-trip.integration.test.d.ts +2 -0
  49. package/dist/core/reasoning-round-trip.integration.test.d.ts.map +1 -0
  50. package/dist/core/reasoning-round-trip.integration.test.js +150 -0
  51. package/dist/core/reasoning-round-trip.integration.test.js.map +1 -0
  52. package/dist/core/sessions/session-manager.d.ts +2 -0
  53. package/dist/core/sessions/session-manager.d.ts.map +1 -1
  54. package/dist/core/sessions/session-manager.js +17 -5
  55. package/dist/core/sessions/session-manager.js.map +1 -1
  56. package/dist/core/sessions/session-overrides.integration.test.d.ts +2 -0
  57. package/dist/core/sessions/session-overrides.integration.test.d.ts.map +1 -0
  58. package/dist/core/sessions/session-overrides.integration.test.js +154 -0
  59. package/dist/core/sessions/session-overrides.integration.test.js.map +1 -0
  60. package/dist/core/sessions/session.d.ts +10 -1
  61. package/dist/core/sessions/session.d.ts.map +1 -1
  62. package/dist/core/sessions/session.js +15 -0
  63. package/dist/core/sessions/session.js.map +1 -1
  64. package/dist/core/sessions/state.d.ts +57 -0
  65. package/dist/core/sessions/state.d.ts.map +1 -1
  66. package/dist/core/sessions/state.js +53 -0
  67. package/dist/core/sessions/state.js.map +1 -1
  68. package/dist/index.d.ts +3 -1
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +1 -1
  71. package/dist/index.js.map +1 -1
  72. package/dist/plugins/services/plugin.d.ts +3 -0
  73. package/dist/plugins/services/plugin.d.ts.map +1 -1
  74. package/dist/plugins/services/plugin.js +50 -14
  75. package/dist/plugins/services/plugin.js.map +1 -1
  76. package/dist/plugins/services/prompt.d.ts.map +1 -1
  77. package/dist/plugins/services/prompt.js +8 -0
  78. package/dist/plugins/services/prompt.js.map +1 -1
  79. package/dist/plugins/services/schema.d.ts +33 -0
  80. package/dist/plugins/services/schema.d.ts.map +1 -1
  81. package/dist/plugins/services/service.d.ts +20 -0
  82. package/dist/plugins/services/service.d.ts.map +1 -1
  83. package/dist/plugins/services/service.js +88 -1
  84. package/dist/plugins/services/service.js.map +1 -1
  85. package/dist/plugins/services/services.integration.test.js +254 -0
  86. package/dist/plugins/services/services.integration.test.js.map +1 -1
  87. package/dist/plugins/session-lifecycle/plugin.d.ts +36 -0
  88. package/dist/plugins/session-lifecycle/plugin.d.ts.map +1 -1
  89. package/dist/plugins/session-lifecycle/plugin.js +53 -1
  90. package/dist/plugins/session-lifecycle/plugin.js.map +1 -1
  91. package/dist/testing/test-harness.d.ts +12 -2
  92. package/dist/testing/test-harness.d.ts.map +1 -1
  93. package/dist/testing/test-harness.js +13 -2
  94. package/dist/testing/test-harness.js.map +1 -1
  95. package/package.json +3 -3
  96. package/src/core/agents/agent.ts +28 -5
  97. package/src/core/agents/state.ts +26 -0
  98. package/src/core/context/state.ts +4 -0
  99. package/src/core/llm/anthropic.ts +71 -10
  100. package/src/core/llm/model-shortlist-live.test.ts +577 -0
  101. package/src/core/llm/openrouter-mapping.test.ts +24 -0
  102. package/src/core/llm/openrouter.ts +28 -12
  103. package/src/core/llm/provider.ts +44 -3
  104. package/src/core/llm/reasoning-details.test.ts +177 -0
  105. package/src/core/llm/state.ts +12 -0
  106. package/src/core/llm/thinking-blocks.test.ts +345 -0
  107. package/src/core/preset/index.ts +3 -0
  108. package/src/core/preset/overrides.ts +24 -0
  109. package/src/core/reasoning-round-trip.integration.test.ts +188 -0
  110. package/src/core/sessions/session-manager.ts +22 -3
  111. package/src/core/sessions/session-overrides.integration.test.ts +210 -0
  112. package/src/core/sessions/session.ts +19 -1
  113. package/src/core/sessions/state.ts +82 -0
  114. package/src/index.ts +9 -1
  115. package/src/plugins/services/plugin.ts +50 -14
  116. package/src/plugins/services/prompt.ts +8 -0
  117. package/src/plugins/services/schema.ts +33 -0
  118. package/src/plugins/services/service.ts +113 -1
  119. package/src/plugins/services/services.integration.test.ts +270 -0
  120. package/src/plugins/session-lifecycle/plugin.ts +59 -1
  121. package/src/testing/test-harness.ts +22 -3
@@ -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
  // ============================================================================
@@ -195,6 +260,8 @@ export const coreReducer = createTypedReducer(
195
260
  role: 'assistant',
196
261
  content: event.response.content ?? '',
197
262
  toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
263
+ reasoningDetails: event.response.reasoningDetails,
264
+ thinkingBlocks: event.response.thinkingBlocks,
198
265
  }
199
266
 
200
267
  return updateAgent(state, event.agentId, (agent) => {
@@ -309,6 +376,9 @@ export const coreReducer = createTypedReducer(
309
376
  role: 'assistant',
310
377
  content: m.content,
311
378
  toolCalls: m.toolCalls,
379
+ // Survives compaction: a retained turn keeps the reasoning the provider wants echoed back.
380
+ reasoningDetails: m.reasoningDetails,
381
+ thinkingBlocks: m.thinkingBlocks,
312
382
  cacheControl: m.cacheControl,
313
383
  }
314
384
  case 'tool':
@@ -429,6 +499,18 @@ export const coreReducer = createTypedReducer(
429
499
  return { ...state, metadata: newMetadata }
430
500
  }
431
501
 
502
+ case 'session_overrides_set': {
503
+ const agents = new Map(state.overrides.agents)
504
+ for (const [definitionName, override] of Object.entries(event.agents ?? {})) {
505
+ if (override === null) agents.delete(definitionName)
506
+ else agents.set(definitionName, override)
507
+ }
508
+ // undefined = untouched, null = cleared. `?? undefined` collapses the
509
+ // explicit null, which the state shape does not carry.
510
+ const defaults = event.defaults === undefined ? state.overrides.defaults : (event.defaults ?? undefined)
511
+ return { ...state, overrides: { defaults, agents } }
512
+ }
513
+
432
514
  case 'agent_paused':
433
515
  return updateAgent(state, event.agentId, (agent) => ({
434
516
  ...agent,
package/src/index.ts CHANGED
@@ -58,6 +58,7 @@ export type { ChatMessageContentItem, LLMCallLogEntry, LLMCallMessage, ToolResul
58
58
  export { contentToString } from '~/core/llm/llm-log-types.js'
59
59
  export { applyMiddleware, useProvider, withAnthropic, withMaxTokens, withOpenRouter, withTemperature } from '~/core/llm/middleware.js'
60
60
  export type { InferenceNext, LLMMiddleware } from '~/core/llm/middleware.js'
61
+ export type { OpenRouterProviderRouting, OpenRouterReasoningOptions, OpenRouterRequestOptions } from '~/core/llm/provider.js'
61
62
  export { ModelId } from '~/core/llm/schema.js'
62
63
  export type { LLMCallId } from '~/core/llm/schema.js'
63
64
  export type { InferenceCompletedEvent, InferenceFailedEvent, InferenceStartedEvent } from '~/core/llm/state.js'
@@ -66,7 +67,14 @@ export { applyEvent } from '~/core/sessions/apply-event.js'
66
67
  export { selectPluginState } from '~/core/sessions/reducer.js'
67
68
  export { SessionId } from '~/core/sessions/schema.js'
68
69
  export type { SessionMetadata } from '~/core/sessions/schema.js'
69
- export { createSessionState, reconstructSessionState } from '~/core/sessions/state.js'
70
+ export type { AgentOverrides, SessionOverrides, SessionOverridesPatch } from '~/core/sessions/state.js'
71
+ export {
72
+ agentOverridesSchema,
73
+ createSessionState,
74
+ reconstructSessionState,
75
+ resolveAgentOverrides,
76
+ sessionOverridesPatchSchema,
77
+ } from '~/core/sessions/state.js'
70
78
  export type { SessionState } from '~/core/sessions/state.js'
71
79
  export type { ToolCallId } from '~/core/tools/schema.js'
72
80
  export type { ToolStartedEvent } from '~/core/tools/state.js'
@@ -22,6 +22,14 @@ export const serviceEvents = createEventsFactory({
22
22
  command: z.string().optional(),
23
23
  pid: z.number().optional(),
24
24
  pidStartTime: z.number().optional(),
25
+ /**
26
+ * Set on a `failed` change when the `restartPolicy` has already queued a
27
+ * revival, so that no consumer — agent prompt, SPA, platform — mistakes it
28
+ * for a terminal failure. Cleared by the next status change.
29
+ */
30
+ restartAt: z.number().optional(),
31
+ restartAttempt: z.number().optional(),
32
+ restartMaxRetries: z.number().optional(),
25
33
  }),
26
34
  },
27
35
  })
@@ -73,6 +81,10 @@ export const servicePlugin = definePlugin('services')
73
81
  const updated: ServiceEntry = {
74
82
  ...existing,
75
83
  status: event.toStatus,
84
+ // A queued revival lives only until the next status change.
85
+ restartAt: event.restartAt,
86
+ restartAttempt: event.restartAttempt,
87
+ restartMaxRetries: event.restartMaxRetries,
76
88
  }
77
89
  if (event.toStatus === 'starting') {
78
90
  updated.startedAt = event.timestamp
@@ -115,17 +127,25 @@ export const servicePlugin = definePlugin('services')
115
127
  let changed = false
116
128
  const newServices = new Map(services)
117
129
  for (const [serviceType, entry] of services) {
118
- if (entry.status === 'starting' || entry.status === 'ready') {
119
- newServices.set(serviceType, {
120
- ...entry,
121
- status: 'stopped',
122
- port: undefined,
123
- pid: undefined,
124
- pidStartTime: undefined,
125
- stoppedAt: event.timestamp,
126
- })
127
- changed = true
130
+ const running = entry.status === 'starting' || entry.status === 'ready'
131
+ // Any queued revival died with the runtime that held its timer.
132
+ if (!running && entry.restartAt === undefined) continue
133
+
134
+ const updated: ServiceEntry = {
135
+ ...entry,
136
+ restartAt: undefined,
137
+ restartAttempt: undefined,
138
+ restartMaxRetries: undefined,
139
+ }
140
+ if (running) {
141
+ updated.status = 'stopped'
142
+ updated.port = undefined
143
+ updated.pid = undefined
144
+ updated.pidStartTime = undefined
145
+ updated.stoppedAt = event.timestamp
128
146
  }
147
+ newServices.set(serviceType, updated)
148
+ changed = true
129
149
  }
130
150
  return changed ? newServices : services
131
151
  }
@@ -151,9 +171,22 @@ export const servicePlugin = definePlugin('services')
151
171
  command: details.command,
152
172
  pid: details.pid,
153
173
  pidStartTime: details.pidStartTime,
174
+ restartAt: details.restartAt,
175
+ restartAttempt: details.restartAttempt,
176
+ restartMaxRetries: details.restartMaxRetries,
154
177
  }))
155
- // Broadcast service status to connected clients (DO → SPA) via WS
156
- ctx.notify('serviceStatus', { sessionId: String(sessionId), serviceType, status, port: details.port })
178
+ // Broadcast service status to connected clients (DO → SPA) via WS.
179
+ // restartAt rides along so a consumer can hold on to a preview URL it
180
+ // would otherwise tear down on `failed`.
181
+ ctx.notify('serviceStatus', {
182
+ sessionId: String(sessionId),
183
+ serviceType,
184
+ status,
185
+ port: details.port,
186
+ restartAt: details.restartAt,
187
+ restartAttempt: details.restartAttempt,
188
+ restartMaxRetries: details.restartMaxRetries,
189
+ })
157
190
  }
158
191
  return { executor }
159
192
  })
@@ -378,7 +411,10 @@ export const servicePlugin = definePlugin('services')
378
411
  .sessionHook('onSessionClose', async (ctx) => {
379
412
  for (const svcConfig of ctx.pluginConfig.services) {
380
413
  const status = ctx.pluginContext.executor.getStatus(svcConfig.type)
381
- if (status === 'ready' || status === 'starting') {
414
+ // A queued revival outlives the session otherwise: its timer holds the
415
+ // executor alive and spawns a process into a session that is already gone.
416
+ const revivalQueued = ctx.pluginContext.executor.hasScheduledRestart(svcConfig.type)
417
+ if (status === 'ready' || status === 'starting' || revivalQueued) {
382
418
  await ctx.pluginContext.executor.stop(svcConfig.type, ctx.sessionId)
383
419
  }
384
420
  }
@@ -395,7 +431,7 @@ export const servicePlugin = definePlugin('services')
395
431
  createTool({
396
432
  name: 'service_start',
397
433
  description:
398
- `Start a stopped or failed session service. Only call this if the session context shows the service is not running — if it is already "ready", you do not need to start it. Available services: ${serviceList}`,
434
+ `Start a stopped or failed session service. Only call this if the session context shows the service is not running — if it is already "ready", or marked as restarting automatically, you do not need to start it. Available services: ${serviceList}`,
399
435
  input: z.object({
400
436
  serviceType: z.string().describe('Service type identifier'),
401
437
  }),
@@ -42,6 +42,14 @@ export function buildServiceStatusMessage(
42
42
  if (entry?.error) {
43
43
  line += ` (error: ${entry.error})`
44
44
  }
45
+ // A queued revival means the service comes back on its own — say so, or the
46
+ // agent burns a turn restarting by hand what was about to restart anyway.
47
+ if (entry?.restartAt !== undefined) {
48
+ const attempt = entry.restartAttempt !== undefined && entry.restartMaxRetries !== undefined
49
+ ? ` (attempt ${entry.restartAttempt}/${entry.restartMaxRetries})`
50
+ : ''
51
+ line += ` — restarting automatically${attempt}, no action needed`
52
+ }
45
53
  // Deliberately no cwd: it is a real host path, while sandboxed agents see the
46
54
  // world through virtual roots — leaking it sends them chasing paths that
47
55
  // don't exist in their namespace (see webmaster path-identity incident).
@@ -101,6 +101,28 @@ export interface ServiceConfig {
101
101
  logBufferSize?: number
102
102
  /** Startup timeout in ms — service marked as failed if not ready within this time (default: 30000) */
103
103
  startupTimeoutMs?: number
104
+ /**
105
+ * Bring the service back after an unexpected exit, on the port it already
106
+ * holds. Omitted means what has always happened: the service is left
107
+ * `failed` and waits for someone to call restart.
108
+ *
109
+ * For a long-running service nobody is watching — a dev server behind a
110
+ * preview URL — a crash otherwise means the URL stays dead until a human
111
+ * or an agent notices. The retry budget is per consecutive failure and
112
+ * resets once the service has stayed up for `healthyAfterMs`, so a process
113
+ * that dies repeatedly at startup still ends up `failed` instead of
114
+ * looping forever.
115
+ */
116
+ restartPolicy?: {
117
+ /** Consecutive automatic restarts before giving up (default: 3) */
118
+ maxRetries?: number
119
+ /** Delay before the first retry; doubles per attempt (default: 1000) */
120
+ initialDelayMs?: number
121
+ /** Ceiling for the doubling (default: 30000) */
122
+ maxDelayMs?: number
123
+ /** Uptime that counts as healthy and resets the budget (default: 60000) */
124
+ healthyAfterMs?: number
125
+ }
104
126
  /** Auto-pause configuration (interface prepared, not yet implemented) */
105
127
  autoPause?: { inactivityMs: number }
106
128
  /**
@@ -133,6 +155,17 @@ export interface ServiceEntry {
133
155
  startedAt?: number
134
156
  readyAt?: number
135
157
  stoppedAt?: number
158
+ /**
159
+ * When a `restartPolicy` revival is queued, the epoch ms at which it fires.
160
+ * Present only while the revival is pending — any later status change clears
161
+ * it — so `failed` with `restartAt` set means "down, but coming back on its
162
+ * own" and nobody downstream has to treat it as terminal.
163
+ */
164
+ restartAt?: number
165
+ /** 1-based attempt number of the queued revival. */
166
+ restartAttempt?: number
167
+ /** Retry budget the queued revival counts against. */
168
+ restartMaxRetries?: number
136
169
  /** Process group PID (tracked for orphan cleanup after restart) */
137
170
  pid?: number
138
171
  /**