docs(spikes): OpenWorkflow and Pi SDK compatibility spikes + ADR-002/003 (W0-B)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
8b90d396b2
commit
ec023a8405
16 files changed
+2884
No files matched your search
@@ -0,0 +1,112 @@
|
||||
# ADR-002:持久任务引擎采用 OpenWorkflow 0.10.1(锁版本),封装在 `TaskEngine` 端口之后
|
||||
|
||||
- 状态:已采纳(2026-09-27,W0-B spike 后)
|
||||
- 关联:REQ §11、docs/plan/00-first-version-plan.md §1 与 §3.1-8、验证记录 `docs/spikes/w0b-openworkflow.md`、代码 `spikes/openworkflow/`
|
||||
|
||||
## 决定
|
||||
|
||||
v0.1 采用 **`[email protected]`(精确锁版本)+ `postgres@^3.4.9`**,表放在同库独立 schema `openworkflow`。所有调用都经过 `packages/tasks` 的 `TaskEngine` 端口,业务代码不 import `openworkflow`。**业务状态不放在引擎里**:引擎只负责"过程推进、定时唤醒、重试、租约";下载任务状态、授权、操作记录都在业务表里。
|
||||
|
||||
暂不实现自研租约任务表。下文附录给出备选设计,只在触发复议条件时启用(REQ §11.1:"只保留清晰的执行接口,不提前实现第二套引擎")。
|
||||
|
||||
## 验证结论摘要
|
||||
|
||||
| 项 | 结果 | 对接入的约束 |
|
||||
| --- | --- | --- |
|
||||
| (a) 表初始化 | `BackendPostgres.connect()` 自动幂等迁移 | 迁移放进统一迁移流程;启动时 `runMigrations:false` |
|
||||
| (b) kill -9 恢复 | 通过。已完成步骤不重跑,正在执行的步骤会在新 worker 上**重跑**(最长等 30s 租约到期) | 步骤体必须幂等 |
|
||||
| 租约丢失 | 引擎拒收过期 worker 的步骤结果,但**步骤体仍会执行完并产生副作用**,拿不到租约状态 | 业务写入要用操作账本做 fencing |
|
||||
| (c) 信号提前到达 | **丢失**。没有等待者时 `sendSignal` 直接返回 `[]`,不落库 | 授权、下载完成都**先写业务库**,信号只用来加速唤醒 |
|
||||
| (d) 取消 | run 变成 `canceled`,后续步骤不再执行;**进行中的步骤不中断**(没有 AbortSignal);已取消的 run 仍会"接收"信号 | 长步骤自己轮询业务侧取消标志;补偿策略显式化 |
|
||||
| (e) 重试 | 步骤级 `maximumAttempts` 生效,超过后 run 变成 `failed` | 默认 10 次偏多,按任务类型显式配置 |
|
||||
| (f) 幂等键 | 同 key 返回已有 run;**24h 窗口,不比较输入** | 幂等键由服务端按语义生成,长期去重靠业务表唯一约束 |
|
||||
| (g) 版本升级 | 旧版本 run 找不到对应 version 的 worker 时无限退避等待,不会失败;同一 version 改代码按步骤名重放,不做确定性检查 | 不兼容的改动必须升 version,worker 同时注册新旧版本 |
|
||||
| (h) 与 Kysely 共库 | 不同 schema,无冲突;引擎用自己的 postgres.js 连接池,**不能和业务写入同事务入队** | outbox |
|
||||
| (i) 双 worker 并发 | 60 个 run 执行 60 次,无重复(`SKIP LOCKED`) | — |
|
||||
| PG 重启 | 自动恢复 | — |
|
||||
|
||||
## 给 `packages/tasks` 的接入要求
|
||||
|
||||
### 1. 端口(业务只依赖它)
|
||||
|
||||
```ts
|
||||
export interface TaskEngine {
|
||||
/** 幂等:同 kind+idempotencyKey 返回同一 taskRunId。调用方负责先在业务事务里写 outbox。 */
|
||||
enqueue<K extends TaskKind>(kind: K, input: TaskInput<K>, opts: { idempotencyKey: string; availableAt?: Date }): Promise<{ taskRunId: string }>;
|
||||
/** 只做唤醒提示。等待方必须以业务库为准;信号丢了也不能影响正确性。 */
|
||||
signal(topic: SignalTopic, payload?: JsonValue): Promise<{ delivered: number }>;
|
||||
/** 停止后续步骤。不撤销已完成的外部动作,也不打断进行中的步骤。 */
|
||||
cancel(taskRunId: string, reason: string): Promise<void>;
|
||||
get(taskRunId: string): Promise<TaskRunView | null>; // 状态/attempts/error/当前步骤,只读投影
|
||||
start(opts: { roles: ("worker")[]; concurrency: number }): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`TaskKind` 对应 plan §3.1-8 的九类任务。每类任务在 `packages/tasks` 里定义 `{ name, version, retryPolicy, handler }`,handler 只能通过注入的业务服务读写 Kysely。
|
||||
|
||||
### 2. 必须遵守的模式
|
||||
|
||||
1. **Outbox 入队**:业务事务里写 `outbox_event(id, kind, idempotency_key, payload, status, attempts, next_attempt_at)`;分发器轮询 `pending` 行,调用 `enqueue`(引擎幂等键 = outbox 的 idempotency_key),成功后标记 `dispatched`。重复分发由 24h 引擎幂等和业务唯一约束兜底。
|
||||
2. **等待外部条件 = 查库 + 带超时的信号**。不要只等一个信号:
|
||||
```ts
|
||||
while (true) {
|
||||
const ok = await step.run({ name: `check-approval-${i}` }, () => approvals.findValid(planId)); // 业务库是唯一真相
|
||||
if (ok) break;
|
||||
await step.waitForSignal({ name: `wait-approval-${i}`, signal: `approval:${planId}`, timeout: "5m" }); // 信号只加速
|
||||
i++;
|
||||
}
|
||||
```
|
||||
注意:同一信号名同一时间只能有一个等待步骤;步骤名要带序号(引擎也会自动加 `:1` 之类的后缀)。每轮循环都消耗 step attempt 配额(单个 run 上限 1000),长期等待用 `timeout` 拉长间隔,例如下载完成用 10m。
|
||||
3. **副作用步骤走操作账本**(REQ §11.2):`asset_operation`/`download_task` 记录 `plan → attempt(started) → result`,步骤体开头先查账本:
|
||||
- 已有 result:直接返回,不重做;
|
||||
- 有 attempt 没有 result:先对账外部状态(qB 按 hash 或管理标签查询,文件按目标路径 + 大小/hash 核验),再决定收尾还是重做;
|
||||
- 提交业务状态时带 fencing 条件,例如 `UPDATE … WHERE id=? AND attempt_token=?`。attempt_token 在步骤开始时写入,重跑的新 attempt 会覆盖它,老 worker 的提交因此失败。
|
||||
4. **取消**:`TaskEngine.cancel` = 先写业务侧 `cancel_requested`,再调用引擎 cancel。长步骤(复制大文件)分块时检查这个标志,自己中止;补偿(暂停 qB、删临时文件)作为显式步骤或单独任务。
|
||||
5. **版本纪律**:handler 有不兼容改动时 `version` +1;worker 同时注册当前和上一个版本,直到旧版本的 run 清空(用 `listWorkflowRuns({ workflowName, status })` 查,并在 `/admin` 显示)。同版本内只允许在末尾追加步骤,或插入幂等步骤,不允许改已有步骤名。
|
||||
6. **重试策略显式化**:每类任务显式给出 `retryPolicy`。外部网络类 5 次,1s→60s;文件类 3 次。用尽后 run 变成 `failed`,由 `resolveDecision`/待处理中心接管。死信视图 = `status='failed'` 的 run 加业务上下文。
|
||||
7. **展示**:UI 的业务时间线读业务表,不读 `step_attempts`,因为里面会有停在 `running` 的孤儿行。
|
||||
8. **连接**:引擎用自己的 postgres.js 连接池(同一个 DATABASE_URL);Kysely 用 pg。两边的连接数都计入 PG `max_connections`。
|
||||
|
||||
### 3. 测试要求
|
||||
|
||||
- vitest 集成测试(testcontainers 或 docker PG):kill worker 后续跑、提前信号 + 查库路径、取消、重试上限、幂等 enqueue、双 worker。
|
||||
- 故障注入(REQ §11.1):qB 接收后断连、复制完成但结果未提交、DB 断连、重复投递、租约丢失(SIGSTOP)、运行中升级。断言对象是**业务账本和文件系统**,不是引擎 `completed`。
|
||||
|
||||
## 已知风险
|
||||
|
||||
- 0.x 版本,API 仍在变(0.1→0.10 一年内发布 25 个版本)。锁精确版本,升级前重跑 spike 用例。
|
||||
- 租约固定 30s,不可配置;worker 挂掉后最长约 30s 才会续跑。
|
||||
- 步骤体拿不到 AbortSignal 和租约状态,fencing 全靠业务层。
|
||||
- 信号不持久,同名只能有一个等待者。
|
||||
- 引擎自带的 idempotency 只有 24h。
|
||||
|
||||
## 复议条件(触发后启用附录中的自研表)
|
||||
|
||||
- 升级到新版本后恢复/重试语义回归,且短期修不好;
|
||||
- 需要与业务写入同事务入队,且 outbox 延迟不可接受;
|
||||
- 需要可配置租约或步骤级取消信号,但上游不支持。
|
||||
|
||||
## 附录:备选的自研租约任务表(本版不实现)
|
||||
|
||||
```sql
|
||||
create table task (
|
||||
id uuid primary key, kind text not null, handler_version int not null,
|
||||
idempotency_key text not null, input jsonb not null, state jsonb not null default '{}', -- 步骤游标 + 已完成步骤结果
|
||||
status text not null check (status in ('pending','running','waiting','succeeded','failed','canceled','dead')),
|
||||
attempts int not null default 0, max_attempts int not null,
|
||||
next_run_at timestamptz not null default now(), lease_owner text, lease_until timestamptz, lease_token uuid,
|
||||
wait_topic text, wait_until timestamptz, cancel_requested_at timestamptz,
|
||||
last_error jsonb, dead_letter_at timestamptz, created_at timestamptz default now(), updated_at timestamptz default now(),
|
||||
unique (kind, idempotency_key)
|
||||
);
|
||||
create index on task (status, next_run_at);
|
||||
create table task_signal (id bigserial primary key, topic text not null, payload jsonb, created_at timestamptz default now(), consumed_by uuid);
|
||||
-- 信号持久化:等待方进入 waiting 前先查未消费的 task_signal,解决"提前到达"
|
||||
-- outbox_event 同 §2.1
|
||||
```
|
||||
|
||||
- 认领:`UPDATE task SET status='running', lease_owner=$w, lease_token=gen_random_uuid(), lease_until=now()+$lease, attempts=attempts+1 WHERE id=(SELECT id FROM task WHERE status IN('pending','waiting') AND next_run_at<=now() AND cancel_requested_at IS NULL ORDER BY priority, next_run_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`。
|
||||
- 心跳:`UPDATE … SET lease_until=… WHERE id=$id AND lease_token=$tok`,影响 0 行说明租约已丢,立即中止并禁止提交。所有业务提交都带 `lease_token` 条件,在同一事务里完成。
|
||||
- 恢复算法:扫描 `status='running' AND lease_until<now()` 的行,`attempts<max_attempts` 时回到 `pending`(next_run_at 按退避计算),否则置为 `dead`(dead_letter_at=now());handler 按 `state` 里的步骤游标续跑,已完成步骤直接取结果。
|
||||
- 版本:`handler_version` 不匹配的任务只由声明兼容该版本的 worker 认领。
|
||||
@@ -0,0 +1,93 @@
|
||||
# ADR-003:Pi 接入采用 pi-agent-core `AgentHarness`,封装在 `AgentRuntimePort` 之后
|
||||
|
||||
- 状态:已采纳(2026-09-27,W0-B spike 后)
|
||||
- 关联:REQ §10、§11.4;plan §1、§3.1-9;验证记录 `docs/spikes/w0b-pi.md`;代码 `spikes/pi/`
|
||||
|
||||
## 决定
|
||||
|
||||
1. 依赖锁精确版本:`@earendil-works/[email protected]`、`@earendil-works/[email protected]`,另加它们要求的 `[email protected]`。**不引入** `@earendil-works/pi-coding-agent`,因为它的 ResourceLoader 会做隐式发现、自带 bash/read 工具,并依赖 TUI 和 jiti 扩展加载。
|
||||
2. 使用 core 的 **`AgentHarness` / `AgentLane`**。它就是 0.87.1 里 REQ §10.1 所说的 AgentSession 层:持久会话、事件流、压缩、abort/resume、工具重放策略都在这一层;而且只使用显式传入的 tools/resources/systemPrompt,不读取任何目录。
|
||||
3. 所有 SDK 类型只出现在 `packages/agent/src/pi/*` 里,对外只暴露 `AgentRuntimePort`。
|
||||
4. 测试统一使用 pi-ai 自带的 `fauxProvider`,配合 `FauxResponseFactory` 按 transcript 决策;生产中的上游用 `createProvider` 显式构造,不依赖环境变量。
|
||||
|
||||
## AgentRuntimePort 草案
|
||||
|
||||
```ts
|
||||
export interface AgentRunSpec {
|
||||
agentRunId: string; // = agent_run.id,同时作为 pi session id
|
||||
goal: string; // 用户目标或事件派生的有界任务
|
||||
scope: { actorId: string; scopeIds: string[]; grantIds: string[] };
|
||||
capabilities: CapabilityId[]; // 本次允许的能力白名单(能力注册中心解析)
|
||||
model: { upstreamId: string; modelId: string; thinkingLevel: ThinkingLevel };
|
||||
budget: { maxTurns: number; maxTokens: number; maxCostUsd?: number; deadlineAt: Date };
|
||||
compaction: { enabled: boolean; reserveTokens: number; keepRecentTokens: number };
|
||||
evidenceRefs: string[];
|
||||
}
|
||||
|
||||
export type AgentRunEvent =
|
||||
| { type: "text_delta"; seq: number; delta: string }
|
||||
| { type: "tool_started"; seq: number; capabilityId: string; invocationId: string; args: unknown }
|
||||
| { type: "tool_finished"; seq: number; capabilityId: string; invocationId: string; isError: boolean; summary: string; recovery?: true }
|
||||
| { type: "usage"; seq: number; input: number; output: number; costUsd: number }
|
||||
| { type: "compaction"; seq: number; reason: "manual" | "threshold" | "overflow"; status: string }
|
||||
| { type: "run_finished"; seq: number; status: "completed" | "aborted" | "failed" | "budget_exceeded"; error?: string };
|
||||
|
||||
export interface AgentRuntimePort {
|
||||
createRun(spec: AgentRunSpec): Promise<{ agentRunId: string }>; // 建会话 + accept prompt,不驱动
|
||||
stream(agentRunId: string, fromSeq?: number): AsyncIterable<AgentRunEvent>; // SSE 用,事件先落库(带 seq)再推送
|
||||
drive(agentRunId: string, signal?: AbortSignal): Promise<AgentRunOutcome>; // 由持久任务调用;内部是 lane.resume / drive
|
||||
abort(agentRunId: string, reason: string): Promise<void>; // lane.abort → 工具收到 context.abortSignal
|
||||
resume(agentRunId: string): Promise<AgentRunOutcome>; // 进程重启后:AgentHarness.create 返回 open → lane.resume
|
||||
followUp(agentRunId: string, message: string): Promise<void>; // 用户纠正
|
||||
}
|
||||
```
|
||||
|
||||
实现要点:
|
||||
|
||||
- `drive/resume` 由 `packages/tasks` 的持久任务(例如 `agentRun` 任务)调用。聊天 HTTP 只做 createRun + stream,断线不影响执行(REQ §10.7)。
|
||||
- 事件监听器(`harness.events.on`)把事件写入 `agent_run_event`(seq 单调递增),SSE 按 seq 补读。
|
||||
- 预算:`usage` 事件累计,`before_request` hook 里发现超预算就 `abort`,状态记为 `budget_exceeded`,然后进入 attention。
|
||||
- 会话存储:v0.1 用 `JsonlSessionRepo`,目录固定为应用数据目录 `data/agent-sessions/`(不放在媒体目录或用户目录),`agent_run` 行记录 session id 和路径。`NodeExecutionEnv` 只当作这个目录的 FileSystem 用,**不把它的 exec/shell 暴露成工具**。PG 存储要等上游或自研 `Storage` 实现(接口约 10 个方法);格式仍是 WIP,所以不在 v0.1 做。
|
||||
- 业务真相放在 PG(`agent_run`、操作账本、能力调用记录),JSONL 只是会话转写和恢复点。REQ §10.5/§11.4 说"不能把压缩摘要当唯一状态",这里同理。
|
||||
|
||||
## 工具白名单机制(三层)
|
||||
|
||||
1. **注册层**:`AgentHarness.create({ tools })` 只传本次 `capabilities` 对应的工具;也可以用 `activeToolNames` / `lane.setActiveTools` 缩小范围。模型调用未注册的工具时,harness 合成 `Tool "x" is unavailable` 错误结果,不会执行(spike 中已验证 `bash`)。
|
||||
2. **授权层**:工具 `execute` 调能力服务。能力服务在服务端校验 `toolContext`(actorId、grants、scope)和持续/单次授权(REQ §10.3),不信任模型参数里的"已同意"。`before_tool` hook 用来记审计和做快速拒绝(`{ block: { reason } }`)。
|
||||
3. **命名映射**:能力 ID `download.submit` ↔ 工具名 `download__submit`,因为上游工具名只允许 `[a-zA-Z0-9_-]`。映射表在 `packages/agent` 里唯一定义。
|
||||
|
||||
每个能力还要声明 `replay`:只读能力(`catalog.lookup`、`inventory.query`、`task.inspect` 等)用 `"safe"`;有副作用的能力(`download.submit`、`organize.execute`、`policy.apply` 等)用 `"never"`。崩溃恢复时,副作用工具会得到"结果未知"。业务侧按服务端生成的幂等键到操作账本对账,模型重新调用时会命中去重,不会产生第二次副作用。
|
||||
|
||||
## fake provider 用法
|
||||
|
||||
```ts
|
||||
const faux = fauxProvider({ provider: "umt-fake", models: [{ id: "fake", contextWindow: 32000 }] });
|
||||
models.setProvider(faux.provider);
|
||||
faux.setResponses([
|
||||
() => fauxAssistantMessage([fauxText("先查库存"), fauxToolCall("inventory__query", { title: "Dune" })], { stopReason: "toolUse" }),
|
||||
(ctx) => /* 读 ctx.messages 里的 toolResult 决定下一步 */ fauxAssistantMessage("完成"),
|
||||
]);
|
||||
// faux.state.callCount、getPendingResponseCount();队列耗尽时返回 errorMessage "No more faux responses queued"
|
||||
```
|
||||
|
||||
`packages/agent` 提供 `createScriptedModel(steps)`,供 testkit 和 e2e 使用(plan §3.1-13,"把 X 电影 1080p 收进来")。
|
||||
|
||||
## 设置页 → SDK 映射
|
||||
|
||||
| 设置项 | SDK |
|
||||
| --- | --- |
|
||||
| 上游接入(Anthropic / OpenAI 兼容) | `createProvider({ id, auth: { apiKey: { resolve } }, models: [Model], api: anthropicMessagesApi() / openAICompletionsApi() })`;`Model.baseUrl`(Anthropic 不带 `/v1`,OpenAI 兼容带 `/v1`)、`Model.id`、`Model.compat` |
|
||||
| 模型选择 | `lane.setModel({ provider, modelId })` |
|
||||
| 思考档位 | `thinkingLevel` / `lane.setThinkingLevel`;可选值来自 `getSupportedThinkingLevels(model)` |
|
||||
| 上下文预算 | 自定义 `Model.contextWindow` / `maxTokens` |
|
||||
| 压缩策略 | `compaction: { enabled, reserveTokens, keepRecentTokens }` + `before_compaction` hook / `customInstructions`(保留目标、约束、证据引用、已完成动作、剩余事项) |
|
||||
| 重试 | `retry: { enabled, maxRetries, baseDelayMs }`、`streamOptions.timeoutMs` |
|
||||
| 子代理开关 | **SDK 不提供**。由我们实现一个 `agent.delegate` 能力:开新 lane 或新会话,继承父任务的授权上限、预算和取消状态;默认关闭 |
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 0.x 版本,存储格式 4 仍是 WIP,可能原地改变。锁精确版本,升级前重跑 `spikes/pi`。
|
||||
- 没有子代理,没有 PG 会话存储,`watchSession` 未实现。
|
||||
- Pi 不提供权限系统(REQ §10.2)。进程、容器、网络白名单要在部署层落实。
|
||||
- 模型流中途崩溃后按"合成 error + 重试"恢复,只读了规范,未实测;v0.1 的 e2e 要补上。
|
||||
- 真实上游只验证了请求形状(本地抓包),没有联网调用。
|
||||
@@ -0,0 +1,178 @@
|
||||
# W0-B spike:OpenWorkflow 0.10.1 兼容性验证记录
|
||||
|
||||
- 日期:2026-09-27;环境:Node v24.15.0、PostgreSQL 16(`postgres:16-alpine`,容器 `umt-spike-pg`,端口 55434)、`[email protected]`、`[email protected]`、`[email protected]` + `pg@8`
|
||||
- 代码:`spikes/openworkflow/`(`common.ts` 定义工作流,`worker.ts` 起 worker,`cli.ts` 投递/信号/取消/查询)
|
||||
- 业务副作用账本:每个步骤体执行时往 `biz.side_effect_ledger`(Kysely + pg)插一行,用来判断步骤有没有重跑、有没有在失去租约后继续写。
|
||||
- 结论见 `docs/adr/ADR-002-task-engine.md`。
|
||||
|
||||
## 0. API 形状(以 node_modules 内 .d.ts 为准)
|
||||
|
||||
```ts
|
||||
import { OpenWorkflow } from "openworkflow";
|
||||
import { BackendPostgres } from "openworkflow/postgres";
|
||||
|
||||
const backend = await BackendPostgres.connect(url, { schema: "openworkflow", namespaceId?, runMigrations?: true });
|
||||
// 或 BackendPostgres.fromPool(postgresJsSql, { schema }) —— 共享 postgres.js 连接池,不跑迁移
|
||||
const ow = new OpenWorkflow({ backend });
|
||||
const wf = ow.defineWorkflow({ name, version?, schema?: StandardSchema, retryPolicy?: { maximumAttempts, initialInterval, backoffCoefficient, maximumInterval } },
|
||||
async ({ input, step, run, version }) => {
|
||||
await step.run({ name, retryPolicy? }, async () => ...); // 结果持久化,重放时直接返回
|
||||
await step.sleep(name, "3s" | "7 days"); // 持久定时
|
||||
const s = await step.waitForSignal<T>({ name?, signal, timeout?, schema? }); // 超时返回 null,默认超时 1 年
|
||||
await step.sendSignal({ signal, data }); await step.runWorkflow(spec, input, { timeout });
|
||||
});
|
||||
const h = await wf.run(input, { idempotencyKey?, availableAt?, deadlineAt? }); // h.workflowRun.id / h.result() / h.cancel()
|
||||
await ow.sendSignal({ signal, data, idempotencyKey? }); // → { workflowRunIds }
|
||||
await ow.cancelWorkflowRun(id);
|
||||
const worker = ow.newWorker({ concurrency }); await worker.start(); await worker.stop();
|
||||
backend.getWorkflowRun({ workflowRunId }); backend.listStepAttempts({ workflowRunId }); backend.listWorkflowRuns({ status, workflowName });
|
||||
```
|
||||
|
||||
- 运行状态:`pending | running | sleeping(legacy) | completed | succeeded | failed | canceled`。等待信号 / sleep 期间,run 的 status 仍是 `running`,只是 `worker_id` 为空且 `available_at` 指向唤醒时间。
|
||||
- 默认值(源码):工作流 `maximumAttempts: 1`;步骤 `maximumAttempts: 10`、`1s` 起指数退避、上限 `100s`;租约 `30s`,由心跳每 15s 续期(不可配置);单个 run 最多 1000 个 step attempt。
|
||||
- 版本:注册表的键是 `(name, version)`;run 创建时记下 version,只有注册了同一 version 的 worker 才会执行它。
|
||||
|
||||
## 验证结果
|
||||
|
||||
### (a) 表初始化 / 迁移 —— 通过
|
||||
|
||||
`BackendPostgres.connect()` 默认执行幂等迁移(`CREATE SCHEMA IF NOT EXISTS` + `openworkflow_migrations` 版本表)。另有 `dist/postgres/scripts/db-migrate.js`(只认默认 URL,生产不宜直接用)。`fromPool()` 不跑迁移。
|
||||
|
||||
```
|
||||
$ npx tsx openworkflow/cli.ts tables
|
||||
biz.side_effect_ledger
|
||||
openworkflow.openworkflow_migrations
|
||||
openworkflow.step_attempts
|
||||
openworkflow.workflow_runs
|
||||
openworkflow.workflow_signals
|
||||
```
|
||||
|
||||
建议:在 `packages/db` 的迁移流程里调用 `migrate(sql, "openworkflow")`(`openworkflow/internal` 导出),应用启动时用 `runMigrations:false`。**未验证** `openworkflow/internal` 的 `migrate` 直接调用。
|
||||
|
||||
### 主路径 —— 通过
|
||||
|
||||
```
|
||||
{"runId":"bdc8f92d-…","status":"pending","version":"v1"}
|
||||
{"status":"running",…} ← 停在 wait-download
|
||||
{"workflowRunIds":["bdc8f92d-…"]} ← signal download-done:d1
|
||||
{"status":"completed","attempts":3,"output":{"sub":{"torrentHash":"hash-d1"},"copied":{"dest":"/lib/d1.mkv"},"signal":{"ok":true}}}
|
||||
submit-download function completed / wait-download signal-wait completed / copy-file function completed
|
||||
register function completed / retention sleep completed / cleanup function completed
|
||||
```
|
||||
|
||||
### (b) kill -9 后重启恢复 —— 通过(语义:已完成步骤不重跑,正在执行的步骤至少执行一次)
|
||||
|
||||
copy-file 步骤内等待 10s;在复制途中对 worker `kill -9`,再启动新 worker A2:
|
||||
|
||||
```
|
||||
kill 之后: {"status":"running","attempts":2} copy-file function running;available_at 比 now 晚约 16s(租约尚未到期)
|
||||
A2 启动约 30s 后:{"status":"completed","attempts":4}
|
||||
submit-download completed / wait-download completed / copy-file running(孤儿行) / copy-file completed / register / retention / cleanup
|
||||
账本:
|
||||
9e50537b submit-download A version=v1
|
||||
9e50537b copy-file A start ← 被 kill 的那次
|
||||
9e50537b copy-file A2 start ← 同一步骤重跑
|
||||
9e50537b copy-file A2 end
|
||||
9e50537b register A2 / cleanup A2
|
||||
```
|
||||
|
||||
- submit-download 与信号等待没有重跑;copy-file 在新 worker 上从头执行。所以步骤体必须幂等(例如复制前先查目标文件和操作日志)。
|
||||
- 要等租约到期(最长约 30s)才会被重新认领。
|
||||
- 被中断的 attempt 行一直停在 `running`,不会被清掉。展示时不要按这张表统计"执行中"。
|
||||
|
||||
### 租约丢失后的写入隔离(补充:SIGSTOP 模拟 worker 卡死) —— 引擎层通过,业务层没有隔离
|
||||
|
||||
对 A 执行 `kill -STOP`,由 B 接管并完成,再 `kill -CONT` A:
|
||||
|
||||
```
|
||||
d23c7db1 copy-file A start
|
||||
d23c7db1 copy-file B start / B end / register B / cleanup B ← run completed
|
||||
d23c7db1 copy-file A end ← A 恢复后步骤体照常跑完并写了业务账本
|
||||
Heartbeat failed: Error: Failed to extend lease for workflow run ← A 的步骤结果被引擎拒绝(worker_id 不再匹配)
|
||||
```
|
||||
|
||||
引擎保证过期 worker 的**步骤结果**不会写入;但步骤体内的**业务副作用**照常发生,而且步骤体拿不到 AbortSignal 或租约状态。REQ §11.1 要求"失去租约的 worker 不得再提交业务状态",这一条要靠业务侧的操作账本做 fencing(见 ADR-002)。
|
||||
|
||||
### (c) 信号先于等待到达 —— **会丢**
|
||||
|
||||
```
|
||||
$ cli signal d2 early-key → {"workflowRunIds":[]} ← 还没有等待者,直接丢弃
|
||||
$ cli start-acquire d2 → run 停在 wait-download(running)
|
||||
$ cli signal d2 early-key → {"workflowRunIds":["f837c0cb-…"]} ← 同一个 idempotencyKey 仍能投递(前一次没落库)
|
||||
$ cli signal d2 key2 → {"workflowRunIds":[]} ← 等待已结束,再发就丢
|
||||
```
|
||||
|
||||
源码 `sendSignal` 只会 `SELECT … step_attempts WHERE kind='signal-wait' AND status='running' FOR UPDATE`,没有等待者就返回空数组,不落库(类型注释也写了:"If no workflow is waiting, the signal is silently dropped")。同一信号名同一时间只允许一个等待步骤。**不能把 signal 当持久授权或完成通知。**
|
||||
|
||||
### (d) 取消传播 —— 部分通过
|
||||
|
||||
```
|
||||
cancel slow(处于 long 步骤中) → {"status":"canceled"}
|
||||
账本:long start … long end (still ran after cancel?) ← 进行中的步骤体跑完了(没有 AbortSignal)
|
||||
after-long 未执行;long 的 attempt 行停在 running
|
||||
cancel acquire(处于 wait-download) → {"status":"canceled"},之后的步骤不再执行
|
||||
但随后 signal d4 → {"workflowRunIds":["282dde2b-…"]} ← 对已取消的 run 仍然"投递成功"
|
||||
cancel 已完成 run → Error: Cannot cancel workflow run … with status completed
|
||||
```
|
||||
|
||||
取消只能保证"后续步骤不再执行"。进行中的步骤不会被中断,子工作流是否级联取消**未验证**。
|
||||
|
||||
### (e) 步骤失败重试与最大次数 —— 通过
|
||||
|
||||
```
|
||||
flaky(前 2 次失败, maximumAttempts=3):completed, attempts=3
|
||||
flaky-step failed "boom attempt 1" / failed "boom attempt 2" / completed
|
||||
flaky(前 5 次失败, maximumAttempts=3):failed, attempts=3, error.message="boom attempt 3"
|
||||
flaky-step failed ×3,不再重试
|
||||
```
|
||||
|
||||
步骤重试的实现方式是整个 run 按退避重新排期、重放前面的步骤;run 的 `attempts` 会随之增长。重试期间 run 显示为 `pending`,错误写在 run.error 上。
|
||||
|
||||
### (f) idempotencyKey 去重 —— 通过(有限制)
|
||||
|
||||
```
|
||||
start-acquire d5 same-key → runId fd705d54… pending
|
||||
start-acquire d5 same-key → runId fd705d54… running ← 返回已有 run
|
||||
start-acquire d5-other same-key → runId fd705d54… ← 输入不同也返回旧 run,不报冲突
|
||||
select count(*) … where idempotency_key='same-key' → 1
|
||||
```
|
||||
|
||||
去重范围是 `(namespace, workflowName, key)`,用 advisory lock 串行化,**窗口只有 24 小时**(`DEFAULT_RUN_IDEMPOTENCY_PERIOD_MS`)。引擎不比较输入。
|
||||
|
||||
### (g) 版本升级 —— 通过(要自己管好版本纪律)
|
||||
|
||||
1. run 以 `version=v1` 停在等待中;停掉 v1 worker,只起 `WF_VERSION=v2` 的 worker,然后发信号:
|
||||
```
|
||||
{"status":"pending","attempts":2,"error":{"message":"Workflow \"acquire\" (version: v1) is not registered"}}
|
||||
```
|
||||
run 没有失败,而是按退避无限重排(`MISSING_DEFINITION_RETRY_POLICY maximumAttempts:0`),等能处理 v1 的 worker 出现。
|
||||
2. 再起一个 v1 worker,但代码改过(同一 version 在 copy 与 register 之间插入 `v2-extra-verify` 步骤):
|
||||
```
|
||||
completed;steps: submit-download / wait-download / copy-file / v2-extra-verify / register / retention / cleanup
|
||||
```
|
||||
重放按**步骤名**匹配历史,没有确定性检查。新插入的步骤会执行;已完成的步骤只要改了名,就会被当作新步骤重跑。
|
||||
|
||||
### (h) 与 Kysely + pg 共库不同 schema —— 通过
|
||||
|
||||
OpenWorkflow(postgres.js 连接池,schema `openworkflow`)和 Kysely(pg 连接池,schema `biz`)在同一个库里跑完了以上全部用例,没有冲突。限制:OpenWorkflow 自己管连接(postgres.js),`createWorkflowRun` 不能接收 Kysely 事务,所以**不能和业务写入在同一个事务里入队**,要用 outbox。
|
||||
|
||||
### (i) 两个 worker 进程并发 —— 通过
|
||||
|
||||
```
|
||||
worker A、B 同时运行,投递 60 个 burst run
|
||||
{"total":"60","runs":"60","a":"32","b":"28"} ← 60 次执行、60 个 run,没有重复
|
||||
completed|60
|
||||
```
|
||||
|
||||
认领 SQL 用的是 `FOR UPDATE SKIP LOCKED`。
|
||||
|
||||
### 补充:PostgreSQL 重启 —— 通过
|
||||
|
||||
run 处于 15s 的 retention sleep 时执行 `docker restart umt-spike-pg`,约 30s 后 run 已 `completed`,6 个步骤都 completed;worker 日志里有 2 条 tick 失败,之后自动恢复。
|
||||
|
||||
## 未验证
|
||||
|
||||
- Deno 下运行(ADR-001 已选 Node)。
|
||||
- `step.runWorkflow` 子工作流取消级联、`deadlineAt`、OpenTelemetry。
|
||||
- 大量 sleeping run(>10k)下的轮询开销。
|
||||
- "qB 接收后断连""复制完成但结果未提交"这类业务故障注入。它们属于业务幂等层,在 `packages/tasks` 集成测试中覆盖(见 ADR-002)。
|
||||
@@ -0,0 +1,159 @@
|
||||
# W0-B spike:Pi SDK(pi-agent-core / pi-ai 0.87.1)验证记录
|
||||
|
||||
- 日期:2026-09-27;Node v24.15.0;`@earendil-works/[email protected]`、`@earendil-works/[email protected]`(依赖 `@earendil-works/chord`、`pi-telemetry`、`[email protected]`)
|
||||
- 代码:`spikes/pi/`(`common.ts` 工具与脚本化模型、`fake-run.ts`、`abort-resume.ts`、`compaction.ts`、`provider-config.ts`)
|
||||
- 参考:包内 README / .d.ts;GitHub `earendil-works/pi` tag `v0.87.1` 的 `packages/agent/docs/harness.md`(AgentHarness 规范)、`packages/agent/test/harness/runtime/drive-public.test.ts`、`packages/coding-agent/examples/sdk/12-full-control.ts`
|
||||
- 结论见 `docs/adr/ADR-003-pi-integration.md`
|
||||
|
||||
## 0. 该用哪一层
|
||||
|
||||
| 层 | 包 | 说明 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| `Agent` / `agentLoop` | pi-agent-core | 进程内状态,无持久化 | 不用 |
|
||||
| **`AgentHarness` + `AgentLane`** | pi-agent-core | 持久会话(entry 树 + 操作状态机),有 prompt/resume/abort/compact、事件、hooks、工具重放策略;**不做任何隐式发现** | **采用** |
|
||||
| `createAgentSession` / `AgentSession` | pi-coding-agent | 编码助手层:ResourceLoader 会发现扩展、skills、AGENTS.md,内置 read/bash 等工具,依赖 pi-tui、jiti(运行时加载 TS 扩展)、photon | 不用。REQ §10.1 说的"AgentSession 层"在 0.87.1 里的对应物,就是 core 的 AgentHarness |
|
||||
|
||||
`grep` 过 pi-agent-core dist:只有 `harness/skills.js`(显式调用的 loader)、`env/nodejs.js`(显式构造的执行环境)、`pico3/jsonl.js` 会接触文件系统。`AgentHarness.create()` 只使用传入的 `tools` / `resources` / `systemPrompt`,**不读取 cwd、home、`.pi/`、AGENTS.md**。
|
||||
|
||||
## 1. 关键 API 形状
|
||||
|
||||
```ts
|
||||
import { createModels, createProvider, fauxProvider, fauxAssistantMessage, fauxToolCall, fauxText } from "@earendil-works/pi-ai";
|
||||
import { AgentHarness, BACKGROUND_CONTEXT, MemorySessionRepo, JsonlSessionRepo, type AgentHarnessTool } from "@earendil-works/pi-agent-core";
|
||||
|
||||
const models = createModels(); models.setProvider(provider);
|
||||
const session = await new MemorySessionRepo().create({ id }, ctx); // 或 JsonlSessionRepo({ fileSystem, sessionsRoot })
|
||||
const { harness, open } = await AgentHarness.create({
|
||||
session, models, model,
|
||||
systemPrompt, tools: [/* AgentHarnessTool */], toolContext: { actorId, grants }, // toolContext 可为 (ctx)=>T
|
||||
activeToolNames?, thinkingLevel: "off"|"minimal"|"low"|"medium"|"high"|"xhigh"|"max",
|
||||
compaction: { enabled, reserveTokens, keepRecentTokens },
|
||||
retry: { enabled, maxRetries, baseDelayMs, maxAgentDelayMs? },
|
||||
streamOptions: { timeoutMs, maxRetries, headers, metadata, cacheRetention, deferred },
|
||||
toolExecution: "sequential" | "parallel", resources?: { skills, promptTemplates },
|
||||
}, ctx); // open = 附着时发现的未完成操作
|
||||
const lane = await harness.lane("main", ctx);
|
||||
await lane.prompt(text, undefined, ctx) / lane.resume(ctx) / lane.abort(ctx) / lane.compact({ customInstructions }, ctx)
|
||||
lane.accept({kind:"prompt",operationId,prompt}) + lane.drive({operationId}) + lane.requestAbort(operationId) // 低层原语,可由外部调度器驱动
|
||||
harness.events.on("tool_start" | "tool_end" | "message_update" | "run_end" | "usage" | "compaction_end" | …, listener)
|
||||
harness.hooks.on("before_tool" | "after_tool" | "before_request" | "before_payload" | "transform_context" | "before_compaction" | …, handler)
|
||||
lane.setModel({provider, modelId}) / setThinkingLevel / setActiveTools;harness.setTools / setCompactionSettings / setRetryPolicy
|
||||
```
|
||||
|
||||
工具:
|
||||
|
||||
```ts
|
||||
const t: AgentHarnessTool<Ctx> = {
|
||||
name, label, description, parameters: Type.Object({...}), // typebox 1.x
|
||||
replay: "safe" | "never", // 崩溃后:safe = 用持久化的参数重跑;never = 合成"执行被中断、结果未知"的错误结果
|
||||
executionMode?: "sequential",
|
||||
async execute(toolCallId, params, onUpdate, toolContext, invocation /* invocationId, getMemo/setMemo */, context /* context.abortSignal */) {
|
||||
return { content: [{ type: "text", text }], details }; // 失败直接 throw → isError:true
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 2. 验证结果
|
||||
|
||||
### 2.1 fake 模型驱动两个工具 + 流式事件 —— 通过(`npx tsx pi/fake-run.ts`)
|
||||
|
||||
fake 模型用 `fauxProvider` + `FauxResponseFactory`(函数收到 transcript,按上一个工具结果决定下一步)实现,不需要 API Key。
|
||||
|
||||
```
|
||||
[run_start] lane=main
|
||||
[turn_start] lane=main
|
||||
[text_delta] "我先查一下库里有没有《D"
|
||||
[text_delta] "une》。"
|
||||
[toolcall_end] inventory__query
|
||||
[usage] in=145 totals.input=145 output=18
|
||||
[hook before_tool] inventory__query args={"title":"Dune","year":2021}
|
||||
[tool_start] inventory__query {"title":"Dune","year":2021}
|
||||
[tool_update] lane=main
|
||||
[tool_end] inventory__query isError=false -> "{\"title\":\"Dune\",\"inLibrary\":false,\"versions\":[]}"
|
||||
[turn_end] lane=main
|
||||
[turn_start] lane=main
|
||||
[text_delta] "库里没有,提交 1080" …
|
||||
[toolcall_end] download__submit
|
||||
[toolcall_end] bash
|
||||
[hook before_tool] download__submit args={"title":"Dune","quality":"1080p"}
|
||||
[tool_start] download__submit {"title":"Dune","quality":"1080p"}
|
||||
[tool_end] download__submit isError=false -> "{\"accepted\":true,\"idempotencyKey\":\"dl:Dune:1080p\",\"status\":\"submitted\"}"
|
||||
[tool_start] bash {"command":"rm -rf /"}
|
||||
[tool_end] bash isError=true -> "Tool \"bash\" is unavailable" ← 不在白名单的工具被合成错误结果,不执行
|
||||
[turn_start] … [text_delta] "下载已提交(仅代表已提交,不代表已入库)…"
|
||||
[run_end] status=completed
|
||||
RESULT {"ok":true,"value":{"operationId":"01a0e235-…","kind":"run","status":"completed",…}}
|
||||
SUBMITTED [{"key":"dl:Dune:1080p","title":"Dune"}] fake calls: 3
|
||||
```
|
||||
|
||||
### 2.2 AbortSignal 取消 —— 通过(`SUBMIT_DELAY_MS=5000 npx tsx pi/abort-resume.ts abort`)
|
||||
|
||||
```
|
||||
[tool_start] download__submit
|
||||
>> lane.abort()
|
||||
[operation_abort]
|
||||
[tool_end] download__submit "aborted by signal" ← execute 里的 context.abortSignal 触发了
|
||||
[tool_end] bash "Tool execution was cancelled before completion." ← 同批次尚未执行的调用被合成为取消结果
|
||||
[run_end] aborted
|
||||
PROMPT RESULT {…"status":"aborted"…}
|
||||
```
|
||||
|
||||
### 2.3 会话持久化与崩溃恢复 —— 通过(JSONL)
|
||||
|
||||
进程 1(`crash`)在 `download.submit` 执行中 `SIGKILL` 自己;进程 2(`resume`)打开同一个 JSONL 会话:
|
||||
|
||||
```
|
||||
found session crash-demo …/.sessions/--opt-uhd_media_tool-spikes-pi-.sessions--/2026-09-27T09-33-19-828Z_crash-demo.jsonl
|
||||
open operations at attach: [{"lane":"main","operationId":"01a0e236-3a62-…","kind":"run","startedAt":1790501599842}]
|
||||
[tool_end] (recovery) download__submit "[Tool execution was interrupted. … the external outcome is unknown.]" ← replay:"never" 不重跑
|
||||
[tool_start] (recovery) bash / [tool_end] (recovery) bash "Tool \"bash\" is unavailable"
|
||||
[run_end] completed
|
||||
fake calls: 1 ← 只多请求一次模型;inventory.query 没有重跑
|
||||
```
|
||||
|
||||
恢复时 `download.submit` 得到的是"结果未知"。业务侧要按 `invocationId` 或服务端幂等键去操作账本对账,不能让模型重新提交一次。
|
||||
|
||||
### 2.4 压缩 —— 手动通过,阈值触发可见(`compaction.ts`)
|
||||
|
||||
```
|
||||
[compaction_start] reason=manual / [compaction_end] reason=manual status=completed
|
||||
REPEAT=400:[compaction_start] reason=threshold → [compaction_end] status=completed
|
||||
单条输入本身超过 contextWindow 时 run 失败:{"code":"assistant_error","message":"Assistant request exceeded the context window"}
|
||||
entry types: compaction,message,…
|
||||
```
|
||||
|
||||
摘要本身也是一次模型调用(fake 模型按脚本返回)。压缩提示词可以通过 `customInstructions` 或 `before_compaction` hook 定制。
|
||||
|
||||
### 2.5 真实 provider 类型的配置(不出外网)—— 通过(`provider-config.ts`)
|
||||
|
||||
用 `createProvider` 分别套 `anthropic-messages` 和 `openai-completions` 两种 API 实现,baseUrl 指向脚本自带的本地抓包服务器(返回 400):
|
||||
|
||||
```
|
||||
builtin anthropic models (first 5): claude-fable-5, claude-fable-5-1, claude-haiku-4-5, …
|
||||
builtin anthropic auth configured without env? undefined
|
||||
supported thinking levels, custom anthropic model: off,minimal,low,medium,high
|
||||
[before_request] upstream-anthropic/claude-sonnet-4-5 step=assistant attempt=1
|
||||
[upstream-anthropic] run: {"status":"failed","error":{"code":"assistant_error","message":"400 {…capture server: refusing on purpose…}"}}
|
||||
CAPTURED POST /v1/messages?beta=true auth=sk-test-anthropic
|
||||
model=claude-sonnet-4-5 max_tokens=8192 thinking={"type":"enabled","budget_tokens":7168,"display":"summarized"} tools=["inventory__query"] stream=true
|
||||
CAPTURED POST /v1/chat/completions auth=Bearer sk-test-openai
|
||||
model=qwen3-235b max_tokens=8192 thinking=true tools=["inventory__query"] stream=true
|
||||
```
|
||||
|
||||
配置字段:
|
||||
|
||||
- `Model`:`id`(上游模型名)、`api`(`anthropic-messages` | `openai-completions` | `openai-responses` | …)、`provider`、`baseUrl`、`reasoning`、`thinkingLevelMap`、`contextWindow`、`maxTokens`、`cost`、`headers`、`compat`(OpenAI 兼容上游的差异开关,如 `supportsDeveloperRole`、`thinkingFormat`、`maxTokensField`)。
|
||||
- baseUrl 的拼接方式:Anthropic 写 **不带 `/v1`** 的根地址(SDK 自己拼 `/v1/messages`);OpenAI 兼容写 **带 `/v1`** 的地址(SDK 拼 `/chat/completions`)。
|
||||
- apiKey:`createProvider({ auth: { apiKey: { name, resolve: async () => ({ auth: { apiKey, baseUrl?, headers? } }) } } })`,每次请求时解析,可以接我们自己的 secret store。内置 provider 用 `envApiKeyAuth` 读环境变量(如 `ANTHROPIC_API_KEY`),也可以用 `createModels({ credentials })` 注入。
|
||||
- 思考档位:thinkingLevel 映射到上游参数(Anthropic high → `budget_tokens 7168`),可用 `getSupportedThinkingLevels(model)` / `clampThinkingLevel` 查询。
|
||||
- 上下文预算:`model.contextWindow`(可以自定义成比上游小的值)配合 `compaction.reserveTokens/keepRecentTokens`。
|
||||
|
||||
## 3. 不支持 / 未验证
|
||||
|
||||
- **子代理**:pi-agent-core 和 pi-ai 里都没有子代理概念(dist 里 grep 不到)。设置页的"子代理开关"要由我们自己实现,例如在工具里开一个新 lane 或新会话,并继承预算和授权。
|
||||
- **工具名不能带点**:`inventory.query` 这种名字上游(Anthropic/OpenAI 要求 `^[a-zA-Z0-9_-]{1,64}$`)不接受,本 spike 统一映射成 `inventory__query`。pi 自身是否校验名字**未验证**。
|
||||
- **PostgreSQL 会话存储**:0.87.1 只提供 Memory / JSONL / SQLite(独立包 `pi-session-backend-sqlite-node`,**未验证**)。Postgres 分区存储在规范 Part 6 里标注为 future。存储格式 4 仍是 WIP,不保证迁移。
|
||||
- `watchSession` 未实现(规范 R12);遥测只有部分实现。
|
||||
- 模型流式输出中途崩溃的恢复(合成 error 后按 retry 重试)只读了规范,**没有实测**。
|
||||
- pi-coding-agent 的 `createAgentSession` / ResourceLoader **没有安装,也没有运行**(按上文结论不采用)。
|
||||
- 与真实上游联网调用:没有(不需要 key)。
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
pi/.sessions/
|
||||
@@ -0,0 +1,15 @@
|
||||
# W0-B spikes(独立 npm 包,不是 pnpm workspace 成员)
|
||||
|
||||
```bash
|
||||
cd spikes && npm install
|
||||
docker run -d --name umt-spike-pg -e POSTGRES_PASSWORD=spike -p 55434:5432 postgres:16-alpine
|
||||
```
|
||||
|
||||
- `openworkflow/`:OpenWorkflow 0.10.1 纵切(提交下载→等信号→复制→登记→保留期 sleep→清理)。
|
||||
`worker.ts`(env: TAG / WF_VERSION / EXTRA_STEP / PIDFILE)、`cli.ts`(start-acquire / signal / cancel / get / steps / ledger / start-flaky / start-slow / start-burst / burst-dups / tables)。
|
||||
记录:`../docs/spikes/w0b-openworkflow.md`,结论 `../docs/adr/ADR-002-task-engine.md`。
|
||||
- `pi/`:pi-agent-core/pi-ai 0.87.1。`fake-run.ts`(脚本化 fake 模型 + 2 个工具 + 事件流)、
|
||||
`abort-resume.ts abort|crash|resume`(取消 / kill -9 后新进程恢复)、`compaction.ts`、`provider-config.ts`(真实 provider 类型打到本地抓包服务器,不出网)。
|
||||
记录:`../docs/spikes/w0b-pi.md`,结论 `../docs/adr/ADR-003-pi-integration.md`。
|
||||
|
||||
运行:`npx tsx openworkflow/cli.ts tables`、`npx tsx pi/fake-run.ts` 等。
|
||||
@@ -0,0 +1,64 @@
|
||||
import { makeClient, initBiz, db } from "./common.ts";
|
||||
import { sql } from "kysely";
|
||||
await initBiz();
|
||||
const { ow, backend, acquire, flaky, slow, burst } = await makeClient();
|
||||
const [cmd, ...args] = process.argv.slice(2);
|
||||
const out = (x: unknown) => console.log(JSON.stringify(x, null, 0));
|
||||
switch (cmd) {
|
||||
case "start-acquire": {
|
||||
const [downloadId, key, copyMs, retention] = args;
|
||||
const h = await acquire.run({ downloadId, copyMs: Number(copyMs ?? 0), retention: retention ?? "3s" },
|
||||
key ? { idempotencyKey: key } : undefined);
|
||||
out({ runId: h.workflowRun.id, status: h.workflowRun.status, version: h.workflowRun.version });
|
||||
break;
|
||||
}
|
||||
case "start-flaky": {
|
||||
const h = await flaky.run({ failTimes: Number(args[0]), maxAttempts: Number(args[1]) });
|
||||
out({ runId: h.workflowRun.id });
|
||||
break;
|
||||
}
|
||||
case "start-slow": { const h = await slow.run({}); out({ runId: h.workflowRun.id }); break; }
|
||||
case "start-burst": {
|
||||
const ids = [];
|
||||
for (let i = 0; i < Number(args[0]); i++) ids.push((await burst.run({ i })).workflowRun.id);
|
||||
out({ count: ids.length });
|
||||
break;
|
||||
}
|
||||
case "signal": {
|
||||
const r = await ow.sendSignal({ signal: `download-done:${args[0]}`, data: { ok: true }, idempotencyKey: args[1] });
|
||||
out(r);
|
||||
break;
|
||||
}
|
||||
case "cancel": { await ow.cancelWorkflowRun(args[0]); out({ canceled: args[0] }); break; }
|
||||
case "get": {
|
||||
const r = await backend.getWorkflowRun({ workflowRunId: args[0] });
|
||||
out({ status: r?.status, version: r?.version, attempts: r?.attempts, error: r?.error, output: r?.output });
|
||||
break;
|
||||
}
|
||||
case "steps": {
|
||||
const r = await backend.listStepAttempts({ workflowRunId: args[0], limit: 100 } as any);
|
||||
for (const s of [...r.data].sort((a,b)=>+a.createdAt-+b.createdAt)) console.log(s.stepName.padEnd(18), s.kind.padEnd(12), s.status, s.error ? JSON.stringify(s.error.message) : "");
|
||||
break;
|
||||
}
|
||||
case "ledger": {
|
||||
const rows = await db.selectFrom("biz.side_effect_ledger").selectAll()
|
||||
.$if(!!args[0], (q) => q.where("run_id", "=", args[0])).orderBy("id").execute();
|
||||
for (const r of rows) console.log(r.run_id.slice(0, 8), r.step.padEnd(16), r.worker.padEnd(6), r.note);
|
||||
break;
|
||||
}
|
||||
case "burst-dups": {
|
||||
const rows = await sql<any>`SELECT count(*) total, count(DISTINCT run_id) runs,
|
||||
count(*) FILTER (WHERE worker='A') a, count(*) FILTER (WHERE worker='B') b
|
||||
FROM biz.side_effect_ledger WHERE step='work'`.execute(db);
|
||||
out(rows.rows[0]);
|
||||
break;
|
||||
}
|
||||
case "tables": {
|
||||
const r = await sql<any>`SELECT table_schema, table_name FROM information_schema.tables
|
||||
WHERE table_schema IN ('openworkflow','biz') ORDER BY 1,2`.execute(db);
|
||||
for (const t of r.rows) console.log(`${t.table_schema}.${t.table_name}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
await backend.stop();
|
||||
await db.destroy();
|
||||
@@ -0,0 +1,110 @@
|
||||
// Shared: DB URLs, OpenWorkflow client, Kysely business DB (schema "biz"), workflow definitions.
|
||||
import { OpenWorkflow } from "openworkflow";
|
||||
import { BackendPostgres } from "openworkflow/postgres";
|
||||
import { Kysely, PostgresDialect, sql } from "kysely";
|
||||
import pg from "pg";
|
||||
|
||||
export const PG_URL = process.env.PG_URL ?? "postgresql://postgres:[email protected]:55434/postgres";
|
||||
export const WF_VERSION = process.env.WF_VERSION ?? "v1";
|
||||
export const TAG = process.env.TAG ?? `pid${process.pid}`;
|
||||
|
||||
export const db = new Kysely<any>({
|
||||
dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 4 }) }),
|
||||
});
|
||||
|
||||
export async function initBiz() {
|
||||
await sql`CREATE SCHEMA IF NOT EXISTS biz`.execute(db);
|
||||
await sql`CREATE TABLE IF NOT EXISTS biz.side_effect_ledger (
|
||||
id bigserial PRIMARY KEY, run_id text, step text, worker text, pid int, note text,
|
||||
at timestamptz DEFAULT now())`.execute(db);
|
||||
}
|
||||
|
||||
export async function ledger(runId: string, step: string, note = "") {
|
||||
await db.insertInto("biz.side_effect_ledger")
|
||||
.values({ run_id: runId, step, worker: TAG, pid: process.pid, note }).execute();
|
||||
console.log(`[${TAG}] run=${runId.slice(0, 8)} step=${step} ${note}`);
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export async function makeClient() {
|
||||
// connect() runs migrations by default (runMigrations: true), schema default "openworkflow".
|
||||
const backend = await BackendPostgres.connect(PG_URL, { schema: "openworkflow" });
|
||||
const ow = new OpenWorkflow({ backend });
|
||||
|
||||
// Main vertical slice: submit download -> wait signal -> copy -> register -> retention sleep -> cleanup
|
||||
const acquire = ow.defineWorkflow(
|
||||
{ name: "acquire", version: WF_VERSION },
|
||||
async ({ input, step, run, version }) => {
|
||||
const { downloadId, copyMs = 0, retention = "3s" } = input as any;
|
||||
const sub = await step.run({ name: "submit-download" }, async () => {
|
||||
await ledger(run.id, "submit-download", `version=${version}`);
|
||||
return { torrentHash: `hash-${downloadId}` };
|
||||
});
|
||||
const done = await step.waitForSignal<{ ok: boolean }>({
|
||||
name: "wait-download", signal: `download-done:${downloadId}`, timeout: "10m",
|
||||
});
|
||||
if (!done) throw new Error("download wait timed out");
|
||||
const copied = await step.run({ name: "copy-file" }, async () => {
|
||||
await ledger(run.id, "copy-file", "start");
|
||||
await wait(copyMs);
|
||||
await ledger(run.id, "copy-file", "end");
|
||||
return { dest: `/lib/${downloadId}.mkv` };
|
||||
});
|
||||
if (process.env.EXTRA_STEP === "1") {
|
||||
// EXTRA_STEP=1 simulates a code change between copy and register (tests code upgrade semantics)
|
||||
await step.run({ name: "v2-extra-verify" }, async () => { await ledger(run.id, "v2-extra-verify"); return 1; });
|
||||
}
|
||||
await step.run({ name: "register" }, async () => { await ledger(run.id, "register", `version=${version}`); return 1; });
|
||||
await step.sleep("retention", retention);
|
||||
await step.run({ name: "cleanup" }, async () => { await ledger(run.id, "cleanup"); return 1; });
|
||||
return { sub, copied, signal: done.data };
|
||||
},
|
||||
);
|
||||
|
||||
// Retry test: step fails `failTimes` times then succeeds; step maximumAttempts = maxAttempts
|
||||
const flaky = ow.defineWorkflow(
|
||||
{ name: "flaky", version: WF_VERSION },
|
||||
async ({ input, step, run }) => {
|
||||
const { failTimes, maxAttempts } = input as any;
|
||||
await step.run({ name: "before" }, async () => { await ledger(run.id, "before"); return 1; });
|
||||
return await step.run(
|
||||
{ name: "flaky-step", retryPolicy: { maximumAttempts: maxAttempts, initialInterval: "200ms", maximumInterval: "1s" } },
|
||||
async () => {
|
||||
const n = await db.selectFrom("biz.side_effect_ledger").select(db.fn.countAll().as("c"))
|
||||
.where("run_id", "=", run.id).where("step", "=", "flaky-step").executeTakeFirst();
|
||||
const attempt = Number(n?.c ?? 0) + 1;
|
||||
await ledger(run.id, "flaky-step", `attempt=${attempt}`);
|
||||
if (attempt <= failTimes) throw new Error(`boom attempt ${attempt}`);
|
||||
return { attempt };
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Cancel test: long step, then a follow-up step
|
||||
const slow = ow.defineWorkflow(
|
||||
{ name: "slow", version: WF_VERSION },
|
||||
async ({ step, run }) => {
|
||||
await step.run({ name: "long" }, async () => {
|
||||
await ledger(run.id, "long", "start");
|
||||
await wait(8000);
|
||||
await ledger(run.id, "long", "end (still ran after cancel?)");
|
||||
return 1;
|
||||
});
|
||||
await step.run({ name: "after-long" }, async () => { await ledger(run.id, "after-long"); return 1; });
|
||||
return "done";
|
||||
},
|
||||
);
|
||||
|
||||
// Concurrency test: many short runs
|
||||
const burst = ow.defineWorkflow(
|
||||
{ name: "burst", version: WF_VERSION },
|
||||
async ({ step, run }) => {
|
||||
await step.run({ name: "work" }, async () => { await wait(300); await ledger(run.id, "work"); return 1; });
|
||||
return 1;
|
||||
},
|
||||
);
|
||||
|
||||
return { ow, backend, acquire, flaky, slow, burst };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { makeClient, initBiz, TAG, WF_VERSION } from "./common.ts";
|
||||
await initBiz();
|
||||
const { ow } = await makeClient();
|
||||
const worker = ow.newWorker({ concurrency: Number(process.env.CONCURRENCY ?? 4) });
|
||||
await worker.start();
|
||||
if (process.env.PIDFILE) (await import("node:fs")).writeFileSync(process.env.PIDFILE, String(process.pid));
|
||||
console.log(`[${TAG}] worker started pid=${process.pid} version=${WF_VERSION}`);
|
||||
process.on("SIGTERM", async () => { await worker.stop(); process.exit(0); });
|
||||
Generated
+1846
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "umt-spikes",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"openworkflow": "0.10.1",
|
||||
"postgres": "^3.4.9",
|
||||
"kysely": "^0.28.0",
|
||||
"pg": "^8.13.0",
|
||||
"@earendil-works/pi-agent-core": "0.87.1",
|
||||
"@earendil-works/pi-ai": "0.87.1",
|
||||
"tsx": "^4.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Modes: abort | crash | resume. Uses a file-backed JSONL session so a second process can recover.
|
||||
import { createModels, fauxProvider } from "@earendil-works/pi-ai";
|
||||
import { AgentHarness, BACKGROUND_CONTEXT, JsonlSessionRepo } from "@earendil-works/pi-agent-core";
|
||||
import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/harness/env/nodejs";
|
||||
import { downloadSubmit, inventoryQuery, script, submitted } from "./common.ts";
|
||||
|
||||
const mode = process.argv[2];
|
||||
const ctx = BACKGROUND_CONTEXT;
|
||||
const faux = fauxProvider({ provider: "umt-fake" });
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
faux.setResponses(mode === "resume" ? [script[2]] : script);
|
||||
|
||||
const root = new URL("./.sessions", import.meta.url).pathname;
|
||||
const fs = new NodeExecutionEnv({ cwd: root }); // used only as FileSystem for session storage; no shell tool is exposed
|
||||
const repo = new JsonlSessionRepo({ fileSystem: fs, sessionsRoot: root });
|
||||
let session;
|
||||
if (mode === "resume") {
|
||||
const list = await repo.list({ cwd: root }, ctx);
|
||||
const meta = list.find((m) => m.id === "crash-demo");
|
||||
console.log("found session", meta?.id, meta?.path);
|
||||
session = await repo.open(meta!, ctx);
|
||||
} else {
|
||||
session = await repo.create({ id: mode === "crash" ? "crash-demo" : `abort-${Date.now()}`, cwd: root }, ctx);
|
||||
}
|
||||
|
||||
const { harness, open } = await AgentHarness.create({
|
||||
session, models, model: faux.getModel(), systemPrompt: "媒体库助手",
|
||||
tools: [inventoryQuery, downloadSubmit], toolContext: { actorId: "user:1", grants: ["download.submit"] },
|
||||
toolExecution: "sequential",
|
||||
}, ctx);
|
||||
console.log("open operations at attach:", JSON.stringify(open));
|
||||
|
||||
let lane = await harness.lane("main", ctx);
|
||||
for (const t of ["tool_start", "tool_end", "run_end", "operation_abort", "run_resume"] as const)
|
||||
harness.events.on(t as any, async (e: any) => {
|
||||
console.log(`[${e.type}]${e.recovery ? " (recovery)" : ""} ${e.toolName ?? ""} ${e.status ?? ""} ${e.result ? JSON.stringify(e.result.content[0]?.text).slice(0, 220) : ""}`);
|
||||
if (e.type === "tool_start" && e.toolName === "download__submit") {
|
||||
if (mode === "abort") setTimeout(async () => { console.log(">> lane.abort()"); console.log("abort result:", JSON.stringify(await lane.abort(ctx))); }, 300);
|
||||
if (mode === "crash") setTimeout(() => { console.log(">> SIGKILL self during download.submit"); process.kill(process.pid, "SIGKILL"); }, 300);
|
||||
}
|
||||
});
|
||||
|
||||
if (mode === "resume") {
|
||||
const r = await lane.resume(ctx);
|
||||
console.log("RESUME RESULT", JSON.stringify(r));
|
||||
} else {
|
||||
const r = await lane.prompt("把《Dune》1080p 收进来", undefined, ctx);
|
||||
console.log("PROMPT RESULT", JSON.stringify(r));
|
||||
}
|
||||
console.log("SUBMITTED in this process", JSON.stringify(submitted), "fake calls:", faux.state.callCount);
|
||||
const entries = (await lane.findEntries(undefined, ctx)).reverse();
|
||||
for (const e of entries as any[]) {
|
||||
if (e.type !== "message") { console.log(" entry", e.type); continue; }
|
||||
const m = e.message;
|
||||
const text = m.role === "assistant" ? m.content.map((c: any) => c.type === "toolCall" ? `toolCall(${c.name})` : c.type === "text" ? c.text : `<${c.type}>`).join(" | ")
|
||||
: Array.isArray(m.content) ? m.content.map((c: any) => c.text).join("") : m.content;
|
||||
console.log(` ${m.role.padEnd(10)} ${m.toolName ?? ""} ${m.isError ? "[isError]" : ""} ${String(text).slice(0, 200)}`);
|
||||
}
|
||||
await harness.close(ctx);
|
||||
@@ -0,0 +1,83 @@
|
||||
// Shared pieces for the Pi spike: whitelisted business tools + scripted fake model.
|
||||
import { Type } from "typebox";
|
||||
import {
|
||||
fauxAssistantMessage, fauxText, fauxThinking, fauxToolCall,
|
||||
type FauxResponseFactory,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { AgentHarnessTool } from "@earendil-works/pi-agent-core";
|
||||
|
||||
// Capability id -> provider-safe tool name. Anthropic/OpenAI tool names must match ^[a-zA-Z0-9_-]{1,64}$,
|
||||
// so "inventory.query" cannot be sent verbatim; the port maps ids <-> names.
|
||||
export const toolName = (capabilityId: string) => capabilityId.replace(/\./g, "__");
|
||||
|
||||
export const submitted: Array<{ key: string; title: string }> = [];
|
||||
|
||||
type Ctx = { actorId: string; grants: string[] };
|
||||
|
||||
export const inventoryQuery: AgentHarnessTool<Ctx> = {
|
||||
name: toolName("inventory.query"),
|
||||
label: "inventory.query",
|
||||
description: "Query local library inventory for a title. Returns whether it exists and which versions.",
|
||||
parameters: Type.Object({ title: Type.String(), year: Type.Optional(Type.Number()) }),
|
||||
replay: "safe", // read-only: harness may re-execute after a crash
|
||||
async execute(_id, params, onUpdate, toolCtx, invocation, context) {
|
||||
onUpdate({ content: [{ type: "text", text: "searching…" }], details: {} });
|
||||
const found = params.title === "Arrival";
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ title: params.title, inLibrary: found, versions: found ? ["2160p"] : [] }) }],
|
||||
details: { actor: toolCtx.actorId, invocationId: invocation.invocationId, aborted: context.abortSignal?.aborted ?? false },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const downloadSubmit: AgentHarnessTool<Ctx> = {
|
||||
name: toolName("download.submit"),
|
||||
label: "download.submit",
|
||||
description: "Submit a download plan. Server validates authorization and generates the idempotency key.",
|
||||
parameters: Type.Object({ title: Type.String(), quality: Type.String() }),
|
||||
replay: "never", // external side effect: after crash the harness synthesizes an 'interrupted' result instead of re-running
|
||||
executionMode: "sequential",
|
||||
async execute(_id, params, _onUpdate, toolCtx, invocation, context) {
|
||||
if (!toolCtx.grants.includes("download.submit")) throw new Error("not authorized: download.submit");
|
||||
// Server-side idempotency key derived from operation semantics, not from the model.
|
||||
const key = `dl:${params.title}:${params.quality}`;
|
||||
const delayMs = Number(process.env.SUBMIT_DELAY_MS ?? 0);
|
||||
if (delayMs) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = setTimeout(resolve, delayMs);
|
||||
context.abortSignal?.addEventListener("abort", () => { clearTimeout(t); reject(new Error("aborted by signal")); });
|
||||
});
|
||||
}
|
||||
if (!submitted.find((s) => s.key === key)) submitted.push({ key, title: params.title });
|
||||
await invocation.setMemo("submitted", key);
|
||||
return { content: [{ type: "text", text: JSON.stringify({ accepted: true, idempotencyKey: key, status: "submitted" }) }], details: { key } };
|
||||
},
|
||||
};
|
||||
|
||||
const lastToolResult = (ctx: any, name: string) =>
|
||||
[...ctx.messages].reverse().find((m: any) => m.role === "toolResult" && m.toolName === name);
|
||||
|
||||
// Scripted fake model: each step is a factory that looks at the transcript (tool results) and decides.
|
||||
export const script: FauxResponseFactory[] = [
|
||||
() => fauxAssistantMessage([
|
||||
fauxThinking("先查本地库存"),
|
||||
fauxText("我先查一下库里有没有《Dune》。"),
|
||||
fauxToolCall(toolName("inventory.query"), { title: "Dune", year: 2021 }),
|
||||
], { stopReason: "toolUse" }),
|
||||
(ctx) => {
|
||||
const inv = JSON.parse(lastToolResult(ctx, toolName("inventory.query")).content[0].text);
|
||||
if (inv.inLibrary) return fauxAssistantMessage("库里已有,无需下载。");
|
||||
return fauxAssistantMessage([
|
||||
fauxText("库里没有,提交 1080p 下载;顺便试一下未授权的 bash。"),
|
||||
fauxToolCall(toolName("download.submit"), { title: "Dune", quality: "1080p" }),
|
||||
fauxToolCall("bash", { command: "rm -rf /" }),
|
||||
], { stopReason: "toolUse" });
|
||||
},
|
||||
(ctx) => {
|
||||
const dl = lastToolResult(ctx, toolName("download.submit"));
|
||||
const bash = lastToolResult(ctx, "bash");
|
||||
return fauxAssistantMessage(
|
||||
`下载已提交(仅代表已提交,不代表已入库):${dl.content[0].text};bash 结果 isError=${bash?.isError}。`,
|
||||
);
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
// Manual + threshold compaction with the fake model (summary text is scripted).
|
||||
import { createModels, fauxProvider, fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { AgentHarness, BACKGROUND_CONTEXT as ctx, MemorySessionRepo } from "@earendil-works/pi-agent-core";
|
||||
const faux = fauxProvider({ provider: "umt-fake", models: [{ id: "tiny", contextWindow: 3000, maxTokens: 500 }] });
|
||||
const models = createModels(); models.setProvider(faux.provider);
|
||||
const session = await new MemorySessionRepo().create({}, ctx);
|
||||
const { harness } = await AgentHarness.create({ session, models, model: faux.getModel(), systemPrompt: "助手",
|
||||
compaction: { enabled: true, reserveTokens: 600, keepRecentTokens: 200 } }, ctx);
|
||||
for (const t of ["compaction_start", "compaction_end", "run_end"] as const)
|
||||
harness.events.on(t as any, (e: any) => console.log(`[${e.type}] reason=${e.reason ?? ""} status=${e.status ?? ""}`));
|
||||
const lane = await harness.lane("main", ctx);
|
||||
const long = "目标:收录 Dune 1080p。约束:只用已授权能力。".repeat(Number(process.env.REPEAT ?? 60));
|
||||
faux.setResponses([fauxAssistantMessage("好的,已记录目标。" + "x".repeat(2000)), fauxAssistantMessage("## 摘要\n目标:收录 Dune 1080p;已完成:无"), fauxAssistantMessage("继续")]);
|
||||
console.log("run1:", JSON.stringify((await lane.prompt(long, undefined, ctx)) as any).slice(0, 400));
|
||||
console.log("run2:", JSON.stringify((await lane.prompt("继续", undefined, ctx)) as any).slice(0, 400));
|
||||
faux.appendResponses([fauxAssistantMessage("## 手动摘要\n目标/约束/证据/已完成/剩余")]);
|
||||
const c = await lane.compact({ customInstructions: "保留目标、约束、证据引用、已完成动作、剩余事项" }, ctx);
|
||||
console.log("manual compact:", JSON.stringify(c).slice(0, 200));
|
||||
const entries = await lane.findEntries(undefined, ctx);
|
||||
console.log("entry types:", entries.map((e: any) => e.type).join(","), "fake calls:", faux.state.callCount);
|
||||
await harness.close(ctx);
|
||||
@@ -0,0 +1,50 @@
|
||||
// Fake model drives AgentHarness through 2 whitelisted tools; streams events.
|
||||
import { createModels, fauxProvider } from "@earendil-works/pi-ai";
|
||||
import { AgentHarness, BACKGROUND_CONTEXT, MemorySessionRepo } from "@earendil-works/pi-agent-core";
|
||||
import { downloadSubmit, inventoryQuery, script, submitted } from "./common.ts";
|
||||
|
||||
const faux = fauxProvider({ provider: "umt-fake" });
|
||||
const models = createModels();
|
||||
models.setProvider(faux.provider);
|
||||
faux.setResponses(script);
|
||||
|
||||
const repo = new MemorySessionRepo();
|
||||
const session = await repo.create({ id: "spike-1" }, BACKGROUND_CONTEXT);
|
||||
const { harness } = await AgentHarness.create({
|
||||
session, models, model: faux.getModel(),
|
||||
systemPrompt: "你是媒体库自动化助手,只能使用提供的工具。",
|
||||
tools: [inventoryQuery, downloadSubmit], // explicit whitelist; no discovery
|
||||
toolContext: { actorId: "user:1", grants: ["download.submit"] },
|
||||
thinkingLevel: "off",
|
||||
compaction: { enabled: true, reserveTokens: 4096, keepRecentTokens: 8000 },
|
||||
retry: { enabled: true, maxRetries: 2, baseDelayMs: 100 },
|
||||
toolExecution: "sequential",
|
||||
}, BACKGROUND_CONTEXT);
|
||||
|
||||
// Server-side authorization gate in addition to the whitelist.
|
||||
harness.hooks.on("before_tool", (ev) => {
|
||||
console.log(` [hook before_tool] ${ev.toolName} args=${JSON.stringify(ev.args)}`);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const types = ["run_start", "turn_start", "message_update", "tool_start", "tool_update", "tool_end", "turn_end", "run_end", "usage", "fault", "handler_error"] as const;
|
||||
for (const t of types) harness.events.on(t as any, (e: any) => {
|
||||
if (e.type === "message_update") {
|
||||
if (e.event.type === "text_delta") process.stdout.write(` [text_delta] ${JSON.stringify(e.event.delta)}\n`);
|
||||
else if (e.event.type === "toolcall_end") console.log(` [toolcall_end] ${e.event.toolCall.name}`);
|
||||
return;
|
||||
}
|
||||
if (e.type === "tool_end") return console.log(`[tool_end] ${e.toolName} isError=${e.isError} -> ${JSON.stringify(e.result.content[0]?.text)}`);
|
||||
if (e.type === "tool_start") return console.log(`[tool_start] ${e.toolName} ${JSON.stringify(e.args)}`);
|
||||
if (e.type === "usage") return console.log(`[usage] in=${e.row?.usage?.input ?? "?"} totals.input=${e.totals.input} output=${e.totals.output}`);
|
||||
if (e.type === "run_end") return console.log(`[run_end] status=${e.status}${e.error ? " error=" + JSON.stringify(e.error) : ""}`);
|
||||
console.log(`[${e.type}]${e.lane ? " lane=" + e.lane : ""}`);
|
||||
});
|
||||
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
const result = await lane.prompt("把《Dune》1080p 收进来", undefined, BACKGROUND_CONTEXT);
|
||||
console.log("RESULT", JSON.stringify(result));
|
||||
console.log("SUBMITTED", JSON.stringify(submitted), "fake calls:", faux.state.callCount);
|
||||
const entries = await lane.findEntries(undefined, BACKGROUND_CONTEXT);
|
||||
console.log("TRANSCRIPT roles:", entries.map((e: any) => e.type === "message" ? e.message.role : e.type).join(","));
|
||||
await harness.close(BACKGROUND_CONTEXT);
|
||||
@@ -0,0 +1,69 @@
|
||||
// Real provider types (Anthropic Messages + OpenAI-compatible Chat Completions) pointed at a LOCAL capture server.
|
||||
// No external network: the capture server records method/path/headers/body and returns HTTP 400.
|
||||
import http from "node:http";
|
||||
import { createModels, createProvider, getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
|
||||
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
||||
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
||||
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
|
||||
import { AgentHarness, BACKGROUND_CONTEXT, MemorySessionRepo } from "@earendil-works/pi-agent-core";
|
||||
import { inventoryQuery } from "./common.ts";
|
||||
|
||||
const captured: any[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = ""; req.on("data", (c) => (body += c)); req.on("end", () => {
|
||||
captured.push({ method: req.method, url: req.url, auth: req.headers["authorization"] ?? req.headers["x-api-key"], body: JSON.parse(body || "{}") });
|
||||
res.writeHead(400, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { type: "invalid_request_error", message: "capture server: refusing on purpose" } }));
|
||||
});
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||
const base = `http://127.0.0.1:${(server.address() as any).port}`;
|
||||
|
||||
// Secret comes from our own secret store; resolve() is called at request time. No env var lookup.
|
||||
const secret = (name: string) => ({ name, resolve: async () => ({ auth: { apiKey: `sk-test-${name}` } }) });
|
||||
|
||||
const anthropicCompatible: Model<"anthropic-messages"> = {
|
||||
id: "claude-sonnet-4-5", name: "Anthropic-compatible upstream", api: "anthropic-messages", provider: "upstream-anthropic",
|
||||
baseUrl: base, reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000, maxTokens: 8192,
|
||||
};
|
||||
const openaiCompatible: Model<"openai-completions"> = {
|
||||
id: "qwen3-235b", name: "OpenAI-compatible upstream", api: "openai-completions", provider: "upstream-openai",
|
||||
baseUrl: `${base}/v1`, reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 131072, maxTokens: 8192, compat: { supportsDeveloperRole: false, thinkingFormat: "qwen" },
|
||||
};
|
||||
|
||||
const models = createModels();
|
||||
models.setProvider(createProvider({ id: "upstream-anthropic", auth: { apiKey: secret("anthropic") }, models: [anthropicCompatible], api: anthropicMessagesApi() }));
|
||||
models.setProvider(createProvider({ id: "upstream-openai", auth: { apiKey: secret("openai") }, models: [openaiCompatible], api: openAICompletionsApi() }));
|
||||
|
||||
// Built-in provider catalog is available too (no request made):
|
||||
const builtin = anthropicProvider();
|
||||
const cat = createModels(); cat.setProvider(builtin);
|
||||
console.log("builtin anthropic models (first 5):", cat.getModels("anthropic").slice(0, 5).map((m) => m.id).join(", "));
|
||||
console.log("builtin anthropic auth configured without env?", JSON.stringify(await cat.checkAuth("anthropic")));
|
||||
console.log("supported thinking levels, custom anthropic model:", getSupportedThinkingLevels(anthropicCompatible).join(","));
|
||||
console.log("supported thinking levels, custom openai model:", getSupportedThinkingLevels(openaiCompatible).join(","));
|
||||
|
||||
for (const model of [anthropicCompatible, openaiCompatible] as Model<any>[]) {
|
||||
const repo = new MemorySessionRepo();
|
||||
const session = await repo.create({}, BACKGROUND_CONTEXT);
|
||||
const { harness } = await AgentHarness.create({
|
||||
session, models, model, systemPrompt: "媒体库助手", tools: [inventoryQuery], toolContext: { actorId: "u", grants: [] },
|
||||
thinkingLevel: "high",
|
||||
compaction: { enabled: true, reserveTokens: 8000, keepRecentTokens: 12000 },
|
||||
retry: { enabled: false, maxRetries: 0, baseDelayMs: 0 },
|
||||
streamOptions: { timeoutMs: 5000, maxRetries: 0, headers: { "x-umt-run": "spike" } },
|
||||
}, BACKGROUND_CONTEXT);
|
||||
harness.hooks.on("before_request", (ev) => { console.log(`[before_request] ${ev.model.provider}/${ev.model.id} step=${ev.step} attempt=${ev.attempt}`); return undefined; });
|
||||
const lane = await harness.lane("main", BACKGROUND_CONTEXT);
|
||||
const r = await lane.prompt("库里有 Dune 吗", undefined, BACKGROUND_CONTEXT);
|
||||
console.log(`[${model.provider}] run:`, JSON.stringify(r.ok ? { status: r.value.status, error: (r.value as any).error } : r.error).slice(0, 300));
|
||||
await harness.close(BACKGROUND_CONTEXT);
|
||||
}
|
||||
for (const c of captured) {
|
||||
const b = c.body;
|
||||
console.log(`CAPTURED ${c.method} ${c.url} auth=${c.auth}`);
|
||||
console.log(` model=${b.model} max_tokens=${b.max_tokens ?? b.max_completion_tokens} thinking=${JSON.stringify(b.thinking ?? b.enable_thinking ?? b.reasoning_effort)} tools=${JSON.stringify((b.tools ?? []).map((t: any) => t.name ?? t.function?.name))} stream=${b.stream}`);
|
||||
}
|
||||
server.close();
|
||||
Reference in new issue
Block a user