refactor(agent): own model selection on each agent

This commit is contained in:
_Kerman committed 2026-10-20 21:09:09 +08:00
1 parent 639602e63a
commit a9c006f9c3
237 files changed
+1051 -1734

No files matched your search

@@ -2,7 +2,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
import { unsupportedModel, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-workspace'
@@ -31,6 +31,7 @@ async function liveAgent(ctx: Context, session: Session): Promise<Agent> {
id: session.id,
options: {},
session,
model: unsupportedModel(),
inbox: unsupportedInbox(),
status: 'idle',
ctx,
@@ -17,7 +17,7 @@ import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/
import { describe, expect, it, vi } from 'vitest'
import { ApiSessionAgentController } from '../src/agent.ts'
import { SessionCommandController } from '../src/commands.ts'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import { unsupportedModel, createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
declare module '@deepseek-ai/dsh-llm' {
@@ -92,6 +92,7 @@ async function commandHarness(
send: vi.fn(),
inject: vi.fn(),
session,
model: unsupportedModel(),
inbox,
status: 'running',
ctx,
@@ -1,9 +1,9 @@
import { AgentModel, modelSelectionProjectionDefinition } from '@deepseek-ai/dsh-agent'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Context, FiberState } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as AgentModelPlugin from '@deepseek-ai/dsh-agent-model'
import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment'
import type {
FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment,
@@ -38,7 +38,6 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandRuntime)
const session = ctx.sessions.create(SESSION, {
@@ -46,14 +45,17 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
})
const inbox = createInboxStub()
const followup = vi.fn()
const options = { provider: 'fixture', model: 'fixture-model' }
const projection = await ctx.sessionProjections.register(modelSelectionProjectionDefinition)
const agent = {
id: session.id,
options: { provider: 'fixture', model: 'fixture-model' },
options,
whenIdle: vi.fn(() => Promise.resolve()),
runMaintenance: async () => { throw new Error('unexpected maintenance') },
send: vi.fn(),
inject: vi.fn(),
session,
model: new AgentModel(() => projection.stateOf(session), session, options),
inbox,
status: 'idle',
ctx,
@@ -2,7 +2,6 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as AgentModelPlugin from '@deepseek-ai/dsh-agent-model'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
@@ -26,7 +25,6 @@ describe('SessionController facade', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const sessionId = SessionId('controller-session')
const header: SessionHeader = {
@@ -3,6 +3,7 @@
* metadata-only listing, Agent-free history reads, subagent ownership
* isolation, and prompt failure mapping.
*/
import { AgentModel, modelSelectionProjectionDefinition } from '@deepseek-ai/dsh-agent'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
@@ -17,8 +18,7 @@ import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { createInboxStub, mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
import type { Agent, Inbox } from '@deepseek-ai/dsh-agent'
import * as AgentModelPlugin from '@deepseek-ai/dsh-agent-model'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import AttachmentStore from '@deepseek-ai/dsh-attachment'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import {
@@ -84,7 +84,6 @@ describe('sessions.list cold merge', () => {
try {
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const historical = header('format-historical', 100)
const current = header('format-current', 200)
@@ -251,7 +250,6 @@ describe('attached updatedAt tracks human prompts', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await new Promise(resolve => setTimeout(resolve, 0))
@@ -397,7 +395,6 @@ describe('Remote Agent and Session lookup policy', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
@@ -419,7 +416,6 @@ describe('Remote Agent and Session lookup policy', () => {
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const sessionId = sid('session-remote-cold')
const meta = header(sessionId, 1000)
@@ -461,7 +457,6 @@ describe('Remote Agent and Session lookup policy', () => {
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const coldId = sid('session-remote-cold-child')
const coldMeta = header(coldId, 1000, {
@@ -507,7 +502,6 @@ describe('Remote Agent and Session lookup policy', () => {
await ctx.plugin(TypertRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const sessionId = sid('session-remote-resumed-child')
const meta = header(sessionId, 1000)
@@ -544,7 +538,6 @@ describe('subagent ownership fence', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const sessionId = sid('session-child')
const meta = header('session-child', 1000, {
@@ -624,7 +617,6 @@ describe('subagent ownership fence', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const sessionId = sid('session-legacy-child')
const meta = header('session-legacy-child', 1000, {
@@ -663,7 +655,6 @@ describe('subagent ownership fence', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } })
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
@@ -724,7 +715,6 @@ describe('subagent ownership fence', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(sid('session-ordinary-fork'), {
seed: [{
@@ -737,9 +727,14 @@ describe('subagent ownership fence', () => {
inheritedEventCount: SessionLogOffset(1),
})
const followup = vi.fn()
const agent = {
id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup,
} as unknown as Agent
const agent: Agent = {
id: session.id, session, model: await modelFor(ctx, session),
options: { provider: 'p', model: 'm' },
inbox: inboxFor(), status: 'idle', ctx, followup,
cancel: vi.fn(), whenIdle: () => Promise.resolve(),
runMaintenance: async () => { throw new Error('unexpected maintenance') },
send: vi.fn(), inject: vi.fn(), steer: vi.fn(),
}
await ctx.agents.register(agent)
const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
@@ -756,13 +751,17 @@ describe('subagent ownership fence', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } })
const followup = vi.fn()
const agent = {
id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup,
} as unknown as Agent
const agent: Agent = {
id: session.id, session, model: await modelFor(ctx, session),
options: { provider: 'p', model: 'm' },
inbox: inboxFor(), status: 'idle', ctx, followup,
cancel: vi.fn(), whenIdle: () => Promise.resolve(),
runMaintenance: async () => { throw new Error('unexpected maintenance') },
send: vi.fn(), inject: vi.fn(), steer: vi.fn(),
}
await ctx.agents.register(agent)
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
@@ -829,7 +828,6 @@ describe('degenerate composition (no persistence, no factory)', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
@@ -852,7 +850,6 @@ describe('degenerate composition (no persistence, no factory)', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const inspect = vi.fn()
const stat = vi.fn(() => Promise.resolve(undefined))
@@ -875,25 +872,31 @@ describe('degenerate composition (no persistence, no factory)', () => {
})
})
async function modelFor(ctx: Context, session: Session): Promise<AgentModel> {
const projection = await ctx.sessionProjections.register(modelSelectionProjectionDefinition)
return new AgentModel(() => projection.stateOf(session), session, { provider: 'p', model: 'm' })
}
describe('sessions.prompt synchronous rejection', () => {
it('rejects content without non-whitespace text or an attachment before delivery or Session events', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(sid('session-empty-prompt'))
const options = { provider: 'p', model: 'm' }
const followup = vi.fn()
const steer = vi.fn()
await ctx.agents.register({
id: session.id,
options: { provider: 'p', model: 'm' },
options,
whenIdle: vi.fn(() => Promise.resolve()),
runMaintenance: async () => { throw new Error('unexpected maintenance') },
send: vi.fn(),
inject: vi.fn(),
cancel: vi.fn(),
session,
model: await modelFor(ctx, session),
inbox: inboxFor(),
status: 'idle',
ctx,
@@ -984,7 +987,6 @@ describe('sessions.prompt synchronous rejection', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(sid('session-throwing'))
// A live structural stub whose delivery verbs throw synchronously, the
@@ -992,12 +994,17 @@ describe('sessions.prompt synchronous rejection', () => {
await ctx.agents.register({
id: session.id,
session,
model: await modelFor(ctx, session),
inbox: inboxFor(),
status: 'idle',
ctx,
options: { provider: 'p', model: 'm' },
cancel: vi.fn(), whenIdle: () => Promise.resolve(),
runMaintenance: async () => { throw new Error('unexpected maintenance') },
send: vi.fn(), inject: vi.fn(),
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
} as unknown as Agent)
})
const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
for (const mode of ['queue', 'steer'] as const) {
@@ -1019,7 +1026,6 @@ describe('sessions.prompt synchronous rejection', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const sessionId = sid('race-resume')
const meta: SessionHeader = header('race-resume', 1000)
@@ -6,7 +6,6 @@ import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as AgentModelPlugin from '@deepseek-ai/dsh-agent-model'
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { ToolCallId, createMessage, createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
@@ -34,7 +33,6 @@ async function composed(workspaces: readonly Workspace[] = []): Promise<Context>
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
ctx.provide('workspaceRegistry', { list: () => workspaces, archivedSessionIds: [] } as never)
@@ -665,7 +663,7 @@ describe('sessions.fork', () => {
const child = ctx.agents.get(response.value.sessionId)
if (child === undefined) throw new Error('fork did not publish the child agent')
expect(child.session.snapshotEvents().slice(0, child.session.inheritedEventCount)).toEqual(inherited)
expect(ctx.agentModel.resolveForNextStep(child)).toEqual({
expect(child.model.current()).toEqual({
provider: 'inherited-provider', model: 'inherited-model', reasoningEffort: 'high',
})
await ctx.fiber.dispose()
@@ -4,6 +4,7 @@
* catalog injection, unavailable-model refusals, and the prompt-assembly
* boundary for a running selection change.
*/
import { AgentModel, modelSelectionProjectionDefinition } from '@deepseek-ai/dsh-agent'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
@@ -11,7 +12,6 @@ import { describe, expect, it, vi, onTestFinished } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as AgentModelPlugin from '@deepseek-ai/dsh-agent-model'
import AttachmentStore from '@deepseek-ai/dsh-attachment'
import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
@@ -99,7 +99,6 @@ async function harness(logged?: {
await ctx.plugin(SystemPrompt, { personaPrefix: '' })
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [
{ provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' },
@@ -128,13 +127,16 @@ async function harness(logged?: {
reason: 'initial',
})
}
const projection = await ctx.sessionProjections.register(modelSelectionProjectionDefinition)
const options = {
get provider() { return ctx.agentDefaultModel.currentSelection().provider },
get model() { return ctx.agentDefaultModel.currentSelection().model },
get reasoningEffort() { return ctx.agentDefaultModel.currentSelection().reasoningEffort },
}
const agent = {
id: session.id,
options: {
get provider() { return ctx.agentDefaultModel.currentSelection().provider },
get model() { return ctx.agentDefaultModel.currentSelection().model },
get reasoningEffort() { return ctx.agentDefaultModel.currentSelection().reasoningEffort },
},
options,
model: new AgentModel(() => projection.stateOf(session), session, options),
session,
status: 'running',
ctx,
@@ -520,7 +522,7 @@ describe('Web session model selection', () => {
model: 'deepseek-reasoner',
reasoningEffort: 'max',
})
expect(ctx.agentModel.resolveForNextStep(agent)).toEqual(selected.selected)
expect(agent.model.current()).toEqual(selected.selected)
expect(agent.session.snapshotEvents().at(-1)).toMatchObject({
type: 'model/selection', data: selected.selected,
})
@@ -581,7 +583,10 @@ describe('Web session model selection', () => {
ctx.agents.setFactory({
async createAgent(ownerCtx, options) {
const session = ctx.sessions.create(options.sessionId)
const agent: Agent = { ...template, id: session.id, session, ctx: ownerCtx, options: options.agentOptions ?? {} }
const agentOptions = options.agentOptions ?? {}
const projection = await ownerCtx.sessionProjections.register(modelSelectionProjectionDefinition)
const model = new AgentModel(() => projection.stateOf(session), session, agentOptions)
const agent: Agent = { ...template, id: session.id, session, ctx: ownerCtx, options: agentOptions, model }
await options.setup?.(ownerCtx, agent)
await ctx.agents.register(agent)
return { agent, dispose: () => Promise.resolve() }
@@ -591,9 +596,9 @@ describe('Web session model selection', () => {
const first = expectValue(await remote.create({ cwd: '/tmp' }))
const second = expectValue(await remote.create({ cwd: '/tmp' }))
const agent = ctx.agents.get(first.sessionId)!
expect(ctx.agentModel.resolveForNextStep(agent)).toEqual(stored)
expect(agent.model.current()).toEqual(stored)
stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' }
expect(ctx.agentModel.resolveForNextStep(agent)).toEqual(stored)
expect(agent.model.current()).toEqual(stored)
const selection = { ...stored, reasoningEffort: ReasoningEffortId('high') }
// The explicit selection is identical to the live fallback, including effort.
stored = selection
@@ -601,7 +606,7 @@ describe('Web session model selection', () => {
expect(agent.session.snapshotEvents().filter(event => event.type === 'model/selection'))
.toMatchObject([{ data: selection }])
expectValue(await remote.selectModel({ sessionId: second.sessionId, provider: 'deepseek-official', model: 'deepseek-chat' }))
expect(ctx.agentModel.resolveForNextStep(agent)).toEqual(selection)
expect(agent.model.current()).toEqual(selection)
expect(currentSelection(ctx, first.sessionId)).toEqual(selection)
})
@@ -634,7 +639,7 @@ describe('Web session model selection', () => {
cwd: '/tmp',
})
expect(ctx.agentModel.resolveForNextStep(agent))
expect(agent.model.current())
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
await ctx.fiber.dispose()
})
@@ -31,7 +31,6 @@ import {
mountAgentLoopTestDependencies,
mountAgentLoopTestHarness,
} from '@deepseek-ai/dsh-agent-loop-testkit'
import * as AgentModelPlugin from '@deepseek-ai/dsh-agent-model'
import { createSessionTestRemote, testSessionPersistence, type TestSessionRemote } from './test-remote.ts'
const ownedContexts = new Set<Context>()
@@ -163,7 +162,6 @@ describe('session.history projections block', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
const parent = ctx.sessions.create(SessionId('wire-seed-parent'), { meta: { cwd: '/workspace' } })
parent.append('turn/start', { turn: 1 })
@@ -581,7 +579,6 @@ describe('session.list projections column', () => {
await ctx.plugin(StorageDomain, { backend: 'json' })
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
await ctx.sessionProjections.register(privatePromptUnit())
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
@@ -637,7 +634,6 @@ describe('session.list projections column', () => {
await ctx.plugin(StorageDomain, { backend: 'json' })
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentModelPlugin)
await ctx.plugin(AgentRegistry)
await ctx.sessionProjections.register(titleProjectionDefinition)
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })