feat(agent): 聊天提交统一走 POST /agent/runs 异步网关

引入 Agent Run 网关替代直连 /sessions/:id/reply,并在 api_lockdown 白名单中放行新入口,避免策略拦截导致聊天不可用。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-29 22:50:00 +08:00
parent 16828a58f7
commit 8f620fa714
14 changed files with 784 additions and 89 deletions
+111 -6
View File
@@ -363,14 +363,16 @@ export function createTkmindProxy({
recipe = null,
} = {},
) {
const resolvedWorkingDir = workingDir ?? await userAuth.resolveWorkingDir(userId);
const resolvedSessionPolicy = sessionPolicy ?? await userAuth.getAgentSessionPolicy(userId);
const startTarget = await pickTarget();
const upstream = await apiFetch(startTarget, apiSecret, '/agent/start', {
method: 'POST',
body: JSON.stringify({
...(workingDir ? { working_dir: workingDir } : {}),
enable_context_memory: sessionPolicy?.enableContextMemory,
...(sessionPolicy?.extensionOverrides
? { extension_overrides: sessionPolicy.extensionOverrides }
...(resolvedWorkingDir ? { working_dir: resolvedWorkingDir } : {}),
enable_context_memory: resolvedSessionPolicy?.enableContextMemory,
...(resolvedSessionPolicy?.extensionOverrides
? { extension_overrides: resolvedSessionPolicy.extensionOverrides }
: {}),
...(recipe ? { recipe } : {}),
}),
@@ -384,12 +386,12 @@ export function createTkmindProxy({
throw new Error('创建会话失败:缺少 session id');
}
await userAuth.registerAgentSession(userId, session.id, startTarget);
if (sessionPolicy?.gooseMode) {
if (resolvedSessionPolicy?.gooseMode) {
const modeRes = await apiFetch(startTarget, apiSecret, '/agent/update_session', {
method: 'POST',
body: JSON.stringify({
session_id: session.id,
goose_mode: sessionPolicy.gooseMode,
goose_mode: resolvedSessionPolicy.gooseMode,
}),
});
if (!modeRes.ok) {
@@ -397,6 +399,29 @@ export function createTkmindProxy({
throw new Error(modeText || `设置会话模式失败 (${modeRes.status})`);
}
}
const publishLayout = await userAuth.getUserPublishLayout(userId);
const userMemories = conversationMemoryService?.listMemories
? await conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => [])
: [];
await reconcileAgentSession(
(pathname, init) => apiFetch(startTarget, apiSecret, pathname, init),
session.id,
{
workingDir: resolvedWorkingDir,
sessionPolicy: resolvedSessionPolicy,
sandboxConstraints: publishLayout?.constraints ?? null,
userMemories,
userContext: publishLayout
? {
userId,
displayName: publishLayout.displayName,
username: publishLayout.username,
slug: publishLayout.slug,
}
: null,
},
);
await applySessionLlmProvider(session.id);
return session;
}
@@ -499,6 +524,68 @@ export function createTkmindProxy({
);
}
async function submitSessionReplyForUser(userId, sessionId, requestId, userMessage) {
if (!userId || !sessionId) throw new Error('缺少会话信息');
const owns = await userAuth.ownsSession(userId, sessionId);
if (!owns) throw new Error('无权访问该会话');
const gate = await userAuth.canUseChat(userId);
if (!gate.ok) {
const err = new Error(gate.message ?? '余额不足,请充值后继续使用');
err.code = gate.code;
err.status = 402;
throw err;
}
await reconcileSessionPolicyForUser(userId, sessionId);
await applySessionLlmProvider(sessionId);
const user = await userAuth.getUserById(userId);
if (!user) throw new Error('用户不存在');
let finalUserMessage = userMessage;
if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) {
const publishLayout = await userAuth.getUserPublishLayout(userId).catch(() => null);
const visionResult = await buildVisionBody(userMessage, userId, publishLayout).catch(() => null);
if (visionResult?.userMessage) {
finalUserMessage = visionResult.userMessage;
}
if (visionResult?.billableImageCount > 0 && subscriptionService) {
await subscriptionService.consumeImageQuota(userId, visionResult.billableImageCount).catch((err) => {
console.warn(
'Subscription image quota consume skipped:',
err instanceof Error ? err.message : err,
);
});
}
}
const policyState = await userAuth.resolveUserPolicies(user);
let body = {
request_id: requestId,
user_message: finalUserMessage,
};
if (!policyState.unrestricted) {
body = injectTaskRoutingHint(
body,
await userAuth.getAgentSessionPolicy(userId),
);
}
const target = await resolveTarget(sessionId);
const upstream = await apiFetch(
target,
apiSecret,
`/sessions/${encodeURIComponent(sessionId)}/reply`,
{
method: 'POST',
body: JSON.stringify(body),
},
);
if (!upstream.ok) {
const text = await upstream.text().catch(() => '');
throw new Error(text || `发送失败 (${upstream.status})`);
}
return { ok: true };
}
const requireUser = async (req, res, next) => {
try {
const session = req.userSession;
@@ -877,6 +964,16 @@ export function createTkmindProxy({
});
return;
}
const isAgentRunPath =
(req.method === 'POST' && pathname === '/agent/runs')
|| (req.method === 'GET' && /^\/agent\/runs\/[^/]+$/.test(pathname));
if (isAgentRunPath) {
res.status(503).json({
message: 'Agent Run 接口需在 Portal 本地处理,请确认 server.mjs 已更新并重启后端',
code: 'AGENT_RUNS_NATIVE_REQUIRED',
});
return;
}
const policyState = await userAuth.resolveUserPolicies(req.currentUser);
const capabilityState = await userAuth.resolveUserCapabilities(req.currentUser);
const gate = evaluateProxyRequest(req.method, pathname, policyState.policies, {
@@ -896,6 +993,13 @@ export function createTkmindProxy({
}
const isReplyPath = pathname.match(/^\/sessions\/[^/]+\/reply$/) && req.method === 'POST';
const sessionMatch = pathname.match(/^\/sessions\/([^/]+)/);
if (isReplyPath) {
res.status(410).json({
message: '聊天提交入口已统一为 POST /agent/runs',
code: 'AGENT_RUNS_REQUIRED',
});
return;
}
let baseBody = req.body;
if (isReplyPath && !policyState.unrestricted) {
@@ -983,6 +1087,7 @@ export function createTkmindProxy({
proxySessionEvents,
resolveTarget,
startSessionForUser,
submitSessionReplyForUser,
apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init),
apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init),
};