feat: complete Aider Page Data review workflow

This commit is contained in:
john
2026-07-25 09:47:25 +08:00
parent be9c25f1d0
commit 104fb4e370
15 changed files with 914 additions and 30 deletions
+205 -7
View File
@@ -230,6 +230,15 @@ function summarizeText(value, limit = TOOL_GATEWAY_SUMMARY_LIMIT) {
return text.slice(text.length - 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) { function normalizeExpectedFileCheck(value) {
if (typeof value === 'string') { if (typeof value === 'string') {
const expectedPath = value.trim(); const expectedPath = value.trim();
@@ -379,6 +388,12 @@ function getRunOptionsFromMessage(userMessage) {
toolMode, toolMode,
taskType: normalizeTaskType(runMetadata?.taskType ?? metadata?.taskType), taskType: normalizeTaskType(runMetadata?.taskType ?? metadata?.taskType),
requiredExecutor: resolveRequiredCodeExecutor(userMessage), 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, forceDeepReasoning: runMetadata?.forceDeepReasoning === true || metadata?.forceDeepReasoning === true,
validation: normalizeToolGatewayValidation(runMetadata?.validation ?? metadata?.toolGatewayValidation), validation: normalizeToolGatewayValidation(runMetadata?.validation ?? metadata?.toolGatewayValidation),
sessionMessageCount: normalizeSessionMessageCount( 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) { function projectRun(row) {
if (!row) return null; if (!row) return null;
return { 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) { async function resolveRunRouting(row, userMessage, runOptions) {
if (!chatIntentRouter?.classify) return null; if (!chatIntentRouter?.classify) return null;
const enabled = chatIntentRouter.isEnabled const enabled = chatIntentRouter.isEnabled
@@ -880,7 +1020,13 @@ export function createAgentRunGateway({
let userMessage = safeJsonParse(row.user_message_json, {}); let userMessage = safeJsonParse(row.user_message_json, {});
const runOptions = getRunOptionsFromMessage(userMessage); const runOptions = getRunOptionsFromMessage(userMessage);
const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null; 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; let disclosureDecision = null;
try { try {
disclosureDecision = systemDisclosurePolicyService?.evaluate?.({ 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 const workingDir = userAuth?.resolveWorkingDir
? await userAuth.resolveWorkingDir(row.user_id) ? await userAuth.resolveWorkingDir(row.user_id)
: undefined; : undefined;
@@ -1094,6 +1244,38 @@ export function createAgentRunGateway({
}); });
throw err; 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 }; return { sessionId: row.agent_session_id ?? null, routing };
} }
@@ -1112,7 +1294,7 @@ export function createAgentRunGateway({
} }
if (!sessionId) { if (!sessionId) {
const sessionOptions = {}; const sessionOptions = {};
if (runOptions.toolMode === 'code' && userAuth?.getCodeAgentSessionPolicy) { if (effectiveToolMode === 'code' && userAuth?.getCodeAgentSessionPolicy) {
sessionOptions.sessionPolicy = await userAuth.getCodeAgentSessionPolicy(row.user_id); sessionOptions.sessionPolicy = await userAuth.getCodeAgentSessionPolicy(row.user_id);
} }
const session = await tkmindProxy.startSessionForUser(row.user_id, sessionOptions); const session = await tkmindProxy.startSessionForUser(row.user_id, sessionOptions);
@@ -1123,7 +1305,7 @@ export function createAgentRunGateway({
); );
await appendEvent(runId, 'session_started', { await appendEvent(runId, 'session_started', {
sessionId, sessionId,
toolMode: runOptions.toolMode, toolMode: effectiveToolMode,
taskType: runOptions.taskType, taskType: runOptions.taskType,
}); });
await appendRunSnapshot(runId); await appendRunSnapshot(runId);
@@ -1165,8 +1347,17 @@ export function createAgentRunGateway({
await invalidatePortalDirectChatSnapshot(sessionId); await invalidatePortalDirectChatSnapshot(sessionId);
let toolEvidence = null; let toolEvidence = null;
const awaitSessionFinish = envFlag(process.env.MEMIND_AGENT_RUN_AWAIT_SESSION_FINISH, true) const awaitSessionFinish = envFlag(process.env.MEMIND_AGENT_RUN_AWAIT_SESSION_FINISH, true)
&& runOptions.toolMode === 'chat' && effectiveToolMode === 'chat'
&& typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function'; && 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) { if (awaitSessionFinish) {
let submitMessage = ensureGooseUserMessageMetadata(userMessage); let submitMessage = ensureGooseUserMessageMetadata(userMessage);
let replacedPoisonedSession = false; let replacedPoisonedSession = false;
@@ -1178,7 +1369,7 @@ export function createAgentRunGateway({
row.request_id, row.request_id,
submitMessage, submitMessage,
{ {
toolMode: runOptions.toolMode, toolMode: effectiveToolMode,
forceDeepReasoning: runOptions.forceDeepReasoning, forceDeepReasoning: runOptions.forceDeepReasoning,
timeoutMs: runTimeoutMs, timeoutMs: runTimeoutMs,
}, },
@@ -1244,11 +1435,18 @@ export function createAgentRunGateway({
row.request_id, row.request_id,
ensureGooseUserMessageMetadata(userMessage), ensureGooseUserMessageMetadata(userMessage),
{ {
toolMode: runOptions.toolMode, toolMode: effectiveToolMode,
forceDeepReasoning: runOptions.forceDeepReasoning, forceDeepReasoning: runOptions.forceDeepReasoning,
}, },
); );
} }
await runRequiredCodeReview({
row,
runId,
userMessage,
runOptions,
sessionId,
});
return { sessionId, routing, toolEvidence }; return { sessionId, routing, toolEvidence };
} }
+190
View File
@@ -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'); 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'),
'<!doctype html><script src="/assets/page-data-client.js"></script>',
);
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 () => { test('agent run fails closed when a generated page violates browser storage policy', async () => {
const pool = createFakePool({ const pool = createFakePool({
sessionDeliverables: { 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'); 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 () => { test('agent run fails non-retryably when tool gateway artifact validation fails', async () => {
const pool = createFakePool(); const pool = createFakePool();
const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-tool-validation-missing-')); const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-tool-validation-missing-'));
+70 -9
View File
@@ -1,5 +1,10 @@
import { normalizeAgentRunToolMode } from './agent-run-gateway.mjs'; 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 { createSessionAccess } from './session-broker.mjs';
import { import {
extractRunFromStreamEvent, extractRunFromStreamEvent,
@@ -58,6 +63,39 @@ function selectedChatSkill(userMessage) {
return String(runMetadata.selectedChatSkill ?? '').trim(); 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, { export function enforceSelectedSkillRuntime(userMessage, {
rawToolMode = 'chat', rawToolMode = 'chat',
taskType = null, taskType = null,
@@ -74,16 +112,24 @@ export function enforceSelectedSkillRuntime(userMessage, {
const runMetadata = metadata.memindRun && typeof metadata.memindRun === 'object' && !Array.isArray(metadata.memindRun) const runMetadata = metadata.memindRun && typeof metadata.memindRun === 'object' && !Array.isArray(metadata.memindRun)
? { ...metadata.memindRun } ? { ...metadata.memindRun }
: {}; : {};
const taskText = extractAiderDevelopmentTask(userMessage);
const requiresPageDataBuild = isPageDataIntent(taskText);
metadata.memindRun = { metadata.memindRun = {
...runMetadata, ...runMetadata,
selectedChatSkill: AIDER_DEVELOPMENT_SKILL_NAME, selectedChatSkill: AIDER_DEVELOPMENT_SKILL_NAME,
executor: 'aider', ...(requiresPageDataBuild
? { reviewExecutor: 'aider', pageDataAiderWorkflow: true }
: { executor: 'aider' }),
}; };
if (requiresPageDataBuild) delete metadata.memindRun.executor;
return { return {
userMessage: { ...message, metadata }, userMessage: requiresPageDataBuild
rawToolMode: 'code', ? rewriteAiderPageDataInstruction({ ...message, metadata }, taskText)
taskType: 'h5_chat_code_task', : { ...message, metadata },
requiredExecutor: 'aider', 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; rawToolMode = selectedSkillRuntime.rawToolMode;
taskType = selectedSkillRuntime.taskType; taskType = selectedSkillRuntime.taskType;
if ( if (
selectedSkillRuntime.requiredExecutor && (selectedSkillRuntime.requiredExecutor || selectedSkillRuntime.requiredReviewExecutor) &&
!extractAiderDevelopmentTask(userMessage)
) {
response.status(400).json({ message: '请输入需要 Aider 执行的具体开发任务' });
return;
}
if (
(selectedSkillRuntime.requiredExecutor || selectedSkillRuntime.requiredReviewExecutor) &&
userAuth?.getUserSkills userAuth?.getUserSkills
) { ) {
const skillState = await userAuth.getUserSkills(request.currentUser.id); const skillState = await userAuth.getUserSkills(request.currentUser.id);
@@ -160,8 +213,11 @@ export function createPostAgentRunsHandler({
}); });
return; return;
} }
if (toolMode === 'code') { if (toolMode === 'code' || selectedSkillRuntime.requiredReviewExecutor) {
const codeRunPolicy = await resolveCodeRunPolicy(request.currentUser.id); const codeRunPolicy = await resolveCodeRunPolicy(request.currentUser.id);
const policyTaskType = selectedSkillRuntime.requiredReviewExecutor
? 'h5_chat_code_task'
: taskType;
if (!codeRunPolicy.enabled) { if (!codeRunPolicy.enabled) {
response.status(403).json({ message: '代码任务灰度未开启' }); response.status(403).json({ message: '代码任务灰度未开启' });
return; return;
@@ -173,7 +229,12 @@ export function createPostAgentRunsHandler({
const taskTypeAllowlist = codeRunPolicy.taskTypeAllowlist ?? []; const taskTypeAllowlist = codeRunPolicy.taskTypeAllowlist ?? [];
if ( if (
taskTypeAllowlist.length > 0 && 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: '当前代码任务类型未开启灰度' }); response.status(403).json({ message: '当前代码任务类型未开启灰度' });
return; return;
+121
View File
@@ -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'); 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 () => { test('selected Aider development skill fails closed when code runs are disabled', async () => {
const handler = createPostAgentRunsHandler({ const handler = createPostAgentRunsHandler({
userAuth: { userAuth: {
@@ -197,6 +274,50 @@ test('selected Aider development skill fails closed when code runs are disabled'
assert.match(res.body.message, /代码任务灰度未开启/); 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 () => { test('POST /agent/runs forwards deep reasoning flag to the run gateway', async () => {
const created = []; const created = [];
const handler = createPostAgentRunsHandler({ const handler = createPostAgentRunsHandler({
+19 -2
View File
@@ -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 = [ export const CAPABILITY_CATALOG = [
{ {
key: 'shell', key: 'shell',
@@ -535,6 +540,7 @@ export function buildAgentExtensionPolicy(
} }
const extensions = []; const extensions = [];
const bundledMcpRuntimeRoot = resolveBundledMcpRuntimeRoot(sandboxMcp);
if (capabilities.static_publish || (capabilities.private_data_space && sandboxMcp)) { if (capabilities.static_publish || (capabilities.private_data_space && sandboxMcp)) {
const localRoot = resolveSandboxMcpLocalRoot(sandboxMcp); const localRoot = resolveSandboxMcpLocalRoot(sandboxMcp);
const compatRoot = resolveSandboxMcpCompatRoot(sandboxMcp); const compatRoot = resolveSandboxMcpCompatRoot(sandboxMcp);
@@ -645,7 +651,12 @@ export function buildAgentExtensionPolicy(
display_name: 'tkmind-search', display_name: 'tkmind-search',
bundled: false, bundled: false,
cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH), 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: { envs: {
TKMIND_SEARCH_ENABLED: '1', TKMIND_SEARCH_ENABLED: '1',
TKMIND_SEARCH_MODE: mindSearchConfig.mode, TKMIND_SEARCH_MODE: mindSearchConfig.mode,
@@ -687,7 +698,13 @@ export function buildAgentExtensionPolicy(
display_name: 'Excel Analyst', display_name: 'Excel Analyst',
bundled: false, bundled: false,
cmd: resolveSandboxMcpNodeExecPath(sandboxMcp?.nodeExecPath), 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: { envs: {
EXCEL_ANALYST_ENABLED: '1', EXCEL_ANALYST_ENABLED: '1',
MINDSPACE_WORKSPACE_ROOT: excelWorkspaceRoot, MINDSPACE_WORKSPACE_ROOT: excelWorkspaceRoot,
+18
View File
@@ -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_ROOT, '/opt/h5/MindSpace/abc123');
assert.equal(sandboxExt.envs.MINDSPACE_WORKSPACE_REF, 'mindspace://users/abc123/workspace'); 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');
});
+30
View File
@@ -52,6 +52,8 @@ const PAGE_DATA_INTENT_PATTERNS = [
/(?:页面|网页|H5|h5).{0,80}(?:每天|每日|新增|添加|填写|记录).{0,80}(?:所有记录|历史记录|管理|汇总|统计)/u, /(?:页面|网页|H5|h5).{0,80}(?:每天|每日|新增|添加|填写|记录).{0,80}(?:所有记录|历史记录|管理|汇总|统计)/u,
/(?:页面|网页|商城|店铺).{0,80}(?:下单|订单|购物车).{0,80}(?:后台|管理|上架|商品|产品|库存)/u, /(?:页面|网页|商城|店铺).{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; const INTERACTIVE_PAGE_DATA_SUBJECT_PATTERN = /(?:便签|便利贴|备忘录|待办|清单)/u;
@@ -180,6 +182,7 @@ export const CHAT_SKILL_DEFINITIONS = [
icon: 'spark', icon: 'spark',
skillName: AIDER_DEVELOPMENT_SKILL_NAME, skillName: AIDER_DEVELOPMENT_SKILL_NAME,
requiresSkill: AIDER_DEVELOPMENT_SKILL_NAME, requiresSkill: AIDER_DEVELOPMENT_SKILL_NAME,
prefillOnly: true,
promptKey: AIDER_DEVELOPMENT_SKILL_NAME, 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) { function buildWebNewsSkillPrompt(skillName) {
return `请使用 ${skillName ?? 'web'} 技能:先搜索今天/最新相关的新闻与热点,优先一手来源和权威媒体,整理 3-5 条最相关结果,按时间或热度排序;然后给出中文摘要、关键信息、事件背景和来源链接。我的问题是:`; return `请使用 ${skillName ?? 'web'} 技能:先搜索今天/最新相关的新闻与热点,优先一手来源和权威媒体,整理 3-5 条最相关结果,按时间或热度排序;然后给出中文摘要、关键信息、事件背景和来源链接。我的问题是:`;
} }
+39
View File
@@ -5,11 +5,13 @@ import {
buildChatSkillPrompt, buildChatSkillPrompt,
CHAT_SKILL_DEFINITIONS, CHAT_SKILL_DEFINITIONS,
filterChatSkills, filterChatSkills,
extractAiderDevelopmentTask,
isExcelAnalysisIntent, isExcelAnalysisIntent,
isPageDataDevIntent, isPageDataDevIntent,
isPageDataIntent, isPageDataIntent,
isPageGenerationIntent, isPageGenerationIntent,
isGenericPageGenerationRequest, isGenericPageGenerationRequest,
mergeChatSkillPromptWithInput,
} from './chat-skills.mjs'; } from './chat-skills.mjs';
test('filterChatSkills shows summarize and analyze without granted skills', () => { 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')); 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', () => { test('filterChatSkills only shows Aider development when granted', () => {
const hidden = filterChatSkills(CHAT_SKILL_DEFINITIONS, { const hidden = filterChatSkills(CHAT_SKILL_DEFINITIONS, {
canPublish: false, 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', () => { test('isPageDataIntent matches implicit interactive sticky-note app requests', () => {
const text = '帮我设计一个便签提醒,可以写便签提交,时间轴来显示'; const text = '帮我设计一个便签提醒,可以写便签提交,时间轴来显示';
assert.equal(isPageDataIntent(text), true); assert.equal(isPageDataIntent(text), true);
+22
View File
@@ -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')); 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 () => { 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' }] }) }) }); 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 }); assert.deepEqual(result[0], { title: 'Goose', url: 'https://example.com', snippet: 'snippet', source: 'searxng', rank: 1 });
+6 -2
View File
@@ -2,7 +2,11 @@ import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState,
import { BrainCircuit, Database, Image, ImageOff, ImagePlus } from 'lucide-react'; import { BrainCircuit, Database, Image, ImageOff, ImagePlus } from 'lucide-react';
import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar'; 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 { getMessageSaveActions } from '../utils/messageSave';
import { getDisplayText } from '../utils/message'; import { getDisplayText } from '../utils/message';
import { import {
@@ -1242,7 +1246,7 @@ export function ChatPanel({
onSelect={submitText} onSelect={submitText}
onPrefill={(prompt, skillId) => { onPrefill={(prompt, skillId) => {
pendingSkillRef.current = skillId ?? null; pendingSkillRef.current = skillId ?? null;
setInput(prompt); setInput((current) => mergeChatSkillPromptWithInput(prompt, current));
}} }}
/> />
)} )}
+35 -5
View File
@@ -117,6 +117,7 @@ async function waitForAgentRun(runId: string): Promise<AgentRun> {
} }
const DIRECT_CHAT_SESSION_POLL_MS = 600; const DIRECT_CHAT_SESSION_POLL_MS = 600;
const AGENT_RUN_STATUS_POLL_MS = 1_500;
const AGENT_RUN_WAIT_TIMEOUT_MS = 16 * 60 * 1000; const AGENT_RUN_WAIT_TIMEOUT_MS = 16 * 60 * 1000;
async function waitForAgentRunWithDirectChatPreview( async function waitForAgentRunWithDirectChatPreview(
@@ -129,6 +130,8 @@ async function waitForAgentRunWithDirectChatPreview(
): Promise<AgentRun> { ): Promise<AgentRun> {
return await new Promise<AgentRun>((resolve, reject) => { return await new Promise<AgentRun>((resolve, reject) => {
let pollTimer: number | null = null; let pollTimer: number | null = null;
let runStatusPollTimer: number | null = null;
let runStatusPollInFlight = false;
let waitTimer: number | null = null; let waitTimer: number | null = null;
let pollingSessionId: string | null = null; let pollingSessionId: string | null = null;
let settled = false; let settled = false;
@@ -138,6 +141,7 @@ async function waitForAgentRunWithDirectChatPreview(
if (settled) return; if (settled) return;
settled = true; settled = true;
stopPoll(); stopPoll();
stopRunStatusPoll();
if (waitTimer != null) { if (waitTimer != null) {
window.clearTimeout(waitTimer); window.clearTimeout(waitTimer);
waitTimer = null; waitTimer = null;
@@ -153,6 +157,13 @@ async function waitForAgentRunWithDirectChatPreview(
} }
}; };
const stopRunStatusPoll = () => {
if (runStatusPollTimer != null) {
window.clearInterval(runStatusPollTimer);
runStatusPollTimer = null;
}
};
const startPolling = (sessionId: string) => { const startPolling = (sessionId: string) => {
if (pollingSessionId === sessionId && pollTimer != null) return; if (pollingSessionId === sessionId && pollTimer != null) return;
pollingSessionId = sessionId; pollingSessionId = sessionId;
@@ -175,9 +186,7 @@ async function waitForAgentRunWithDirectChatPreview(
}, DIRECT_CHAT_SESSION_POLL_MS); }, DIRECT_CHAT_SESSION_POLL_MS);
}; };
unsubscribe = subscribeAgentRunEvents( const handleRunStatus = (run: AgentRun) => {
runId,
(run) => {
if (run.sessionId) { if (run.sessionId) {
handlers.onSessionId?.(run.sessionId); handlers.onSessionId?.(run.sessionId);
if (isDirectChatSessionId(run.sessionId)) { if (isDirectChatSessionId(run.sessionId)) {
@@ -191,11 +200,32 @@ async function waitForAgentRunWithDirectChatPreview(
if (run.status === 'failed') { if (run.status === 'failed') {
settle(() => reject(new Error(run.error || '后台任务失败,请稍后重试'))); 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) => { (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(() => { waitTimer = window.setTimeout(() => {
void getAgentRun(runId) void getAgentRun(runId)
+4 -2
View File
@@ -291,6 +291,7 @@ export function resolveAgentRunOptions(
{ {
taskType = 'code_task', taskType = 'code_task',
forceCode = false, forceCode = false,
requiredExecutor,
allowAutodetect = clientCodeRunsAutodetectEnabled(), allowAutodetect = clientCodeRunsAutodetectEnabled(),
allowPageDataDevAutodetect = clientPageDataDevAutodetectEnabled(), allowPageDataDevAutodetect = clientPageDataDevAutodetectEnabled(),
userId = null, userId = null,
@@ -310,8 +311,9 @@ export function resolveAgentRunOptions(
} = {}, } = {},
): AgentRunCreateOptions { ): AgentRunCreateOptions {
const normalizedText = String(text ?? '').trim(); const normalizedText = String(text ?? '').trim();
const pageDataDevTaskType = const pageDataDevTaskType = allowPageDataDevAutodetect
allowPageDataDevAutodetect && resolvePageDataDevTaskType(normalizedText); ? resolvePageDataDevTaskType(normalizedText)
: null;
const effectiveTaskType = pageDataDevTaskType ?? taskType; const effectiveTaskType = pageDataDevTaskType ?? taskType;
const shouldUseDeepReasoning = const shouldUseDeepReasoning =
forceCode || forceCode ||
+2
View File
@@ -2,6 +2,7 @@ import {
buildChatSkillPrompt, buildChatSkillPrompt,
CHAT_SKILL_DEFINITIONS, CHAT_SKILL_DEFINITIONS,
filterChatSkills as filterChatSkillDefinitions, filterChatSkills as filterChatSkillDefinitions,
mergeChatSkillPromptWithInput,
} from '../../chat-skills.mjs'; } from '../../chat-skills.mjs';
import { buildPublishSkillPrompt, PUBLISH_SKILL_NAME } from './publishSkill'; import { buildPublishSkillPrompt, PUBLISH_SKILL_NAME } from './publishSkill';
@@ -45,3 +46,4 @@ export function filterChatSkills(
} }
export { buildPublishSkillPrompt, PUBLISH_SKILL_NAME }; export { buildPublishSkillPrompt, PUBLISH_SKILL_NAME };
export { mergeChatSkillPromptWithInput };
+103 -2
View File
@@ -1,5 +1,7 @@
import { spawn as nodeSpawn } from 'node:child_process'; import { spawn as nodeSpawn } from 'node:child_process';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import fs from 'node:fs/promises';
import path from 'node:path';
const CODE_EXECUTORS = new Set(['aider', 'openhands']); const CODE_EXECUTORS = new Set(['aider', 'openhands']);
const DEFAULT_STDIO_LIMIT = 64 * 1024; const DEFAULT_STDIO_LIMIT = 64 * 1024;
@@ -55,6 +57,92 @@ export function extractToolInstruction(userMessage) {
.trim(); .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({ export function createToolGateway({
llmProviderService, llmProviderService,
env = process.env, env = process.env,
@@ -110,16 +198,29 @@ export function createToolGateway({
throw new Error('Tool Gateway job missing instruction'); throw new Error('Tool Gateway job missing instruction');
} }
const executor = selectExecutor({ userMessage, taskType }); 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, cwd,
mode: 'headless', mode: 'headless',
instruction, instruction: executorInstruction,
purpose: 'default', purpose: 'default',
includeSecret: true, includeSecret: true,
}); });
if (!plan?.ok) { if (!plan?.ok) {
throw new Error(plan?.message ?? `Tool Gateway launch plan unavailable for ${executor}`); 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) { if (dryRun) {
return { return {
ok: true, ok: true,
+50 -1
View File
@@ -1,6 +1,15 @@
import assert from 'node:assert/strict'; 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 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', () => { test('tool gateway extracts text instructions from H5 message content', () => {
assert.equal( assert.equal(
@@ -58,6 +67,46 @@ test('tool gateway honors an explicitly required Aider executor over task defaul
}), 'aider'); }), '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 () => { test('tool gateway dry run builds executor launch plan without spawning', async () => {
const plans = []; const plans = [];
const gateway = createToolGateway({ const gateway = createToolGateway({