diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs
index bf89946..cbfaccd 100644
--- a/agent-run-gateway.mjs
+++ b/agent-run-gateway.mjs
@@ -230,6 +230,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();
@@ -379,6 +388,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(
@@ -387,6 +402,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 {
@@ -858,6 +915,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
@@ -880,7 +1020,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?.({
@@ -1040,7 +1186,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;
@@ -1094,6 +1244,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 };
}
@@ -1112,7 +1294,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);
@@ -1123,7 +1305,7 @@ export function createAgentRunGateway({
);
await appendEvent(runId, 'session_started', {
sessionId,
- toolMode: runOptions.toolMode,
+ toolMode: effectiveToolMode,
taskType: runOptions.taskType,
});
await appendRunSnapshot(runId);
@@ -1165,8 +1347,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;
@@ -1178,7 +1369,7 @@ export function createAgentRunGateway({
row.request_id,
submitMessage,
{
- toolMode: runOptions.toolMode,
+ toolMode: effectiveToolMode,
forceDeepReasoning: runOptions.forceDeepReasoning,
timeoutMs: runTimeoutMs,
},
@@ -1244,11 +1435,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 47d192a..3569e61 100644
--- a/agent-run-gateway.test.mjs
+++ b/agent-run-gateway.test.mjs
@@ -1088,6 +1088,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: {
@@ -2074,6 +2182,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 8b0cbe3..1ecd6a4 100644
--- a/capabilities.mjs
+++ b/capabilities.mjs
@@ -162,6 +162,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',
@@ -535,6 +540,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);
@@ -645,7 +651,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,
@@ -687,7 +698,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 94ba67d..d9e65e6 100644
--- a/capabilities.test.mjs
+++ b/capabilities.test.mjs
@@ -503,3 +503,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 cb303d9..1b2198f 100644
--- a/chat-skills.mjs
+++ b/chat-skills.mjs
@@ -52,6 +52,8 @@ const PAGE_DATA_INTENT_PATTERNS = [
/(?:页面|网页|H5|h5).{0,80}(?:每天|每日|新增|添加|填写|记录).{0,80}(?:所有记录|历史记录|管理|汇总|统计)/u,
/(?:页面|网页|商城|店铺).{0,80}(?:下单|订单|购物车).{0,80}(?:后台|管理|上架|商品|产品|库存)/u,
/(?:后台|管理).{0,80}(?:上架|下架|商品|产品|库存).{0,80}(?:下单|订单|购物车|页面|网页|商城|店铺)/u,
+ /(?:下单|订单).{0,40}(?:后台|管理|记录|保存|查询|状态)/u,
+ /(?:后台|管理).{0,40}(?:下单|订单)/u,
];
const INTERACTIVE_PAGE_DATA_SUBJECT_PATTERN = /(?:便签|便利贴|备忘录|待办|清单)/u;
@@ -180,6 +182,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,
},
{
@@ -298,6 +301,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 d545403..7368487 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,
@@ -176,6 +208,13 @@ test('isPageDataIntent treats storefront ordering plus product administration as
);
});
+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/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({