diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index e05269465..b015dd5e9 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -6,6 +6,7 @@ import { userCancellationReason, } from '#/_base/utils/abort'; import { Error2, ErrorCodes, isError2 } from '#/errors'; +import { FLOW_TOOL_NAMES } from '#/features/flow/flow'; import { toInputJsonSchema } from '#/tool/input-schema'; import { matchesGlobRuleSubject } from '#/tool/rule-match'; import { @@ -514,20 +515,23 @@ function buildProfileDescriptions( source: ToolReference['source'], ) => boolean, ): string { + const advertisableTools = tools.filter((tool) => !FLOW_TOOL_NAMES.has(tool.name)); return profiles .map((profile) => { const details = [profile.description, profile.whenToUse].filter( (part): part is string => part !== undefined && part.length > 0, ); const header = details.length === 0 ? `- ${profile.name}` : `- ${profile.name}: ${details.join(' ')}`; - const activeTools = resolveActiveToolNames(profile); - const externallyRestricted = tools.some( + const activeTools = resolveActiveToolNames(profile)?.filter( + (name) => !FLOW_TOOL_NAMES.has(name), + ); + const externallyRestricted = advertisableTools.some( (tool) => evaluateToolActive(profile, tool.name, tool.source) && !isToolActive(profile, tool.name, tool.source), ); if (externallyRestricted) { - const effectiveTools = tools + const effectiveTools = advertisableTools .filter((tool) => isToolActive(profile, tool.name, tool.source)) .map((tool) => tool.name); if (effectiveTools.length === 0) { diff --git a/packages/agent-core-v2/src/features/flow/flow.ts b/packages/agent-core-v2/src/features/flow/flow.ts index 83e34b1ef..02006a0eb 100644 --- a/packages/agent-core-v2/src/features/flow/flow.ts +++ b/packages/agent-core-v2/src/features/flow/flow.ts @@ -12,6 +12,18 @@ export const FLOW_ADVANCE_TOOL_NAME = 'FlowAdvance'; export const FLOW_ABORT_TOOL_NAME = 'FlowAbort'; export const FLOW_JUMP_TOOL_NAME = 'FlowJump'; +/** + * Builtin flow tool names. They are registered supervisor-only (main agent), + * so a spawned worker never receives them regardless of its profile's + * allowlist. + */ +export const FLOW_TOOL_NAMES: ReadonlySet = new Set([ + FLOW_START_TOOL_NAME, + FLOW_ADVANCE_TOOL_NAME, + FLOW_ABORT_TOOL_NAME, + FLOW_JUMP_TOOL_NAME, +]); + export const FLOWS_PROJECT_DIR = '.kimi-code/flows'; export const FlowGateKindSchema = z.enum(['ai', 'human', 'ai-then-human']); diff --git a/packages/agent-core-v2/src/features/flow/flowService.ts b/packages/agent-core-v2/src/features/flow/flowService.ts index c05a2ef6a..2ee219eef 100644 --- a/packages/agent-core-v2/src/features/flow/flowService.ts +++ b/packages/agent-core-v2/src/features/flow/flowService.ts @@ -28,6 +28,8 @@ import { FLOW_ADVANCE_TOOL_NAME, FLOW_FLAG_ID, FLOW_JUMP_TOOL_NAME, + FLOW_START_TOOL_NAME, + FLOW_TOOL_NAMES, FlowDefinitionSchema, IAgentFlowService, type FlowAdvanceOutcome, @@ -50,13 +52,6 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo import { FlowJumped, FlowRunEnded, FlowRunStarted, FlowVerdict, flowGatesKey, flowKey } from './flowOps'; -const FLOW_TOOL_NAMES: ReadonlySet = new Set([ - 'FlowStart', - 'FlowAdvance', - 'FlowAbort', - 'FlowJump', -]); - export class AgentFlowService extends Disposable implements IAgentFlowService { declare readonly _serviceBrand: undefined; @@ -141,8 +136,10 @@ export class AgentFlowService extends Disposable implements IAgentFlowService { this._register( toolExecutor.onBeforeExecuteTool((event) => { if (!this.flags.enabled(FLOW_FLAG_ID)) return; - if (event.toolCall.name !== 'TodoList') return; - const startsInBatch = event.toolCalls.some((call) => call.name === 'FlowStart'); + if (event.toolCall.name !== 'TodoList' || !this.isBuiltinTool('TodoList')) return; + const startsInBatch = event.toolCalls.some( + (call) => call.name === FLOW_START_TOOL_NAME && this.isBuiltinFlowTool(call.name), + ); if (!this.run().active && !startsInBatch) return; event.veto( denyToolExecution( @@ -157,6 +154,7 @@ export class AgentFlowService extends Disposable implements IAgentFlowService { eventBus.subscribe(ContextUndone, () => { this.epoch += 1; if (!this.flags.enabled(FLOW_FLAG_ID)) return; + if (this.scopeContext.agentId !== 'main') return; void this.dispatcher.dispatch(new AgentStatusUpdated({ flowRun: this.summary() })); }), ); @@ -185,6 +183,10 @@ export class AgentFlowService extends Disposable implements IAgentFlowService { private isBuiltinFlowTool(name: string): boolean { if (!FLOW_TOOL_NAMES.has(name)) return false; + return this.isBuiltinTool(name); + } + + private isBuiltinTool(name: string): boolean { return this.toolRegistry .listReferences() .some((reference) => reference.name === name && reference.source === 'builtin'); diff --git a/packages/agent-core-v2/src/features/flow/tools/start/startTool.ts b/packages/agent-core-v2/src/features/flow/tools/start/startTool.ts index 8c8621a87..e324c8b43 100644 --- a/packages/agent-core-v2/src/features/flow/tools/start/startTool.ts +++ b/packages/agent-core-v2/src/features/flow/tools/start/startTool.ts @@ -63,52 +63,71 @@ export class FlowStartTool implements IFlowStartTool { }; } - let text: string | undefined; + let projectText: string | undefined; const lease = this.runtime.acquire(['fs']); try { if (lease.runtime.identity.generation !== generation) { return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; } try { - text = await lease.runtime.fs!.readText(path); + projectText = await lease.runtime.fs!.readText(path); } catch { - text = undefined; + projectText = undefined; } } finally { lease.dispose(); } - let sourcePath = path; - if (text === undefined) { - const userPath = userFlowDefinitionPath(this.bootstrap.homeDir, args.flow); - try { - text = await this.hostFs.readText(userPath); - sourcePath = userPath; - } catch { - return { - isError: true, - output: `Could not read the flow definition at ${path} or ${userPath}. Check that the file exists under ${FLOWS_PROJECT_DIR}/ or ${userFlowsDir(this.bootstrap.homeDir)}/.`, - }; - } + const project = + projectText === undefined ? undefined : this.validateDefinition(projectText, path, args.flow); + if (project?.definition !== undefined) { + return this.startRun(project.definition, args); } + const userPath = userFlowDefinitionPath(this.bootstrap.homeDir, args.flow); + let userText: string | undefined; + try { + userText = await this.hostFs.readText(userPath); + } catch { + userText = undefined; + } + const user = + userText === undefined ? undefined : this.validateDefinition(userText, userPath, args.flow); + if (user?.definition !== undefined) { + return this.startRun(user.definition, args); + } + + if (project !== undefined) return { isError: true, output: project.error! }; + if (user !== undefined) return { isError: true, output: user.error! }; + return { + isError: true, + output: `Could not read the flow definition at ${path} or ${userPath}. Check that the file exists under ${FLOWS_PROJECT_DIR}/ or ${userFlowsDir(this.bootstrap.homeDir)}/.`, + }; + } + + private validateDefinition( + text: string, + sourcePath: string, + flowId: string, + ): { definition?: FlowDefinition; error?: string } { let definition: FlowDefinition; try { definition = parseFlowDefinition(text); } catch (error) { if (error instanceof FlowDefinitionParseError) { - return { isError: true, output: `${error.message} (${sourcePath})` }; + return { error: `${error.message} (${sourcePath})` }; } throw error; } - - if (definition.id !== args.flow) { + if (definition.id !== flowId) { return { - isError: true, - output: `The definition at ${sourcePath} declares id \`${definition.id}\`, which does not match the requested flow \`${args.flow}\`. Fix the file's id or request the flow by its declared id.`, + error: `The definition at ${sourcePath} declares id \`${definition.id}\`, which does not match the requested flow \`${flowId}\`. Fix the file's id or request the flow by its declared id.`, }; } + return { definition }; + } + private startRun(definition: FlowDefinition, args: FlowStartInput): ExecutableToolResult { if (this.flow.hasPendingActivation()) { return { isError: true, diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 627a39fcf..ba95ede1d 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -291,7 +291,7 @@ describe('FullCompaction', () => { properties: expect.objectContaining({ agent_id: 'main', source: 'manual', - tokens_before: 3_313, + tokens_before: 3_302, tokens_after: expect.any(Number), duration_ms: expect.any(Number), compacted_count: 6, @@ -570,7 +570,7 @@ describe('FullCompaction', () => { session_id: 'test-session', cwd: dir, trigger: 'auto', - token_count: 3_313, + token_count: 3_302, }); expect(post).toMatchObject({ hook_event_name: 'PostCompact', @@ -656,7 +656,7 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'manual', - tokens_before: 14_991, + tokens_before: 14_980, retry_count: 1, trace_id: 'trace-compact-1', }), @@ -1039,7 +1039,7 @@ describe('FullCompaction', () => { properties: expect.objectContaining({ agent_id: 'main', source: 'manual', - tokens_before: 14_991, + tokens_before: 14_980, duration_ms: expect.any(Number), round: 1, retry_count: 0, @@ -1264,7 +1264,7 @@ describe('FullCompaction', () => { event: 'compaction_failed', properties: expect.objectContaining({ source: 'manual', - tokens_before: 14_991, + tokens_before: 14_980, duration_ms: expect.any(Number), retry_count: 4, error_type: 'APIConnectionError', @@ -1637,8 +1637,8 @@ describe('FullCompaction', () => { event: 'compaction_finished', properties: expect.objectContaining({ source: 'auto', - tokens_before: 3_320, - tokens_after: 3_304, + tokens_before: 3_309, + tokens_after: 3_293, compacted_count: 7, retry_count: 0, }), diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index a7f3453fe..eeaf5d182 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -134,8 +134,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "time": "