From c5f84f54b9b558b62c97f9fbf1194e7cae8b354b Mon Sep 17 00:00:00 2001 From: john Date: Fri, 24 Jul 2026 19:48:04 +0800 Subject: [PATCH 1/2] 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({ From 0122d89db3ca9e7eac6be57bc6f7d0b77e8791a6 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 25 Jul 2026 09:47:25 +0800 Subject: [PATCH 2/2] feat: complete Aider Page Data review workflow --- agent-run-gateway.mjs | 212 ++++++++++++++++++- agent-run-gateway.test.mjs | 190 +++++++++++++++++ agent-run-routes.mjs | 79 ++++++- agent-run-routes.test.mjs | 121 +++++++++++ capabilities.mjs | 21 +- capabilities.test.mjs | 18 ++ chat-skills.mjs | 30 +++ chat-skills.test.mjs | 39 ++++ mindsearch.test.mjs | 22 ++ server/portal-gateway-services-bootstrap.mjs | 19 +- src/components/ChatPanel.tsx | 8 +- src/hooks/useTKMindChat.ts | 40 +++- src/utils/agentRunMode.ts | 6 +- src/utils/chatSkills.ts | 2 + tool-gateway.mjs | 105 ++++++++- tool-gateway.test.mjs | 51 ++++- 16 files changed, 929 insertions(+), 34 deletions(-) diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 0eac688..6b9369c 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -211,6 +211,15 @@ function summarizeText(value, limit = TOOL_GATEWAY_SUMMARY_LIMIT) { return text.slice(text.length - limit); } +export function buildCodeRunCompletionReply(result) { + const executor = String(result?.executor ?? 'code executor').trim() || 'code executor'; + const output = summarizeText(result?.stdout, 2400).trim(); + return [ + `已由 ${executor} 完成执行,并通过平台文件验收。`, + output ? `\n${output}` : '', + ].join('').trim(); +} + function normalizeExpectedFileCheck(value) { if (typeof value === 'string') { const expectedPath = value.trim(); @@ -360,6 +369,12 @@ function getRunOptionsFromMessage(userMessage) { toolMode, taskType: normalizeTaskType(runMetadata?.taskType ?? metadata?.taskType), requiredExecutor: resolveRequiredCodeExecutor(userMessage), + reviewExecutor: ['aider', 'openhands'].includes( + String(runMetadata?.reviewExecutor ?? '').trim().toLowerCase(), + ) + ? String(runMetadata.reviewExecutor).trim().toLowerCase() + : null, + pageDataAiderWorkflow: runMetadata?.pageDataAiderWorkflow === true, forceDeepReasoning: runMetadata?.forceDeepReasoning === true || metadata?.forceDeepReasoning === true, validation: normalizeToolGatewayValidation(runMetadata?.validation ?? metadata?.toolGatewayValidation), sessionMessageCount: normalizeSessionMessageCount( @@ -368,6 +383,48 @@ function getRunOptionsFromMessage(userMessage) { }; } +export async function collectAiderReviewFiles(cwd, sinceMs = 0) { + if (!cwd) return []; + const root = path.resolve(String(cwd)); + const candidates = [ + { relativeDir: 'public', extensions: new Set(['.html', '.css', '.js']) }, + { relativeDir: '.mindspace/page-data-policies', extensions: new Set(['.json']) }, + ]; + const files = []; + async function walk(baseDir, relativeDir, extensions) { + let entries = []; + try { + entries = await fs.readdir(baseDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (files.length >= 40) return; + const absolute = path.join(baseDir, entry.name); + const relative = path.posix.join(relativeDir.split(path.sep).join('/'), entry.name); + if (entry.isDirectory()) { + await walk(absolute, relative, extensions); + continue; + } + if (!entry.isFile() || !extensions.has(path.extname(entry.name).toLowerCase())) continue; + try { + const stat = await fs.stat(absolute); + if (Number(stat.mtimeMs) + 5_000 >= Number(sinceMs || 0)) files.push(relative); + } catch { + // File disappeared between directory listing and stat. + } + } + } + for (const candidate of candidates) { + await walk( + path.join(root, candidate.relativeDir), + candidate.relativeDir, + candidate.extensions, + ); + } + return files; +} + function projectRun(row) { if (!row) return null; return { @@ -699,6 +756,89 @@ export function createAgentRunGateway({ } } + async function runRequiredCodeReview({ + row, + runId, + userMessage, + runOptions, + sessionId, + }) { + if (!runOptions.reviewExecutor) return; + const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null; + assertRequiredCodeExecutorAvailable(runOptions.reviewExecutor, toolGatewayStatus); + const workingDir = userAuth?.resolveWorkingDir + ? await userAuth.resolveWorkingDir(row.user_id) + : undefined; + const claimedRun = await getRunById(runId); + const contextFiles = await collectAiderReviewFiles( + workingDir, + claimedRun?.started_at ?? row.started_at ?? 0, + ); + const reviewMessage = { + ...userMessage, + content: [{ + type: 'text', + text: [ + '[Mandatory Aider Page Data review]', + `Original user request: ${extractRunDisplayText(row)}`, + `Session: ${sessionId}`, + 'Review the provided workspace files, fix concrete HTML/client-policy defects if needed,', + 'do not recreate PostgreSQL tables or datasets, and update the required validation receipt.', + 'Do not commit, push, publish, or modify files outside the current user workspace.', + contextFiles.length + ? `Review files:\n${contextFiles.map((item) => `- ${item}`).join('\n')}` + : 'No recent page files were detected; record that fact in the receipt so platform delivery validation can fail closed.', + `Receipt requestId: ${row.request_id}`, + ].join('\n'), + }], + metadata: { + ...(userMessage?.metadata ?? {}), + memindRun: { + ...(userMessage?.metadata?.memindRun ?? {}), + executor: runOptions.reviewExecutor, + aiderContextFiles: contextFiles, + }, + }, + }; + await appendEvent(runId, 'required_code_review_dispatch', { + executor: runOptions.reviewExecutor, + contextFiles, + }); + const result = await toolGateway.executeJob({ + runId, + userId: row.user_id, + requestId: row.request_id, + userMessage: reviewMessage, + taskType: 'page_data_dev', + cwd: workingDir, + timeoutMs: runTimeoutMs, + }); + if ( + String(result?.executor ?? '').trim().toLowerCase() !== runOptions.reviewExecutor + ) { + const error = new Error( + `审查执行器不匹配:要求 ${runOptions.reviewExecutor},实际 ${result?.executor ?? 'unknown'}`, + ); + error.code = 'REQUIRED_REVIEW_EXECUTOR_MISMATCH'; + error.retryable = false; + throw error; + } + await appendEvent(runId, 'required_code_review_result', { + executor: result.executor ?? null, + exitCode: result.exitCode ?? null, + stdoutTail: summarizeText(result.stdout), + stderrTail: summarizeText(result.stderr), + }); + const validation = await validateToolGatewayResult({ + result, + validation: runOptions.validation, + cwd: workingDir, + }); + if (validation) { + await appendEvent(runId, 'required_code_review_validation', validation); + } + } + async function resolveRunRouting(row, userMessage, runOptions) { if (!chatIntentRouter?.classify) return null; const enabled = chatIntentRouter.isEnabled @@ -721,7 +861,13 @@ 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); + assertRequiredCodeExecutorAvailable( + runOptions.requiredExecutor ?? runOptions.reviewExecutor, + toolGatewayStatus, + ); + const effectiveToolMode = runOptions.pageDataAiderWorkflow + ? 'chat' + : runOptions.toolMode; let disclosureDecision = null; try { disclosureDecision = systemDisclosurePolicyService?.evaluate?.({ @@ -881,7 +1027,11 @@ export function createAgentRunGateway({ } } } - if (runOptions.toolMode === 'code' && toolGatewayStatus?.enabled) { + if ( + runOptions.toolMode === 'code' && + !runOptions.pageDataAiderWorkflow && + toolGatewayStatus?.enabled + ) { const workingDir = userAuth?.resolveWorkingDir ? await userAuth.resolveWorkingDir(row.user_id) : undefined; @@ -935,6 +1085,38 @@ export function createAgentRunGateway({ }); throw err; } + if (runOptions.requiredExecutor) { + if (!directChatService?.respondDeterministically) { + const error = new Error('代码任务已执行,但结果回传服务不可用'); + error.code = 'CODE_RUN_RESULT_DELIVERY_UNAVAILABLE'; + error.retryable = false; + throw error; + } + const delivery = await directChatService.respondDeterministically({ + userId: row.user_id, + sessionId: row.agent_session_id ?? null, + requestId: row.request_id, + userMessage, + reply: buildCodeRunCompletionReply(result), + metadata: { + source: 'tool-gateway-code-run', + executor: result.executor ?? null, + validated: true, + }, + onSessionReady: async (activeSessionId) => { + await pool.query( + `UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`, + [activeSessionId, nowMs(), runId], + ); + await appendRunSnapshot(runId); + }, + }); + await appendEvent(runId, 'tool_gateway_result_delivered', { + sessionId: delivery.sessionId, + executor: result.executor ?? null, + }); + return { sessionId: delivery.sessionId, routing }; + } return { sessionId: row.agent_session_id ?? null, routing }; } @@ -953,7 +1135,7 @@ export function createAgentRunGateway({ } if (!sessionId) { const sessionOptions = {}; - if (runOptions.toolMode === 'code' && userAuth?.getCodeAgentSessionPolicy) { + if (effectiveToolMode === 'code' && userAuth?.getCodeAgentSessionPolicy) { sessionOptions.sessionPolicy = await userAuth.getCodeAgentSessionPolicy(row.user_id); } const session = await tkmindProxy.startSessionForUser(row.user_id, sessionOptions); @@ -964,7 +1146,7 @@ export function createAgentRunGateway({ ); await appendEvent(runId, 'session_started', { sessionId, - toolMode: runOptions.toolMode, + toolMode: effectiveToolMode, taskType: runOptions.taskType, }); await appendRunSnapshot(runId); @@ -1006,8 +1188,17 @@ export function createAgentRunGateway({ await invalidatePortalDirectChatSnapshot(sessionId); let toolEvidence = null; const awaitSessionFinish = envFlag(process.env.MEMIND_AGENT_RUN_AWAIT_SESSION_FINISH, true) - && runOptions.toolMode === 'chat' + && effectiveToolMode === 'chat' && typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function'; + if ( + runOptions.pageDataAiderWorkflow && + typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser !== 'function' + ) { + const error = new Error('Page Data + Aider 工作流需要等待 Page Data Agent 完成'); + error.code = 'PAGE_DATA_AIDER_FINISH_UNAVAILABLE'; + error.retryable = false; + throw error; + } if (awaitSessionFinish) { let submitMessage = ensureGooseUserMessageMetadata(userMessage); let replacedPoisonedSession = false; @@ -1019,7 +1210,7 @@ export function createAgentRunGateway({ row.request_id, submitMessage, { - toolMode: runOptions.toolMode, + toolMode: effectiveToolMode, forceDeepReasoning: runOptions.forceDeepReasoning, timeoutMs: runTimeoutMs, }, @@ -1085,11 +1276,18 @@ export function createAgentRunGateway({ row.request_id, ensureGooseUserMessageMetadata(userMessage), { - toolMode: runOptions.toolMode, + toolMode: effectiveToolMode, forceDeepReasoning: runOptions.forceDeepReasoning, }, ); } + await runRequiredCodeReview({ + row, + runId, + userMessage, + runOptions, + sessionId, + }); return { sessionId, routing, toolEvidence }; } diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 6a1e365..fa43846 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -892,6 +892,114 @@ test('Page Data run succeeds only after a generated session page is detected', a await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); }); +test('Page Data plus Aider workflow builds with Agent and then performs mandatory Aider review', async () => { + const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-page-data-aider-')); + const pool = createFakePool({ + sessionDeliverables: { + 'user-1:session-order-system': [{ + page_id: 'page-order', + title: '下单系统', + publication_id: 'pub-order', + publication_status: 'online', + public_url: 'http://127.0.0.1:5173/u/john/pages/page-order', + workspace_relative_path: 'public/order.html', + }], + }, + }); + const reviewJobs = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: { + async resolveWorkingDir() { + return workdir; + }, + }, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-order-system' }; + }, + async submitSessionReplyAndAwaitFinishForUser(_userId, _sessionId, _requestId, _message, options) { + assert.equal(options.toolMode, 'chat'); + await fs.mkdir(path.join(workdir, 'public'), { recursive: true }); + await fs.writeFile( + path.join(workdir, 'public', 'order.html'), + '', + ); + return { + ok: true, + finishEvent: { type: 'Finish' }, + toolEvidence: { calls: ['private_data_execute', 'private_data_bind_workspace_page'] }, + }; + }, + }, + toolGateway: { + getStatus() { + return { + enabled: true, + protocol: 'agent-run-v1', + executors: ['aider', 'openhands'], + }; + }, + async executeJob(job) { + reviewJobs.push(job); + await fs.mkdir(path.join(workdir, '.memind', 'agent-runs'), { recursive: true }); + await fs.writeFile( + path.join(workdir, '.memind', 'agent-runs', 'req-page-data-aider.json'), + JSON.stringify({ requestId: 'req-page-data-aider', review: 'passed' }), + ); + return { + ok: true, + executor: 'aider', + exitCode: 0, + cwd: workdir, + stdout: 'Reviewed public/order.html and the Page Data client usage.', + }; + }, + }, + syncUserPagesOnSuccess: async () => ({ + pageDataBind: { errors: [] }, + pageDataRelativePaths: ['public/order.html'], + }), + validateRunDeliverables: async () => ({ errors: [] }), + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-page-data-aider', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '创建下单系统并在后台管理订单' }], + metadata: { + displayText: '创建下单系统并在后台管理订单', + memindRun: { + reviewExecutor: 'aider', + pageDataAiderWorkflow: true, + validation: { + expectedFile: { + path: '.memind/agent-runs/req-page-data-aider.json', + contains: 'req-page-data-aider', + }, + }, + }, + }, + }, + toolMode: 'chat', + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(reviewJobs.length, 1); + assert.equal(reviewJobs[0].taskType, 'page_data_dev'); + assert.deepEqual( + reviewJobs[0].userMessage.metadata.memindRun.aiderContextFiles, + ['public/order.html'], + ); + assert.ok( + pool.events.some( + (event) => event.runId === run.id && event.eventType === 'required_code_review_validation', + ), + ); +}); + test('agent run fails closed when a generated page violates browser storage policy', async () => { const pool = createFakePool({ sessionDeliverables: { @@ -1878,6 +1986,88 @@ test('agent run validates expected tool gateway artifacts before succeeding', as assert.equal(JSON.parse(validationEvent.dataJson).expectedFiles[0].path, 'RESULT.md'); }); +test('required Aider run persists a validated result into a chat session', async () => { + const pool = createFakePool(); + const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-aider-delivery-')); + const deliveries = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: { + async resolveWorkingDir() { + return workdir; + }, + }, + tkmindProxy: {}, + directChatService: { + async respondDeterministically(options) { + deliveries.push(options); + await options.onSessionReady('h5direct_aider_result'); + return { sessionId: 'h5direct_aider_result' }; + }, + }, + toolGateway: { + getStatus() { + return { + enabled: true, + protocol: 'agent-run-v1', + executors: ['aider', 'openhands'], + }; + }, + async executeJob() { + await fs.mkdir(path.join(workdir, '.memind', 'agent-runs'), { recursive: true }); + await fs.writeFile( + path.join(workdir, '.memind', 'agent-runs', 'req-aider-delivery.json'), + JSON.stringify({ requestId: 'req-aider-delivery', tests: 'passed' }), + ); + return { + ok: true, + dryRun: false, + executor: 'aider', + exitCode: 0, + cwd: workdir, + stdout: 'Implemented the requested page and ran its checks.', + stderr: '', + }; + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-aider-delivery', + userMessage: { + role: 'user', + content: [{ type: 'text', text: 'build the page' }], + metadata: { + displayText: 'build the page', + memindRun: { + executor: 'aider', + validation: { + expectedFile: { + path: '.memind/agent-runs/req-aider-delivery.json', + contains: 'req-aider-delivery', + }, + }, + }, + }, + }, + toolMode: 'code', + taskType: 'h5_chat_code_task', + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(pool.runs.get(run.id).agent_session_id, 'h5direct_aider_result'); + assert.equal(deliveries.length, 1); + assert.match(deliveries[0].reply, /Aider/i); + assert.match(deliveries[0].reply, /通过平台文件验收/); + assert.equal( + pool.events.some( + (event) => event.runId === run.id && event.eventType === 'tool_gateway_result_delivered', + ), + true, + ); +}); + test('agent run fails non-retryably when tool gateway artifact validation fails', async () => { const pool = createFakePool(); const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-tool-validation-missing-')); diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index 6d98b60..509860d 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -1,5 +1,10 @@ import { normalizeAgentRunToolMode } from './agent-run-gateway.mjs'; -import { AIDER_DEVELOPMENT_SKILL_NAME } from './chat-skills.mjs'; +import { + AIDER_DEVELOPMENT_SKILL_NAME, + buildChatSkillPrompt, + extractAiderDevelopmentTask, + isPageDataIntent, +} from './chat-skills.mjs'; import { createSessionAccess } from './session-broker.mjs'; import { extractRunFromStreamEvent, @@ -58,6 +63,39 @@ function selectedChatSkill(userMessage) { return String(runMetadata.selectedChatSkill ?? '').trim(); } +function rewriteAiderPageDataInstruction(userMessage, taskText) { + const aiderPrompt = buildChatSkillPrompt( + AIDER_DEVELOPMENT_SKILL_NAME, + AIDER_DEVELOPMENT_SKILL_NAME, + ); + const pageDataPrompt = buildChatSkillPrompt('page-data-collect', 'page-data-collect'); + const compositePrompt = [ + pageDataPrompt, + taskText, + '\n\n[强制 Aider 审查]', + '先由当前 Agent 使用 private_data_* 和 Page Data 工具完成建表、dataset、页面与绑定。', + '完成后平台会强制调用 Aider 审查当前工作区产物;禁止省略该审查或声称 Aider 已执行。', + ].join(''); + const content = Array.isArray(userMessage?.content) + ? userMessage.content + .filter((item) => ( + item?.type !== 'text' || + !String(item.text ?? '').trim().startsWith('[Memind code-run validation]') + )) + .map((item) => { + if (item?.type !== 'text') return item; + const text = String(item.text ?? ''); + return { + ...item, + text: text.includes(aiderPrompt) + ? text.replace(`${aiderPrompt}${taskText}`, compositePrompt) + : text, + }; + }) + : userMessage?.content; + return { ...userMessage, content }; +} + export function enforceSelectedSkillRuntime(userMessage, { rawToolMode = 'chat', taskType = null, @@ -74,16 +112,24 @@ export function enforceSelectedSkillRuntime(userMessage, { const runMetadata = metadata.memindRun && typeof metadata.memindRun === 'object' && !Array.isArray(metadata.memindRun) ? { ...metadata.memindRun } : {}; + const taskText = extractAiderDevelopmentTask(userMessage); + const requiresPageDataBuild = isPageDataIntent(taskText); metadata.memindRun = { ...runMetadata, selectedChatSkill: AIDER_DEVELOPMENT_SKILL_NAME, - executor: 'aider', + ...(requiresPageDataBuild + ? { reviewExecutor: 'aider', pageDataAiderWorkflow: true } + : { executor: 'aider' }), }; + if (requiresPageDataBuild) delete metadata.memindRun.executor; return { - userMessage: { ...message, metadata }, - rawToolMode: 'code', - taskType: 'h5_chat_code_task', - requiredExecutor: 'aider', + userMessage: requiresPageDataBuild + ? rewriteAiderPageDataInstruction({ ...message, metadata }, taskText) + : { ...message, metadata }, + rawToolMode: requiresPageDataBuild ? 'chat' : 'code', + taskType: requiresPageDataBuild ? null : 'h5_chat_code_task', + requiredExecutor: requiresPageDataBuild ? null : 'aider', + requiredReviewExecutor: requiresPageDataBuild ? 'aider' : null, }; } @@ -142,7 +188,14 @@ export function createPostAgentRunsHandler({ rawToolMode = selectedSkillRuntime.rawToolMode; taskType = selectedSkillRuntime.taskType; if ( - selectedSkillRuntime.requiredExecutor && + (selectedSkillRuntime.requiredExecutor || selectedSkillRuntime.requiredReviewExecutor) && + !extractAiderDevelopmentTask(userMessage) + ) { + response.status(400).json({ message: '请输入需要 Aider 执行的具体开发任务' }); + return; + } + if ( + (selectedSkillRuntime.requiredExecutor || selectedSkillRuntime.requiredReviewExecutor) && userAuth?.getUserSkills ) { const skillState = await userAuth.getUserSkills(request.currentUser.id); @@ -160,8 +213,11 @@ export function createPostAgentRunsHandler({ }); return; } - if (toolMode === 'code') { + if (toolMode === 'code' || selectedSkillRuntime.requiredReviewExecutor) { const codeRunPolicy = await resolveCodeRunPolicy(request.currentUser.id); + const policyTaskType = selectedSkillRuntime.requiredReviewExecutor + ? 'h5_chat_code_task' + : taskType; if (!codeRunPolicy.enabled) { response.status(403).json({ message: '代码任务灰度未开启' }); return; @@ -173,7 +229,12 @@ export function createPostAgentRunsHandler({ const taskTypeAllowlist = codeRunPolicy.taskTypeAllowlist ?? []; if ( taskTypeAllowlist.length > 0 && - (!taskType || !taskTypeAllowlist.map((item) => String(item).toLowerCase()).includes(taskType.toLowerCase())) + ( + !policyTaskType || + !taskTypeAllowlist + .map((item) => String(item).toLowerCase()) + .includes(policyTaskType.toLowerCase()) + ) ) { response.status(403).json({ message: '当前代码任务类型未开启灰度' }); return; diff --git a/agent-run-routes.test.mjs b/agent-run-routes.test.mjs index bf8f6d6..d82b164 100644 --- a/agent-run-routes.test.mjs +++ b/agent-run-routes.test.mjs @@ -158,6 +158,83 @@ test('selected Aider development skill forces code mode, task type, and Aider ex assert.equal(created[0].payload.userMessage.metadata.memindRun.executor, 'aider'); }); +test('selected Aider development skill rejects a template-only task before creating a run', async () => { + let created = false; + const handler = createPostAgentRunsHandler({ + userAuth: { + async getUserSkills() { + return { skills: { 'aider-development': true } }; + }, + }, + agentRunGateway: { + async createRun() { + created = true; + return { id: 'must-not-run' }; + }, + }, + codeRunsEnabled: true, + }); + const res = createResponseRecorder(); + const template = + '请使用 aider-development 技能:本轮必须由 Aider code run 在当前用户工作区执行开发、修复和验证;Aider 或 Tool Gateway 不可用时直接失败,禁止回退到 Goose、OpenHands 或普通聊天冒充执行。我的开发任务是:'; + + await handler( + { + currentUser: { id: 'user-1' }, + body: { + request_id: 'req-aider-empty', + user_message: { + role: 'user', + content: [{ type: 'text', text: template }], + metadata: { + displayText: template, + memindRun: { selectedChatSkill: 'aider-development' }, + }, + }, + }, + }, + res, + ); + + assert.equal(res.statusCode, 400); + assert.match(res.body.message, /具体开发任务/); + assert.equal(created, false); +}); + +test('Aider development routes Page Data creation through Agent then requires Aider review', () => { + const template = + '请使用 aider-development 技能:本轮必须由 Aider code run 在当前用户工作区执行开发、修复和验证;Aider 或 Tool Gateway 不可用时直接失败,禁止回退到 Goose、OpenHands 或普通聊天冒充执行。我的开发任务是:'; + const task = '帮我设计一个简单下单系统,不要支付,可以有简单后台管理订单'; + const enforced = enforceSelectedSkillRuntime( + { + role: 'user', + content: [ + { type: 'text', text: `${template}${task}` }, + { + type: 'text', + text: '[Memind code-run validation]\nBefore finishing, create the receipt.', + }, + ], + metadata: { + displayText: `${template}${task}`, + memindRun: { selectedChatSkill: 'aider-development', executor: 'openhands' }, + }, + }, + { rawToolMode: 'code', taskType: 'h5_chat_code_task' }, + ); + assert.equal(enforced.rawToolMode, 'chat'); + assert.equal(enforced.taskType, null); + assert.equal(enforced.requiredExecutor, null); + assert.equal(enforced.requiredReviewExecutor, 'aider'); + assert.equal(enforced.userMessage.metadata.memindRun.executor, undefined); + assert.equal(enforced.userMessage.metadata.memindRun.reviewExecutor, 'aider'); + assert.equal(enforced.userMessage.metadata.memindRun.pageDataAiderWorkflow, true); + assert.match(enforced.userMessage.content[0].text, /private_data_execute/); + assert.match(enforced.userMessage.content[0].text, /强制 Aider 审查/); + assert.match(enforced.userMessage.content[0].text, /简单下单系统/); + assert.equal(enforced.userMessage.content.length, 1); +}); + test('selected Aider development skill fails closed when code runs are disabled', async () => { const handler = createPostAgentRunsHandler({ userAuth: { @@ -197,6 +274,50 @@ test('selected Aider development skill fails closed when code runs are disabled' assert.match(res.body.message, /代码任务灰度未开启/); }); +test('Page Data plus Aider review also respects the code-run policy gate', async () => { + const template = + '请使用 aider-development 技能:本轮必须由 Aider code run 在当前用户工作区执行开发、修复和验证;Aider 或 Tool Gateway 不可用时直接失败,禁止回退到 Goose、OpenHands 或普通聊天冒充执行。我的开发任务是:'; + const task = '创建下单系统并在后台管理订单'; + const handler = createPostAgentRunsHandler({ + userAuth: { + async getUserSkills() { + return { skills: { 'aider-development': true } }; + }, + }, + agentRunGateway: { + async createRun() { + assert.fail('disabled review policy must not create a run'); + }, + }, + codeRunsEnabled: false, + }); + const res = createResponseRecorder(); + await handler( + { + currentUser: { id: 'user-1' }, + body: { + request_id: 'req-page-data-aider-disabled', + user_message: { + role: 'user', + content: [{ type: 'text', text: `${template}${task}` }], + metadata: { + displayText: `${template}${task}`, + memindRun: { + selectedChatSkill: 'aider-development', + validation: { + expectedFile: '.memind/agent-runs/req-page-data-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/capabilities.mjs b/capabilities.mjs index 7fafc3e..aa6debf 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -120,6 +120,11 @@ export function resolveExcelMcpServerPath(overridePath, runtimeRoot) { ); } +function resolveBundledMcpRuntimeRoot(sandboxMcp) { + if (!sandboxMcp?.containerized || !sandboxMcp?.serverPath) return undefined; + return path.dirname(sandboxMcp.serverPath); +} + export const CAPABILITY_CATALOG = [ { key: 'shell', @@ -494,6 +499,7 @@ export function buildAgentExtensionPolicy( } const extensions = []; + const bundledMcpRuntimeRoot = resolveBundledMcpRuntimeRoot(sandboxMcp); if (capabilities.static_publish || (capabilities.private_data_space && sandboxMcp)) { const localRoot = resolveSandboxMcpLocalRoot(sandboxMcp); const compatRoot = resolveSandboxMcpCompatRoot(sandboxMcp); @@ -604,7 +610,12 @@ export function buildAgentExtensionPolicy( display_name: 'tkmind-search', bundled: false, cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH), - args: [resolveMindSearchMcpServerPath(process.env.TKMIND_SEARCH_MCP_SERVER_PATH)], + args: [ + resolveMindSearchMcpServerPath( + process.env.TKMIND_SEARCH_MCP_SERVER_PATH, + bundledMcpRuntimeRoot, + ), + ], envs: { TKMIND_SEARCH_ENABLED: '1', TKMIND_SEARCH_MODE: mindSearchConfig.mode, @@ -646,7 +657,13 @@ export function buildAgentExtensionPolicy( display_name: 'Excel Analyst', bundled: false, cmd: resolveSandboxMcpNodeExecPath(sandboxMcp?.nodeExecPath), - args: [resolveExcelMcpServerPath(process.env.GOOSED_EXCEL_MCP_SERVER_PATH), excelWorkspaceRoot], + args: [ + resolveExcelMcpServerPath( + process.env.GOOSED_EXCEL_MCP_SERVER_PATH, + bundledMcpRuntimeRoot, + ), + excelWorkspaceRoot, + ], envs: { EXCEL_ANALYST_ENABLED: '1', MINDSPACE_WORKSPACE_ROOT: excelWorkspaceRoot, diff --git a/capabilities.test.mjs b/capabilities.test.mjs index d167b84..6da26ca 100644 --- a/capabilities.test.mjs +++ b/capabilities.test.mjs @@ -463,3 +463,21 @@ test('sandboxMcp can use workspaceRoot as the local runtime root compatibility f assert.equal(sandboxExt.envs.MINDSPACE_WORKSPACE_ROOT, '/opt/h5/MindSpace/abc123'); assert.equal(sandboxExt.envs.MINDSPACE_WORKSPACE_REF, 'mindspace://users/abc123/workspace'); }); + +test('Excel analyst uses the container-visible bundled MCP directory', () => { + const policy = buildAgentExtensionPolicy( + { + ...DEFAULT_USER_CAPABILITIES, + excel_analysis: true, + }, + { + sandboxMcp: { + containerized: true, + serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs', + sandboxRoot: '/opt/portal/MindSpace/user-1', + }, + }, + ); + const extension = policy.extensionOverrides.find((ext) => ext.name === 'tkmind-excel'); + assert.equal(extension.args[0], '/opt/portal/tkmind-excel-mcp.mjs'); +}); diff --git a/chat-skills.mjs b/chat-skills.mjs index 08afb79..766aa46 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -50,6 +50,8 @@ const PAGE_DATA_INTENT_PATTERNS = [ /(?:记账|账本|收支|流水|日记|习惯打卡|每日打卡).{0,40}(?:页面|记录|管理|汇总|统计)/u, /(?:管理页面|管理页).{0,30}(?:查看|记录|汇总|统计|明细|删除|修改)/u, /(?:页面|网页|H5|h5).{0,80}(?:每天|每日|新增|添加|填写|记录).{0,80}(?:所有记录|历史记录|管理|汇总|统计)/u, + /(?:下单|订单).{0,40}(?:后台|管理|记录|保存|查询|状态)/u, + /(?:后台|管理).{0,40}(?:下单|订单)/u, ]; const INTERACTIVE_PAGE_DATA_SUBJECT_PATTERN = /(?:便签|便利贴|备忘录|待办|清单)/u; @@ -178,6 +180,7 @@ export const CHAT_SKILL_DEFINITIONS = [ icon: 'spark', skillName: AIDER_DEVELOPMENT_SKILL_NAME, requiresSkill: AIDER_DEVELOPMENT_SKILL_NAME, + prefillOnly: true, promptKey: AIDER_DEVELOPMENT_SKILL_NAME, }, { @@ -296,6 +299,33 @@ export function buildChatSkillPrompt(promptKey, skillName) { } } +export function mergeChatSkillPromptWithInput(prompt, currentInput) { + const normalizedPrompt = String(prompt ?? ''); + const existing = String(currentInput ?? '').trim(); + if (!existing) return normalizedPrompt; + if (existing.startsWith(normalizedPrompt)) return existing; + return `${normalizedPrompt}${existing}`; +} + +export function extractAiderDevelopmentTask(userMessage) { + const metadata = userMessage?.metadata; + const displayText = String(metadata?.displayText ?? '').trim(); + const contentText = Array.isArray(userMessage?.content) + ? userMessage.content + .filter((item) => item?.type === 'text') + .map((item) => String(item.text ?? '').trim()) + .filter(Boolean) + .join('\n') + : String(userMessage?.content ?? userMessage?.text ?? userMessage?.value ?? '').trim(); + const source = displayText || contentText; + const prompt = buildChatSkillPrompt( + AIDER_DEVELOPMENT_SKILL_NAME, + AIDER_DEVELOPMENT_SKILL_NAME, + ); + if (!source.startsWith(prompt)) return source; + return source.slice(prompt.length).trim(); +} + function buildWebNewsSkillPrompt(skillName) { return `请使用 ${skillName ?? 'web'} 技能:先搜索今天/最新相关的新闻与热点,优先一手来源和权威媒体,整理 3-5 条最相关结果,按时间或热度排序;然后给出中文摘要、关键信息、事件背景和来源链接。我的问题是:`; } diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index 4be47a9..9fb6bfb 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -5,11 +5,13 @@ import { buildChatSkillPrompt, CHAT_SKILL_DEFINITIONS, filterChatSkills, + extractAiderDevelopmentTask, isExcelAnalysisIntent, isPageDataDevIntent, isPageDataIntent, isPageGenerationIntent, isGenericPageGenerationRequest, + mergeChatSkillPromptWithInput, } from './chat-skills.mjs'; test('filterChatSkills shows summarize and analyze without granted skills', () => { @@ -47,6 +49,36 @@ test('filterChatSkills shows service integration smoke when granted', () => { assert.ok(visible.some((item) => item.id === 'service-integration-smoke')); }); +test('Aider development skill prefills instead of submitting an empty template', () => { + const aider = CHAT_SKILL_DEFINITIONS.find((item) => item.id === 'aider-development'); + assert.equal(aider?.prefillOnly, true); +}); + +test('mergeChatSkillPromptWithInput preserves an existing user task', () => { + const prompt = buildChatSkillPrompt('aider-development', 'aider-development'); + const task = '帮我设计一个简单下单系统,不要支付,可以有简单后台'; + assert.equal(mergeChatSkillPromptWithInput(prompt, task), `${prompt}${task}`); + assert.equal(mergeChatSkillPromptWithInput(prompt, `${prompt}${task}`), `${prompt}${task}`); +}); + +test('extractAiderDevelopmentTask rejects the template-only submission', () => { + const prompt = buildChatSkillPrompt('aider-development', 'aider-development'); + assert.equal( + extractAiderDevelopmentTask({ + metadata: { displayText: prompt }, + content: [{ type: 'text', text: prompt }], + }), + '', + ); + assert.equal( + extractAiderDevelopmentTask({ + metadata: { displayText: `${prompt}修复页面` }, + content: [{ type: 'text', text: `${prompt}修复页面` }], + }), + '修复页面', + ); +}); + test('filterChatSkills only shows Aider development when granted', () => { const hidden = filterChatSkills(CHAT_SKILL_DEFINITIONS, { canPublish: false, @@ -165,6 +197,13 @@ test('isPageDataIntent matches survey and backend keywords', () => { assert.equal(isPageDataIntent('帮我做一个苏州攻略页面'), false); }); +test('isPageDataIntent recognizes order systems with an admin backend', () => { + assert.equal( + isPageDataIntent('帮我设计一个简单下单系统,不要支付,可以有简单后台管理订单'), + true, + ); +}); + test('isPageDataIntent matches implicit interactive sticky-note app requests', () => { const text = '帮我设计一个便签提醒,可以写便签提交,时间轴来显示'; assert.equal(isPageDataIntent(text), true); diff --git a/mindsearch.test.mjs b/mindsearch.test.mjs index 3f20559..daa1328 100644 --- a/mindsearch.test.mjs +++ b/mindsearch.test.mjs @@ -144,6 +144,28 @@ test('MindSearch never changes legacy web extension and is gated by capability/c assert.ok(extension.available_tools.includes('tkmind_research_cancel')); }); +test('MindSearch uses the container-visible bundled MCP directory', () => { + const policy = buildAgentExtensionPolicy( + { + ...DEFAULT_USER_CAPABILITIES, + search_external: true, + }, + { + sandboxMcp: { + containerized: true, + serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs', + }, + mindSearchConfig: { + enabled: true, + mode: 'assist', + providers: {}, + }, + }, + ); + const extension = policy.extensionOverrides.find((ext) => ext.name === 'tkmind-search'); + assert.equal(extension.args[0], '/opt/portal/tkmind-search-mcp.mjs'); +}); + test('SearXNG adapter normalizes provider results without requiring a live network', async () => { const result = await searchSearxng('goose', { endpoint: 'http://search.local', fetchImpl: async () => ({ ok: true, json: async () => ({ results: [{ title: 'Goose', url: 'https://example.com', content: 'snippet' }] }) }) }); assert.deepEqual(result[0], { title: 'Goose', url: 'https://example.com', snippet: 'snippet', source: 'searxng', rank: 1 }); diff --git a/server/portal-gateway-services-bootstrap.mjs b/server/portal-gateway-services-bootstrap.mjs index 87ab315..0c7ccc1 100644 --- a/server/portal-gateway-services-bootstrap.mjs +++ b/server/portal-gateway-services-bootstrap.mjs @@ -207,6 +207,10 @@ export function bootstrapPortalGatewayServices({ }); const validateRunDeliverables = createRunDeliverablesValidatorFn({ h5Root }); + const agentRunAutoDispatch = isEnabledFlag( + env.MEMIND_AGENT_RUN_AUTODISPATCH, + '1', + ); const agentRunGateway = createAgentRunGatewayFn({ pool, userAuth, @@ -242,10 +246,7 @@ export function bootstrapPortalGatewayServices({ isSessionExternallyBusy: ({ sessionId }) => isSessionPageDeliveryActive(sessionId), validateRunDeliverables, - autoDispatch: isEnabledFlag( - env.MEMIND_AGENT_RUN_AUTODISPATCH, - '1', - ), + autoDispatch: agentRunAutoDispatch, maxConcurrentRuns: Number( env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1, ), @@ -254,6 +255,16 @@ export function bootstrapPortalGatewayServices({ 15 * 60 * 1000, ), }); + if (agentRunAutoDispatch && agentRunGateway?.dispatchQueuedRuns) { + void agentRunGateway.dispatchQueuedRuns({ + limit: Number(env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1), + }).catch((err) => { + console.warn( + '[AgentRun] startup queue recovery failed:', + err instanceof Error ? err.message : err, + ); + }); + } return { tkmindProxy, diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index a348d15..2f3ec63 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -2,7 +2,11 @@ import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, import { BrainCircuit, Database, Image, ImageOff, ImagePlus } from 'lucide-react'; import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { openAvatarPicker } from '../utils/userAvatar'; -import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills'; +import { + CHAT_SKILL_OPTIONS, + filterChatSkills, + mergeChatSkillPromptWithInput, +} from '../utils/chatSkills'; import { getMessageSaveActions } from '../utils/messageSave'; import { getDisplayText } from '../utils/message'; import { @@ -1242,7 +1246,7 @@ export function ChatPanel({ onSelect={submitText} onPrefill={(prompt, skillId) => { pendingSkillRef.current = skillId ?? null; - setInput(prompt); + setInput((current) => mergeChatSkillPromptWithInput(prompt, current)); }} /> )} diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 8dbab7e..926a1f1 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -117,6 +117,7 @@ async function waitForAgentRun(runId: string): Promise { } const DIRECT_CHAT_SESSION_POLL_MS = 600; +const AGENT_RUN_STATUS_POLL_MS = 1_500; const AGENT_RUN_WAIT_TIMEOUT_MS = 16 * 60 * 1000; async function waitForAgentRunWithDirectChatPreview( @@ -129,6 +130,8 @@ async function waitForAgentRunWithDirectChatPreview( ): Promise { return await new Promise((resolve, reject) => { let pollTimer: number | null = null; + let runStatusPollTimer: number | null = null; + let runStatusPollInFlight = false; let waitTimer: number | null = null; let pollingSessionId: string | null = null; let settled = false; @@ -138,6 +141,7 @@ async function waitForAgentRunWithDirectChatPreview( if (settled) return; settled = true; stopPoll(); + stopRunStatusPoll(); if (waitTimer != null) { window.clearTimeout(waitTimer); waitTimer = null; @@ -153,6 +157,13 @@ async function waitForAgentRunWithDirectChatPreview( } }; + const stopRunStatusPoll = () => { + if (runStatusPollTimer != null) { + window.clearInterval(runStatusPollTimer); + runStatusPollTimer = null; + } + }; + const startPolling = (sessionId: string) => { if (pollingSessionId === sessionId && pollTimer != null) return; pollingSessionId = sessionId; @@ -175,9 +186,7 @@ async function waitForAgentRunWithDirectChatPreview( }, DIRECT_CHAT_SESSION_POLL_MS); }; - unsubscribe = subscribeAgentRunEvents( - runId, - (run) => { + const handleRunStatus = (run: AgentRun) => { if (run.sessionId) { handlers.onSessionId?.(run.sessionId); if (isDirectChatSessionId(run.sessionId)) { @@ -191,11 +200,32 @@ async function waitForAgentRunWithDirectChatPreview( if (run.status === 'failed') { settle(() => reject(new Error(run.error || '后台任务失败,请稍后重试'))); } - }, + }; + + const pollRunStatus = () => { + if (settled || runStatusPollInFlight || handlers.isCancelled?.()) return; + runStatusPollInFlight = true; + void getAgentRun(runId) + .then(handleRunStatus) + .catch(() => { + // SSE remains primary; polling only closes terminal-state gaps. + }) + .finally(() => { + runStatusPollInFlight = false; + }); + }; + + unsubscribe = subscribeAgentRunEvents( + runId, + handleRunStatus, (error) => { - settle(() => reject(error)); + void getAgentRun(runId) + .then(handleRunStatus) + .catch(() => settle(() => reject(error))); }, ); + runStatusPollTimer = window.setInterval(pollRunStatus, AGENT_RUN_STATUS_POLL_MS); + pollRunStatus(); waitTimer = window.setTimeout(() => { void getAgentRun(runId) diff --git a/src/utils/agentRunMode.ts b/src/utils/agentRunMode.ts index ac431a5..cb719e1 100644 --- a/src/utils/agentRunMode.ts +++ b/src/utils/agentRunMode.ts @@ -291,6 +291,7 @@ export function resolveAgentRunOptions( { taskType = 'code_task', forceCode = false, + requiredExecutor, allowAutodetect = clientCodeRunsAutodetectEnabled(), allowPageDataDevAutodetect = clientPageDataDevAutodetectEnabled(), userId = null, @@ -310,8 +311,9 @@ export function resolveAgentRunOptions( } = {}, ): AgentRunCreateOptions { const normalizedText = String(text ?? '').trim(); - const pageDataDevTaskType = - allowPageDataDevAutodetect && resolvePageDataDevTaskType(normalizedText); + const pageDataDevTaskType = allowPageDataDevAutodetect + ? resolvePageDataDevTaskType(normalizedText) + : null; const effectiveTaskType = pageDataDevTaskType ?? taskType; const shouldUseDeepReasoning = forceCode || diff --git a/src/utils/chatSkills.ts b/src/utils/chatSkills.ts index 10ff7b0..dff5ac9 100644 --- a/src/utils/chatSkills.ts +++ b/src/utils/chatSkills.ts @@ -2,6 +2,7 @@ import { buildChatSkillPrompt, CHAT_SKILL_DEFINITIONS, filterChatSkills as filterChatSkillDefinitions, + mergeChatSkillPromptWithInput, } from '../../chat-skills.mjs'; import { buildPublishSkillPrompt, PUBLISH_SKILL_NAME } from './publishSkill'; @@ -45,3 +46,4 @@ export function filterChatSkills( } export { buildPublishSkillPrompt, PUBLISH_SKILL_NAME }; +export { mergeChatSkillPromptWithInput }; diff --git a/tool-gateway.mjs b/tool-gateway.mjs index 38bca68..74a66f4 100644 --- a/tool-gateway.mjs +++ b/tool-gateway.mjs @@ -1,5 +1,7 @@ import { spawn as nodeSpawn } from 'node:child_process'; import { EventEmitter } from 'node:events'; +import fs from 'node:fs/promises'; +import path from 'node:path'; const CODE_EXECUTORS = new Set(['aider', 'openhands']); const DEFAULT_STDIO_LIMIT = 64 * 1024; @@ -55,6 +57,92 @@ export function extractToolInstruction(userMessage) { .trim(); } +function runValidation(userMessage) { + const metadata = userMessage?.metadata ?? {}; + const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {}; + const validation = runMetadata.validation ?? metadata.toolGatewayValidation; + return validation && typeof validation === 'object' && !Array.isArray(validation) + ? validation + : null; +} + +function validationFilePaths(userMessage) { + const validation = runValidation(userMessage); + if (!validation) return []; + const candidates = [ + validation.expectedFile ?? validation.expectedPath, + ...(Array.isArray(validation.expectedFiles) ? validation.expectedFiles : []), + ]; + return candidates + .map((item) => { + if (typeof item === 'string') return item.trim(); + if (!item || typeof item !== 'object' || Array.isArray(item)) return ''; + return String(item.path ?? item.file ?? item.relativePath ?? '').trim(); + }) + .filter(Boolean); +} + +export function resolveAiderReceiptPath(userMessage, requestId) { + const expected = `.memind/agent-runs/${String(requestId ?? '').trim()}.json`; + return validationFilePaths(userMessage).find((item) => item === expected) ?? null; +} + +export async function prepareAiderReceiptFile(cwd, relativePath) { + if (!cwd || !relativePath) return null; + const root = path.resolve(String(cwd)); + const target = path.resolve(root, relativePath); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) { + throw new Error(`Aider receipt path escapes working directory: ${relativePath}`); + } + await fs.mkdir(path.dirname(target), { recursive: true }); + try { + await fs.access(target); + } catch { + await fs.writeFile( + target, + `${JSON.stringify({ status: 'pending', executor: 'aider' }, null, 2)}\n`, + { flag: 'wx' }, + ); + } + return target; +} + +async function resolveAiderContextFiles(userMessage, cwd) { + const metadata = userMessage?.metadata ?? {}; + const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {}; + const candidates = Array.isArray(runMetadata.aiderContextFiles) + ? runMetadata.aiderContextFiles + : []; + if (!cwd || candidates.length === 0) return []; + const root = path.resolve(String(cwd)); + const resolved = []; + for (const relativePath of candidates.slice(0, 40)) { + const target = path.resolve(root, String(relativePath ?? '')); + if (target === root || !target.startsWith(`${root}${path.sep}`)) continue; + try { + const stat = await fs.stat(target); + if (stat.isFile()) resolved.push(target); + } catch { + // Review context is best-effort; delivery validation remains authoritative. + } + } + return resolved; +} + +export function hardenAiderLaunchPlan(plan, receiptPath = null, contextFiles = []) { + const args = [...(plan?.args ?? [])]; + for (const flag of ['--no-git', '--no-auto-commits', '--no-dirty-commits']) { + if (!args.includes(flag)) args.push(flag); + } + for (const contextFile of contextFiles) { + if (!args.includes(contextFile)) args.push('--file', contextFile); + } + if (receiptPath && !args.includes(receiptPath)) { + args.push('--file', receiptPath); + } + return { ...plan, args }; +} + export function createToolGateway({ llmProviderService, env = process.env, @@ -110,16 +198,29 @@ export function createToolGateway({ throw new Error('Tool Gateway job missing instruction'); } const executor = selectExecutor({ userMessage, taskType }); - const plan = await llmProviderService.getExecutorLaunchPlan(executor, { + const receiptPath = executor === 'aider' + ? resolveAiderReceiptPath(userMessage, requestId) + : null; + const executorInstruction = receiptPath + ? `${instruction}\n\nThe validation receipt is already included in the Aider chat. Edit it directly; do not ask the user to add it.` + : instruction; + let plan = await llmProviderService.getExecutorLaunchPlan(executor, { cwd, mode: 'headless', - instruction, + instruction: executorInstruction, purpose: 'default', includeSecret: true, }); if (!plan?.ok) { throw new Error(plan?.message ?? `Tool Gateway launch plan unavailable for ${executor}`); } + if (executor === 'aider') { + const preparedReceipt = dryRun + ? (receiptPath ? path.resolve(String(cwd), receiptPath) : null) + : await prepareAiderReceiptFile(cwd, receiptPath); + const contextFiles = await resolveAiderContextFiles(userMessage, cwd); + plan = hardenAiderLaunchPlan(plan, preparedReceipt, contextFiles); + } if (dryRun) { return { ok: true, diff --git a/tool-gateway.test.mjs b/tool-gateway.test.mjs index 8766afd..69ae44d 100644 --- a/tool-gateway.test.mjs +++ b/tool-gateway.test.mjs @@ -1,6 +1,15 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; -import { createToolGateway, extractToolInstruction } from './tool-gateway.mjs'; +import { + createToolGateway, + extractToolInstruction, + hardenAiderLaunchPlan, + prepareAiderReceiptFile, + resolveAiderReceiptPath, +} from './tool-gateway.mjs'; test('tool gateway extracts text instructions from H5 message content', () => { assert.equal( @@ -58,6 +67,46 @@ test('tool gateway honors an explicitly required Aider executor over task defaul }), 'aider'); }); +test('tool gateway resolves and prepares the required Aider receipt without pre-validating it', async () => { + const requestId = 'req-receipt'; + const relativePath = `.memind/agent-runs/${requestId}.json`; + const userMessage = { + metadata: { + memindRun: { + validation: { + expectedFile: { path: relativePath, contains: requestId }, + }, + }, + }, + }; + assert.equal(resolveAiderReceiptPath(userMessage, requestId), relativePath); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-aider-receipt-')); + try { + const target = await prepareAiderReceiptFile(cwd, relativePath); + const pending = JSON.parse(await fs.readFile(target, 'utf8')); + assert.deepEqual(pending, { status: 'pending', executor: 'aider' }); + assert.equal((await fs.readFile(target, 'utf8')).includes(requestId), false); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } +}); + +test('tool gateway hardens Aider against repository commits and includes the receipt', () => { + const plan = hardenAiderLaunchPlan( + { ok: true, args: ['--model', 'test-model'] }, + '/tmp/work/.memind/agent-runs/req.json', + ['/tmp/work/public/order.html'], + ); + assert.ok(plan.args.includes('--no-git')); + assert.ok(plan.args.includes('--no-auto-commits')); + assert.ok(plan.args.includes('--no-dirty-commits')); + assert.deepEqual( + plan.args.slice(-2), + ['--file', '/tmp/work/.memind/agent-runs/req.json'], + ); + assert.ok(plan.args.includes('/tmp/work/public/order.html')); +}); + test('tool gateway dry run builds executor launch plan without spawning', async () => { const plans = []; const gateway = createToolGateway({