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
20 changes: 13 additions & 7 deletions src/main/tape/domain/executionContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export class ExecutionContractDispatchError extends Error {
export interface ExecutionContractDispatchInput {
request: DeepChatExecutionContractRequest
currentTool: MCPToolDefinition
parentTool?: MCPToolDefinition
currentWorkspace: DeepChatExecutionWorkspaceCeiling
currentMaxSubagentDepth: number
requestedSubagentDepth: number
Expand Down Expand Up @@ -943,8 +944,10 @@ export function assertExecutionContractAllowsDispatch(
}

let currentTool: DeepChatExecutionToolCeiling
let dispatchTool: DeepChatExecutionToolCeiling
try {
currentTool = buildExecutionToolCeiling(input.currentTool)
dispatchTool = input.parentTool ? buildExecutionToolCeiling(input.parentTool) : currentTool
} catch (error) {
throw new ExecutionContractDispatchError(
'Current tool authority is missing a valid stable identity or execution policy.',
Expand All @@ -953,31 +956,34 @@ export function assertExecutionContractAllowsDispatch(
)
}
const ceiling = contract.ceilings.tools.find(
(candidate) => candidate.target.providerVisibleName === currentTool.target.providerVisibleName
(candidate) => candidate.target.providerVisibleName === dispatchTool.target.providerVisibleName
)
if (!ceiling) {
throw new ExecutionContractDispatchError(
`Tool '${currentTool.target.providerVisibleName}' is outside the frozen View ceiling.`,
`Tool '${dispatchTool.target.providerVisibleName}' is outside the frozen View ceiling.`,
'tool_not_allowed'
)
}
if (
buildExecutionToolTargetKey(ceiling.target) !== buildExecutionToolTargetKey(currentTool.target)
buildExecutionToolTargetKey(ceiling.target) !== buildExecutionToolTargetKey(dispatchTool.target)
) {
throw new ExecutionContractDispatchError(
`Tool '${currentTool.target.providerVisibleName}' no longer resolves to the frozen target.`,
`Tool '${dispatchTool.target.providerVisibleName}' no longer resolves to the frozen target.`,
'target_mismatch'
)
}
if (!isToolEffectWithinCeiling(currentTool.execution.effect, ceiling.execution.effect)) {
if (
!isToolEffectWithinCeiling(dispatchTool.execution.effect, ceiling.execution.effect) ||
!isToolEffectWithinCeiling(currentTool.execution.effect, dispatchTool.execution.effect)
) {
throw new ExecutionContractDispatchError(
`Tool '${currentTool.target.providerVisibleName}' exceeds its frozen effect ceiling.`,
'effect_exceeds_ceiling'
)
}
if (currentTool.execution.mode !== ceiling.execution.mode) {
if (dispatchTool.execution.mode !== ceiling.execution.mode) {
throw new ExecutionContractDispatchError(
`Tool '${currentTool.target.providerVisibleName}' execution mode changed after View assembly.`,
`Tool '${dispatchTool.target.providerVisibleName}' execution mode changed after View assembly.`,
'execution_mode_mismatch'
)
}
Expand Down
28 changes: 20 additions & 8 deletions src/main/tool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ import {
} from '@/cli/programmaticToolParentRegistry'
import { buildToolSearchDefinition } from './agentTools/toolSearchTool'

type MainProcessToolCallOptions = ToolCallOptions & {
type MainProcessToolCallOptions = InternalToolCallOptions & {
readonly toolSurfaceDeferredDispatch?: ToolSurfaceDeferredDispatch
readonly toolSurfaceContext?: ToolSurfaceExecutionContext
readonly toolSurfaceSnapshot?: ToolSurfaceSnapshot
Expand Down Expand Up @@ -227,10 +227,12 @@ type ConversationToolModeContext = {
mode: ToolModeConfiguration['mode']
frontend: 'codex' | 'function'
executionCatalog: readonly MCPToolDefinition[]
codeEntryTools?: readonly MCPToolDefinition[]
}

type InternalToolCallOptions = ToolCallOptions & {
codeModeNested?: boolean
codeModeParentTool?: MCPToolDefinition
}

type CodexExecInput = {
Expand Down Expand Up @@ -631,17 +633,20 @@ export class ToolService implements ToolServicePort {

if (input.mode === 'code') this.assertCodeModeCatalog(frontend, executionCatalog)
this.publishConfiguredCatalog(conversationId, [...executionCatalog, ...codeModeDirectTools])
const codeEntryTools =
input.mode === 'agent'
? []
: frontend === 'codex'
? createCodexCodeModeToolDefinitions(decoratedCatalog)
: [createRunCodeToolDefinition()]
this.conversationToolModes.set(conversationId, {
mode: input.mode,
frontend,
executionCatalog
executionCatalog,
codeEntryTools
})

if (input.mode === 'agent') return executionCatalog
const codeEntryTools =
frontend === 'codex'
? createCodexCodeModeToolDefinitions(decoratedCatalog)
: [createRunCodeToolDefinition()]
return [...codeEntryTools, ...codeModeDirectTools]
}

Expand Down Expand Up @@ -1295,6 +1300,12 @@ export class ToolService implements ToolServicePort {
: this.parseAgentToolArguments(request.function.arguments, toolName),
target: { serverName: CODE_MODE_TOOL_SERVER_NAME, originalName: toolName }
}
const runtimeOptions: InternalToolCallOptions = {
...options,
codeModeParentTool: context.codeEntryTools?.find(
(definition) => definition.function.name === toolName
)
}
const result = waitInput
? await this.runCodeRuntime.wait({
sessionId,
Expand All @@ -1304,7 +1315,7 @@ export class ToolService implements ToolServicePort {
maxTokens: waitInput.maxTokens,
terminate: waitInput.terminate,
outerDispatch,
options: options ?? {}
options: runtimeOptions
})
: await this.runCodeRuntime.execute({
sessionId,
Expand All @@ -1321,7 +1332,7 @@ export class ToolService implements ToolServicePort {
: {}),
executionCatalog: context.executionCatalog,
outerDispatch,
options: options ?? {}
options: runtimeOptions
})
const rawData = result.rawData ?? {
content: result.content,
Expand Down Expand Up @@ -1700,6 +1711,7 @@ export class ToolService implements ToolServicePort {
requestSeq: requestSeq as number
},
currentTool: currentDefinition,
parentTool: options?.codeModeNested ? options.codeModeParentTool : undefined,
currentWorkspace: currentProjectDir
? { kind: 'path', path: currentProjectDir }
: { kind: 'runtime_default' },
Expand Down
80 changes: 80 additions & 0 deletions test/main/tape/executionContract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,86 @@ describe('ExecutionContract domain', () => {
).toThrow(expect.objectContaining({ code: 'target_mismatch' }))
})

it('bounds nested tools by their frozen parent without requiring child View membership', () => {
const parentTool = agentTool('run_code', TOOL_EXECUTION.write)
const contract = buildExecutionContract(buildInput({ tools: [parentTool] }))
const dispatchInput = {
request: contract.request,
parentTool,
currentTool: agentTool('read'),
currentWorkspace: { kind: 'path' as const, path: '/workspace/project' },
currentMaxSubagentDepth: 1,
requestedSubagentDepth: 0
}

expect(() => assertExecutionContractAllowsDispatch(contract, dispatchInput)).not.toThrow()
expect(() =>
assertExecutionContractAllowsDispatch(contract, {
...dispatchInput,
currentTool: agentTool('exec', TOOL_EXECUTION.write)
})
).not.toThrow()
expect(() =>
assertExecutionContractAllowsDispatch(contract, { ...dispatchInput, parentTool: undefined })
).toThrow(expect.objectContaining({ code: 'tool_not_allowed' }))
expect(() =>
assertExecutionContractAllowsDispatch(contract, {
...dispatchInput,
parentTool: agentTool('missing_parent', TOOL_EXECUTION.write)
})
).toThrow(expect.objectContaining({ code: 'tool_not_allowed' }))
expect(() =>
assertExecutionContractAllowsDispatch(contract, {
...dispatchInput,
parentTool: { ...parentTool, server: { ...parentTool.server, name: 'other-server' } }
})
).toThrow(expect.objectContaining({ code: 'target_mismatch' }))

const readParent = agentTool('run_code', TOOL_EXECUTION.read.sequential)
const readContract = buildExecutionContract(buildInput({ tools: [readParent] }))
expect(() =>
assertExecutionContractAllowsDispatch(readContract, {
...dispatchInput,
parentTool: readParent
})
).not.toThrow()
expect(() =>
assertExecutionContractAllowsDispatch(readContract, {
...dispatchInput,
parentTool: readParent,
currentTool: agentTool('exec', TOOL_EXECUTION.write)
})
).toThrow(expect.objectContaining({ code: 'effect_exceeds_ceiling' }))
expect(() => assertExecutionContractAllowsDispatch(readContract, dispatchInput)).toThrow(
expect.objectContaining({ code: 'effect_exceeds_ceiling' })
)
expect(() =>
assertExecutionContractAllowsDispatch(contract, {
...dispatchInput,
parentTool: agentTool('run_code', TOOL_EXECUTION.read.parallel)
})
).toThrow(expect.objectContaining({ code: 'execution_mode_mismatch' }))
expect(() =>
assertExecutionContractAllowsDispatch(contract, {
...dispatchInput,
currentWorkspace: { kind: 'path', path: '/workspace/other' }
})
).toThrow(expect.objectContaining({ code: 'workspace_mismatch' }))
expect(() =>
assertExecutionContractAllowsDispatch(contract, {
...dispatchInput,
requestedSubagentDepth: 1,
currentMaxSubagentDepth: 0
})
).toThrow(expect.objectContaining({ code: 'subagent_depth_exceeded' }))
expect(() =>
assertExecutionContractAllowsDispatch(contract, {
...dispatchInput,
request: { ...contract.request, requestSeq: 3 }
})
).toThrow(expect.objectContaining({ code: 'identity_mismatch' }))
})

it('binds paused execution to the complete provider View identity and contract hash', () => {
const contract = buildExecutionContract(buildInput())
const binding = buildExecutionContractBinding(contract)
Expand Down
144 changes: 144 additions & 0 deletions test/main/tool/toolService.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { TOOL_EXECUTION, type MCPToolDefinition } from '@shared/types/mcp'
import { RUN_CODE_PROTOCOL_VERSION, type RunCodeParentMessage } from '@shared/codeModeProtocol'
import { ToolService } from '@/tool'
import { RunCodeRuntimeManager } from '@/tool/codeMode/runCodeRuntimeManager'
import { POSIX_COMMAND_SHELL } from '../../helpers/commandShell'
Expand Down Expand Up @@ -1682,6 +1684,148 @@ describe('ToolService', () => {
).toThrow("both map to 'mcp_read'")
})

it.each(['deepseek', 'openai-codex'])(
'dispatches Code Mode subagent children under the %s entrypoint contract',
async (providerId) => {
const definitions: MCPToolDefinition[] = [
...['exec', 'read', 'glob'].map((name) => ({
...buildToolDefinition(name, 'agent-filesystem'),
source: 'agent' as const,
server: { name: 'agent-filesystem', icons: '', description: 'Filesystem tools' },
execution: name === 'exec' ? TOOL_EXECUTION.write : TOOL_EXECUTION.read.parallel
})),
buildContractMcpDefinition()
]
const authority = {
sessionId: 'session-1',
agentId: 'agent-1',
projectDir: '/workspace',
sessionKind: 'subagent' as const,
parentSessionId: 'parent-1',
disabledAgentTools: [],
enabledMcpServerIds: [definitions[3].server.id!],
subagentCapability: {
available: false as const,
reason: 'policy_disabled' as const,
cacheKey: 'off'
}
}
const mcpService = {
callTool: vi.fn(async () => ({ content: 'remote result', rawData: {} }))
} as any
const toolService = new ToolService({
skillSettings: { isEnabled: () => false } as any,
mcpService,
agentSettings: { resolveDeepChatAgentConfig: vi.fn(async () => ({})) } as any,
providerSettings: { getModelConfig: vi.fn() } as any,
settings: { get: vi.fn() },
commandPermissionHandler: new CommandPermissionService(),
agentTools: buildAgentToolRuntimeMock({
resolveConversationSessionInfo: vi.fn(async (sessionId: string) =>
sessionId === 'parent-1'
? { ...authority, sessionId, sessionKind: 'regular' }
: authority
),
resolveConversationExecutionAuthority: vi.fn(async () => authority),
resolveConversationExecutionAuthorityNow: vi.fn(() => authority)
})
})
const agentToolManager = (toolService as any).ensureAgentToolManager(null)
agentToolManager.callTool = vi.fn(async () => 'native result')
const [entrypoint] = toolService.configureToolMode({
conversationId: 'session-1',
mode: 'code',
providerId,
commandShell: POSIX_COMMAND_SHELL,
executionCatalog: definitions
})
const nestedResults: RunCodeParentMessage[] = []
const host = Object.assign(new EventEmitter(), {
pid: 42,
kill: vi.fn(() => true),
postMessage(message: RunCodeParentMessage) {
if (message.type === 'START') {
queueMicrotask(() => {
for (const binding of message.bindings) {
host.emit('message', {
type: 'NESTED_CALL',
version: RUN_CODE_PROTOCOL_VERSION,
cellId: message.cellId,
callId: binding.id,
bindingId: binding.id,
arguments: {}
})
}
})
} else if (message.type === 'NESTED_RESULT') {
nestedResults.push(message)
if (nestedResults.length === definitions.length) {
queueMicrotask(() =>
host.emit('message', {
type: 'RESULT',
version: RUN_CODE_PROTOCOL_VERSION,
cellId: message.cellId,
output: [],
store: {}
})
)
}
} else if (message.type === 'STOP') {
queueMicrotask(() => host.emit('exit', 0))
}
}
})
const runtime = (toolService as any).runCodeRuntime
runtime.managerOptions.spawnHost = async () => {
setTimeout(
() =>
host.emit('message', {
type: 'READY',
version: RUN_CODE_PROTOCOL_VERSION,
pid: host.pid
}),
0
)
return host
}

try {
await toolService.callTool(
{
id: 'code-call-1',
type: 'function',
function: {
name: entrypoint.function.name,
arguments:
providerId === 'openai-codex'
? 'await tools.exec({})'
: JSON.stringify({
code: 'await tools.exec({})',
description: 'Run nested tools'
})
},
conversationId: 'session-1'
},
{
runId: CONTRACT_RUN_ID,
messageId: 'message-1',
requestSeq: 1,
executionContract: buildToolExecutionContract(entrypoint),
permissionMode: 'full_access'
}
)
expect(nestedResults).toHaveLength(definitions.length)
for (const result of nestedResults) {
expect(result).toMatchObject({ type: 'NESTED_RESULT', ok: true })
}
expect(agentToolManager.callTool).toHaveBeenCalledTimes(3)
expect(mcpService.callTool).toHaveBeenCalledOnce()
} finally {
await toolService.shutdownCodeRuntime()
}
}
)

it('meets the frozen execution contract with current authority at dispatch', async () => {
const definition = buildContractMcpDefinition()
const resolveConversationExecutionAuthority = vi.fn(async (sessionId: string) => ({
Expand Down