From 2f122e7402c5271b1a1a2928cea7f2d4bc6840be Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Sun, 26 Jul 2026 19:02:05 -0400 Subject: [PATCH 1/2] fix: infer provider from model for long-horizon agent sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A LH/worker agent whose settings set `model` but left `provider` null ran fine (provider inferred at query time) but was hard-blocked from switching models with "Session has no provider configured" — even to the same model. buildLongHorizonAgentSessionConfig and the regular long-term-agent branch now infer the provider from the model (mirroring custom-agent.ts) when agent.provider is unset. model-switch-handler additionally infers the previous provider from the stored model instead of throwing, so stranded sessions can switch immediately instead of waiting for a wake/refresh. --- .../src/lib/agent/model-switch-handler.ts | 9 ++++++- .../space/runtime/space-runtime-service.ts | 24 +++++++++++++------ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/daemon/src/lib/agent/model-switch-handler.ts b/packages/daemon/src/lib/agent/model-switch-handler.ts index a038dce2b..a87d89d36 100644 --- a/packages/daemon/src/lib/agent/model-switch-handler.ts +++ b/packages/daemon/src/lib/agent/model-switch-handler.ts @@ -30,6 +30,7 @@ import { ErrorCategory } from '../error-manager'; import type { Logger } from '../logger'; import { isValidModel, resolveModelAlias, getModelInfo } from '../model-service'; import { getProviderRegistry } from '../providers/factory.js'; +import { inferProviderForModel } from '../providers/registry'; import { KimiProvider } from '../providers/kimi-provider.js'; import { stripThinkingBlocksFromSessionFile } from '../sdk-session-file-manager'; import type { ContextTracker } from './context-tracker'; @@ -172,7 +173,13 @@ export class ModelSwitchHandler { } = this.ctx; const previousModel = session.config.model; - const previousProvider = session.config.provider; + // Infer the provider from the stored model when the session config has none. + // Long-horizon/worker agent sessions created before provider inference was added + // may have a model but a blank provider; without this they are hard-blocked from + // ever switching models. Downstream usages (alias resolution, acp handling) only + // become more correct with the inferred value. + const previousProvider = + session.config.provider ?? (previousModel ? inferProviderForModel(previousModel) : undefined); const previousAcpSessionId = session.acpSessionId; const previousSdkSessionId = session.sdkSessionId; const previousSdkOriginPath = session.sdkOriginPath; diff --git a/packages/daemon/src/lib/space/runtime/space-runtime-service.ts b/packages/daemon/src/lib/space/runtime/space-runtime-service.ts index 978079583..56b6a0c38 100644 --- a/packages/daemon/src/lib/space/runtime/space-runtime-service.ts +++ b/packages/daemon/src/lib/space/runtime/space-runtime-service.ts @@ -58,6 +58,7 @@ import { createSpaceAgentMcpServer } from '../tools/space-agent-tools'; import type { ReplyRoutingRegistry } from './reply-routing-registry'; import { buildSpaceChatSystemPrompt } from '../agents/space-chat-agent'; import { resolveCustomAgentPrompt } from '../agents/custom-agent'; +import { inferProviderForModel } from '../../providers/registry'; import { Logger } from '../../logger'; import { createDbQueryMcpServer, type DbQueryMcpServer } from '../../db-query/tools'; import { createAgentMemoryMcpServer } from '../tools/agent-memory-tools'; @@ -515,12 +516,19 @@ export class SpaceRuntimeService { const customDisallowedBuiltins = deriveWorkerDisallowedTools(customTools); const agentKey = sanitizeLongTermAgentKey(agent.displayName); + const model = + agent.model ?? + space.defaultModel ?? + (agent.provider ? undefined : DEFAULT_LONG_HORIZON_AGENT_MODEL); + // Infer provider from the model when the agent record has no explicit provider, + // mirroring the worker agent pattern (custom-agent.ts). Without this, a LH agent + // whose settings only set `model` runs fine but is hard-blocked from switching + // models ("Session has no provider configured"). + const provider = (agent.provider ?? + (model ? inferProviderForModel(model) : undefined)) as Session['config']['provider']; return { - model: - agent.model ?? - space.defaultModel ?? - (agent.provider ? undefined : DEFAULT_LONG_HORIZON_AGENT_MODEL), - provider: (agent.provider ?? undefined) as Session['config']['provider'], + model, + provider, thinkingLevel: agent.thinkingLevel ?? undefined, systemPrompt: { type: 'preset', @@ -646,9 +654,11 @@ export class SpaceRuntimeService { const customTools = agent.tools; const customDisallowedBuiltins = deriveWorkerDisallowedTools(customTools); const agentKey = sanitizeLongTermAgentKey(agent.name); + const model = agent.model ?? space.defaultModel; const regularAgentConfig: Partial = { - model: agent.model ?? space.defaultModel, - provider: agent.provider as Session['config']['provider'], + model, + provider: (agent.provider ?? + (model ? inferProviderForModel(model) : undefined)) as Session['config']['provider'], thinkingLevel: agent.thinkingLevel, systemPrompt: { type: 'preset', From 121e1c5ec62efdaedc9c22bcaf0e572ed80e6be8 Mon Sep 17 00:00:00 2001 From: Marc Liu Date: Sun, 26 Jul 2026 19:02:48 -0400 Subject: [PATCH 2/2] test: add provider-inference regression tests for LH agent model switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers buildLongHorizonAgentSessionConfig inference (kimi + non-kimi + explicit-provider-wins + default fallback), the regular worker-agent branch, refreshLongHorizonAgentSessionConfig self-heal (undefined → kimi), and the model-switch-handler guard inferring the provider from the stored model — including the kimi → kimi same-model case from the bug report. --- .../1-core/agent/model-switch-handler.test.ts | 33 ++- .../runtime/space-runtime-service.test.ts | 223 +++++++++++++++++- 2 files changed, 253 insertions(+), 3 deletions(-) diff --git a/packages/daemon/tests/unit/1-core/agent/model-switch-handler.test.ts b/packages/daemon/tests/unit/1-core/agent/model-switch-handler.test.ts index 95cee76f3..f2e557d19 100644 --- a/packages/daemon/tests/unit/1-core/agent/model-switch-handler.test.ts +++ b/packages/daemon/tests/unit/1-core/agent/model-switch-handler.test.ts @@ -511,9 +511,10 @@ describe('ModelSwitchHandler', () => { expect(handleErrorSpy).toHaveBeenCalled(); }); - it('should return error when session has no provider configured', async () => { - // Remove provider from session config + it('should return error when session has no provider and no model to infer from', async () => { + // No provider AND no model that could infer one — the guard still throws. (mockSession.config as Record).provider = undefined; + (mockSession.config as Record).model = undefined; handler = createHandler({ queryObject: null }); const result = await handler.switchModel(VALID_MODEL, 'anthropic'); @@ -521,6 +522,34 @@ describe('ModelSwitchHandler', () => { expect(result.success).toBe(false); expect(result.error).toContain('Session has no provider configured'); }); + + it('infers provider from the stored model when provider is empty (Task #768)', async () => { + // Long-horizon/worker agent session: model is set but provider was never + // stored. Previously this was hard-blocked with "Session has no provider + // configured"; the guard now infers the provider from the model. + mockSession.config.model = 'glm-5'; + (mockSession.config as Record).provider = undefined; + handler = createHandler({ queryObject: null }); + + const result = await handler.switchModel(VALID_MODEL, 'anthropic'); + + expect(result.success).toBe(true); + expect(mockSession.config.provider).toBe('anthropic'); + }); + + it('switches a kimi session whose provider was never stored, even to the same model (Task #768 regression)', async () => { + // Reproduces the original bug report: an LH agent with model='kimi-...' and a + // blank provider could not switch models — not even kimi → kimi. + mockSession.config.model = 'kimi-k3[1m]'; + (mockSession.config as Record).provider = undefined; + handler = createHandler({ queryObject: null }); + + const result = await handler.switchModel('k3[1m]', 'kimi'); + + expect(result.success).toBe(true); + expect(mockSession.config.model).toBe('kimi-k3[1m]'); + expect(mockSession.config.provider).toBe('kimi'); + }); }); describe('context tracker update', () => { diff --git a/packages/daemon/tests/unit/5-space/runtime/space-runtime-service.test.ts b/packages/daemon/tests/unit/5-space/runtime/space-runtime-service.test.ts index 7ec34373a..c56c74198 100644 --- a/packages/daemon/tests/unit/5-space/runtime/space-runtime-service.test.ts +++ b/packages/daemon/tests/unit/5-space/runtime/space-runtime-service.test.ts @@ -27,7 +27,13 @@ import type { TaskAgentManager } from '../../../../src/lib/space/runtime/task-ag import type { SessionManager } from '../../../../src/lib/session-manager.ts'; import type { AgentSession } from '../../../../src/lib/agent/agent-session.ts'; import type { NodeExecutionRepository } from '../../../../src/storage/repositories/node-execution-repository.ts'; -import type { McpServerConfig, Session, Space } from '@hyperneo/shared'; +import type { + McpServerConfig, + Session, + Space, + SpaceLongHorizonAgent, + SpaceWorkerAgent, +} from '@hyperneo/shared'; import { createTables, runMigrations } from '../../../../src/storage/schema/index.ts'; import { SpaceWorkflowRepository } from '../../../../src/storage/repositories/space-workflow-repository.ts'; import { SpaceWorkflowRunRepository as SpaceWorkflowRunRepo } from '../../../../src/storage/repositories/space-workflow-run-repository.ts'; @@ -89,6 +95,32 @@ function makeNoopNodeExecutionRepo(): NodeExecutionRepository { } as unknown as NodeExecutionRepository; } +// Minimal long-horizon agent record for config-builder tests. Only the fields the +// builder reads are populated; the rest are defaulted and cast. +function buildLongHorizonAgent( + overrides: Partial = {} +): SpaceLongHorizonAgent { + return { + id: 'lh-agent-1', + spaceId: 'space-1', + handle: 'researcher', + displayName: 'Researcher', + templateKey: null, + status: 'active', + sessionId: null, + instructions: '', + autonomyLevel: null, + model: null, + thinkingLevel: null, + provider: null, + settingSources: null, + toolPermissions: { mode: 'inherit', tools: [] }, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + } as SpaceLongHorizonAgent; +} + // ─── Tests ──────────────────────────────────────────────────────────────────── describe('SpaceRuntimeService', () => { @@ -2202,3 +2234,192 @@ describe('activateWorkflowNode() — InternalEventBus forwarding', () => { } }); }); + +// ─── Long-horizon agent provider inference (Task #768) ────────────────────── +// +// A LH/worker agent whose settings set `model` but left `provider` null used to +// run fine (provider inferred at query time) but was hard-blocked from switching +// models ("Session has no provider configured"). The config builders now infer +// the provider from the model, mirroring the worker pattern in custom-agent.ts. + +describe('buildLongHorizonAgentSessionConfig — provider inference (Task #768)', () => { + const service = new SpaceRuntimeService(buildConfig(createMockSpaceManager(mockSpace))); + + function callBuilder(agent: SpaceLongHorizonAgent): Partial { + return ( + service as unknown as { + buildLongHorizonAgentSessionConfig: ( + space: Space, + a: SpaceLongHorizonAgent + ) => Partial; + } + ).buildLongHorizonAgentSessionConfig(mockSpace, agent); + } + + test('infers kimi provider when model is kimi and provider is unset', () => { + const config = callBuilder(buildLongHorizonAgent({ model: 'kimi-for-coding' })); + expect(config.model).toBe('kimi-for-coding'); + expect(config.provider).toBe('kimi'); + }); + + test('infers anthropic provider for a non-kimi model when provider is unset', () => { + const config = callBuilder(buildLongHorizonAgent({ model: 'claude-sonnet-4.6' })); + expect(config.model).toBe('claude-sonnet-4.6'); + expect(config.provider).toBe('anthropic'); + }); + + test('explicit agent.provider wins over inference', () => { + const config = callBuilder( + buildLongHorizonAgent({ model: 'kimi-for-coding', provider: 'openrouter' }) + ); + expect(config.provider).toBe('openrouter'); + }); + + test('falls back to the default model + inferred anthropic provider when neither is set', () => { + const config = callBuilder(buildLongHorizonAgent({})); + expect(config.model).toBe('claude-sonnet-4-6'); // DEFAULT_LONG_HORIZON_AGENT_MODEL + expect(config.provider).toBe('anthropic'); + }); +}); + +describe('refreshLongHorizonAgentSessionConfig — self-heals undefined provider (Task #768)', () => { + const service = new SpaceRuntimeService(buildConfig(createMockSpaceManager(mockSpace))); + const refresh = () => + service as unknown as { + refreshLongHorizonAgentSessionConfig: ( + session: AgentSession, + config: Partial + ) => Promise; + }; + + test('updates provider undefined → kimi on the next wake', async () => { + // A stranded session (provider never stored) is refreshed with a config whose + // provider was inferred from the model. refresh must detect the change and + // call updateConfig + resetQuery so the session repairs itself. + const updateConfig = mock(async () => {}); + const resetQuery = mock(async () => ({ success: true })); + const stranded = { + getSessionData: () => ({ config: { model: 'kimi-for-coding', provider: undefined } }), + updateConfig, + resetQuery, + } as unknown as AgentSession; + + const built = ( + service as unknown as { + buildLongHorizonAgentSessionConfig: ( + s: Space, + a: SpaceLongHorizonAgent + ) => Partial; + } + ).buildLongHorizonAgentSessionConfig( + mockSpace, + buildLongHorizonAgent({ model: 'kimi-for-coding' }) + ); + + await refresh().refreshLongHorizonAgentSessionConfig(stranded, built); + + expect(updateConfig).toHaveBeenCalledTimes(1); + expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({ provider: 'kimi' })); + expect(resetQuery).toHaveBeenCalledWith({ restartQuery: true }); + }); + + test('is a no-op when the provider already matches', async () => { + const updateConfig = mock(async () => {}); + const resetQuery = mock(async () => ({ success: true })); + const session = { + getSessionData: () => ({ config: { model: 'kimi-for-coding', provider: 'kimi' } }), + updateConfig, + resetQuery, + } as unknown as AgentSession; + + await refresh().refreshLongHorizonAgentSessionConfig(session, { + model: 'kimi-for-coding', + provider: 'kimi', + }); + + expect(updateConfig).not.toHaveBeenCalled(); + expect(resetQuery).not.toHaveBeenCalled(); + }); +}); + +describe('ensureLongTermAgentSession — regular worker agent provider inference (Task #768)', () => { + async function captureRegularAgentConfig( + agent: SpaceWorkerAgent + ): Promise | undefined> { + const createdConfigs: Partial[] = []; + let lookups = 0; + const sessionMock = { + getSessionData: () => ({ id: 'sess-1', metadata: {} }), + mergeRuntimeMcpServers: () => {}, + }; + const sessionManager = { + getSessionAsync: mock(async () => { + lookups += 1; + return lookups === 1 ? null : sessionMock; + }), + createSession: mock(async (opts: { config: Partial }) => { + createdConfigs.push(opts.config); + }), + } as unknown as SessionManager; + const spaceAgentManager = { + getById: mock(() => agent), + } as unknown as SpaceAgentManager; + + const svc = new SpaceRuntimeService({ + ...buildConfig(createMockSpaceManager(mockSpace)), + sessionManager, + spaceAgentManager, + }); + // longHorizonAgentRepo intentionally unset → regular worker-agent branch. + // Avoid real MCP wiring; the config under test is built before attach runs. + ( + svc as unknown as { attachLongTermAgentMcpServers: () => void } + ).attachLongTermAgentMcpServers = () => {}; + ( + svc as unknown as { missingLongTermAgentMcpServers: () => boolean } + ).missingLongTermAgentMcpServers = () => false; + + await ( + svc as unknown as { ensureLongTermAgentSession: (a: ActorRef) => Promise } + ).ensureLongTermAgentSession({ + actorId: `agent:${agent.id}`, + spaceId: agent.spaceId, + } as ActorRef); + + return createdConfigs[0]; + } + + test('infers kimi provider for a kimi model with no explicit provider', async () => { + const config = await captureRegularAgentConfig({ + id: 'worker-1', + spaceId: 'space-1', + name: 'Worker', + model: 'kimi-for-coding', + provider: null, + thinkingLevel: null, + customPrompt: '', + tools: [], + settingSources: null, + } as unknown as SpaceWorkerAgent); + + expect(config).toBeDefined(); + expect(config?.model).toBe('kimi-for-coding'); + expect(config?.provider).toBe('kimi'); + }); + + test('explicit provider wins over inference for regular agents', async () => { + const config = await captureRegularAgentConfig({ + id: 'worker-2', + spaceId: 'space-1', + name: 'Worker', + model: 'kimi-for-coding', + provider: 'openrouter', + thinkingLevel: null, + customPrompt: '', + tools: [], + settingSources: null, + } as unknown as SpaceWorkerAgent); + + expect(config?.provider).toBe('openrouter'); + }); +});