diff --git a/docs/architecture/proactive-multi-agent-orchestration/spec.md b/docs/architecture/proactive-multi-agent-orchestration/spec.md index 215161802a..39544fc280 100644 --- a/docs/architecture/proactive-multi-agent-orchestration/spec.md +++ b/docs/architecture/proactive-multi-agent-orchestration/spec.md @@ -202,7 +202,13 @@ Host enforcement, not prompt wording, owns delegation consent. Generation settings and safety state have different lifetimes: -- model and generation settings are frozen when each child turn starts; +- self-target children inherit the parent Session model and generation settings; +- cross-agent DeepChat children use the target Agent's default model when configured, otherwise + they inherit the parent model. An unavailable configured model fails instead of falling back; +- switching to a different target model uses that model's generation defaults and the target + Agent's system prompt, without copying parent reasoning or token/context overrides; +- initial model and generation settings are resolved before admission waiting. Agent default + changes affect new delegations; follow-up turns retain the existing child Session configuration; - permission mode, workdir identity, Session deletion, and capability revocation are checked continuously and take effect for active work; - the host revalidates safety before authorization, immediately before tool dispatch, and before a diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index fa63e4b4a6..52682c8d70 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -175,6 +175,7 @@ import { AgentUnavailableError } from '@/agent/shared/agentCatalogCodec' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' import { SessionQuery } from '@/session/query' import { SessionAssignmentPolicy } from '@/session/assignmentPolicy' +import { sanitizeGenerationSettings } from '@/agent/deepchat/runtime/generationSettings' import { SessionAssignment } from '@/session/assignment' import { SessionDeletion } from '@/session/deletion' import { SessionTranscriptMutations } from '@/session/transcriptMutations' @@ -2268,7 +2269,11 @@ export async function createMainProcessControl(dependencies: { getDefaultModel: () => agentSettings.getDefaultModel(), getDefaultProjectPath: () => projectService.getDefaultProjectPath(), resolveDeepChatAgentConfig: async (agentId) => - await agentSettings.resolveDeepChatAgentConfig(agentId) + await agentSettings.resolveDeepChatAgentConfig(agentId), + isModelAvailable: (providerId, modelId) => + providerSettings.getProviderById(providerId)?.enable === true && + providerSettings.isKnownModel(providerId, modelId) && + providerSettings.getModelStatus(providerId, modelId) } ) const clearNewAgentSessionSkills = skillService.clearNewAgentSessionSkills @@ -2501,6 +2506,24 @@ export async function createMainProcessControl(dependencies: { sessions: { ...agentToolDependencies.sessions, ...agentToolDependencies.subagents, + resolveSubagentExecutionSnapshot: async (input) => { + const assignment = await sessionAssignmentPolicy.resolveSubagentAssignment(input) + const isAcp = agentManager.resolveBackend(assignment.agentId).descriptor.kind === 'acp' + return { + providerId: assignment.providerId, + modelId: assignment.modelId, + toolModeOverride: assignment.toolModeOverride, + generationSettings: isAcp + ? null + : await sanitizeGenerationSettings( + providerSettings, + promptSettings, + assignment.providerId, + assignment.modelId, + assignment.generationSettings ?? {} + ) + } + }, findDelegationChild: async (parentSessionId, delegationId) => { const matches = appSessionService .list({ includeSubagents: true, parentSessionId }) diff --git a/src/main/orchestration/liveDelegationSafety.ts b/src/main/orchestration/liveDelegationSafety.ts index b1a8fffe87..0c636e29ec 100644 --- a/src/main/orchestration/liveDelegationSafety.ts +++ b/src/main/orchestration/liveDelegationSafety.ts @@ -115,7 +115,8 @@ export class LiveDelegationSafetyCoordinator implements LiveDelegationSafetyPort permissionMode: input.parentPermissionMode, generationSettings: child.generationSettings ?? undefined, disabledAgentTools: child.disabledAgentTools, - activeSkills: child.activeSkills + activeSkills: child.activeSkills, + preserveExecutionConfig: true }) if (assignment.agentId !== child.agentId) { throw new Error(`Subagent target changed for child Session ${child.sessionId}.`) diff --git a/src/main/orchestration/liveDelegationService.ts b/src/main/orchestration/liveDelegationService.ts index 0003a0df94..7ce37440ee 100644 --- a/src/main/orchestration/liveDelegationService.ts +++ b/src/main/orchestration/liveDelegationService.ts @@ -46,6 +46,7 @@ import type { DeepChatTaskContractContext } from '@shared/types/task-contract' import { projectTaskEvaluationSummary } from '@/tape/domain/taskEvaluation' import type { SessionRuntimeUpdate } from '@/session/runtimeEvents' import type { SessionDeletionGatePort } from '@/session/deletionGate' +import type { SubagentAssignmentInput } from '@/session/contracts' import { classifyToolEffect } from '@/tool/effectClassification' import type { ToolEffectObservation } from '@/tool/effectObserver' import { resolveToolPermissionMode } from '@/tool/permission/permissionMode' @@ -119,6 +120,9 @@ export interface LiveDelegationAssistantResult { export interface LiveDelegationServiceSessionPort extends AgentToolSessionPort, AgentSubagentToolPort { + resolveSubagentExecutionSnapshot( + input: SubagentAssignmentInput + ): Promise findDelegationChild( parentSessionId: string, delegationId: string @@ -400,8 +404,21 @@ export class LiveDelegationService { const delegationId = nanoid() const turnId = nanoid() - const executionSnapshot = createTurnExecutionSnapshot(parent) + const parentSnapshot = createTurnExecutionSnapshot(parent) const projectDir = await this.resolveParentProjectDir(parent) + const executionSnapshot = await this.options.sessions.resolveSubagentExecutionSnapshot({ + agentId: targetAgentId, + parentAgentId: parent.agentId, + targetAgentId, + projectDir, + providerId: parentSnapshot.providerId, + modelId: parentSnapshot.modelId, + generationSettings: parentSnapshot.generationSettings ?? undefined, + toolModeOverride: parentSnapshot.toolModeOverride, + permissionMode: parent.permissionMode, + disabledAgentTools: parent.disabledAgentTools, + activeSkills: parent.activeSkills + }) const created = this.runAuthorizedStartMutation(parent, 'spawn', authorization, () => this.options.repository.create( { @@ -1057,6 +1074,7 @@ export class LiveDelegationService { projectDir: safety.projectDir, providerId: executionSnapshot.providerId, modelId: executionSnapshot.modelId, + preserveExecutionConfig: true, permissionMode: safety.parent.permissionMode, toolModeOverride: executionSnapshot.toolModeOverride, generationSettings: executionSnapshot.generationSettings @@ -2039,7 +2057,11 @@ function createTurnExecutionSnapshot( providerId: session.providerId, modelId: session.modelId, generationSettings: session.generationSettings - ? structuredClone(session.generationSettings) + ? { + ...structuredClone(session.generationSettings), + thinkingBudget: session.generationSettings.thinkingBudget, + reasoningEffort: session.generationSettings.reasoningEffort + } : null, toolModeOverride: session.toolModeOverride } diff --git a/src/main/session/assignmentPolicy.ts b/src/main/session/assignmentPolicy.ts index 9ecb6a7ee3..d36cddf9f2 100644 --- a/src/main/session/assignmentPolicy.ts +++ b/src/main/session/assignmentPolicy.ts @@ -165,23 +165,35 @@ export class SessionAssignmentPolicy implements SessionAssignmentPolicyPort { } } - // Cross-agent child: keep the parent workdir/model and intersect its live authority with the - // target Agent policy. A target default may restrict the parent but can never elevate it. const agentConfig = await this.config.resolveDeepChatAgentConfig(descriptor.id) + const modelPreset = input.preserveExecutionConfig ? null : agentConfig?.defaultModelPreset + const providerId = modelPreset ? modelPreset.providerId?.trim() : input.providerId + const modelId = modelPreset ? modelPreset.modelId?.trim() : input.modelId + if ( + !providerId || + !modelId || + (modelPreset && !this.config.isModelAvailable(providerId, modelId)) + ) { + throw new Error(`Subagent ${descriptor.id} default model is unavailable.`) + } + this.assertAcpSessionHasWorkdir(providerId, input.projectDir) + const modelChanged = providerId !== input.providerId || modelId !== input.modelId const parentGeneration = input.generationSettings ?? {} - const generationSettings = this.mergeDefaultGenerationSettings(agentConfig, { - ...parentGeneration, - systemPrompt: - typeof agentConfig?.systemPrompt === 'string' - ? agentConfig.systemPrompt - : parentGeneration.systemPrompt - }) + const generationSettings = input.preserveExecutionConfig + ? input.generationSettings + : this.mergeDefaultGenerationSettings(agentConfig, { + ...(modelChanged ? {} : parentGeneration), + systemPrompt: + typeof agentConfig?.systemPrompt === 'string' + ? agentConfig.systemPrompt + : parentGeneration.systemPrompt + }) return { agentId: descriptor.id, targetAgentId, - providerId: input.providerId, - modelId: input.modelId, + providerId, + modelId, permissionMode: resolveCrossAgentPermissionMode( input.permissionMode, agentConfig?.permissionMode diff --git a/src/main/session/contracts.ts b/src/main/session/contracts.ts index a120374147..bc05b993ca 100644 --- a/src/main/session/contracts.ts +++ b/src/main/session/contracts.ts @@ -396,6 +396,7 @@ export interface SessionAssignmentConfigPort { getDefaultModel(): { providerId: string; modelId: string } | null | undefined getDefaultProjectPath(): string | null resolveDeepChatAgentConfig(agentId: string): Promise + isModelAvailable(providerId: string, modelId: string): boolean } export interface CreateAssignmentInput { @@ -434,6 +435,7 @@ export interface SubagentAssignmentInput { disabledAgentTools?: string[] activeSkills?: string[] toolModeOverride?: ToolModeOverride + preserveExecutionConfig?: boolean } export interface ResolvedSubagentAssignment { @@ -610,6 +612,7 @@ export interface SessionLifecycleSubagentInput { activeSkills?: string[] toolModeOverride?: ToolModeOverride liveDelegationContext?: LiveDelegationSubagentContext + preserveExecutionConfig?: boolean } export interface SessionLifecyclePort { diff --git a/src/main/session/lifecycle.ts b/src/main/session/lifecycle.ts index 9e0fccefd5..bde5551a12 100644 --- a/src/main/session/lifecycle.ts +++ b/src/main/session/lifecycle.ts @@ -390,7 +390,8 @@ export class SessionLifecycle implements SessionLifecyclePort { generationSettings: input.generationSettings, disabledAgentTools: input.disabledAgentTools, activeSkills: input.activeSkills, - toolModeOverride: input.toolModeOverride + toolModeOverride: input.toolModeOverride, + preserveExecutionConfig: input.preserveExecutionConfig }) return await this.dependencies.agentLifecycle.runWithAgentOperation( runtimeConfig.agentId, diff --git a/src/main/tool/runtimePorts.ts b/src/main/tool/runtimePorts.ts index 68a329c73f..e9d29536e2 100644 --- a/src/main/tool/runtimePorts.ts +++ b/src/main/tool/runtimePorts.ts @@ -94,6 +94,7 @@ export interface CreateSubagentSessionInput { disabledAgentTools?: string[] activeSkills?: string[] liveDelegationContext?: LiveDelegationSubagentContext + preserveExecutionConfig?: boolean } export interface AgentToolSessionPort { diff --git a/test/main/orchestration/liveDelegationService.test.ts b/test/main/orchestration/liveDelegationService.test.ts index baa50893dc..ecf05004fb 100644 --- a/test/main/orchestration/liveDelegationService.test.ts +++ b/test/main/orchestration/liveDelegationService.test.ts @@ -7,7 +7,8 @@ import { LiveDelegationDetailSchema, type LiveDelegationDetail } from '@shared/orchestration/liveDelegation' -import type { ConversationSessionInfo } from '@/tool/runtimePorts' +import type { ConversationSessionInfo, CreateSubagentSessionInput } from '@/tool/runtimePorts' +import type { SubagentAssignmentInput } from '@/session/contracts' import type { SessionRuntimeUpdate } from '@/session/runtimeEvents' import { SessionDeletionGate } from '@/session/deletionGate' import { LiveDelegationConsentAuthority } from '@/orchestration/liveDelegationConsent' @@ -1102,6 +1103,68 @@ describeIfSqlite('LiveDelegationService', () => { await vi.waitFor(() => expect(repository.require(spawned.delegation.id).status).toBe('idle')) }) + it('applies target default changes only to new delegations, including admission waiting', async () => { + let defaults = { + providerId: 'anthropic', + modelId: 'review-model-a', + generationSettings: { systemPrompt: 'Reviewer prompt A', temperature: 0.2 }, + toolModeOverride: null + } + harness.sessions.resolveSubagentExecutionSnapshot.mockImplementation(async () => + structuredClone(defaults) + ) + const firstBlocker = await admission.acquire({ ownerId: 'other-session-1' }) + const secondBlocker = await admission.acquire({ ownerId: 'other-session-2' }) + const spawned = await service.spawn('parent', { + slotId: 'reviewer', + title: 'Use the configured reviewer', + prompt: 'Review this change.' + }) + defaults = { + ...defaults, + modelId: 'review-model-b', + generationSettings: { systemPrompt: 'Reviewer prompt B', temperature: 0.6 } + } + firstBlocker.release() + secondBlocker.release() + + await vi.waitFor(() => expect(harness.sessions.sendConversationMessage).toHaveBeenCalledOnce()) + const childId = repository.require(spawned.delegation.id).childSessionId! + const child = await harness.sessions.resolveConversationSessionInfo(childId) + expect(child).toMatchObject({ + providerId: 'anthropic', + modelId: 'review-model-a', + generationSettings: { systemPrompt: 'Reviewer prompt A', temperature: 0.2 } + }) + harness.publishAnswer(childId, 'First review complete.', 200) + harness.publish({ sessionId: childId, kind: 'status', updatedAt: 201, status: 'idle' }) + await vi.waitFor(() => expect(repository.require(spawned.delegation.id).status).toBe('idle')) + + await service.followUp('parent', spawned.delegation.id, 'Check the remaining files.') + await vi.waitFor(() => + expect(harness.sessions.sendConversationMessage).toHaveBeenCalledTimes(2) + ) + expect(child).toMatchObject({ modelId: 'review-model-a' }) + harness.publishAnswer(childId, 'Follow-up review complete.', 300) + harness.publish({ sessionId: childId, kind: 'status', updatedAt: 301, status: 'idle' }) + await vi.waitFor(() => expect(repository.require(spawned.delegation.id).status).toBe('idle')) + + const next = await service.spawn('parent', { + slotId: 'reviewer', + title: 'Start another review', + prompt: 'Review another change.' + }) + await vi.waitFor(() => + expect(harness.sessions.sendConversationMessage).toHaveBeenCalledTimes(3) + ) + const nextChildId = repository.require(next.delegation.id).childSessionId! + expect(await harness.sessions.resolveConversationSessionInfo(nextChildId)).toMatchObject({ + providerId: 'anthropic', + modelId: 'review-model-b', + generationSettings: { systemPrompt: 'Reviewer prompt B', temperature: 0.6 } + }) + }) + it('restores the child execution snapshot before a queued follow-up starts', async () => { const spawned = await service.spawn('parent', { slotId: 'reviewer', @@ -2858,16 +2921,26 @@ function createSessionHarness(db: InstanceType) { } const sessions = { + resolveSubagentExecutionSnapshot: vi.fn(async (input: SubagentAssignmentInput) => ({ + providerId: input.providerId, + modelId: input.modelId, + generationSettings: input.generationSettings + ? structuredClone(input.generationSettings) + : null, + toolModeOverride: input.toolModeOverride ?? null + })), resolveConversationWorkdir: vi.fn().mockResolvedValue('/repo'), resolveConversationSessionInfo: vi.fn(async (sessionId: string) => sessionId === 'parent' ? parent : (children.get(sessionId) ?? null) ), - createSubagentSession: vi.fn( - async (input: { liveDelegationContext?: { delegationId: string } }) => { - const delegationId = input.liveDelegationContext?.delegationId ?? 'missing' - return addChild(`child-${delegationId}`, delegationId, 'idle') - } - ), + createSubagentSession: vi.fn(async (input: CreateSubagentSessionInput) => { + const delegationId = input.liveDelegationContext?.delegationId ?? 'missing' + const child = addChild(`child-${delegationId}`, delegationId, 'idle') + child.providerId = input.providerId + child.modelId = input.modelId + child.generationSettings = input.generationSettings ?? null + return child + }), linkSubagentTape: vi.fn(async (input: { childSessionId: string; outcome: string }) => ({ linkEntry: { sessionId: 'parent', entryId: 10 }, childSessionId: input.childSessionId, diff --git a/test/main/session/assignmentPolicy.test.ts b/test/main/session/assignmentPolicy.test.ts index bbbc498a65..3c38757154 100644 --- a/test/main/session/assignmentPolicy.test.ts +++ b/test/main/session/assignmentPolicy.test.ts @@ -38,7 +38,8 @@ function createHarness() { const config = { getDefaultModel: vi.fn(() => ({ providerId: 'openai', modelId: 'gpt-4' })), getDefaultProjectPath: vi.fn(() => '/global-project'), - resolveDeepChatAgentConfig: vi.fn(async (agentId: string) => configs.get(agentId) ?? null) + resolveDeepChatAgentConfig: vi.fn(async (agentId: string) => configs.get(agentId) ?? null), + isModelAvailable: vi.fn(() => true) } return { policy: new SessionAssignmentPolicy(catalog, config), catalog, config, configs } } @@ -217,7 +218,7 @@ describe('SessionAssignmentPolicy', () => { }) }) - it('leaves cross-agent subagent skills for target catalog validation', async () => { + it('uses the target model and prompt while intersecting cross-agent authority', async () => { const { policy, configs } = createHarness() configs.set('reviewer', { defaultModelPreset: { providerId: 'anthropic', modelId: 'claude-review' }, @@ -243,12 +244,11 @@ describe('SessionAssignmentPolicy', () => { ).resolves.toEqual({ agentId: 'reviewer', targetAgentId: 'reviewer', - providerId: 'openai', - modelId: 'gpt-4', + providerId: 'anthropic', + modelId: 'claude-review', permissionMode: 'default', generationSettings: { - systemPrompt: 'Reviewer prompt', - temperature: 0.4 + systemPrompt: 'Reviewer prompt' }, disabledAgentTools: ['exec', 'read', 'write'], activeSkills: ['skill-a', 'skill-b', 'skill-c'], @@ -256,6 +256,74 @@ describe('SessionAssignmentPolicy', () => { }) }) + it('inherits the parent model and overrides when the target has no default model', async () => { + const { policy, configs } = createHarness() + configs.set('reviewer', { systemPrompt: 'Reviewer prompt' }) + + await expect( + policy.resolveSubagentAssignment({ + agentId: 'reviewer', + parentAgentId: 'deepchat', + projectDir: '/repo', + providerId: 'openai', + modelId: 'gpt-4', + generationSettings: { systemPrompt: 'Parent prompt', temperature: 0.4 } + }) + ).resolves.toMatchObject({ + providerId: 'openai', + modelId: 'gpt-4', + generationSettings: { systemPrompt: 'Reviewer prompt', temperature: 0.4 } + }) + }) + + it.each([ + { providerId: 'anthropic', modelId: 'unavailable' }, + { providerId: '', modelId: 'claude-review' }, + { providerId: 'anthropic', modelId: '' } + ])('rejects an unavailable target model without falling back: %j', async (defaultModelPreset) => { + const { policy, configs, config } = createHarness() + configs.set('reviewer', { defaultModelPreset }) + config.isModelAvailable.mockReturnValue(false) + + await expect( + policy.resolveSubagentAssignment({ + agentId: 'reviewer', + parentAgentId: 'deepchat', + projectDir: '/repo', + providerId: 'openai', + modelId: 'gpt-4' + }) + ).rejects.toThrow('Subagent reviewer default model is unavailable.') + }) + + it('preserves frozen execution settings while applying the current target permission policy', async () => { + const { policy, configs, config } = createHarness() + configs.set('reviewer', { + defaultModelPreset: { providerId: 'anthropic', modelId: 'changed-model' }, + systemPrompt: 'Changed prompt', + permissionMode: 'default' + }) + config.isModelAvailable.mockReturnValue(false) + + await expect( + policy.resolveSubagentAssignment({ + agentId: 'reviewer', + parentAgentId: 'deepchat', + projectDir: '/repo', + providerId: 'openai', + modelId: 'frozen-model', + permissionMode: 'full_access', + generationSettings: { systemPrompt: 'Frozen prompt', temperature: 0.4 }, + preserveExecutionConfig: true + }) + ).resolves.toMatchObject({ + providerId: 'openai', + modelId: 'frozen-model', + generationSettings: { systemPrompt: 'Frozen prompt', temperature: 0.4 }, + permissionMode: 'default' + }) + }) + it('never elevates parent authority for cross-agent subagents', async () => { const { policy, configs } = createHarness() diff --git a/test/main/session/sessionFixture.ts b/test/main/session/sessionFixture.ts index 78c624c21d..2e8a4b2783 100644 --- a/test/main/session/sessionFixture.ts +++ b/test/main/session/sessionFixture.ts @@ -60,7 +60,11 @@ export const createSessionFixture = (input: { resolveDeepChatAgentConfig: async (agentId) => { if (typeof input.providerSettings.resolveDeepChatAgentConfig !== 'function') return null return await input.providerSettings.resolveDeepChatAgentConfig(agentId) - } + }, + isModelAvailable: (providerId, modelId) => + input.providerSettings.getProviderById(providerId)?.enable === true && + input.providerSettings.isKnownModel(providerId, modelId) && + input.providerSettings.getModelStatus(providerId, modelId) } ) const deletion = new SessionDeletion({