OnWorks Agent 任务原理

基于 OnWorks work_desk 封装整理。Codex CLI 的请求结构、工具循环、rollout、沙盒和原生压缩见 onworks 和 codex 对比.html。

1. 总链路

浏览器 query
  ↓ POST /tasks
OnWorks FastAPI
  ├─ MySQL: sessions / tasks / task_files
  ├─ 后台线程 _run_task
  ├─ 组装 stdin prompt
  ↓
codex exec --json --ephemeral
  ├─ 组装 Responses API 请求
  ↓
LLM provider(SSE)
  ↓
Codex agent loop(执行本地工具)
  ↓ stdout JSONL
OnWorks FastAPI
  ├─ MySQL: tasks.steps / tasks.result / task_files / usage_records
  ↓
浏览器 GET /tasks?sessionId=...

OnWorks 不使用 codex exec resume,也不保存 thread_id。 每个任务轮次都是短命进程。文本连续性由 OnWorks 数据库历史拼回新 prompt;文件连续性由同一会话共享的 scratch 目录保留。

2. 完整测试任务

假设用户在会话中发送:

sessionId = s-123
requirement = 把 sales.xlsx 按月份汇总,生成 summary.xlsx
files = [sales.xlsx]

2.1 创建任务:MySQL 初始写入

POST /tasks 先保存原始请求,再启动后台线程:

-- tasks
id           = t-456
user_id      = u-001
session_id   = s-123
title        = 把 sales.xlsx 按月份汇总,生成 summary.xlsx
requirement  = 把 sales.xlsx 按月份汇总,生成 summary.xlsx
status       = processing
progress     =
steps        =
result       =
error        =
model        = <active model>

-- task_files
id            = f-input-1
task_id       = t-456
filename      = sales.xlsx
stored_name   = 9f8e7d6c.xlsx
content_type  = application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
kind          = spreadsheet
size          = 20480
role          = input

原始文件保存在磁盘任务目录,task_files.stored_name 只保存文件名映射。此时还没有 token 计费。

2.2 OnWorks 组装给 Codex 的 stdin prompt

_run_task() 读取同会话聊天消息和已完成任务,排除当前任务,转成 Markdown transcript。下面是本轮 stdin 的完整形状:

你是 onworks AI 助手——这个工作台自带的 AI 助手。用户无论是在「文本」里和你对话,还是把一份长程任务交给你执行,你都是同一个 onworks AI 助手:始终以这个身份回答,不要用别的名字介绍自己,也不要提及背后的模型、厂商或技术细节;被问到你是谁时,就说明你是 onworks AI 助手。回答直接、具体、可用:先给结论或结果,再给必要的说明;资料里没有的、你也不确定的内容就直说,不要编造数据、来源或引用。使用与用户相同的语言作答。

# 对话历史(同一对话中此前的轮次,最近的在后)

## 用户
上周我们讨论过销售报告口径,月度汇总用订单创建日期。

## 助手
好的,后续报告按订单创建日期统计月份。

# 任务要求
把 sales.xlsx 按月份汇总,生成 summary.xlsx

# 附件资料(已放在当前工作目录)
1. sales.xlsx(Excel,20480 字节)→ ./attachments/t456ab12/01-sales.xlsx
   已解析的纯文本:./attachments/t456ab12/01-sales.xlsx.txt

# 交付要求
- 需要计算、核对或转换时,直接用当前目录下的文件与 shell 工具完成,不要凭印象估算。
- 最终回复用 Markdown 直接给出可交付内容,不要复述任务本身。
- 回复里不要出现任何文件路径(不要写 output/… 或 work/…):用户不需要知道文件存在哪里,系统会在回复下方自动列出交付文件供预览和下载。你只需在回复里说明做了什么和结果要点。
- 不要修改或删除 attachments/ 目录里的原始附件。
- 如果任务要求交付文件(修改后的文档、生成的表格/代码等),把用户点名要的成品写进当前目录下的 output/ 目录,文件名沿用用户要求的名称;系统会自动把它列给用户。回复正文不要提这个路径。
- 系统 Python 已装好以下库(`import` 即可用,不要 pip install):python-docx(Word 读写)、openpyxl(Excel 读写)、weasyprint(HTML 转 PDF)。需要其他包时用 `pip3 install --break-system-packages 包名` 装到系统(所有对话共享,装一次就行),不要往当前工作目录里装——那里的包只对这一次任务可见,下次对话又得重新下载。
- 交付文件只放用户要的成品,通常就一个。同一内容的其它格式(表格旁的 .csv 核对副本)、支撑图表的数据、对照表、脚本、临时文件等一切中间产物都放进当前目录下的 work/ 目录,不要当成交付文件——成品被自己的副产物淹没,用户就得在一堆过程文件里找交付物。已经把图表页、核对副本写进 output/ 也没关系,系统会按它们的角色归入「中间产物」,照样可预览可下载,只是不列进「交付文件」。
- Markdown 不算交付文件(正文直接在最终回复里用 Markdown 给出即可):.md 不会被列为产物,草稿放在 output/ 之外。
- 需要图表或可视化时,额外产出一个自包含的 output/report.html(数据内嵌,图表库内联或走 CDN 都行):系统会把它当页面直接渲染出来,并归入「中间产物」;原始数据导出一份 csv 放进 work/ 备查,只有用户明确要一份数据文件时才放进 output/。图表由浏览器里的 JS 渲染,不需要在沙箱里安装浏览器、截图或校验渲染效果;也不要为了出图去装一堆依赖——能内联的库直接下载,其余用标准库。

# Excel 表格
- 本轮的表格附件:sales.xlsx。
- 每张表都已解析成同名的 .txt(按工作表分开,用制表符分列):数值、日期和布尔值都按 Excel 里显示的样子给出,读取内容以它为准,不要凭文件名猜。
- 解析文本若被截断,末尾会写明「本表共 N 行,此处只提供前 …」;这时用 shell 工具直接读 attachments/ 里的原文件补齐,不要用半张表做统计。
- 需要交付表格时,把成品写进 output/:用已装好的 openpyxl 直接生成,并同时导出一份 .csv 方便用户核对与二次计算(这份 .csv 只是核对副本,不是交付文件,系统会归入「中间产物」)。
- attachments/ 里的原表不要改动。
- 使用与任务要求相同的语言作答。

2.3 OnWorks 启动 Codex 的具体命令

外层还有 mount namespace 隔离,真正执行的 Codex 命令近似:

codex exec \
  --json \
  --ephemeral \
  --skip-git-repo-check \
  -C /tmp/.onworks/work \
  -s workspace-write \
  -c approval_policy="never" \
  -c sandbox_workspace_write.network_access=true \
  -m "$MODEL" \
  -

- 表示 prompt 从 stdin 读入。外层 unshare 把真实目录 backend/app/media/work/s-123/work bind 到 /tmp/.onworks/work,并把 CODEX_HOME bind 到受限视图。

2.4 文件处理发生在拼 prompt 前

是的。Agent 模式的顺序是:读取 task_files → 调 _codex_workdir() → 复制和抽取文件 → 生成 attachment notes → 调 _codex_prompt()。所以 stdin prompt 里的文件说明已经包含解析结果;Codex 看到任务时,scratch 目录里已经有原文件和 .txt 副本。

上传/创建任务:
backend/app/media/tasks/t-456/9f8e7d6c.xlsx
  ↓
_codex_workdir() 复制到会话工作目录:
backend/app/media/work/s-123/work/attachments/t456ab12/01-sales.xlsx
  ↓
documents.extract() 解析并写纯文本:
attachments/t456ab12/01-sales.xlsx.txt
  ↓
把文件路径/解析状态写进 notes
  ↓
_codex_prompt(task, notes, history)

各类型处理规则:

类型解析器抽取结果与截断
.docx读 zip 里的 Word XML,包括正文、文本框、页眉页脚提取成多行文本;.doc 旧二进制不解析。结果进入 prompt 前最多 30,000 字符,超长写「…(内容过长已截断)」。
.xlsx/.xlsm/.xltx/.xltmopenpyxl 按工作表读输出 ## 工作表名 和制表符分列的行;prompt 侧每文件最多 30,000 字符。模型仍可用 shell/openpyxl 读原文件补齐。
.xls安装了 xlrd 才解析;否则只传文件名并说明原因解析成功后同样截断到 30,000 字符。
.pdfpypdf 读文本层;没有文本层时用 pdftoppm + tesseract OCR文本层最多读 80 页,OCR 最多 40 页;结果进入 prompt 前仍受 30,000 字符截断。
.pptx读演示文稿 XML提取成多页文本;旧 .ppt 不解析。
文本/代码/CSV/JSON/HTML 等直接按文本解码同样进入 30,000 字符截断。
图片/其他二进制不解析正文只按文件名和「解析失败原因」参与 prompt。

2.5 Agent 任务涉及的 MySQL 表

按一次 POST /tasks 的完整生命周期算,Agent 任务通常涉及 7 张表。更精确地说:

阶段表动作为什么需要
鉴权users读从 JWT subject 找到当前用户,并读取 is_admin 判断是否绕过额度。
额度检查usage_records读统计今天 kind in ("chat","image") 的次数;task 本身还没写,所以要靠已有账本判断额度。
定位会话sessions读确认 sessionId 存在且属于当前用户。
创建任务tasks写保存任务要求和 processing 状态。
保存附件task_files写每份上传文件一行,role=input。
历史拼装messages读读取同会话普通聊天轮次。
历史拼装tasks读读取同会话已完成/失败任务,作为 assistant 侧历史。
执行中/结束tasks写更新 progress、steps、status、result 或 error。
保存产物task_files写把交付文件和中间产物落成 role=output/intermediate。
记账usage_records读 + 写额度检查时读;任务完成后写一条 kind=chat、ref_id=task_id 的账目。
额度被拒(异常)quota_blocks写记录一次被 429 拒绝的尝试;成功任务不写这张表。

所以可以总结成三种口径:

长历史的压缩摘要默认写在文件 media/work/<session_id>/.compaction.json,不是 MySQL 表。如果部署关闭压缩,历史超出窗口就只加省略说明。

-- sessions:对话容器,只读
id           会话 id
user_id      归属用户
project_id   可选项目 id
title        对话标题
created_at   创建时间
updated_at   更新时间

-- messages:普通聊天历史,只在拼 prompt 时读
id           消息 id
session_id   所属会话
role         user / assistant
content      消息正文
created_at   创建时间
-- tasks:一次 Agent 执行
id           任务 id
user_id      归属用户
session_id   任务挂到哪个会话;可空
title        列表/时间线标题
requirement  用户原始任务要求
status       processing / completed / failed / cancelled
progress     执行中最近一条提示,例如「排队中...」
steps        JSON 数组:UI 需要的思考/命令/写文件摘要,不是原始 JSONL
result       最终 agent_message 清洗后的 Markdown
error        失败原因
model        当时使用的模型
created_at   创建时间
updated_at   状态变更时间
-- task_files:输入附件 + 输出产物 + 中间产物
id            文件行 id
task_id       所属任务
filename      展示文件名
stored_name   磁盘上的随机文件名;不暴露原始路径
content_type  MIME 类型
kind          word / pdf / other;注意 Excel 是 other
size          字节数
role          input / output / intermediate
created_at    入库时间
-- usage_records:计费/额度账本
id            账目 id
user_id       归属用户
kind          chat / image / internal
model         模型名
input_tokens  输入 token
output_tokens 输出 token
quantity      默认 1
amount        计算后的金额
ref_id        回链到任务 id
created_at    记账时间
-- quota_blocks:额度拒绝埋点,正常成功任务不写
id           拒绝记录 id
user_id      被拒用户
surface      task / chat / image
created_at   时间

关键点:task_files.kind 只有三类,不细分 Excel;Excel/CSV/PPT 等靠 filename 扩展名识别。role 才决定它出现在输入附件、交付文件还是中间产物。

3. Prompt 位置与缓存

当前 OnWorks 把任务所需的业务内容拼成一份 stdin prompt。Codex CLI 会把它作为 Responses API input 里的最后一条 user message;Codex 自己的开发者上下文和环境上下文在前,OnWorks 的业务信息不在外层 instructions 里。

值得优化,但不一定值得立刻改。如果把身份、输出格式、交付规则这类真正静态的内容放到稳定的 developer instructions,并把动态历史、当前 query、附件注记放在后面的 user prompt,静态前缀更容易形成稳定 cache 前缀。历史和 query 不应该放进 instructions,否则它们会破坏这个前缀,还会混淆“固定规则”和“本次输入”。

当前实现里,persona.BASE 是静态前缀,但后面紧跟动态对话历史;交付要求又出现在动态内容之后。也就是说,较长的静态规则没有处在完整 prompt 的稳定前缀位置。要优化时,应该把静态规则放在最前,动态内容放最后;同时不要把长文档塞进 instructions。更稳妥的做法是用 codex exec 的真实请求和 cached_input_tokens 做对比后再改。

4. 任务标题与 query

tasks.requirement 来自前端创建任务时提交的表单字段 requirement(也就是用户在任务输入框里填的 query)。FastAPI 的 POST /tasks 用 requirement: str = Form("") 接收,校验不超过 50,000 字符后,保存的是 requirement.strip()。所以它是本轮用户原始任务要求的完整正文,不是从标题、附件或模型推导出来的。

tasks.title 是列表/时间线的展示标题,不是模型生成的。创建任务时,_make_title() 取 requirement 的第一个非空行,最多截断 60 个字符;如果 requirement 为空,就取第一个上传附件的文件名,也最多 60 个字符;仍然没有内容时使用「未命名任务」。

requirement = "把 sales.xlsx 按月份汇总\n顺便生成图表"
title       = "把 sales.xlsx 按月份汇总"

完整 query 不等于标题。tasks.requirement 保存完整的用户任务要求,上限是 MAX_MESSAGE_CHARS = 50,000 字符;很短的 query 会保存原文,很长的 query 也保存原文,只是 tasks.title 另外截成 60 字符。如果 query 超过 50,000 字符,创建接口先返回 413,不会落库执行。

5. 文件读取与裁剪

OnWorks 有两条路径,裁剪规则不同:

路径处理
普通 LLM 任务调用 complete_llm();每个解析文件最多 ONWORKS_PER_FILE_CHARS(默认 30,000)字符,所有附件合计另受 ONWORKS_ATTACH_CHARS(默认 120,000)字符限制。模型只看到解析文本,看不到原文件。
Codex Agent 任务每个解析成功的附件生成一个 .txt 旁路文件,单个解析文本最多 30,000 字符;没有叠加 120,000 总限制。原附件也复制进会话 scratch 目录,Agent 可用工具读取原文件补齐。

所以「30,000 字符」不是只在解析失败后才生效。只要 Codex 路径成功提取了正文,就会先截断这 30,000 字符作为快捷上下文。解析失败时,OnWorks 不让任务失败,而是在附件注记中写明原因,并提示 Agent 用 shell、Python 等工具自行读取原文件。历史也有独立预算:Agent 历史 ONWORKS_AGENT_HISTORY_CHARS 默认 80,000 字符,单轮历史最多 6,000 字符。

6. stdout JSONL 的接收与存储

普通 Codex 会话可以写本地 rollout 文件,codex exec --json 也会同时向 stdout 输出事件。但 OnWorks 使用 --ephemeral,含义就是“不把 session 文件持久化到磁盘”。OnWorks 也不打开、不解析本地 rollout 文件,只读取子进程的 stdout JSONL。

用户看到的不是原始事件流。浏览器轮询拿到的是 API DTO;任务失败或取消时也只看到 tasks.error 或状态,不给原始 stdout。

7. 响应字段与数据库字段

更准确地说:result、steps 是 tasks 表上的真实字段;outputs、intermediates 不是数据库字段,而是 API 序列化时按 task_files.role 过滤出来的列表。

响应字段来源含义
resulttasks.result最终 agent message 清洗后的 Markdown。
stepstasks.steps 中的 JSON 数组思考、执行命令、写入文件等 UI 摘要;不是完整 agent 轨迹。
outputstask_files.role = "output" 的行用户要的交付文件。
intermediatestask_files.role = "intermediate" 的行脚本、图表、核对副本等中间产物。
filestask_files.role = "input" 的行本轮上传附件的元数据。

它们不是在任务运行中实时更新。三个角色分别在三个时点产生:

MySQL 行只保存元数据;文件内容在磁盘。下面是一次任务结束后的例子:

-- task_files
id            = f-in-1
task_id       = t-456
filename      = sales.xlsx
stored_name   = 8f1a2b3c4d5e6f70.xlsx
content_type  = application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
kind          = other
size          = 20480
role          = input          → API task.files

id            = f-out-1
task_id       = t-456
filename      = summary.xlsx
stored_name   = 1a2b3c4d5e6f7081.xlsx
content_type  = application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
kind          = other
size          = 12288
role          = output         → API task.outputs

id            = f-mid-1
task_id       = t-456
filename      = report.html
stored_name   = 5e6f7a8b9c0d1e2f.html
content_type  = text/html
kind          = other
size          = 45056
role          = intermediate   → API task.intermediates

-- 对应磁盘
backend/app/media/tasks/t-456/8f1a2b3c4d5e6f70.xlsx
backend/app/media/tasks/t-456/1a2b3c4d5e6f7081.xlsx
backend/app/media/tasks/t-456/5e6f7a8b9c0d1e2f.html

浏览器先轮询 GET /tasks 或 GET /tasks/{id} 拿到列表;点击文件卡时才请求下载/预览端点,后端按 task_id + file_id 查行,再把 stored_name 映射到磁盘文件返回。