diff --git a/.env.example b/.env.example index a599f59..c416d93 100644 --- a/.env.example +++ b/.env.example @@ -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= -# 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 重连补发。 diff --git a/scripts/agent-run-worker.mjs b/scripts/agent-run-worker.mjs index c08ace8..5e306e3 100644 --- a/scripts/agent-run-worker.mjs +++ b/scripts/agent-run-worker.mjs @@ -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; }); diff --git a/session-reconcile.mjs b/session-reconcile.mjs index d75e432..3aa5f6c 100644 --- a/session-reconcile.mjs +++ b/session-reconcile.mjs @@ -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) diff --git a/session-reconcile.test.mjs b/session-reconcile.test.mjs index e32c913..7fbe192 100644 --- a/session-reconcile.test.mjs +++ b/session-reconcile.test.mjs @@ -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) => { diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index 43004c8..4974f35 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -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 diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 57c8c8a..f1301d2 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -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;