feat: complete Aider Page Data review workflow
This commit is contained in:
+205
-7
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
'<!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 () => {
|
||||
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-'));
|
||||
|
||||
+70
-9
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
+19
-2
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 条最相关结果,按时间或热度排序;然后给出中文摘要、关键信息、事件背景和来源链接。我的问题是:`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -117,6 +117,7 @@ async function waitForAgentRun(runId: string): Promise<AgentRun> {
|
||||
}
|
||||
|
||||
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<AgentRun> {
|
||||
return await new Promise<AgentRun>((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)
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+103
-2
@@ -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,
|
||||
|
||||
+50
-1
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user