@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.
- package/dist/bootstrap.d.ts +36 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/core/agents/agent.d.ts +11 -0
- package/dist/core/agents/agent.d.ts.map +1 -1
- package/dist/core/agents/agent.js +26 -5
- package/dist/core/agents/agent.js.map +1 -1
- package/dist/core/agents/state.d.ts +24 -0
- package/dist/core/agents/state.d.ts.map +1 -1
- package/dist/core/agents/state.js.map +1 -1
- package/dist/core/context/state.d.ts.map +1 -1
- package/dist/core/context/state.js +4 -0
- package/dist/core/context/state.js.map +1 -1
- package/dist/core/llm/anthropic.d.ts.map +1 -1
- package/dist/core/llm/anthropic.js +49 -8
- package/dist/core/llm/anthropic.js.map +1 -1
- package/dist/core/llm/model-shortlist-live.test.d.ts +35 -0
- package/dist/core/llm/model-shortlist-live.test.d.ts.map +1 -0
- package/dist/core/llm/model-shortlist-live.test.js +452 -0
- package/dist/core/llm/model-shortlist-live.test.js.map +1 -0
- package/dist/core/llm/openrouter-mapping.test.js +18 -0
- package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
- package/dist/core/llm/openrouter.d.ts.map +1 -1
- package/dist/core/llm/openrouter.js +20 -11
- package/dist/core/llm/openrouter.js.map +1 -1
- package/dist/core/llm/provider.d.ts +34 -3
- package/dist/core/llm/provider.d.ts.map +1 -1
- package/dist/core/llm/provider.js.map +1 -1
- package/dist/core/llm/reasoning-details.test.d.ts +2 -0
- package/dist/core/llm/reasoning-details.test.d.ts.map +1 -0
- package/dist/core/llm/reasoning-details.test.js +144 -0
- package/dist/core/llm/reasoning-details.test.js.map +1 -0
- package/dist/core/llm/state.d.ts +2 -0
- package/dist/core/llm/state.d.ts.map +1 -1
- package/dist/core/llm/state.js +11 -0
- package/dist/core/llm/state.js.map +1 -1
- package/dist/core/llm/thinking-blocks.test.d.ts +2 -0
- package/dist/core/llm/thinking-blocks.test.d.ts.map +1 -0
- package/dist/core/llm/thinking-blocks.test.js +291 -0
- package/dist/core/llm/thinking-blocks.test.js.map +1 -0
- package/dist/core/preset/index.d.ts +1 -0
- package/dist/core/preset/index.d.ts.map +1 -1
- package/dist/core/preset/index.js +2 -0
- package/dist/core/preset/index.js.map +1 -1
- package/dist/core/preset/overrides.d.ts +14 -0
- package/dist/core/preset/overrides.d.ts.map +1 -0
- package/dist/core/preset/overrides.js +20 -0
- package/dist/core/preset/overrides.js.map +1 -0
- package/dist/core/reasoning-round-trip.integration.test.d.ts +2 -0
- package/dist/core/reasoning-round-trip.integration.test.d.ts.map +1 -0
- package/dist/core/reasoning-round-trip.integration.test.js +150 -0
- package/dist/core/reasoning-round-trip.integration.test.js.map +1 -0
- package/dist/core/sessions/session-manager.d.ts +2 -0
- package/dist/core/sessions/session-manager.d.ts.map +1 -1
- package/dist/core/sessions/session-manager.js +17 -5
- package/dist/core/sessions/session-manager.js.map +1 -1
- package/dist/core/sessions/session-overrides.integration.test.d.ts +2 -0
- package/dist/core/sessions/session-overrides.integration.test.d.ts.map +1 -0
- package/dist/core/sessions/session-overrides.integration.test.js +154 -0
- package/dist/core/sessions/session-overrides.integration.test.js.map +1 -0
- package/dist/core/sessions/session.d.ts +10 -1
- package/dist/core/sessions/session.d.ts.map +1 -1
- package/dist/core/sessions/session.js +15 -0
- package/dist/core/sessions/session.js.map +1 -1
- package/dist/core/sessions/state.d.ts +57 -0
- package/dist/core/sessions/state.d.ts.map +1 -1
- package/dist/core/sessions/state.js +53 -0
- package/dist/core/sessions/state.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/services/plugin.d.ts +3 -0
- package/dist/plugins/services/plugin.d.ts.map +1 -1
- package/dist/plugins/services/plugin.js +50 -14
- package/dist/plugins/services/plugin.js.map +1 -1
- package/dist/plugins/services/prompt.d.ts.map +1 -1
- package/dist/plugins/services/prompt.js +8 -0
- package/dist/plugins/services/prompt.js.map +1 -1
- package/dist/plugins/services/schema.d.ts +33 -0
- package/dist/plugins/services/schema.d.ts.map +1 -1
- package/dist/plugins/services/service.d.ts +20 -0
- package/dist/plugins/services/service.d.ts.map +1 -1
- package/dist/plugins/services/service.js +88 -1
- package/dist/plugins/services/service.js.map +1 -1
- package/dist/plugins/services/services.integration.test.js +254 -0
- package/dist/plugins/services/services.integration.test.js.map +1 -1
- package/dist/plugins/session-lifecycle/plugin.d.ts +36 -0
- package/dist/plugins/session-lifecycle/plugin.d.ts.map +1 -1
- package/dist/plugins/session-lifecycle/plugin.js +53 -1
- package/dist/plugins/session-lifecycle/plugin.js.map +1 -1
- package/dist/testing/test-harness.d.ts +12 -2
- package/dist/testing/test-harness.d.ts.map +1 -1
- package/dist/testing/test-harness.js +13 -2
- package/dist/testing/test-harness.js.map +1 -1
- package/package.json +3 -3
- package/src/core/agents/agent.ts +28 -5
- package/src/core/agents/state.ts +26 -0
- package/src/core/context/state.ts +4 -0
- package/src/core/llm/anthropic.ts +71 -10
- package/src/core/llm/model-shortlist-live.test.ts +577 -0
- package/src/core/llm/openrouter-mapping.test.ts +24 -0
- package/src/core/llm/openrouter.ts +28 -12
- package/src/core/llm/provider.ts +44 -3
- package/src/core/llm/reasoning-details.test.ts +177 -0
- package/src/core/llm/state.ts +12 -0
- package/src/core/llm/thinking-blocks.test.ts +345 -0
- package/src/core/preset/index.ts +3 -0
- package/src/core/preset/overrides.ts +24 -0
- package/src/core/reasoning-round-trip.integration.test.ts +188 -0
- package/src/core/sessions/session-manager.ts +22 -3
- package/src/core/sessions/session-overrides.integration.test.ts +210 -0
- package/src/core/sessions/session.ts +19 -1
- package/src/core/sessions/state.ts +82 -0
- package/src/index.ts +9 -1
- package/src/plugins/services/plugin.ts +50 -14
- package/src/plugins/services/prompt.ts +8 -0
- package/src/plugins/services/schema.ts +33 -0
- package/src/plugins/services/service.ts +113 -1
- package/src/plugins/services/services.integration.test.ts +270 -0
- package/src/plugins/session-lifecycle/plugin.ts +59 -1
- 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 {
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|