@tasklens/protocol
v0.1.0
Published
TaskLens 事件协议:类型定义、运行时校验、事件流工具与状态派生(零依赖,框架无关)
Maintainers
Readme
@tasklens/protocol
TaskLens 事件协议。 Agent 的执行过程(计划 / 步骤 / 工具调用 / 人工审批 / 重试)在这里被定义成一份只追加的事件流,界面上看到的一切都由它派生。
零运行时依赖、纯 TypeScript、Node 与浏览器双端可用。框架无关 —— 这是整个 TaskLens 唯一的"真相来源"。
pnpm add @tasklens/protocol事件信封(必填)
每条事件都是一个 JSON 对象,下面 6 个字段必填,缺任何一个都会被判定为畸形事件:
{
id: 'e_7', // 全局唯一,去重键
runId: 'run_abc', // 一次执行
seq: 7, // 单调递增,排序键(不是 at)
at: 1735689600000, // 时间戳,仅用于展示
schemaVersion: 1, // 协议版本,当前 1
type: 'tool:call', // 事件类型
// …该类型的载荷
}
seq是排序键,不是at。 多进程 / 跨机器的墙上时钟不可信,只按时间戳排序会让时间线错乱。 用createRun()(@tasklens/core)时这些字段全部自动生成,你不需要手写。
不要手写事件——用宽容解析
真实场景里,"手写一份 JSON"几乎一定会漏掉信封字段。与其让用户先读懂规范,
不如在入口处补全(协议本身不放宽:去重靠 id、排序靠 seq、审计靠 at,这些不能丢):
import { toRunEvents } from '@tasklens/protocol'
// 只有 type 和业务字段 —— 完全不合规,但这就是手写日志的真实样子
const handwritten = [
{ type: 'run:start', goal: '给 20 位未激活用户发激活邮件' },
{ type: 'plan', steps: ['查询未激活用户', '生成邮件草稿', '发送'] },
{ type: 'step:start', stepId: 'step_1' },
{ type: 'tool:call', stepId: 'step_1', name: 'db.query', args: { table: 'users' } },
{ type: 'tool:result', ok: true, result: { rows: 20 }, ms: 41 },
{ type: 'step:done', stepId: 'step_1' },
]
const { events, renderable, malformed, issues, meta } = toRunEvents(handwritten)
// events 校验通过的事件(可直接派生出完整面板状态)
// renderable 给面板用的完整列表:合规事件 + 被包成合法信封的坏行,**保持输入顺序**
// malformed 修不好的原始行(原样保留,不丢)
// issues 我改了你哪些地方 —— 别丢,见下面「透明」一节
// meta 行数统计、runId 列表、解析出的 goal它做四件事:
| 步骤 | 内容 |
| --- | --- |
| 拆包 | 数组 / { events: [...] } / 单条对象都能进 |
| 补信封 | id runId seq at schemaVersion 缺哪个补哪个 |
| 修载荷 | 只做无歧义推断,见下表 |
| 报告 | 每处改动进 issues;修不好的行降级保留 |
自动推断的全部清单(每一项都无歧义,不涉及语义猜测):
| 推断 | 依据 |
| --- | --- |
| tool_call / toolCall / ToolCall → tool:call | 命名风格归一 |
| step_end / step_finish → step:done | 少量固定别名 |
| plan.steps 传字符串数组 → { id, title } | 补 step_N 作为 id |
| tool:result 缺 callId | 与最近一次未配对的 tool:call 配对 |
| tool:result 缺 ok | 有 error 就是 false,否则 true |
| tool:call / approval:request 缺 stepId | 归给唯一"进行中"的步骤;否则不猜 |
| approval:decision 缺 approvalId | 与待决策的那条审批配对 |
| decision: 'approved' / 'ok' / 'denied' … | 同义词收敛到 approve / edit / reject |
| approval:request 缺 risk / summary | 补中性值 medium / 取同义字段(title message reason) |
| step:error 缺 retryable | 补 false("不自动重试"是安全的一侧) |
| run:paused 缺 reason | 有待决策审批就是 approval,否则 user |
| 整份日志没有 run:start | 补一条,面板才有运行头部(可用 synthesizeRunStart: false 关掉) |
| 部分行缺 seq | 整体按数组顺序重编号;全部都有 seq 时一个都不动(审计保真) |
| id 撞车但内容不同 | 重新分配;内容完全相同的重复投递则保留(交给去重层) |
不猜 —— 这是最重要的设计约束
宁可让用户看到一条折叠的原始 JSON,也不要让面板显示一个"看起来正常、其实是编出来的"执行过程。
需要语义判断的地方一律不补:不知道工具名就不补 name、不知道属于哪一步就不补 stepId、
decision: 'maybe' 就老老实实降级。修不好的行仍然带合法信封(所以能进时间线被折叠展示),
但永远不会被当成正常事件执行。
透明:改了就要说
issues 里每一条都是「哪一行、改了什么、为什么」,level 分四级:
issues.filter((i) => i.level === 'fixed') // 已自动补全
issues.filter((i) => i.level === 'degraded') // 修不好,降级保留
issues.filter((i) => i.level === 'dropped') // 整行无法解析,已丢弃
issues.filter((i) => i.level === 'info') // 只是告知(未知类型会折叠等)别把 issues 扔掉。 静默补全是最危险的一种"好用":
用户看到面板一切正常,就永远不知道自己那份日志本来是有问题的,
下次还会写出同样的错。<RunReplay> 会把改动直接显示在面板上方。
生成合规样例数据
想造一份完全合规的事件流(演示 / 测试 / 文档用),用内置生成器:
import { createMockRun } from '@tasklens/protocol'
const events = createMockRun({
goal: '给 20 位未激活用户发激活邮件',
stepCount: 4,
withApproval: true,
approvalOutcome: 'pending', // 事件流停在"等待审批",模拟一条正在进行的 run
withFailure: true, // 含一个可重试的失败步骤
withUnknownEvent: false, // 含一条未知类型事件(验证前向兼容折叠)
})校验:不抛错、不丢弃
import { splitEvents, normalize, deriveRunState, deriveRunStateFromUnknown } from '@tasklens/protocol'
const { events, malformed, ok } = splitEvents(rawInputFromAnywhere)
// events 通过校验的事件(含未知类型,前向兼容)
// malformed 校验失败的事件 —— 原样保留,可以退化成原始 JSON 展示
// ok 是否全部通过
const state = deriveRunState(normalize(events)) // normalize = 按 seq 排序 + 按 id 去重
// 连校验都懒得管、只想让它别炸的时候:
const state2 = deriveRunStateFromUnknown(rawInputFromAnywhere)这是这套协议的核心承诺:脏数据不会让面板白屏。缺字段的事件退化成一行原始 JSON,未知类型折叠成"未知事件",而不是抛异常。
事件类型
| type | 说明 |
| --- | --- |
| run:start / run:done / run:error / run:paused | 执行生命周期 |
| plan | 步骤计划(用于渲染"还剩几步") |
| step:start / step:progress / step:done / step:error | 步骤状态,error 带 retryable |
| tool:call / tool:result | 工具调用与结果(含 ms / tokens / cost) |
| approval:request / approval:decision | 人工介入(summary + 结构化 preview + risk) |
| retry:request | 步骤级重试锚点(fromEventId) |
主要 API
// 校验
validateEvent(v) / validateEnvelope(v) / validateEventPayload(v) // → issues[] / EventValidation
isValidEvent(x) / isEnvelope(x) / assertEvent(x)
splitEvents(unknown) // → { events, malformed, ok }
isKnownEventType(t) / RUN_EVENT_TYPES / APPROVAL_EVENT_TYPES
// 宽容解析(档 0:手写 JSON 直接出面板)
toRunEvents(input, options?) // → { events, renderable, malformed, issues, meta }
isCoerceResult(value) // 判定是不是上面那个结果(组件用它避免二次解析)
// 事件流工具(全部纯函数,不改原数组)
sortBySeq · dedupeById · normalize · appendEvent · appendEvents
filterByRun · sinceSeq · upToEventId · latestSeq · compareBySeq
// 状态派生
deriveRunState(events) // → RunState
deriveRunStateFromUnknown(unknown) // 不校验直接派生
createDeriver() // 增量推导(大事件流用,避免每次全量重算)
hasPendingApproval(state, stepId) · RISK_WEIGHT
// 测试与演示
createMockRun(options) · createMockEvents() · createMockEvent() · scenarios · getScenario可选:zod 集成
zod 是可选的 peerDependency,只有用到时才需要装:
import { … } from '@tasklens/protocol/zod'几条硬规则
| 规则 | 原因 |
| --- | --- |
| seq 是排序键,at 只用于展示 | 时钟不可信 |
| 按 id 去重 | SSE 断线重连、Last-Event-ID 续传一定会重复推事件 |
| 协议里没有 HTML、没有模型厂商字段 | args / result 是 unknown,渲染交给使用方 |
| 未知事件类型只折叠、不崩 | 旧前端遇新事件、新前端遇旧事件都不该炸 |
| 同一主版本内只允许新增可选字段 | 更高主版本按未知事件处理 |
| approval:* 是一等公民 | 审批不是"工具调用的特例" |
许可
MIT © 李学丹 · 仓库与完整文档
