diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 6d2ffce..191fc6c 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js index ddaef99..41e8068 100644 --- a/lib/codex-app-runtime.js +++ b/lib/codex-app-runtime.js @@ -22,6 +22,8 @@ const RUNTIME_STREAM_DELTA_MAX_CHARS = readPositiveIntEnv('CC_WEB_CODEX_APP_STRE const RUNTIME_MAX_TOOL_CALLS = readPositiveIntEnv('CC_WEB_CODEX_APP_RUNTIME_MAX_TOOL_CALLS', 120, { min: 1, max: 1000 }); const RUNTIME_TRUNCATED_HEAD = '[cc-web: 前文过长,已保留尾部以保护服务稳定性]\n'; const RUNTIME_TRUNCATED_TAIL = '\n[cc-web: 内容过长,已截断以保护服务稳定性]'; +const CODEX_APP_PLAN_ITEM_TYPES = new Set(['plan', 'plan_list', 'planlist', 'todo', 'todo_list', 'todolist', 'task_list']); +const CODEX_APP_PLAN_TOOL_NAMES = new Set(['update_plan', 'plan', 'plan_list', 'todo_list', 'updateplan', 'todolist']); function createCodexAppRuntime(deps = {}) { const { @@ -131,6 +133,123 @@ function createCodexAppRuntime(deps = {}) { return true; } + function normalizeIdentifier(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + } + + function isPlanToolName(value) { + const name = normalizeIdentifier(value); + return CODEX_APP_PLAN_TOOL_NAMES.has(name) || name.endsWith('_update_plan'); + } + + function isPlanLikeItem(item) { + if (!item || typeof item !== 'object') return false; + if (CODEX_APP_PLAN_ITEM_TYPES.has(normalizeIdentifier(item.type))) return true; + return isPlanToolName(item.tool || item.name || item.functionName || item.function?.name); + } + + function parseMaybeJsonValue(value) { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if (!trimmed || !/^[{[]/.test(trimmed)) return value; + try { + return JSON.parse(trimmed); + } catch { + return value; + } + } + + function extractPlanEntries(value, depth = 0) { + if (value === null || value === undefined || depth > 3) return null; + const source = parseMaybeJsonValue(value); + if (Array.isArray(source)) return source; + if (!source || typeof source !== 'object') return null; + const keys = ['plan', 'items', 'todos', 'tasks', 'steps']; + for (const key of keys) { + if (Array.isArray(source[key])) return source[key]; + } + const nestedKeys = ['arguments', 'input', 'params', 'payload', 'structuredContent', 'result']; + for (const key of nestedKeys) { + const nested = extractPlanEntries(source[key], depth + 1); + if (nested) return nested; + } + if (Array.isArray(source.contentItems)) { + for (const part of source.contentItems) { + const text = typeof part?.text === 'string' ? part.text : ''; + const nested = extractPlanEntries(text, depth + 1); + if (nested) return nested; + } + } + return null; + } + + function planEntryCompleted(entry) { + if (!entry || typeof entry !== 'object') return false; + if (entry.completed === true || entry.done === true) return true; + const status = normalizeIdentifier(entry.status || entry.state); + return ['completed', 'complete', 'done', 'success', 'succeeded'].includes(status); + } + + function planEntryText(entry) { + if (typeof entry === 'string') return entry; + if (!entry || typeof entry !== 'object') return ''; + return entry.step || entry.text || entry.title || entry.name || entry.description || entry.task || entry.item || entry.content || ''; + } + + function normalizeTodoListFromPlanItem(item) { + if (!isPlanLikeItem(item)) return null; + const candidates = [ + item.arguments, + item.input, + item.params, + item.payload, + item.structuredContent, + item.result?.structuredContent, + item.result, + item, + ]; + let entries = null; + for (const candidate of candidates) { + entries = extractPlanEntries(candidate); + if (entries) break; + } + if (!Array.isArray(entries)) return null; + const items = entries + .map((entry) => { + const text = truncateEnd(planEntryText(entry), RUNTIME_TOOL_INPUT_MAX_CHARS); + if (!text) return null; + return { + text, + completed: planEntryCompleted(entry), + }; + }) + .filter(Boolean); + return { + id: item.id || item.itemId || item.planId || 'codex-app-plan', + type: 'todo_list', + items, + }; + } + + function planUpdateItemFromParams(params = {}) { + const item = params.item && typeof params.item === 'object' ? { ...params.item } : {}; + return { + ...params, + ...item, + id: item.id || params.itemId || params.id || params.planId || 'codex-app-plan', + type: item.type || params.type || 'planList', + status: item.status || params.status || 'inProgress', + plan: item.plan || params.plan, + items: item.items || params.items, + todos: item.todos || params.todos, + tasks: item.tasks || params.tasks, + }; + } + function codexAppErrorMessage(value) { if (!value) return ''; if (typeof value === 'string') return value; @@ -178,6 +297,7 @@ function createCodexAppRuntime(deps = {}) { } function itemKind(item) { + if (normalizeTodoListFromPlanItem(item)) return 'todo_list'; switch (item?.type) { case 'commandExecution': return 'command_execution'; @@ -203,6 +323,7 @@ function createCodexAppRuntime(deps = {}) { } function itemName(item) { + if (normalizeTodoListFromPlanItem(item)) return 'PlanList'; switch (item?.type) { case 'commandExecution': return 'CommandExecution'; @@ -227,6 +348,8 @@ function createCodexAppRuntime(deps = {}) { function itemInput(item) { if (!item) return null; + const todoList = normalizeTodoListFromPlanItem(item); + if (todoList) return todoList; switch (item.type) { case 'commandExecution': return { command: truncateEnd(item.command || '', RUNTIME_TOOL_INPUT_MAX_CHARS) }; @@ -292,6 +415,14 @@ function createCodexAppRuntime(deps = {}) { function itemMeta(item) { if (!item) return null; + if (normalizeTodoListFromPlanItem(item)) { + return { + kind: 'todo_list', + title: 'Plan List', + subtitle: item.explanation || item.title || item.tool || '', + status: item.status || null, + }; + } switch (item.type) { case 'commandExecution': return { @@ -346,6 +477,8 @@ function createCodexAppRuntime(deps = {}) { function itemResult(item) { if (!item) return ''; + const todoList = normalizeTodoListFromPlanItem(item); + if (todoList) return JSON.stringify(todoList, null, 2); switch (item.type) { case 'commandExecution': return truncateEnd(item.aggregatedOutput || '', RUNTIME_TOOL_RESULT_MAX_CHARS); @@ -390,7 +523,7 @@ function createCodexAppRuntime(deps = {}) { toolCall.name = itemName(item); toolCall.kind = kind; toolCall.meta = itemMeta(item) || toolCall.meta || null; - if (toolCall.input == null) toolCall.input = itemInput(item); + if (toolCall.input == null || kind === 'todo_list') toolCall.input = itemInput(item); return toolCall; } @@ -608,6 +741,22 @@ function createCodexAppRuntime(deps = {}) { return { done: false }; } + case 'plan/updated': + case 'turn/plan/updated': + case 'item/plan/updated': + case 'item/todoList/updated': { + const item = planUpdateItemFromParams(params); + const todoList = normalizeTodoListFromPlanItem(item); + if (!todoList) return { done: false }; + updateToolResult(entry, sessionId, todoList.id, JSON.stringify(todoList, null, 2), false, { + name: 'PlanList', + kind: 'todo_list', + input: todoList, + meta: itemMeta(item), + }); + return { done: false }; + } + case 'item/reasoning/summaryTextDelta': case 'item/reasoning/textDelta': { const itemId = params.itemId; diff --git a/public/rag-for-pm-v2.html b/public/rag-for-pm-v2.html new file mode 100644 index 0000000..f09388e --- /dev/null +++ b/public/rag-for-pm-v2.html @@ -0,0 +1,447 @@ + + + + + +RAG 入门:原理、流程与使用 + + + +
+ +
+
+
RAG Primer原理和使用
+

RAG · LLM · Retrieval · Prompt · Agent · MCP

+

RAG 入门:让 AI 先查资料再回答

+

从 LLM 的对话限制出发,理解 RAG 的建库、检索、排序、提示词和 Agent 扩展。

+
+ RAG解释LLM解释上下文限制幻觉注意力顺序切片向量化排序语义检索提示词工程AGENTSMCP +
+ +
开场不用讲算法,先讲主线:大模型会说话,但它不是企业知识库。因为它有上下文、幻觉、注意力和顺序方面的限制,所以需要 RAG 这套“先查资料,再组织答案”的机制。
+ +
+
+ +
+
+
Roadmap学习路径
+

由浅入深

+

从“会生成”到“会查资料、会调用工具”

+
+
1

基础概念

  • RAG 是什么
  • LLM 是什么
+
+
2

LLM 限制

  • 上下文
  • 幻觉
  • 注意力与顺序
+
+
3

离线建库

  • 资料清洗
  • 切片
  • 向量化入库
+
+
4

在线问答

  • 语义检索
  • 排序 / 重排
  • 提示词工程
+
+
5

能力扩展

  • Agents 分工
  • MCP 连接工具
+
+ +
这页把路线说清楚。后面每一页都围绕 LLM 限制到 RAG 方案,再到提示词工程和 Agent 的路径推进。
+ +
+
+ +
+
+
RAG检索增强生成
+

RAG 解释

+

RAG = 先检索资料,再增强上下文,最后生成答案

+
+
Retrieval

检索

从知识库找到相关资料。

+
+
Augmented

增强

把资料放进上下文。

+
+
Generation

生成

LLM 基于资料回答。

+
+
+

不是训练模型

知识不写进模型参数,而是每次回答前动态查资料。

+

不是全量塞资料

只取与问题相关的片段,降低上下文压力和噪声。

+
+ +
这页讲 RAG 的基本定义。先给出完整定义,再强调 RAG 不是训练模型,也不是把全部资料塞给模型。
+ +
+
+ +
+
+
LLM大语言模型
+

定义

+

LLM:理解上下文,生成自然语言

+
+
+ 能力 +

理解上下文,生成答案

+
    +
  • 读懂用户问题的大意
  • +
  • 把零散信息组织成自然语言
  • +
  • 按要求改写、总结、解释、翻译
  • +
+
+
+
+ 边界 +

企业资料库或事实系统

+
    +
  • 不会天然知道最新制度
  • +
  • 不知道内部文档和私有数据
  • +
  • 不能保证每句话都有来源
  • +
+
+
+

定位

LLM 负责读懂和表达,事实依据来自外部资料。

+ +
这里不要把 LLM 讲成搜索引擎。LLM 最强的是语言理解和生成,事实来源要靠外部知识、数据库或工具补充。
+ +
+
+ +
+
+
Limits对话限制
+

对话限制

+

LLM 的 4 个对话限制

+
+
1

上下文有限

输入窗口有限;资料过多会变慢、变贵、变乱。

+
2

会有幻觉

资料不足或指令不清时,会生成看似合理的错误内容。

+
3

专注度下降

长资料和噪声会稀释重点,关键信息可能被忽略。

+
4

顺序不稳定

位置、相似内容、前后冲突都会影响答案。

+
+

处理策略:每次只给最相关、最可信的少量资料。

+ +
这一页要明确回应用户大纲:上下文限制、幻觉、专注度、不会稳定关注内容顺序。它们共同解释了为什么不能简单粗暴地把所有文档塞进去。
+ +
+
+ +
+
+
Why RAG限制带来方案
+

从限制到方案

+

RAG 的核心:不是让模型记住全部知识,而是让它按需查资料

+
+
全量输入

全部塞给 LLM

长、乱、贵,容易混入过期和无权限资料。

+
+
RAG

先查,再答

每次只取跟问题最相关的资料片段。

+
+
生成

基于资料回答

LLM 根据资料生成,必要时引用来源。

+
+

RAG = 检索增强生成

检索相关资料 → 放入上下文 → LLM 生成答案。

+ +
这页把 RAG 的必要性讲出来:不是因为向量数据库酷,而是因为 LLM 的上下文和注意力有限,必须把资料筛小、筛准,再交给模型。
+ +
+
+ +
+
+
Offline Pipeline后台整理资料
+

离线流程

+

资料整理成可检索的知识库

+
+
01

收集资料

产品手册、FAQ、制度、接口文档、案例、流程说明。

+
02

清洗资料

去掉重复、过期、广告、目录噪声和格式错误。

+
03

切片

把长文档拆成能独立表达意思的小片段。

+
04

加元数据

来源、版本、时间、部门、权限、适用范围。

+
05

向量化入库

把每个片段变成语义向量,写入向量库。

+
+

资料质量决定检索质量。

+ +
这里是“前期准备”。强调 RAG 不只是问答界面,后台知识库准备很关键。元数据也很重要,因为后面排序和权限过滤会用到。
+ +
+
+ +
+
+
Chunk & Embedding切片和向量化
+

索引构建

+

切片 + 向量化:支持语义检索

+
+
切片 A:退费条件

购买后 7 天内,且未使用核心服务,可以申请全额退款。

+
切片 B:不可退场景

已开具发票、已交付定制服务、超过合同期限,不支持自动退款。

+
切片 C:审批路径

超过 5 万元的退款申请,需要客户成功经理和财务双审批。

+
+
+
切片

粒度适中

过大噪声多,过小语义断;每片覆盖一个局部问题。

+
向量库

语义坐标

文字转换成数字向量;语义相近,距离更近。

+
+ +
用卡片和地图类比:切片是把书拆成卡片,向量化是给卡片标语义坐标。用户问法不一样,也能找到意思接近的片段。
+ +
+
+ +
+
+
Online Retrieval检索和排序
+

在线流程

+

用户提问后:检索候选,排序后交给 LLM

+
+
01

问题改写

把口语问题改成更适合检索的查询。

+
02

问题向量化

把用户问题也转成语义向量。

+
03

召回候选

从向量库里找语义距离近的片段。

+
04

排序 / 重排

按相关性、时效、权限、来源可信度重新排序。

+
05

拼上下文

只把最有用的几段资料交给 LLM。

+
+ + +
这里必须引出“排序”概念。召回只是先捞一批候选,排序/重排才决定哪些片段真正进入上下文。排序质量直接影响最终答案。
+ +
+
+ +
+
+
Prompt Engineering系统提示词
+

生成约束

+

系统提示词规定 LLM 的资料使用规则

+ +

RAG 找资料;提示词规定资料的使用方式。

+ +
这一页承接用户大纲:给到 LLM 的时候需要系统提示词,由此引出提示词工程。提示词工程重点不是花哨话术,而是角色、边界、格式、引用和兜底。
+ +
+
+ +
+
+
Agent复杂任务的分工协作
+

复杂任务

+

复杂任务:多步骤、多角色、多 LLM 协作

+
+
规划者

拆任务

规划检索、工具调用和结果组织。

+
+
检索者

查资料

使用 RAG 从知识库里找依据,必要时多轮检索。

+
+
执行者

调用工具

查订单、建工单、读数据库、调用业务系统接口。

+
+
+

Agent

目标驱动流程:规划步骤、选择工具、读取结果、继续推进。

+

和 RAG 的关系

RAG 提供知识入口;Agent 负责任务编排。

+
+ +
不要把 Agent 讲玄。它就是更复杂任务里的规划和编排:可能一个 LLM 规划,一个 LLM 检索,一个 LLM 写答案,也可能调用外部工具。
+ +
+
+ +
+
+
MCP工具连接
+

MCP

+

MCP:让 Agent 稳定连接外部工具和业务系统

+
+
统一接口

工具接入规范

把不同系统的能力包装成模型可调用的工具。

+
上下文供给

读取外部信息

查数据库、读文件、取工单、访问知识系统。

+
动作执行

调用业务能力

创建工单、查询订单、发送通知、写入结果。

+
+
+ CRM工单数据库搜索文件 +
+

RAG 负责查知识;Agent 负责编排任务;MCP 负责连接工具。

+ +
这页讲 MCP。它不需要展开协议细节,只要说明 MCP 是让模型/Agent 稳定连接外部工具和系统的接口层。
+ +
+
+ +
+
+
Takeaway关键链路
+

核心链路

+

RAG → LLM 限制 → 切片 → 向量化 → 语义检索 → 排序 → 提示词 → Agent → MCP

+
+
限制

LLM 不能吃下全部知识

上下文有限、会幻觉、专注度和顺序都不稳定。

+
建库

资料要先整理成片段

清洗、切片、向量化、加元数据,再放入向量库。

+
检索

提问时先找资料

召回候选,再排序过滤,把最相关内容放进上下文。

+
扩展

Agent + MCP

Agent 编排多步骤任务;MCP 连接外部工具和系统。

+
+

RAG 不是替代 LLM,而是给 LLM 配一套“会查资料的工作台”。

+ +
收尾不要再加新概念。重复主线:LLM 有限制,所以需要 RAG;RAG 后台建库,前台检索排序,再通过提示词交给 LLM;复杂任务用 Agent。
+ +
+
+ +
+
+ + + + diff --git a/public/rag-for-pm.html b/public/rag-for-pm.html new file mode 100644 index 0000000..f09388e --- /dev/null +++ b/public/rag-for-pm.html @@ -0,0 +1,447 @@ + + + + + +RAG 入门:原理、流程与使用 + + + +
+ +
+
+
RAG Primer原理和使用
+

RAG · LLM · Retrieval · Prompt · Agent · MCP

+

RAG 入门:让 AI 先查资料再回答

+

从 LLM 的对话限制出发,理解 RAG 的建库、检索、排序、提示词和 Agent 扩展。

+
+ RAG解释LLM解释上下文限制幻觉注意力顺序切片向量化排序语义检索提示词工程AGENTSMCP +
+ +
开场不用讲算法,先讲主线:大模型会说话,但它不是企业知识库。因为它有上下文、幻觉、注意力和顺序方面的限制,所以需要 RAG 这套“先查资料,再组织答案”的机制。
+ +
+
+ +
+
+
Roadmap学习路径
+

由浅入深

+

从“会生成”到“会查资料、会调用工具”

+
+
1

基础概念

  • RAG 是什么
  • LLM 是什么
+
+
2

LLM 限制

  • 上下文
  • 幻觉
  • 注意力与顺序
+
+
3

离线建库

  • 资料清洗
  • 切片
  • 向量化入库
+
+
4

在线问答

  • 语义检索
  • 排序 / 重排
  • 提示词工程
+
+
5

能力扩展

  • Agents 分工
  • MCP 连接工具
+
+ +
这页把路线说清楚。后面每一页都围绕 LLM 限制到 RAG 方案,再到提示词工程和 Agent 的路径推进。
+ +
+
+ +
+
+
RAG检索增强生成
+

RAG 解释

+

RAG = 先检索资料,再增强上下文,最后生成答案

+
+
Retrieval

检索

从知识库找到相关资料。

+
+
Augmented

增强

把资料放进上下文。

+
+
Generation

生成

LLM 基于资料回答。

+
+
+

不是训练模型

知识不写进模型参数,而是每次回答前动态查资料。

+

不是全量塞资料

只取与问题相关的片段,降低上下文压力和噪声。

+
+ +
这页讲 RAG 的基本定义。先给出完整定义,再强调 RAG 不是训练模型,也不是把全部资料塞给模型。
+ +
+
+ +
+
+
LLM大语言模型
+

定义

+

LLM:理解上下文,生成自然语言

+
+
+ 能力 +

理解上下文,生成答案

+
    +
  • 读懂用户问题的大意
  • +
  • 把零散信息组织成自然语言
  • +
  • 按要求改写、总结、解释、翻译
  • +
+
+
+
+ 边界 +

企业资料库或事实系统

+
    +
  • 不会天然知道最新制度
  • +
  • 不知道内部文档和私有数据
  • +
  • 不能保证每句话都有来源
  • +
+
+
+

定位

LLM 负责读懂和表达,事实依据来自外部资料。

+ +
这里不要把 LLM 讲成搜索引擎。LLM 最强的是语言理解和生成,事实来源要靠外部知识、数据库或工具补充。
+ +
+
+ +
+
+
Limits对话限制
+

对话限制

+

LLM 的 4 个对话限制

+
+
1

上下文有限

输入窗口有限;资料过多会变慢、变贵、变乱。

+
2

会有幻觉

资料不足或指令不清时,会生成看似合理的错误内容。

+
3

专注度下降

长资料和噪声会稀释重点,关键信息可能被忽略。

+
4

顺序不稳定

位置、相似内容、前后冲突都会影响答案。

+
+

处理策略:每次只给最相关、最可信的少量资料。

+ +
这一页要明确回应用户大纲:上下文限制、幻觉、专注度、不会稳定关注内容顺序。它们共同解释了为什么不能简单粗暴地把所有文档塞进去。
+ +
+
+ +
+
+
Why RAG限制带来方案
+

从限制到方案

+

RAG 的核心:不是让模型记住全部知识,而是让它按需查资料

+
+
全量输入

全部塞给 LLM

长、乱、贵,容易混入过期和无权限资料。

+
+
RAG

先查,再答

每次只取跟问题最相关的资料片段。

+
+
生成

基于资料回答

LLM 根据资料生成,必要时引用来源。

+
+

RAG = 检索增强生成

检索相关资料 → 放入上下文 → LLM 生成答案。

+ +
这页把 RAG 的必要性讲出来:不是因为向量数据库酷,而是因为 LLM 的上下文和注意力有限,必须把资料筛小、筛准,再交给模型。
+ +
+
+ +
+
+
Offline Pipeline后台整理资料
+

离线流程

+

资料整理成可检索的知识库

+
+
01

收集资料

产品手册、FAQ、制度、接口文档、案例、流程说明。

+
02

清洗资料

去掉重复、过期、广告、目录噪声和格式错误。

+
03

切片

把长文档拆成能独立表达意思的小片段。

+
04

加元数据

来源、版本、时间、部门、权限、适用范围。

+
05

向量化入库

把每个片段变成语义向量,写入向量库。

+
+

资料质量决定检索质量。

+ +
这里是“前期准备”。强调 RAG 不只是问答界面,后台知识库准备很关键。元数据也很重要,因为后面排序和权限过滤会用到。
+ +
+
+ +
+
+
Chunk & Embedding切片和向量化
+

索引构建

+

切片 + 向量化:支持语义检索

+
+
切片 A:退费条件

购买后 7 天内,且未使用核心服务,可以申请全额退款。

+
切片 B:不可退场景

已开具发票、已交付定制服务、超过合同期限,不支持自动退款。

+
切片 C:审批路径

超过 5 万元的退款申请,需要客户成功经理和财务双审批。

+
+
+
切片

粒度适中

过大噪声多,过小语义断;每片覆盖一个局部问题。

+
向量库

语义坐标

文字转换成数字向量;语义相近,距离更近。

+
+ +
用卡片和地图类比:切片是把书拆成卡片,向量化是给卡片标语义坐标。用户问法不一样,也能找到意思接近的片段。
+ +
+
+ +
+
+
Online Retrieval检索和排序
+

在线流程

+

用户提问后:检索候选,排序后交给 LLM

+
+
01

问题改写

把口语问题改成更适合检索的查询。

+
02

问题向量化

把用户问题也转成语义向量。

+
03

召回候选

从向量库里找语义距离近的片段。

+
04

排序 / 重排

按相关性、时效、权限、来源可信度重新排序。

+
05

拼上下文

只把最有用的几段资料交给 LLM。

+
+ + +
这里必须引出“排序”概念。召回只是先捞一批候选,排序/重排才决定哪些片段真正进入上下文。排序质量直接影响最终答案。
+ +
+
+ +
+
+
Prompt Engineering系统提示词
+

生成约束

+

系统提示词规定 LLM 的资料使用规则

+ +

RAG 找资料;提示词规定资料的使用方式。

+ +
这一页承接用户大纲:给到 LLM 的时候需要系统提示词,由此引出提示词工程。提示词工程重点不是花哨话术,而是角色、边界、格式、引用和兜底。
+ +
+
+ +
+
+
Agent复杂任务的分工协作
+

复杂任务

+

复杂任务:多步骤、多角色、多 LLM 协作

+
+
规划者

拆任务

规划检索、工具调用和结果组织。

+
+
检索者

查资料

使用 RAG 从知识库里找依据,必要时多轮检索。

+
+
执行者

调用工具

查订单、建工单、读数据库、调用业务系统接口。

+
+
+

Agent

目标驱动流程:规划步骤、选择工具、读取结果、继续推进。

+

和 RAG 的关系

RAG 提供知识入口;Agent 负责任务编排。

+
+ +
不要把 Agent 讲玄。它就是更复杂任务里的规划和编排:可能一个 LLM 规划,一个 LLM 检索,一个 LLM 写答案,也可能调用外部工具。
+ +
+
+ +
+
+
MCP工具连接
+

MCP

+

MCP:让 Agent 稳定连接外部工具和业务系统

+
+
统一接口

工具接入规范

把不同系统的能力包装成模型可调用的工具。

+
上下文供给

读取外部信息

查数据库、读文件、取工单、访问知识系统。

+
动作执行

调用业务能力

创建工单、查询订单、发送通知、写入结果。

+
+
+ CRM工单数据库搜索文件 +
+

RAG 负责查知识;Agent 负责编排任务;MCP 负责连接工具。

+ +
这页讲 MCP。它不需要展开协议细节,只要说明 MCP 是让模型/Agent 稳定连接外部工具和系统的接口层。
+ +
+
+ +
+
+
Takeaway关键链路
+

核心链路

+

RAG → LLM 限制 → 切片 → 向量化 → 语义检索 → 排序 → 提示词 → Agent → MCP

+
+
限制

LLM 不能吃下全部知识

上下文有限、会幻觉、专注度和顺序都不稳定。

+
建库

资料要先整理成片段

清洗、切片、向量化、加元数据,再放入向量库。

+
检索

提问时先找资料

召回候选,再排序过滤,把最相关内容放进上下文。

+
扩展

Agent + MCP

Agent 编排多步骤任务;MCP 连接外部工具和系统。

+
+

RAG 不是替代 LLM,而是给 LLM 配一套“会查资料的工作台”。

+ +
收尾不要再加新概念。重复主线:LLM 有限制,所以需要 RAG;RAG 后台建库,前台检索排序,再通过提示词交给 LLM;复杂任务用 Agent。
+ +
+
+ +
+
+ + + + diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index ff49bdf..06a6d33 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -24,6 +24,7 @@ if (args[0] !== 'app-server') { const threads = new Map(); const pendingServerRequests = new Map(); +const resumeMismatchThreads = new Set(); let nextServerRequestId = 1; let mcpReloadCount = 0; @@ -64,6 +65,12 @@ function tokenUsage(text) { }; } +function retryScenarioKey(text, marker) { + return new RegExp(marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(String(text || '')) + ? marker + : String(text || ''); +} + function collaborationSummary(params = {}) { const collaborationMode = params.collaborationMode; const settings = collaborationMode?.settings || {}; @@ -623,8 +630,9 @@ function startTurn(params) { } if (/codexapp capacity retry/i.test(text)) { - const attempts = (thread.capacityRetryAttempts.get(text) || 0) + 1; - thread.capacityRetryAttempts.set(text, attempts); + const retryKey = retryScenarioKey(text, 'codexapp capacity retry'); + const attempts = (thread.capacityRetryAttempts.get(retryKey) || 0) + 1; + thread.capacityRetryAttempts.set(retryKey, attempts); if (attempts <= 2) { if (attempts === 2) emitPartialCapacityOutput(thread, turnId); emitCapacityError(thread, turnId); @@ -633,8 +641,9 @@ function startTurn(params) { } if (/codexapp reconnect retry/i.test(text)) { - const attempts = (thread.reconnectRetryAttempts.get(text) || 0) + 1; - thread.reconnectRetryAttempts.set(text, attempts); + const retryKey = retryScenarioKey(text, 'codexapp reconnect retry'); + const attempts = (thread.reconnectRetryAttempts.get(retryKey) || 0) + 1; + thread.reconnectRetryAttempts.set(retryKey, attempts); if (attempts === 1) { emitPartialCapacityOutput(thread, turnId); send({ @@ -650,6 +659,17 @@ function startTurn(params) { } } + if (/codexapp retry thread mismatch/i.test(text)) { + const retryKey = retryScenarioKey(text, 'codexapp retry thread mismatch'); + const attempts = (thread.capacityRetryAttempts.get(retryKey) || 0) + 1; + thread.capacityRetryAttempts.set(retryKey, attempts); + if (attempts === 1) { + resumeMismatchThreads.add(thread.id); + emitCapacityError(thread, turnId); + return { turn: { id: turnId, status: 'running', items: [] } }; + } + } + if (/collaboration/i.test(text)) { completeTurn(thread, turnId, `collaboration mode: ${collaborationSummary(params)}`); return { turn: { id: turnId, status: 'running', items: [] } }; @@ -787,6 +807,11 @@ function handleRequest(message) { return; } if (method === 'thread/resume') { + if (params.threadId && resumeMismatchThreads.delete(params.threadId)) { + const thread = ensureThread(null, params); + send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } }); + return; + } const thread = ensureThread(params.threadId, params); send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } }); return; diff --git a/scripts/regression.js b/scripts/regression.js index d9f597b..502dcae 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -1311,17 +1311,21 @@ async function main() { /自动重试/.test(msg.message || '') ), 10000); assert(/Codex 服务暂时繁忙/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient capacity failure should announce automatic retry'); + assert(/第 1\/2 次/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App transient retry should start at attempt 1'); + assert(/从中断处继续/.test(codexAppCapacityRetryNotice.message || ''), 'Codex App retry after a started turn should announce continuation mode'); const codexAppPartialCapacityRetryNotice = await nextMessage(messages, ws, (msg) => ( msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /自动重试/.test(msg.message || '') ), 10000); assert(/第 2\/2 次/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App transient retry should continue after partial output'); + assert(/从中断处继续/.test(codexAppPartialCapacityRetryNotice.message || ''), 'Codex App partial-output retry should stay in continuation mode'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000); const storedCodexAppAfterCapacityRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); const codexAppCapacityRetryUsers = storedCodexAppAfterCapacityRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryText); assert(codexAppCapacityRetryUsers.length === 1, 'Codex App transient retry should not duplicate the user message'); assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /codexapp capacity retry prompt/.test(String(message.content || ''))), 'Codex App transient retry should persist the successful assistant response'); + assert(storedCodexAppAfterCapacityRetry.messages.some((message) => message.role === 'assistant' && /继续上一轮/.test(String(message.content || ''))), 'Codex App transient retry should ask the model to continue instead of replaying the original prompt'); const codexAppReconnectRetryText = 'codexapp reconnect retry prompt'; ws.send(JSON.stringify({ type: 'message', text: codexAppReconnectRetryText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); @@ -1331,11 +1335,40 @@ async function main() { /自动重试/.test(msg.message || '') ), 10000); assert(/Codex 服务暂时繁忙/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect failure should announce automatic retry'); + assert(/第 1\/2 次/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App retry counter should reset after the previous retry succeeds'); + assert(/从中断处继续/.test(codexAppReconnectRetryNotice.message || ''), 'Codex App reconnect retry after a started turn should announce continuation mode'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000); const storedCodexAppAfterReconnectRetry = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); const codexAppReconnectRetryUsers = storedCodexAppAfterReconnectRetry.messages.filter((message) => message.role === 'user' && message.content === codexAppReconnectRetryText); assert(codexAppReconnectRetryUsers.length === 1, 'Codex App reconnect retry should not duplicate the user message'); assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /codexapp reconnect retry prompt/.test(String(message.content || ''))), 'Codex App reconnect retry should persist the successful assistant response'); + assert(storedCodexAppAfterReconnectRetry.messages.some((message) => message.role === 'assistant' && /继续上一轮/.test(String(message.content || ''))), 'Codex App reconnect retry should continue the interrupted turn instead of replaying the original prompt'); + + const codexAppThreadBeforeMismatch = storedCodexAppAfterReconnectRetry.codexAppThreadId; + assert(codexAppThreadBeforeMismatch, 'Codex App retry mismatch regression needs an existing app-server thread'); + const codexAppRetryMismatchText = 'codexapp retry thread mismatch prompt'; + ws.send(JSON.stringify({ type: 'message', text: codexAppRetryMismatchText, sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); + const codexAppRetryMismatchNotice = await nextMessage(messages, ws, (msg) => ( + msg.type === 'system_message' && + msg.sessionId === codexAppSession.sessionId && + /自动重试/.test(msg.message || '') + ), 10000); + assert(/Codex 服务暂时繁忙/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App thread mismatch retry should first announce automatic retry'); + assert(/第 1\/2 次/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App retry counter should reset for the next independent retryable turn'); + assert(/从中断处继续/.test(codexAppRetryMismatchNotice.message || ''), 'Codex App thread mismatch retry should also be a continuation retry'); + const codexAppRetryMismatchError = await nextMessage(messages, ws, (msg) => ( + msg.type === 'error' && + msg.sessionId === codexAppSession.sessionId && + /不同线程/.test(msg.message || '') && + /上下文丢失/.test(msg.message || '') + ), 20000); + assert(/已停止/.test(codexAppRetryMismatchError.message || ''), 'Codex App retry should stop when resume returns a different thread'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 20000); + const storedCodexAppAfterRetryMismatch = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); + assert(storedCodexAppAfterRetryMismatch.codexAppThreadId === codexAppThreadBeforeMismatch, 'Codex App retry mismatch must not replace the persisted app-server thread id'); + const codexAppRetryMismatchUsers = storedCodexAppAfterRetryMismatch.messages.filter((message) => message.role === 'user' && message.content === codexAppRetryMismatchText); + assert(codexAppRetryMismatchUsers.length === 1, 'Codex App retry mismatch should not duplicate the user message'); + assert(!storedCodexAppAfterRetryMismatch.messages.some((message) => message.role === 'assistant' && /codexapp retry thread mismatch prompt/.test(String(message.content || ''))), 'Codex App retry mismatch should not persist a successful assistant response on the wrong thread'); ws.send(JSON.stringify({ type: 'message', text: '/goal improve benchmark coverage', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppGoalSet = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || '')); diff --git a/server.js b/server.js index 1511bf2..9b42e4e 100644 --- a/server.js +++ b/server.js @@ -4689,6 +4689,77 @@ function hasRuntimeOutput(entry) { return Array.isArray(entry.toolCalls) && entry.toolCalls.length > 0; } +function retryTailText(value, maxChars) { + const text = String(value || '').trim(); + if (!text || text.length <= maxChars) return text; + const marker = '[cc-web: 前文过长,下面仅保留尾部]\n'; + return `${marker}${text.slice(Math.max(0, text.length - Math.max(0, maxChars - marker.length)))}`; +} + +function retryPreviewValue(value, maxChars) { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return retryTailText(value, maxChars); + try { + return retryTailText(JSON.stringify(sanitizePersistValue(value, { + maxString: maxChars, + maxDepth: 4, + maxArray: 20, + maxKeys: 40, + }), null, 2), maxChars); + } catch { + return retryTailText(String(value), maxChars); + } +} + +function buildCodexRetryToolSummary(toolCalls) { + const list = Array.isArray(toolCalls) ? toolCalls.filter(Boolean).slice(-8) : []; + if (list.length === 0) return ''; + return list.map((tool, index) => { + const name = tool.name || tool.kind || tool.id || `tool-${index + 1}`; + const status = tool.done ? 'done' : (tool.status || tool.meta?.status || 'inProgress'); + const lines = [`${index + 1}. ${name} (${status})`]; + if (tool.input !== undefined && tool.input !== null) { + lines.push(` input: ${retryPreviewValue(tool.input, 1200)}`); + } + if (tool.result !== undefined && tool.result !== null) { + lines.push(` result: ${retryPreviewValue(tool.result, 2400)}`); + } + return lines.join('\n'); + }).join('\n'); +} + +function shouldUseCodexAppContinuationRetry(entry) { + return (entry?.agent || '') === 'codexapp' && !!(entry.turnId || hasRuntimeOutput(entry)); +} + +function buildCodexAppContinuationRetryText(entry, retryRequest, rawError) { + const original = retryPreviewValue( + retryRequest.originalRuntimeText || retryRequest.runtimeText || retryRequest.originalText || retryRequest.text || '', + 5000, + ); + const partialText = retryPreviewValue(entry.fullText || '', 7000); + const toolSummary = buildCodexRetryToolSummary(entry.toolCalls || []); + const errorText = retryPreviewValue(rawError || entry.lastError || '', 1200); + const parts = [ + '继续上一轮被临时服务或网络错误中断的 Codex App 任务。', + '不要从头重做,不要重复已经完成的命令、工具调用或文件修改;请基于现有线程上下文和下面 cc-web 已观察到的中断前状态继续执行。不要在回复中复述这段内部重试说明。', + ]; + if (original) { + parts.push(`原始用户请求(仅用于理解目标,不要当成新请求从头执行):\n${original}`); + } + if (partialText) { + parts.push(`cc-web 已观察到的中断前助手输出(尾部):\n${partialText}`); + } + if (toolSummary) { + parts.push(`cc-web 已观察到的工具/执行摘要:\n${toolSummary}`); + } + if (errorText) { + parts.push(`中断原因:\n${errorText}`); + } + parts.push('请从中断处继续完成剩余工作。'); + return parts.filter(Boolean).join('\n\n'); +} + function getCodexRetryConfig() { return normalizeCodexRetryConfig(loadCodexConfig().retry); } @@ -4723,7 +4794,7 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) { cancelCodexCapacityRetry(sessionId); return false; } - const attempts = (previous?.attempts || 0) + 1; + const attempts = (previous?.attempts || entry.codexRetry?.attempt || 0) + 1; if (retryConfig.mode === 'limited' && attempts > retryConfig.maxAttempts) { cancelCodexCapacityRetry(sessionId); return false; @@ -4731,14 +4802,32 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) { const delayMs = codexTransientRetryDelayMs(retryConfig); if (previous?.timer) clearTimeout(previous.timer); + const expectedThreadId = retryRequest.expectedThreadId + || entry.expectedThreadId + || entry.codexRetry?.expectedThreadId + || entry.threadId + || previous?.expectedThreadId + || null; + const originalText = retryRequest.originalText || retryRequest.text || retryRequest.runtimeText || ''; + const originalRuntimeText = retryRequest.originalRuntimeText || retryRequest.runtimeText || retryRequest.text || ''; + const useContinuationRetry = shouldUseCodexAppContinuationRetry(entry); + const continuationText = useContinuationRetry + ? buildCodexAppContinuationRetryText(entry, retryRequest, rawError) + : ''; + const retryRuntimeText = continuationText || originalRuntimeText || originalText; + const retryText = retryRuntimeText || originalText; const retry = { - text: retryRequest.text || retryRequest.runtimeText || '', - runtimeText: retryRequest.runtimeText || retryRequest.text || '', + text: retryText, + runtimeText: retryRuntimeText, + originalText, + originalRuntimeText, mode: retryRequest.mode || 'yolo', agent: retryRequest.agent || entry.agent || 'codex', - attachments: Array.isArray(retryRequest.attachments) ? retryRequest.attachments : [], + attachments: useContinuationRetry ? [] : (Array.isArray(retryRequest.attachments) ? retryRequest.attachments : []), mcpContext: retryRequest.mcpContext || {}, + expectedThreadId, + useContinuationRetry, attempts, retryMode: retryConfig.mode, timer: null, @@ -4756,17 +4845,22 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) { return; } if (activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId)) { + pendingCodexCapacityRetries.delete(sessionId); plog('WARN', 'codex_capacity_retry_skipped_busy', { sessionId: sessionId.slice(0, 8), attempt: latest.attempts, }); + if (latest.ws && latest.ws.readyState === 1) sendSessionList(latest.ws); return; } + pendingCodexCapacityRetries.delete(sessionId); const ws = latest.ws && latest.ws.readyState === 1 ? latest.ws : null; plog('INFO', 'codex_capacity_retry_start', { sessionId: sessionId.slice(0, 8), attempt: latest.attempts, + expectedThreadId: latest.expectedThreadId ? String(latest.expectedThreadId).slice(0, 24) : null, + continuation: !!latest.useContinuationRetry, }); handleMessage(ws, { type: 'message', @@ -4779,6 +4873,17 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) { hideInHistory: true, runtimeText: latest.runtimeText, mcpContext: latest.mcpContext, + codexRetry: latest.agent === 'codexapp' + ? { + isAutoRetry: true, + attempt: latest.attempts, + retryMode: latest.retryMode, + expectedThreadId: latest.expectedThreadId || null, + originalText: latest.originalText || latest.text || '', + originalRuntimeText: latest.originalRuntimeText || latest.runtimeText || '', + useContinuationRetry: !!latest.useContinuationRetry, + } + : null, skipPendingCrossConversationFlush: true, }); }, delayMs); @@ -4790,16 +4895,19 @@ function scheduleCodexCapacityRetry(sessionId, entry, rawError) { maxAttempts: retryConfig.mode === 'limited' ? retryConfig.maxAttempts : null, retryMode: retryConfig.mode, delayMs, + expectedThreadId: expectedThreadId ? String(expectedThreadId).slice(0, 24) : null, + continuation: useContinuationRetry, error: String(rawError || '').slice(0, 300), }); if (entry.ws) { const attemptText = retryConfig.mode === 'forever' ? `第 ${attempts} 次` : `第 ${attempts}/${retryConfig.maxAttempts} 次`; + const continuationText = useContinuationRetry ? ',将从中断处继续' : ''; wsSend(entry.ws, { type: 'system_message', sessionId, - message: `Codex 服务暂时繁忙,${retryConfig.intervalSeconds} 秒后自动重试(${attemptText})。`, + message: `Codex 服务暂时繁忙,${retryConfig.intervalSeconds} 秒后自动重试(${attemptText}${continuationText})。`, }); } return true; @@ -6869,6 +6977,7 @@ function handleMessage(ws, msg, options = {}) { return handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachments, { mcpContext: options.mcpContext || {}, crossConversation: options.crossConversation || null, + codexRetry: options.codexRetry || null, }); } @@ -8236,6 +8345,19 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment return { ok: false, code: 'empty_message', message: '消息内容不能为空。' }; } + const codexRetry = options.codexRetry && typeof options.codexRetry === 'object' + ? { + isAutoRetry: !!options.codexRetry.isAutoRetry, + attempt: Number.isFinite(Number(options.codexRetry.attempt)) ? Number(options.codexRetry.attempt) : null, + retryMode: options.codexRetry.retryMode || null, + expectedThreadId: options.codexRetry.expectedThreadId || null, + originalText: options.codexRetry.originalText || null, + originalRuntimeText: options.codexRetry.originalRuntimeText || null, + useContinuationRetry: !!options.codexRetry.useContinuationRetry, + } + : null; + const currentThreadId = getRuntimeSessionId(session); + const expectedThreadId = codexRetry?.expectedThreadId || currentThreadId || null; const retryAttachments = resolvedAttachments.map((attachment) => ({ id: attachment.id, kind: 'image', @@ -8250,24 +8372,30 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment ws, agent: 'codexapp', cwd: session.cwd || getDefaultSessionCwd(), - threadId: getRuntimeSessionId(session), + threadId: expectedThreadId, + expectedThreadId, turnId: null, fullText: '', toolCalls: [], toolOutputDeltas: new Map(), agentMessageItems: new Map(), mcpContext: options.mcpContext || {}, + codexRetry, lastUsage: null, lastError: null, errorSent: false, crossConversationReplyRequestId: options.crossConversation?.replyRequestId || null, retryRequest: { - text: runtimeTextValue, - runtimeText: runtimeTextValue, + text: codexRetry?.originalText || runtimeTextValue, + runtimeText: codexRetry?.originalRuntimeText || runtimeTextValue, + originalText: codexRetry?.originalText || runtimeTextValue, + originalRuntimeText: codexRetry?.originalRuntimeText || runtimeTextValue, + lastRetryText: runtimeTextValue, mode: session.permissionMode || 'yolo', agent: 'codexapp', attachments: retryAttachments, mcpContext: options.mcpContext || {}, + expectedThreadId, }, clientUserMessageId: crypto.randomUUID(), startedAt: new Date().toISOString(), @@ -8293,11 +8421,33 @@ async function startCodexAppTurn(sessionId, input) { const client = clientResult.client; await client.start(); - let threadId = getRuntimeSessionId(session); + const currentThreadId = getRuntimeSessionId(session); + const expectedThreadId = entry.expectedThreadId + || entry.codexRetry?.expectedThreadId + || entry.retryRequest?.expectedThreadId + || entry.threadId + || currentThreadId + || null; + let threadId = expectedThreadId || currentThreadId; const threadParams = codexAppThreadParams(session, { mcpContext: entry.mcpContext || {} }); if (threadId) { - const resumed = await client.request('thread/resume', { ...threadParams, threadId }, 60000); - threadId = resumed?.thread?.id || threadId; + const requestedThreadId = threadId; + const resumed = await client.request('thread/resume', { ...threadParams, threadId: requestedThreadId }, 60000); + const resumedThreadId = resumed?.thread?.id || requestedThreadId; + if (expectedThreadId && resumedThreadId !== expectedThreadId) { + const expectedShort = String(expectedThreadId).slice(0, 24); + const actualShort = String(resumedThreadId).slice(0, 24); + plog('WARN', 'codex_app_thread_resume_mismatch', { + sessionId: sessionId.slice(0, 8), + expectedThreadId: expectedShort, + actualThreadId: actualShort, + autoRetry: !!entry.codexRetry?.isAutoRetry, + retryAttempt: entry.codexRetry?.attempt || null, + }); + const prefix = entry.codexRetry?.isAutoRetry ? 'Codex App 自动重试' : 'Codex App'; + throw new Error(`${prefix}恢复到不同线程,已停止以避免上下文丢失(期望 ${expectedShort},实际 ${actualShort})。`); + } + threadId = resumedThreadId; } else { const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000); threadId = started?.thread?.id || null;