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);
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 条最相关结果,按时间或热度排序;然后给出中文摘要、关键信息、事件背景和来源链接。我的问题是:`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user