fix(agent-run): tolerate goosed stdio MCP listing gaps during session reconcile.
Memind CI / Test, build, and release guards (push) Failing after 4s
Memind CI / Test, build, and release guards (push) Failing after 4s
Skip hot-adding stdio extensions goosed omits from /extensions, stabilize MCP scope during session start, and wire chat intent routing into the agent-run worker. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-1
@@ -166,7 +166,7 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
||||
# MEMIND_CHAT_ROUTER_MIN_CONFIDENCE=0.65
|
||||
# MEMIND_CHAT_ROUTER_TIMEOUT_MS=1200
|
||||
# MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID=<h5_llm_provider_keys.id>
|
||||
# MEMIND_CHAT_ROUTER_MODEL=qwen2.5:3b
|
||||
# MEMIND_CHAT_ROUTER_MODEL=deepseek-v4-pro
|
||||
|
||||
# H5 Session stream replay(docs/h5-session-architecture-20260706.md Patch 4c)
|
||||
# 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
|
||||
|
||||
+102
-56
@@ -3,8 +3,13 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createAgentRunGateway } from '../agent-run-gateway.mjs';
|
||||
import { createManagedChatIntentRouter } from '../chat-intent-router.mjs';
|
||||
import { createConversationMemoryService } from '../conversation-memory.mjs';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { createEpisodicMemoryService } from '../episodic-memory.mjs';
|
||||
import { createLlmProviderService } from '../llm-providers.mjs';
|
||||
import { createMemoryV2AdminConfigService } from '../memory-v2-admin-config.mjs';
|
||||
import { createManagedMemoryV2Runtime } from '../memory-v2-runtime.mjs';
|
||||
import { createTkmindProxy } from '../tkmind-proxy.mjs';
|
||||
import { createToolGateway } from '../tool-gateway.mjs';
|
||||
import { createUserAuth } from '../user-auth.mjs';
|
||||
@@ -94,62 +99,103 @@ if (args.help) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
const workflowShadowEnabled = isEnabledFlag(
|
||||
process.env.MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED,
|
||||
);
|
||||
const workflowShadowObserver = workflowShadowEnabled
|
||||
? createWorkflowShadowObserver({
|
||||
configService: createOrchestratorAdminConfigService(pool),
|
||||
serviceToken: process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN,
|
||||
})
|
||||
: null;
|
||||
const baseUserAuth = createUserAuth(pool, {
|
||||
usersRoot: process.env.H5_USERS_ROOT ?? path.join(root, 'users'),
|
||||
h5Root: root,
|
||||
});
|
||||
const overrideWorkingDir = String(process.env.MEMIND_AGENT_RUN_WORKDIR_OVERRIDE ?? '').trim();
|
||||
const overrideWorkingDirUserId = String(process.env.MEMIND_AGENT_RUN_WORKDIR_USER_ID ?? '').trim();
|
||||
const userAuth = overrideWorkingDir
|
||||
? {
|
||||
...baseUserAuth,
|
||||
async resolveWorkingDir(userId) {
|
||||
if (!overrideWorkingDirUserId || overrideWorkingDirUserId === userId) {
|
||||
return path.resolve(overrideWorkingDir);
|
||||
}
|
||||
return baseUserAuth.resolveWorkingDir(userId);
|
||||
},
|
||||
}
|
||||
: baseUserAuth;
|
||||
const llmProviderService = createLlmProviderService(pool, {
|
||||
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
|
||||
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
|
||||
});
|
||||
const toolGateway = createToolGateway({ llmProviderService });
|
||||
const apiTargets = parseTargets();
|
||||
const tkmindProxy = createTkmindProxy({
|
||||
apiTarget: apiTargets[0],
|
||||
apiTargets,
|
||||
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
|
||||
userAuth,
|
||||
});
|
||||
const gateway = createAgentRunGateway({
|
||||
pool,
|
||||
userAuth,
|
||||
tkmindProxy,
|
||||
toolGateway,
|
||||
autoDispatch: false,
|
||||
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
|
||||
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000),
|
||||
observeWorkflowRun: workflowShadowObserver,
|
||||
observeWorkflowValidation:
|
||||
workflowShadowObserver?.observeValidation ?? null,
|
||||
enforcePageDataWorkflowValidation:
|
||||
workflowShadowEnabled
|
||||
&& isEnabledFlag(
|
||||
process.env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED,
|
||||
),
|
||||
});
|
||||
async function bootstrapWorker() {
|
||||
const pool = createDbPool();
|
||||
const workflowShadowEnabled = isEnabledFlag(
|
||||
process.env.MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED,
|
||||
);
|
||||
const workflowShadowObserver = workflowShadowEnabled
|
||||
? createWorkflowShadowObserver({
|
||||
configService: createOrchestratorAdminConfigService(pool),
|
||||
serviceToken: process.env.MEMIND_ORCHESTRATOR_SERVICE_TOKEN,
|
||||
})
|
||||
: null;
|
||||
const baseUserAuth = createUserAuth(pool, {
|
||||
usersRoot: process.env.H5_USERS_ROOT ?? path.join(root, 'users'),
|
||||
h5Root: root,
|
||||
});
|
||||
const overrideWorkingDir = String(process.env.MEMIND_AGENT_RUN_WORKDIR_OVERRIDE ?? '').trim();
|
||||
const overrideWorkingDirUserId = String(process.env.MEMIND_AGENT_RUN_WORKDIR_USER_ID ?? '').trim();
|
||||
const userAuth = overrideWorkingDir
|
||||
? {
|
||||
...baseUserAuth,
|
||||
async resolveWorkingDir(userId) {
|
||||
if (!overrideWorkingDirUserId || overrideWorkingDirUserId === userId) {
|
||||
return path.resolve(overrideWorkingDir);
|
||||
}
|
||||
return baseUserAuth.resolveWorkingDir(userId);
|
||||
},
|
||||
}
|
||||
: baseUserAuth;
|
||||
const llmProviderService = createLlmProviderService(pool, {
|
||||
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
|
||||
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
|
||||
});
|
||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
||||
const getEffectiveEnv = async () => {
|
||||
const state = await memoryV2ConfigService.getRuntimeState().catch(() => null);
|
||||
return {
|
||||
...process.env,
|
||||
...(state?.overrides ?? {}),
|
||||
};
|
||||
};
|
||||
const conversationMemoryService = createConversationMemoryService(pool, {
|
||||
llmProviderService,
|
||||
getEffectiveEnv,
|
||||
});
|
||||
const memoryV2 = await createManagedMemoryV2Runtime({
|
||||
legacyMemoryService: conversationMemoryService,
|
||||
configService: memoryV2ConfigService,
|
||||
mysqlPool: pool,
|
||||
});
|
||||
const episodicMemoryService = createEpisodicMemoryService(pool, {
|
||||
getEffectiveEnv,
|
||||
logger: console,
|
||||
});
|
||||
await episodicMemoryService.ensureSchema().catch((err) => {
|
||||
console.warn(
|
||||
'[agent-run-worker] episodic memory schema setup degraded:',
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
});
|
||||
const chatIntentRouter = createManagedChatIntentRouter({
|
||||
llmProviderService,
|
||||
memoryV2,
|
||||
conversationMemoryService,
|
||||
episodicMemoryService,
|
||||
configService: memoryV2ConfigService,
|
||||
});
|
||||
const toolGateway = createToolGateway({ llmProviderService });
|
||||
const apiTargets = parseTargets();
|
||||
const tkmindProxy = createTkmindProxy({
|
||||
apiTarget: apiTargets[0],
|
||||
apiTargets,
|
||||
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
|
||||
userAuth,
|
||||
});
|
||||
const gateway = createAgentRunGateway({
|
||||
pool,
|
||||
userAuth,
|
||||
tkmindProxy,
|
||||
toolGateway,
|
||||
chatIntentRouter,
|
||||
conversationMemoryService,
|
||||
autoDispatch: false,
|
||||
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
|
||||
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000),
|
||||
observeWorkflowRun: workflowShadowObserver,
|
||||
observeWorkflowValidation:
|
||||
workflowShadowObserver?.observeValidation ?? null,
|
||||
enforcePageDataWorkflowValidation:
|
||||
workflowShadowEnabled
|
||||
&& isEnabledFlag(
|
||||
process.env.MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED,
|
||||
),
|
||||
});
|
||||
return { pool, gateway };
|
||||
}
|
||||
|
||||
const { pool, gateway } = await bootstrapWorker();
|
||||
|
||||
let stopping = false;
|
||||
process.on('SIGINT', () => { stopping = true; });
|
||||
|
||||
@@ -66,6 +66,16 @@ export function extensionsNeedingRefresh(currentExtensions, desiredExtensions) {
|
||||
continue;
|
||||
}
|
||||
if (!extensionConfigsMatch(ext, wanted)) {
|
||||
const currentExec = extensionExecutionConfig(ext);
|
||||
const wantedExec = extensionExecutionConfig(wanted);
|
||||
const stdioCmdArgsMatch = currentExec.type === 'stdio'
|
||||
&& wantedExec.type === 'stdio'
|
||||
&& currentExec.cmd === wantedExec.cmd
|
||||
&& JSON.stringify(currentExec.args) === JSON.stringify(wantedExec.args);
|
||||
if (stdioCmdArgsMatch) {
|
||||
// goosed cannot hot-refresh stdio MCP env/tool grants after session start.
|
||||
continue;
|
||||
}
|
||||
toRemove.push(name);
|
||||
toAdd.push(wanted);
|
||||
}
|
||||
@@ -76,6 +86,9 @@ export function extensionsNeedingRefresh(currentExtensions, desiredExtensions) {
|
||||
if (!name) continue;
|
||||
const exists = current.some((ext) => extensionName(ext) === name);
|
||||
if (!exists) {
|
||||
// goosed may omit active stdio MCP extensions from the session listing
|
||||
// even though /agent/start already loaded them; do not hot-add stdio.
|
||||
if (String(config?.type ?? '') === 'stdio') continue;
|
||||
toAdd.push(config);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +96,17 @@ export function extensionsNeedingRefresh(currentExtensions, desiredExtensions) {
|
||||
return { toRemove, toAdd };
|
||||
}
|
||||
|
||||
function stdioListingOmissionAllowed(currentExt, desiredConfig) {
|
||||
if (String(desiredConfig?.type ?? '') !== 'stdio') return false;
|
||||
if (!currentExt) return true;
|
||||
const currentExec = extensionExecutionConfig(currentExt);
|
||||
const desiredExec = extensionExecutionConfig(desiredConfig);
|
||||
return currentExec.type === 'stdio'
|
||||
&& desiredExec.type === 'stdio'
|
||||
&& currentExec.cmd === desiredExec.cmd
|
||||
&& JSON.stringify(currentExec.args) === JSON.stringify(desiredExec.args);
|
||||
}
|
||||
|
||||
export function extensionPolicyViolations(currentExtensions, desiredExtensions) {
|
||||
const current = currentExtensions ?? [];
|
||||
const desired = desiredExtensions ?? [];
|
||||
@@ -108,6 +132,9 @@ export function extensionPolicyViolations(currentExtensions, desiredExtensions)
|
||||
const missingOrMismatched = [];
|
||||
for (const [name, desiredConfig] of desiredByName) {
|
||||
const configs = currentByName.get(name) ?? [];
|
||||
if (stdioListingOmissionAllowed(configs[0] ?? null, desiredConfig)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
configs.length !== 1
|
||||
|| !extensionConfigsMatch(configs[0], desiredConfig)
|
||||
|
||||
@@ -122,6 +122,24 @@ test('extensionsNeedingRefresh adds missing extensions', () => {
|
||||
assert.equal(toAdd[0].name, 'summon');
|
||||
});
|
||||
|
||||
test('extensionsNeedingRefresh does not hot-add missing stdio extensions', () => {
|
||||
const { toRemove, toAdd } = extensionsNeedingRefresh(
|
||||
[{ name: 'developer', available_tools: ['read_image'] }],
|
||||
[
|
||||
{ name: 'developer', available_tools: ['read_image'] },
|
||||
{
|
||||
type: 'stdio',
|
||||
name: 'sandbox-fs',
|
||||
cmd: '/usr/local/bin/node',
|
||||
args: ['/opt/portal/mindspace-sandbox-mcp.mjs', '/tmp/user-1'],
|
||||
available_tools: ['read_file'],
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(toRemove, []);
|
||||
assert.deepEqual(toAdd, []);
|
||||
});
|
||||
|
||||
test('extensionPolicyViolations reports unexpected, duplicate, and mismatched extensions', () => {
|
||||
assert.deepEqual(
|
||||
extensionPolicyViolations(
|
||||
@@ -143,6 +161,36 @@ test('extensionPolicyViolations reports unexpected, duplicate, and mismatched ex
|
||||
);
|
||||
});
|
||||
|
||||
test('extensionPolicyViolations ignores stdio extensions omitted from goosed listing', () => {
|
||||
assert.deepEqual(
|
||||
extensionPolicyViolations(
|
||||
[{ name: 'developer', available_tools: ['read_image'] }],
|
||||
[
|
||||
{ name: 'developer', available_tools: ['read_image'] },
|
||||
{
|
||||
type: 'stdio',
|
||||
name: 'sandbox-fs',
|
||||
cmd: '/usr/local/bin/node',
|
||||
args: ['/opt/portal/mindspace-sandbox-mcp.mjs', '/tmp/user-1'],
|
||||
available_tools: ['read_file'],
|
||||
},
|
||||
{
|
||||
type: 'stdio',
|
||||
name: 'tkmind-search',
|
||||
cmd: '/usr/local/bin/node',
|
||||
args: ['/opt/portal/tkmind-search-mcp.mjs'],
|
||||
available_tools: ['search_external'],
|
||||
},
|
||||
],
|
||||
),
|
||||
{
|
||||
unexpected: [],
|
||||
duplicate: [],
|
||||
missingOrMismatched: [],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('reconcileAgentSession tolerates invalid working directory during resume sync without restart', async () => {
|
||||
const calls = [];
|
||||
const apiFetch = async (pathname) => {
|
||||
|
||||
+23
-10
@@ -1438,7 +1438,17 @@ export function createTkmindProxy({
|
||||
} = {},
|
||||
) {
|
||||
const resolvedWorkingDir = workingDir ?? await userAuth.resolveWorkingDir(userId);
|
||||
const baseSessionPolicy = sessionPolicy ?? await userAuth.getAgentSessionPolicy(userId);
|
||||
const mcpScopeSessionId = crypto.randomUUID();
|
||||
const mcpScope = sessionPolicy
|
||||
? null
|
||||
: {
|
||||
sessionId: mcpScopeSessionId,
|
||||
packageId: `cp_${mcpScopeSessionId}`,
|
||||
};
|
||||
const baseSessionPolicy = sessionPolicy ?? await userAuth.getAgentSessionPolicy(
|
||||
userId,
|
||||
mcpScope ?? {},
|
||||
);
|
||||
const resolvedSessionPolicy = disableImageReading
|
||||
? withoutSessionImageRead(baseSessionPolicy)
|
||||
: baseSessionPolicy;
|
||||
@@ -1472,15 +1482,18 @@ export function createTkmindProxy({
|
||||
const baseSessionScopedPolicy =
|
||||
typeof userAuth.getAgentSessionPolicy ===
|
||||
'function'
|
||||
? await userAuth.getAgentSessionPolicy(
|
||||
userId,
|
||||
{
|
||||
toolMode:
|
||||
resolvedSessionPolicy
|
||||
?.toolMode ?? 'chat',
|
||||
sessionId: session.id,
|
||||
packageId: `cp_${session.id}`,
|
||||
},
|
||||
? (
|
||||
sessionPolicy
|
||||
? resolvedSessionPolicy
|
||||
: await userAuth.getAgentSessionPolicy(
|
||||
userId,
|
||||
{
|
||||
toolMode:
|
||||
resolvedSessionPolicy
|
||||
?.toolMode ?? 'chat',
|
||||
...mcpScope,
|
||||
},
|
||||
)
|
||||
)
|
||||
: resolvedSessionPolicy;
|
||||
const sessionScopedPolicy = disableImageReading
|
||||
|
||||
+1
-1
@@ -2504,7 +2504,7 @@ test('wechat mp service reconciles existing dedicated session before reply', asy
|
||||
assert.equal(result.status, 200);
|
||||
await result.task;
|
||||
assert.equal(calls.some(([pathname]) => pathname === '/agent/update_working_dir'), true);
|
||||
assert.equal(calls.some(([pathname]) => pathname === '/agent/add_extension'), true);
|
||||
assert.equal(calls.some(([pathname]) => pathname === '/agent/add_extension'), false);
|
||||
assert.equal(calls.some(([pathname]) => pathname === '/sessions/session-1/reply'), true);
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
|
||||
Reference in New Issue
Block a user