Skip to content

Commit 94dd558

Browse files
committed
fix(工作台): 改进 MCP 降级与会话/运行恢复
MCP 不可用时跳过并记录 unavailableResources,避免整次 run 失败;会话记忆与前端恢复为失败 run 补齐孤儿 user 消息占位,并统一 run forwardedProps 与 token 用量汇总。
1 parent 1cfca08 commit 94dd558

22 files changed

Lines changed: 1457 additions & 286 deletions

apps/api/src/conversation-memory.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -252,11 +252,9 @@ export const buildConversationMemoryMessagesWithReport = (
252252
});
253253
const currentUserMessageId = lastUserMessage(input.runInput)?.id ?? `${input.runId}:user`;
254254
const messages: Message[] = selected.summary ? [summaryToMessage(selected.summary, policy.maxMessageChars)] : [];
255-
messages.push(...selected.history.map((record) => ({
256-
id: `memory:${record.id}`,
257-
role: record.role,
258-
content: boundText(record.content_text, policy.maxMessageChars)
259-
})));
255+
messages.push(
256+
...normalizeHistoryMessagePairs(selected.history, policy.maxMessageChars)
257+
);
260258

261259
messages.push({
262260
id: currentUserMessageId,
@@ -490,6 +488,50 @@ const generateSummaryText = async (input: {
490488
});
491489
};
492490

491+
/**
492+
* Normalises orphaned user messages in conversation history into valid
493+
* user/assistant pairs by injecting a synthetic assistant error reply.
494+
*
495+
* An orphaned user message is a user-role record that is NOT followed by an
496+
* assistant reply – either it sits at the tail of the history or it is
497+
* directly followed by another user message. This happens when a run fails
498+
* before the agent produces any output: `persistCurrentUserMessage` has
499+
* already written the record but no corresponding assistant record is stored.
500+
*
501+
* Simply discarding these records loses conversation context. Instead we
502+
* inject a placeholder assistant message so the LLM:
503+
* 1. Sees the full history of what the user asked.
504+
* 2. Understands that the previous attempt failed to produce a response.
505+
* 3. Receives a strictly alternating user/assistant sequence, which all
506+
* major providers (Anthropic, DashScope, …) require.
507+
*/
508+
const ORPHANED_USER_MESSAGE_PLACEHOLDER =
509+
"Previous request failed before the assistant produced a response.";
510+
511+
const normalizeHistoryMessagePairs = (
512+
records: ConversationMessageRecord[],
513+
maxMessageChars: number
514+
): Message[] => {
515+
const result: Message[] = [];
516+
for (let i = 0; i < records.length; i++) {
517+
const cur = records[i]!;
518+
const next = records[i + 1] as ConversationMessageRecord | undefined;
519+
result.push({
520+
id: `memory:${cur.id}`,
521+
role: cur.role,
522+
content: boundText(cur.content_text, maxMessageChars)
523+
});
524+
if (cur.role === "user" && (!next || next.role === "user")) {
525+
result.push({
526+
id: `memory:${cur.id}:error-placeholder`,
527+
role: "assistant",
528+
content: ORPHANED_USER_MESSAGE_PLACEHOLDER
529+
});
530+
}
531+
}
532+
return result;
533+
};
534+
493535
const selectBudgetedHistory = (input: {
494536
currentUserText: string;
495537
history: ConversationMessageRecord[];

apps/api/src/run-config-resolver.ts

Lines changed: 115 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export type ResolvedRunConfig = {
3636
export type McpRuntime = {
3737
servers: PolicyMcpClientConfig[];
3838
toolNames: string[];
39+
skipped?: { id: string; reason: string }[];
3940
};
4041

4142
type ResolveRunConfigInput = {
@@ -110,6 +111,20 @@ export const resolveRunConfig = (input: ResolveRunConfigInput): ResolvedRunConfi
110111
input.userId,
111112
input.workspaceId
112113
);
114+
if (mcpRuntime.skipped && mcpRuntime.skipped.length > 0) {
115+
const skippedIds = new Set(mcpRuntime.skipped.map((entry) => entry.id));
116+
effectiveRunConfig.enabledMcpServerIds = effectiveRunConfig.enabledMcpServerIds.filter(
117+
(id) => !skippedIds.has(id)
118+
);
119+
effectiveRunConfig.unavailableResources = [
120+
...(effectiveRunConfig.unavailableResources ?? []),
121+
...mcpRuntime.skipped.map((entry) => ({
122+
kind: "mcp-server" as const,
123+
id: entry.id,
124+
reason: entry.reason
125+
}))
126+
];
127+
}
113128
enforceSkillMcpPolicy(skillSelection.effectiveToolPolicy.allowedTools, mcpRuntime.toolNames);
114129

115130
return {
@@ -441,78 +456,112 @@ const resolveMcpRuntime = (
441456
): McpRuntime => {
442457
const usedNames = new Set<string>();
443458
const toolNames: string[] = [];
444-
const servers = serverIds.map((id): PolicyMcpClientConfig => {
445-
const resource = metadataStore.configResources.get({
446-
id,
447-
workspace_id: workspaceId,
448-
user_id: userId,
449-
kind: "mcp-server"
450-
});
451-
const transport = stringRecordValue(resource.payload, "transport") ?? "streamable-http";
452-
if (transport !== "streamable-http" && transport !== "sse" && transport !== "stdio") {
453-
throw new Error(`MCP_TRANSPORT_UNSUPPORTED:${id}:${transport}`);
459+
const servers: PolicyMcpClientConfig[] = [];
460+
const skipped: { id: string; reason: string }[] = [];
461+
for (const id of serverIds) {
462+
try {
463+
servers.push(
464+
buildMcpServerConfig({
465+
id,
466+
metadataStore,
467+
usedNames,
468+
toolNames,
469+
userId,
470+
workspaceId
471+
})
472+
);
473+
} catch (error) {
474+
skipped.push({
475+
id,
476+
reason: error instanceof Error ? error.message : String(error)
477+
});
454478
}
455-
const urlOrCommand = stringRecordValue(resource.payload, "serverUrl") ?? stringRecordValue(resource.payload, "url");
456-
if (!urlOrCommand) {
457-
throw new Error(`MCP_SERVER_URL_REQUIRED:${id}`);
479+
}
480+
return {
481+
servers,
482+
toolNames,
483+
...(skipped.length > 0 ? { skipped } : {})
484+
};
485+
};
486+
487+
const buildMcpServerConfig = (input: {
488+
id: string;
489+
metadataStore: MetadataStore;
490+
usedNames: Set<string>;
491+
toolNames: string[];
492+
userId: string;
493+
workspaceId: string;
494+
}): PolicyMcpClientConfig => {
495+
const { id, metadataStore, usedNames, toolNames, userId, workspaceId } = input;
496+
const resource = metadataStore.configResources.get({
497+
id,
498+
workspace_id: workspaceId,
499+
user_id: userId,
500+
kind: "mcp-server"
501+
});
502+
const transport = stringRecordValue(resource.payload, "transport") ?? "streamable-http";
503+
if (transport !== "streamable-http" && transport !== "sse" && transport !== "stdio") {
504+
throw new Error(`MCP_TRANSPORT_UNSUPPORTED:${id}:${transport}`);
505+
}
506+
const urlOrCommand = stringRecordValue(resource.payload, "serverUrl") ?? stringRecordValue(resource.payload, "url");
507+
if (!urlOrCommand) {
508+
throw new Error(`MCP_SERVER_URL_REQUIRED:${id}`);
509+
}
510+
const manifest = resource.payload.toolManifest;
511+
if (!Array.isArray(manifest)) {
512+
throw new Error(`MCP_TOOL_MANIFEST_REQUIRED:${id}`);
513+
}
514+
const toolAllowlist = stringArrayRecordValue(resource.payload, "toolAllowlist")
515+
?? csvRecordValue(resource.payload, "toolAllowlist");
516+
manifest.forEach((tool) => {
517+
if (!isRecord(tool) || typeof tool.name !== "string") {
518+
throw new Error(`MCP_TOOL_MANIFEST_INVALID:${id}`);
458519
}
459-
const manifest = resource.payload.toolManifest;
460-
if (!Array.isArray(manifest)) {
461-
throw new Error(`MCP_TOOL_MANIFEST_REQUIRED:${id}`);
520+
if (!matchesMcpToolAllowlist(id, tool.name, toolAllowlist)) {
521+
return;
462522
}
463-
const toolAllowlist = stringArrayRecordValue(resource.payload, "toolAllowlist")
464-
?? csvRecordValue(resource.payload, "toolAllowlist");
465-
manifest.forEach((tool) => {
466-
if (!isRecord(tool) || typeof tool.name !== "string") {
467-
throw new Error(`MCP_TOOL_MANIFEST_INVALID:${id}`);
468-
}
469-
if (!matchesMcpToolAllowlist(id, tool.name, toolAllowlist)) {
470-
return;
471-
}
472-
const baseName = `mcp__${sanitizeMcpName(id)}__${sanitizeMcpName(tool.name)}`;
473-
let resolved = baseName.slice(0, 64);
474-
let suffix = 1;
475-
while (usedNames.has(resolved)) {
476-
const marker = `_${suffix}`;
477-
resolved = `${baseName.slice(0, 64 - marker.length)}${marker}`;
478-
suffix += 1;
479-
}
480-
usedNames.add(resolved);
481-
toolNames.push(resolved);
482-
});
483-
const secret = resource.secret_ref
484-
? metadataStore.secrets.get({ ref: resource.secret_ref, workspace_id: workspaceId, user_id: userId })
485-
: {};
486-
const headers = resolveMcpHeaders(resource.payload, secret);
487-
const timeoutMs = numericRecordValue(resource.payload, "timeoutMs") ?? numericRecordValue(resource.payload, "timeout_ms");
488-
const common = {
489-
serverId: id,
490-
...(toolAllowlist && toolAllowlist.length > 0 ? { toolAllowlist } : {}),
491-
...(timeoutMs !== undefined ? { timeoutMs: Math.max(1000, Math.min(10 * 60 * 1000, Math.floor(timeoutMs))) } : {})
492-
};
493-
if (transport === "stdio") {
494-
const stdio = resolveStdioCommand(resource.payload, urlOrCommand);
495-
const cwd = stringRecordValue(resource.payload, "cwd");
496-
const env = recordStringMapValue(resource.payload.env);
497-
return {
498-
...common,
499-
type: "stdio",
500-
command: stdio.command,
501-
...(stdio.args.length > 0 ? { args: stdio.args } : {}),
502-
...(cwd ? { cwd } : {}),
503-
...(env ? { env } : {})
504-
};
523+
const baseName = `mcp__${sanitizeMcpName(id)}__${sanitizeMcpName(tool.name)}`;
524+
let resolved = baseName.slice(0, 64);
525+
let suffix = 1;
526+
while (usedNames.has(resolved)) {
527+
const marker = `_${suffix}`;
528+
resolved = `${baseName.slice(0, 64 - marker.length)}${marker}`;
529+
suffix += 1;
505530
}
531+
usedNames.add(resolved);
532+
toolNames.push(resolved);
533+
});
534+
const secret = resource.secret_ref
535+
? metadataStore.secrets.get({ ref: resource.secret_ref, workspace_id: workspaceId, user_id: userId })
536+
: {};
537+
const headers = resolveMcpHeaders(resource.payload, secret);
538+
const timeoutMs = numericRecordValue(resource.payload, "timeoutMs") ?? numericRecordValue(resource.payload, "timeout_ms");
539+
const common = {
540+
serverId: id,
541+
...(toolAllowlist && toolAllowlist.length > 0 ? { toolAllowlist } : {}),
542+
...(timeoutMs !== undefined ? { timeoutMs: Math.max(1000, Math.min(10 * 60 * 1000, Math.floor(timeoutMs))) } : {})
543+
};
544+
if (transport === "stdio") {
545+
const stdio = resolveStdioCommand(resource.payload, urlOrCommand);
546+
const cwd = stringRecordValue(resource.payload, "cwd");
547+
const env = recordStringMapValue(resource.payload.env);
506548
return {
507-
type: transport === "streamable-http" ? "http" : "sse",
508-
url: urlOrCommand,
509-
serverId: id,
510-
...(toolAllowlist && toolAllowlist.length > 0 ? { toolAllowlist } : {}),
511-
...(timeoutMs !== undefined ? { timeoutMs: Math.max(1000, Math.min(10 * 60 * 1000, Math.floor(timeoutMs))) } : {}),
512-
...(Object.keys(headers).length > 0 ? { headers } : {})
549+
...common,
550+
type: "stdio",
551+
command: stdio.command,
552+
...(stdio.args.length > 0 ? { args: stdio.args } : {}),
553+
...(cwd ? { cwd } : {}),
554+
...(env ? { env } : {})
513555
};
514-
});
515-
return { servers, toolNames };
556+
}
557+
return {
558+
type: transport === "streamable-http" ? "http" : "sse",
559+
url: urlOrCommand,
560+
serverId: id,
561+
...(toolAllowlist && toolAllowlist.length > 0 ? { toolAllowlist } : {}),
562+
...(timeoutMs !== undefined ? { timeoutMs: Math.max(1000, Math.min(10 * 60 * 1000, Math.floor(timeoutMs))) } : {}),
563+
...(Object.keys(headers).length > 0 ? { headers } : {})
564+
};
516565
};
517566

518567
const resolveMcpHeaders = (

apps/api/src/run-finalizer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ export const createRunStatusDelta = (
194194
): BaseEvent => ({
195195
type: EventType.STATE_DELTA,
196196
delta: [
197-
{ op: "replace", path: "/runStatus", value: status },
197+
{ op: "add", path: "/runStatus", value: status },
198198
...(errorMessage ? [{ op: "add", path: "/errorMessage", value: errorMessage }] : [])
199199
],
200200
timestamp: Date.now()

apps/api/src/run-input.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ export type EffectiveRunConfig = {
5050
* The run continues; this list is surfaced in `run.config.resolved` for diagnostics.
5151
*/
5252
disabledByPolicy?: { kind: "knowledge-base" | "mcp-server" | "model-profile"; id: string }[];
53+
/**
54+
* Resources silently dropped because runtime configuration is invalid (missing manifest,
55+
* URL, etc.). The run continues without those MCP tools.
56+
*/
57+
unavailableResources?: { kind: "mcp-server"; id: string; reason: string }[];
5358
};
5459

5560
export type PerRunMentionKind = "db" | "kb" | "mcp" | "skill";

0 commit comments

Comments
 (0)