From c5f84f54b9b558b62c97f9fbf1194e7cae8b354b Mon Sep 17 00:00:00 2001 From: john Date: Fri, 24 Jul 2026 19:48:04 +0800 Subject: [PATCH] feat: add fail-closed Aider development skill --- agent-run-gateway.mjs | 33 +++++++ agent-run-gateway.test.mjs | 32 +++++++ agent-run-routes.mjs | 59 ++++++++++++- agent-run-routes.test.mjs | 97 +++++++++++++++++++++ chat-skills.mjs | 14 +++ chat-skills.test.mjs | 15 ++++ skills-registry.mjs | 3 + skills-registry.test.mjs | 6 ++ skills/aider-development/SKILL.md | 23 +++++ skills/aider-development/agents/openai.yaml | 4 + skills/aider-development/skill.yaml | 6 ++ src/api/client.ts | 5 +- src/hooks/useTKMindChat.ts | 8 +- src/utils/agentRunMode.ts | 7 +- tool-gateway.test.mjs | 15 ++++ 15 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 skills/aider-development/SKILL.md create mode 100644 skills/aider-development/agents/openai.yaml create mode 100644 skills/aider-development/skill.yaml diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index c68fd93..0eac688 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -327,6 +327,26 @@ function normalizeSessionMessageCount(value) { return Math.floor(parsed); } +export function resolveRequiredCodeExecutor(userMessage) { + const metadata = userMessage?.metadata; + const runMetadata = metadata?.[RUN_METADATA_KEY] ?? metadata?.agentRun ?? {}; + const executor = String(runMetadata?.executor ?? '').trim().toLowerCase(); + return ['aider', 'openhands'].includes(executor) ? executor : null; +} + +export function assertRequiredCodeExecutorAvailable(requiredExecutor, toolGatewayStatus) { + if (!requiredExecutor) return; + const executors = Array.isArray(toolGatewayStatus?.executors) + ? toolGatewayStatus.executors.map((item) => String(item).trim().toLowerCase()) + : []; + if (!toolGatewayStatus?.enabled || !executors.includes(requiredExecutor)) { + const error = new Error(`必需执行器 ${requiredExecutor} 不可用,任务已停止且不会回退`); + error.code = 'REQUIRED_EXECUTOR_UNAVAILABLE'; + error.retryable = false; + throw error; + } +} + function getRunOptionsFromMessage(userMessage) { const metadata = userMessage?.metadata; const runMetadata = metadata?.[RUN_METADATA_KEY] ?? metadata?.agentRun ?? {}; @@ -339,6 +359,7 @@ function getRunOptionsFromMessage(userMessage) { return { toolMode, taskType: normalizeTaskType(runMetadata?.taskType ?? metadata?.taskType), + requiredExecutor: resolveRequiredCodeExecutor(userMessage), forceDeepReasoning: runMetadata?.forceDeepReasoning === true || metadata?.forceDeepReasoning === true, validation: normalizeToolGatewayValidation(runMetadata?.validation ?? metadata?.toolGatewayValidation), sessionMessageCount: normalizeSessionMessageCount( @@ -700,6 +721,7 @@ export function createAgentRunGateway({ let userMessage = safeJsonParse(row.user_message_json, {}); const runOptions = getRunOptionsFromMessage(userMessage); const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null; + assertRequiredCodeExecutorAvailable(runOptions.requiredExecutor, toolGatewayStatus); let disclosureDecision = null; try { disclosureDecision = systemDisclosurePolicyService?.evaluate?.({ @@ -878,6 +900,17 @@ export function createAgentRunGateway({ cwd: workingDir, timeoutMs: runTimeoutMs, }); + if ( + runOptions.requiredExecutor && + String(result?.executor ?? '').trim().toLowerCase() !== runOptions.requiredExecutor + ) { + const error = new Error( + `执行器不匹配:要求 ${runOptions.requiredExecutor},实际 ${result?.executor ?? 'unknown'}`, + ); + error.code = 'REQUIRED_EXECUTOR_MISMATCH'; + error.retryable = false; + throw error; + } await appendEvent(runId, 'tool_gateway_result', { executor: result.executor ?? null, dryRun: Boolean(result.dryRun), diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 78fb062..6a1e365 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -5,10 +5,42 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { + assertRequiredCodeExecutorAvailable, assertRequiredImageGenerationCompleted, createAgentRunGateway, + resolveRequiredCodeExecutor, } from './agent-run-gateway.mjs'; +test('required code executor is read from run metadata', () => { + assert.equal(resolveRequiredCodeExecutor({ + metadata: { memindRun: { executor: 'AIDER' } }, + }), 'aider'); + assert.equal(resolveRequiredCodeExecutor({ + metadata: { memindRun: { executor: 'unknown' } }, + }), null); +}); + +test('required Aider executor fails closed when Tool Gateway is unavailable', () => { + assert.doesNotThrow(() => assertRequiredCodeExecutorAvailable('aider', { + enabled: true, + executors: ['aider', 'openhands'], + })); + assert.throws( + () => assertRequiredCodeExecutorAvailable('aider', { + enabled: false, + executors: ['aider', 'openhands'], + }), + (error) => error?.code === 'REQUIRED_EXECUTOR_UNAVAILABLE' && error?.retryable === false, + ); + assert.throws( + () => assertRequiredCodeExecutorAvailable('aider', { + enabled: true, + executors: ['openhands'], + }), + (error) => error?.code === 'REQUIRED_EXECUTOR_UNAVAILABLE', + ); +}); + test('required image generation cannot succeed without a verified raster image_make result', () => { const row = { user_message_json: JSON.stringify({ diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index 80d9bfb..6d98b60 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -1,4 +1,5 @@ import { normalizeAgentRunToolMode } from './agent-run-gateway.mjs'; +import { AIDER_DEVELOPMENT_SKILL_NAME } from './chat-skills.mjs'; import { createSessionAccess } from './session-broker.mjs'; import { extractRunFromStreamEvent, @@ -51,6 +52,41 @@ function hasExpectedFileValidation(userMessage) { }); } +function selectedChatSkill(userMessage) { + const metadata = userMessage?.metadata; + const runMetadata = metadata?.memindRun ?? metadata?.agentRun ?? {}; + return String(runMetadata.selectedChatSkill ?? '').trim(); +} + +export function enforceSelectedSkillRuntime(userMessage, { + rawToolMode = 'chat', + taskType = null, +} = {}) { + if (selectedChatSkill(userMessage) !== AIDER_DEVELOPMENT_SKILL_NAME) { + return { userMessage, rawToolMode, taskType, requiredExecutor: null }; + } + const message = userMessage && typeof userMessage === 'object' && !Array.isArray(userMessage) + ? { ...userMessage } + : { value: userMessage }; + const metadata = message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata) + ? { ...message.metadata } + : {}; + const runMetadata = metadata.memindRun && typeof metadata.memindRun === 'object' && !Array.isArray(metadata.memindRun) + ? { ...metadata.memindRun } + : {}; + metadata.memindRun = { + ...runMetadata, + selectedChatSkill: AIDER_DEVELOPMENT_SKILL_NAME, + executor: 'aider', + }; + return { + userMessage: { ...message, metadata }, + rawToolMode: 'code', + taskType: 'h5_chat_code_task', + requiredExecutor: 'aider', + }; +} + export function createPostAgentRunsHandler({ userAuth, sessionAccess = null, @@ -86,9 +122,9 @@ export function createPostAgentRunsHandler({ try { const sessionId = String(request.body?.session_id ?? '').trim() || null; const requestId = String(request.body?.request_id ?? '').trim(); - const userMessage = request.body?.user_message ?? null; - const rawToolMode = request.body?.tool_mode ?? request.body?.toolMode ?? 'chat'; - const taskType = String(request.body?.task_type ?? request.body?.taskType ?? '').trim() || null; + let userMessage = request.body?.user_message ?? null; + let rawToolMode = request.body?.tool_mode ?? request.body?.toolMode ?? 'chat'; + let taskType = String(request.body?.task_type ?? request.body?.taskType ?? '').trim() || null; const forceDeepReasoning = request.body?.force_deep_reasoning === true || request.body?.forceDeepReasoning === true; if (!requestId) { response.status(400).json({ message: '缺少 request_id' }); @@ -98,6 +134,23 @@ export function createPostAgentRunsHandler({ response.status(400).json({ message: '缺少 user_message' }); return; } + const selectedSkillRuntime = enforceSelectedSkillRuntime(userMessage, { + rawToolMode, + taskType, + }); + userMessage = selectedSkillRuntime.userMessage; + rawToolMode = selectedSkillRuntime.rawToolMode; + taskType = selectedSkillRuntime.taskType; + if ( + selectedSkillRuntime.requiredExecutor && + userAuth?.getUserSkills + ) { + const skillState = await userAuth.getUserSkills(request.currentUser.id); + if (!skillState?.skills?.[AIDER_DEVELOPMENT_SKILL_NAME]) { + response.status(403).json({ message: '当前用户未授权 Aider 开发技能' }); + return; + } + } let toolMode = 'chat'; try { toolMode = normalizeAgentRunToolMode(rawToolMode); diff --git a/agent-run-routes.test.mjs b/agent-run-routes.test.mjs index 4affb9c..bf8f6d6 100644 --- a/agent-run-routes.test.mjs +++ b/agent-run-routes.test.mjs @@ -4,6 +4,7 @@ import { createAgentRunEventsHandler, createGetAgentRunHandler, createPostAgentRunsHandler, + enforceSelectedSkillRuntime, } from './agent-run-routes.mjs'; function createResponseRecorder() { @@ -100,6 +101,102 @@ test('POST /agent/runs creates a run and returns 202', async () => { ]); }); +test('selected Aider development skill forces code mode, task type, and Aider executor', async () => { + const userMessage = { + role: 'user', + content: [{ type: 'text', text: '修改页面并测试' }], + metadata: { + memindRun: { + selectedChatSkill: 'aider-development', + executor: 'openhands', + validation: { expectedFile: '.memind/agent-runs/req-aider.json' }, + }, + }, + }; + const enforced = enforceSelectedSkillRuntime(userMessage, { + rawToolMode: 'chat', + taskType: 'page_data_dev_complex', + }); + assert.equal(enforced.rawToolMode, 'code'); + assert.equal(enforced.taskType, 'h5_chat_code_task'); + assert.equal(enforced.requiredExecutor, 'aider'); + assert.equal(enforced.userMessage.metadata.memindRun.executor, 'aider'); + + const created = []; + const handler = createPostAgentRunsHandler({ + userAuth: { + async getUserSkills() { + return { skills: { 'aider-development': true } }; + }, + }, + agentRunGateway: { + async createRun(userId, payload) { + created.push({ userId, payload }); + return { id: 'run-aider', status: 'queued' }; + }, + }, + codeRunsEnabled: true, + requireCodeRunValidation: true, + }); + const res = createResponseRecorder(); + await handler( + { + currentUser: { id: 'user-1' }, + body: { + request_id: 'req-aider', + user_message: userMessage, + tool_mode: 'chat', + task_type: 'page_data_dev_complex', + }, + }, + res, + ); + + assert.equal(res.statusCode, 202); + assert.equal(created[0].payload.toolMode, 'code'); + assert.equal(created[0].payload.taskType, 'h5_chat_code_task'); + assert.equal(created[0].payload.userMessage.metadata.memindRun.executor, 'aider'); +}); + +test('selected Aider development skill fails closed when code runs are disabled', async () => { + const handler = createPostAgentRunsHandler({ + userAuth: { + async getUserSkills() { + return { skills: { 'aider-development': true } }; + }, + }, + agentRunGateway: { + async createRun() { + throw new Error('must not fall back to a normal run'); + }, + }, + codeRunsEnabled: false, + }); + const res = createResponseRecorder(); + await handler( + { + currentUser: { id: 'user-1' }, + body: { + request_id: 'req-aider-disabled', + user_message: { + role: 'user', + content: [{ type: 'text', text: '修复代码' }], + metadata: { + memindRun: { + selectedChatSkill: 'aider-development', + validation: { expectedFile: '.memind/agent-runs/req-aider-disabled.json' }, + }, + }, + }, + }, + }, + res, + ); + + assert.equal(res.statusCode, 403); + assert.match(res.body.message, /代码任务灰度未开启/); +}); + test('POST /agent/runs forwards deep reasoning flag to the run gateway', async () => { const created = []; const handler = createPostAgentRunsHandler({ diff --git a/chat-skills.mjs b/chat-skills.mjs index 3c64f67..08afb79 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -2,6 +2,7 @@ const PUBLISH_SKILL_NAME = 'static-page-publish'; export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect'; export const EXCEL_ANALYST_SKILL_NAME = 'excel-analyst'; +export const AIDER_DEVELOPMENT_SKILL_NAME = 'aider-development'; export const SKILL_ROUTER_V2_ENV = 'TKMIND_SKILL_ROUTER_V2'; const EXCEL_ANALYSIS_INTENT_PATTERNS = [ @@ -171,6 +172,14 @@ export const CHAT_SKILL_DEFINITIONS = [ prefillOnly: true, promptKey: 'service-integration-smoke', }, + { + id: AIDER_DEVELOPMENT_SKILL_NAME, + label: 'Aider 开发', + icon: 'spark', + skillName: AIDER_DEVELOPMENT_SKILL_NAME, + requiresSkill: AIDER_DEVELOPMENT_SKILL_NAME, + promptKey: AIDER_DEVELOPMENT_SKILL_NAME, + }, { id: 'form-collect', label: '表单收集', @@ -251,6 +260,11 @@ export function buildChatSkillPrompt(promptKey, skillName) { ); case 'service-integration-smoke': return `请使用 ${skillName ?? 'service-integration-smoke'} 技能:按标准联调流程检查当前服务,覆盖身份、普通聊天、记忆读取,以及我本轮明确要求验证的技能/发布链路,并输出通过项、失败项、待确认项:`; + case AIDER_DEVELOPMENT_SKILL_NAME: + return ( + `请使用 ${skillName ?? AIDER_DEVELOPMENT_SKILL_NAME} 技能:` + + '本轮必须由 Aider code run 在当前用户工作区执行开发、修复和验证;Aider 或 Tool Gateway 不可用时直接失败,禁止回退到 Goose、OpenHands 或普通聊天冒充执行。我的开发任务是:' + ); case 'table-viewer': return `请使用 ${skillName ?? 'table-viewer'} 技能:请把以下数据整理成可排序、可筛选的交互式表格:`; case 'product-campaign-page': diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index 6a7c741..4be47a9 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -47,6 +47,19 @@ test('filterChatSkills shows service integration smoke when granted', () => { assert.ok(visible.some((item) => item.id === 'service-integration-smoke')); }); +test('filterChatSkills only shows Aider development when granted', () => { + const hidden = filterChatSkills(CHAT_SKILL_DEFINITIONS, { + canPublish: false, + grantedSkills: [], + }); + assert.equal(hidden.some((item) => item.id === 'aider-development'), false); + const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { + canPublish: false, + grantedSkills: ['aider-development'], + }); + assert.ok(visible.some((item) => item.id === 'aider-development')); +}); + test('filterChatSkills shows page-data-collect when granted without static publish', () => { const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { canPublish: false, @@ -69,6 +82,8 @@ test('buildChatSkillPrompt includes skill name for platform skills', () => { assert.match(enhancedPrompt, /tkmind_search/); assert.match(enhancedPrompt, /web_search/); assert.match(buildChatSkillPrompt('service-integration-smoke'), /标准联调流程/); + assert.match(buildChatSkillPrompt('aider-development'), /必须由 Aider code run/); + assert.match(buildChatSkillPrompt('aider-development'), /禁止回退/); assert.match(buildChatSkillPrompt('product-campaign-page'), /商品宣传 \/ 活动页/); assert.match(buildChatSkillPrompt('image-generation'), /asset\.htmlSrc/); assert.match(buildChatSkillPrompt('image-generation'), /workspaceRelativePath 只用于文件操作/); diff --git a/skills-registry.mjs b/skills-registry.mjs index ad3e7e5..b6d82b7 100644 --- a/skills-registry.mjs +++ b/skills-registry.mjs @@ -13,6 +13,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect'; export const EXCEL_ANALYST_SKILL_NAME = 'excel-analyst'; export const IMAGE_GENERATION_SKILL_NAME = 'image-generation'; +export const AIDER_DEVELOPMENT_SKILL_NAME = 'aider-development'; export const DEFAULT_USER_SKILLS = { web: true, @@ -28,6 +29,7 @@ export const DEFAULT_USER_SKILLS = { 'long-image-download': true, [IMAGE_GENERATION_SKILL_NAME]: true, [PAGE_DATA_COLLECT_SKILL_NAME]: true, + [AIDER_DEVELOPMENT_SKILL_NAME]: false, [PUBLISH_SKILL_NAME]: false, }; @@ -42,6 +44,7 @@ export const USER_ROLE_SKILL_PRESETS = { }, developer: { ...DEFAULT_USER_SKILLS, + [AIDER_DEVELOPMENT_SKILL_NAME]: true, git: true, 'diff-viewer': true, 'code-playground': true, diff --git a/skills-registry.test.mjs b/skills-registry.test.mjs index 716c184..b480275 100644 --- a/skills-registry.test.mjs +++ b/skills-registry.test.mjs @@ -6,6 +6,7 @@ import { listPlatformSkillCatalog, normalizeSkillPatch, resolveSkillMap, + USER_ROLE_SKILL_PRESETS, } from './skills-registry.mjs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -25,6 +26,9 @@ test('lists static-page-publish in platform catalog', () => { assert.ok(imageGeneration.manifest.trigger.keywords.includes('AI配图')); assert.equal(imageGeneration.manifest.router.promptKey, 'image-generation'); assert.ok(catalog.some((item) => item.name === 'excel-analyst')); + const aiderDevelopment = catalog.find((item) => item.name === 'aider-development'); + assert.ok(aiderDevelopment); + assert.deepEqual(aiderDevelopment.executors, ['aider']); }); test('granting page-data-collect enables static_publish capability', () => { @@ -73,4 +77,6 @@ test('DEFAULT_USER_SKILLS enables common platform skills', () => { assert.equal(DEFAULT_USER_SKILLS['page-data-collect'], true); assert.equal(DEFAULT_USER_SKILLS['static-page-publish'], false); assert.equal(DEFAULT_USER_SKILLS['excel-analyst'], false); + assert.equal(DEFAULT_USER_SKILLS['aider-development'], false); + assert.equal(USER_ROLE_SKILL_PRESETS.developer['aider-development'], true); }); diff --git a/skills/aider-development/SKILL.md b/skills/aider-development/SKILL.md new file mode 100644 index 0000000..8356cd2 --- /dev/null +++ b/skills/aider-development/SKILL.md @@ -0,0 +1,23 @@ +--- +name: aider-development +description: Force development, debugging, refactoring, and code validation tasks in the current MindSpace workspace to run through the Aider executor. Use only when the user explicitly selects the Aider 开发 skill or asks to continue a task already started with that selected skill; fail closed when Aider or the code-run gateway is unavailable. +--- + +# Aider 开发 + +必须通过平台注入的 Aider code run 执行当前工作区内的开发任务。不得切换到 Goose、OpenHands 或普通聊天执行器。 + +## 工作流 + +1. 先检查任务相关文件、现有实现和验证入口,限制修改范围。 +2. 实现用户要求的代码或页面修改,保留无关文件和现有数据。 +3. 执行与改动对应的最小测试、构建或静态验证。 +4. 按运行时注入的 `[Memind code-run validation]` 要求写入验收 receipt。 +5. 只有产物与验证都成功后才报告完成;失败时返回真实错误和未完成项。 + +## 边界 + +- 只操作当前用户被授权的工作区,不越界访问其它用户或平台生产目录。 +- 不执行发布、推送、合并主线或生产变更,除非用户另行明确授权且平台闸门允许。 +- Aider 没有 `private_data_*` 能力时,不得伪造建表、dataset 注册或页面绑定结果;应明确报告需转交 `page-data-collect`/Goose 的步骤。 +- Aider、模型绑定或 Tool Gateway 不可用时必须失败关闭,不得退回普通 Agent 冒充 Aider 完成。 diff --git a/skills/aider-development/agents/openai.yaml b/skills/aider-development/agents/openai.yaml new file mode 100644 index 0000000..135b856 --- /dev/null +++ b/skills/aider-development/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Aider 开发" + short_description: "强制使用 Aider 在用户工作区内开发、修复并验证代码" + default_prompt: "使用 $aider-development 在当前工作区实现并验证这项开发任务。" diff --git a/skills/aider-development/skill.yaml b/skills/aider-development/skill.yaml new file mode 100644 index 0000000..036e397 --- /dev/null +++ b/skills/aider-development/skill.yaml @@ -0,0 +1,6 @@ +name: aider-development +version: 1.0.0 +label: Aider 开发 +description: 强制使用 Aider 在当前用户工作区开发、修复并验证代码 +executors: + - aider diff --git a/src/api/client.ts b/src/api/client.ts index de7f117..91d15df 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -174,7 +174,7 @@ function withAgentRunValidationMetadata( function prepareAgentRunUserMessage(message: Message, options: AgentRunCreateOptions): Message { const normalized = normalizeUserMessageForApi(message); const withValidation = withAgentRunValidationMetadata(normalized, options.validation); - const withRunMetadata = options.forceDeepReasoning + const withRunMetadata = options.forceDeepReasoning || options.executor ? { ...withValidation, metadata: { @@ -186,7 +186,8 @@ function prepareAgentRunUserMessage(message: Message, options: AgentRunCreateOpt ? withValidation.metadata.memindRun : {} ), - forceDeepReasoning: true, + ...(options.forceDeepReasoning ? { forceDeepReasoning: true } : {}), + ...(options.executor ? { executor: options.executor } : {}), }, }, } diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 09dbcf0..8dbab7e 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -51,7 +51,10 @@ import type { } from '../types'; import { buildContextPrefix } from '../utils/mindspaceChatContext'; import { buildUserAddressPrefix } from '../utils/userAddress'; -import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs'; +import { + AIDER_DEVELOPMENT_SKILL_NAME, + buildAutoChatSkillPrefix, +} from '../../chat-skills.mjs'; import { reconcileSessionEventRequestContext, resolvePostAgentRunChatState, @@ -1473,8 +1476,11 @@ export function useTKMindChat( try { const pageDataDevTaskType = resolvePageDataDevTaskType(trimmed); + const requiresAider = options?.selectedChatSkill === AIDER_DEVELOPMENT_SKILL_NAME; const runOptions = resolveAgentRunOptions(trimmed, { taskType: pageDataDevTaskType ?? 'h5_chat_code_task', + forceCode: requiresAider, + requiredExecutor: requiresAider ? 'aider' : undefined, userId: userRef.current?.id ?? null, requestId, mindspaceContext: options?.mindspaceContext ?? null, diff --git a/src/utils/agentRunMode.ts b/src/utils/agentRunMode.ts index ad3db83..ac431a5 100644 --- a/src/utils/agentRunMode.ts +++ b/src/utils/agentRunMode.ts @@ -3,6 +3,7 @@ import type { MindSpaceChatContext, AgentCodeRunClientPolicy } from '../types'; export type AgentRunCreateOptions = { toolMode?: 'chat' | 'code'; taskType?: string | null; + executor?: 'aider' | 'openhands'; forceDeepReasoning?: boolean; validation?: AgentRunValidation | null; validationInstruction?: string | null; @@ -299,6 +300,7 @@ export function resolveAgentRunOptions( }: { taskType?: string; forceCode?: boolean; + requiredExecutor?: 'aider' | 'openhands'; allowAutodetect?: boolean; allowPageDataDevAutodetect?: boolean; userId?: string | null; @@ -313,15 +315,17 @@ export function resolveAgentRunOptions( const effectiveTaskType = pageDataDevTaskType ?? taskType; const shouldUseDeepReasoning = forceCode || + Boolean(requiredExecutor) || Boolean(pageDataDevTaskType) || DEEP_REASONING_TASK_PATTERNS.some((pattern) => pattern.test(normalizedText)); - if (!agentCodeRunsEnabledForUser(userId)) { + if (!agentCodeRunsEnabledForUser(userId) && !requiredExecutor) { return shouldUseDeepReasoning ? { forceDeepReasoning: true, taskType: effectiveTaskType } : {}; } const shouldUseCode = forceCode || + Boolean(requiredExecutor) || Boolean(pageDataDevTaskType) || (allowAutodetect && CODE_TASK_PATTERNS.some((pattern) => pattern.test(normalizedText))); if (!shouldUseCode) { @@ -339,6 +343,7 @@ export function resolveAgentRunOptions( return { toolMode: 'code', taskType: effectiveTaskType, + ...(requiredExecutor ? { executor: requiredExecutor } : {}), forceDeepReasoning: true, validation: mergeAgentRunValidationFiles(receipt.validation, taskValidation.validation), validationInstruction: `${receipt.instruction}${taskValidation.instruction}`, diff --git a/tool-gateway.test.mjs b/tool-gateway.test.mjs index dd06b17..8766afd 100644 --- a/tool-gateway.test.mjs +++ b/tool-gateway.test.mjs @@ -43,6 +43,21 @@ test('tool gateway selects openhands for page_data_dev_complex by default', () = assert.equal(gateway.selectExecutor({ taskType: 'page_data_dev_complex' }), 'openhands'); }); +test('tool gateway honors an explicitly required Aider executor over task defaults', () => { + const gateway = createToolGateway({ env: {} }); + assert.equal(gateway.selectExecutor({ + taskType: 'page_data_dev_complex', + userMessage: { + metadata: { + memindRun: { + executor: 'aider', + selectedChatSkill: 'aider-development', + }, + }, + }, + }), 'aider'); +}); + test('tool gateway dry run builds executor launch plan without spawning', async () => { const plans = []; const gateway = createToolGateway({