Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion src/main/app/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 })
Expand Down
3 changes: 2 additions & 1 deletion src/main/orchestration/liveDelegationSafety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.`)
Expand Down
26 changes: 24 additions & 2 deletions src/main/orchestration/liveDelegationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -119,6 +120,9 @@ export interface LiveDelegationAssistantResult {

export interface LiveDelegationServiceSessionPort
extends AgentToolSessionPort, AgentSubagentToolPort {
resolveSubagentExecutionSnapshot(
input: SubagentAssignmentInput
): Promise<LiveDelegationTurnExecutionSnapshot>
findDelegationChild(
parentSessionId: string,
delegationId: string
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
34 changes: 23 additions & 11 deletions src/main/session/assignmentPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/main/session/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export interface SessionAssignmentConfigPort {
getDefaultModel(): { providerId: string; modelId: string } | null | undefined
getDefaultProjectPath(): string | null
resolveDeepChatAgentConfig(agentId: string): Promise<DeepChatAgentConfig | null>
isModelAvailable(providerId: string, modelId: string): boolean
}

export interface CreateAssignmentInput {
Expand Down Expand Up @@ -434,6 +435,7 @@ export interface SubagentAssignmentInput {
disabledAgentTools?: string[]
activeSkills?: string[]
toolModeOverride?: ToolModeOverride
preserveExecutionConfig?: boolean
}

export interface ResolvedSubagentAssignment {
Expand Down Expand Up @@ -610,6 +612,7 @@ export interface SessionLifecycleSubagentInput {
activeSkills?: string[]
toolModeOverride?: ToolModeOverride
liveDelegationContext?: LiveDelegationSubagentContext
preserveExecutionConfig?: boolean
}

export interface SessionLifecyclePort {
Expand Down
3 changes: 2 additions & 1 deletion src/main/session/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/main/tool/runtimePorts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export interface CreateSubagentSessionInput {
disabledAgentTools?: string[]
activeSkills?: string[]
liveDelegationContext?: LiveDelegationSubagentContext
preserveExecutionConfig?: boolean
}

export interface AgentToolSessionPort {
Expand Down
87 changes: 80 additions & 7 deletions test/main/orchestration/liveDelegationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -2858,16 +2921,26 @@ function createSessionHarness(db: InstanceType<typeof DatabaseCtor>) {
}

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,
Expand Down
Loading