mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
feat(workflow): add workflow engine, /workflows panel, /ultracode skill
将 feat/sdk-backend 分支中 workflow 相关的 20 个 commit 压缩为单 commit: - 工作流引擎核心:phase / agent / parallel / pipeline 编排原语(packages/workflow-engine/) - /workflows 面板:三区焦点布局(顶部 run tabs + 左侧 phase 侧栏 + 右侧 agent 列表) - /ultracode skill:多 agent workflow 编排入口 - 进度存储 / journal / notification 系统 - WorkflowService 生命周期管理 + SentryErrorBoundary - 脚本沙箱:禁用 dynamic import()、JSON args 防御性归一化 - journal 与 named-workflow 路径统一在 projectRoot - 错误处理:parallel/pipeline hooks 错误日志、failure routing、semaphore abort - workflow 工具升级为 core 工具 + PascalCase 命名 Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>
This commit is contained in:
124
packages/workflow-engine/examples/registry-demo.ts
Normal file
124
packages/workflow-engine/examples/registry-demo.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* registry 多后端路由演示(mock adapter,无需 API key)。
|
||||
*
|
||||
* 两个 adapter:strong(被 researcher 路由命中)+ fast(默认)。
|
||||
* 脚本里 agent({agentType:'researcher'}) → strong,其余 → fast。
|
||||
* 证明 agent 后端可通过 AgentAdapterRegistry 插拔 + 路由,引擎不关心实现。
|
||||
*
|
||||
* 用法:bun run packages/workflow-engine/examples/registry-demo.ts
|
||||
*/
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
AgentAdapterRegistry,
|
||||
createFileJournalStore,
|
||||
createHostHandle,
|
||||
runWorkflow,
|
||||
type AgentAdapter,
|
||||
type AgentRunParams,
|
||||
type AgentRunResult,
|
||||
type WorkflowPorts,
|
||||
} from '@claude-code-best/workflow-engine'
|
||||
|
||||
const strongAdapter: AgentAdapter = {
|
||||
id: 'strong',
|
||||
capabilities: { structuredOutput: true, tools: true },
|
||||
async run(p: AgentRunParams): Promise<AgentRunResult> {
|
||||
return {
|
||||
kind: 'ok',
|
||||
output: `[strong] ← ${p.prompt}`,
|
||||
usage: { outputTokens: 1 },
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const fastAdapter: AgentAdapter = {
|
||||
id: 'fast',
|
||||
capabilities: { structuredOutput: false },
|
||||
async run(p: AgentRunParams): Promise<AgentRunResult> {
|
||||
return {
|
||||
kind: 'ok',
|
||||
output: `[fast] ← ${p.prompt}`,
|
||||
usage: { outputTokens: 1 },
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const registry = new AgentAdapterRegistry()
|
||||
.register(strongAdapter)
|
||||
.register(fastAdapter)
|
||||
.route({ kind: 'agentType', agentType: 'researcher', adapter: 'strong' })
|
||||
.default('fast')
|
||||
|
||||
const SCRIPT = `
|
||||
export const meta = { name: 'registry-demo', description: 'multi-adapter routing' }
|
||||
phase('Route')
|
||||
const research = await agent('深度调研任务', { agentType: 'researcher', label: 'research' })
|
||||
const quick = await agent('快速小任务', { label: 'quick' })
|
||||
return { research, quick }
|
||||
`
|
||||
|
||||
function makePorts(runsDir: string): WorkflowPorts {
|
||||
return {
|
||||
// registry 优先,agentRunner 仅作形状占位(不会被调到)
|
||||
agentRunner: { runAgentToResult: async () => ({ kind: 'dead' }) },
|
||||
agentAdapterRegistry: registry,
|
||||
progressEmitter: {
|
||||
emit: e => {
|
||||
if (e.type === 'phase_started') console.log(`\n━ phase: ${e.phase}`)
|
||||
else if (e.type === 'agent_done') {
|
||||
const out =
|
||||
e.result.kind === 'ok'
|
||||
? String(e.result.output)
|
||||
: `[${e.result.kind}]`
|
||||
console.log(` ✓ ${e.label} → ${out}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
taskRegistrar: {
|
||||
register: () => ({
|
||||
runId: 'demo',
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
complete() {},
|
||||
fail() {},
|
||||
kill() {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: createFileJournalStore(runsDir),
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: process.cwd(),
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await registry.initializeAll()
|
||||
try {
|
||||
const result = await runWorkflow({
|
||||
script: SCRIPT,
|
||||
runId: `demo-${Date.now()}`,
|
||||
ports: makePorts(join(tmpdir(), 'wf-registry-demo')),
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: process.cwd(),
|
||||
budgetTotal: null,
|
||||
})
|
||||
console.log(`\n■ ${result.status}`)
|
||||
if (result.status === 'completed') {
|
||||
const ret = result.returnValue as { research: string; quick: string }
|
||||
console.log(
|
||||
`research(agentType:researcher) → ${ret.research.startsWith('[strong]') ? 'strong adapter ✓' : '??'}`,
|
||||
)
|
||||
console.log(
|
||||
`quick(默认) → ${ret.quick.startsWith('[fast]') ? 'fast adapter ✓' : '??'}`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await registry.disposeAll()
|
||||
}
|
||||
}
|
||||
74
packages/workflow-engine/examples/research-report/README.md
Normal file
74
packages/workflow-engine/examples/research-report/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# research-report —— 库优先运行示例
|
||||
|
||||
用 `@claude-code-best/workflow-engine` **直接**运行一个 workflow,绕开 Workflow 工具与核心 `runAgent`。
|
||||
|
||||
## 状态
|
||||
|
||||
- **引擎层**:完整且测试覆盖 **99.65% 行 / 99.20% 函数**(workflow-engine 包 112 个 mock 测试全绿)。
|
||||
- **本 example**:编排逻辑(`parallel` / `pipeline` / `schema` / `args`)经 mock 端到端验证;**真实 LLM 已跑通**(直连 Anthropic SDK)。
|
||||
- **定位**:库 API 与引擎逻辑的**参考实现 + 冒烟示范**,不是生产服务——见下方「生产就绪」。
|
||||
|
||||
## 它演示了什么
|
||||
|
||||
- **库可独立使用**:`run.ts` 只 `import { runWorkflow, ... } from '@claude-code-best/workflow-engine'`,自己组装 7 个端口,不依赖 `src/` 任何核心模块。
|
||||
- **agent 后端直连 Anthropic SDK**:`agentRunner` 调 `client.messages.create`,子 agent = 一次模型调用(不经核心 `runAgent`、不经 Workflow 工具)。
|
||||
- **真实 LLM + 结构化输出**:`agent(schema)` → prompt 追加 JSON 指令 → 提取 JSON → `validateAgainstSchema`(Ajv)校验,失败回退 `dead`。
|
||||
- **引擎能力全覆盖**:`parallel`(屏障,多角度 fan-out)→ `pipeline`(无屏障,逐条深挖)→ `phase` / `log` / `args`。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-... \
|
||||
bun run packages/workflow-engine/examples/research-report/run.ts "Edge Computing"
|
||||
```
|
||||
|
||||
环境变量:
|
||||
|
||||
- `ANTHROPIC_API_KEY`(必填)
|
||||
- `ANTHROPIC_MODEL`:默认 `claude-sonnet-4-5`
|
||||
- `WORKFLOW_API_CONCURRENCY`:API 并发上限,默认 `3`(见下)。低 tier 可设 `1` 串行
|
||||
- `RESEARCH_RUNS_DIR`:journal 目录,默认 `~/.claude/workflow-runs`(resume 时复用)
|
||||
|
||||
## 健壮性与排错
|
||||
|
||||
runner 内置了几项让真实 API 跑得稳的处理:
|
||||
|
||||
- **API 并发限制**:`llmAgent` 经独立信号量限并发(默认 3),**独立于引擎的 CPU 级 semaphore**——LLM API 对并发远比 CPU 敏感,按 cores(可能 14)放并发会触发 429。用 `WORKFLOW_API_CONCURRENCY` 调。
|
||||
- **429/5xx 重试**:指数退避(500ms → 1s → 2s → 4s,最多 4 次);连接/超时错误也重试。
|
||||
- **SDK 日志关闭**:`new Anthropic({ logLevel: 'off' })`(options 优先级最高,压过 `ANTHROPIC_LOG` env)。否则 SDK 会打 `[log_xxxxx] sending request {…}` 这种完整请求 JSON。
|
||||
- **错误摘要精简**:失败只打 `HTTP 429 rate_limit_error` 这种短行,不打印含 request body 的整段 message。
|
||||
- **synthesize 防 JSON**:prompt 明确禁止把输入的 `deepFindings` JSON 原样粘进报告。
|
||||
|
||||
排错速查:
|
||||
|
||||
| 现象 | 原因 / 处理 |
|
||||
|------|------|
|
||||
| `HTTP 429 ...` 频繁 | 降 `WORKFLOW_API_CONCURRENCY=1`(或 2) |
|
||||
| agent `✗ [dead]` 多 | 模型未按 schema 返回 JSON;换更强模型或放宽 schema |
|
||||
| `[log_xxx] sending request` 刷屏 | 不应再出现(已 `logLevel:'off'`);若仍出现检查 env 是否覆盖 |
|
||||
| 报告被截断 | synthesize 已 `maxTokens:8192`;仍不够可改 workflow 脚本 |
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `research-report.workflow.mjs` | workflow 脚本(编排逻辑,纯 JS,引擎沙箱执行) |
|
||||
| `run.ts` | runner:组装端口 + 直连 SDK + 运行 + 终端进度 |
|
||||
| (同级 `../smoke.ts`) | 最小冒烟入口(3 次调用,秒级验证通路) |
|
||||
|
||||
## 扩展点
|
||||
|
||||
- **联网调研**:给 `llmAgent` 的 `messages.create` 加 `tools: [{ type: 'web_search_20250305' }]`(Anthropic server-side web search),research 角度即可联网。
|
||||
- **命名命令复用**:把 `research-report.workflow.mjs` 复制到项目 `.claude/workflows/research-report.mjs`,即可通过 `/research-report` 或 Workflow 工具运行(同一脚本,两种入口)。
|
||||
- **token 预算**:`runWorkflow({ budgetTotal: 200000 })` 设上限;脚本内用 `budget.remaining()` 自适应规模。
|
||||
- **resume**:同 `runId` + `resume: true` 重放 journal,已完成的 agent 不重跑。
|
||||
|
||||
## 生产就绪(诚实)
|
||||
|
||||
本 example 验证的是**库的 API 与引擎编排逻辑**,不是生产服务。要上生产还差:
|
||||
|
||||
- **真实 LLM 压测**:长 workflow、大量并发、中断/resume 的真实场景验证(mock 覆盖不到模型行为)。
|
||||
- **核心 adapter 的 v1 延期项**:`budgetTotal` 注入、skip/retry UI、worktree 隔离、StructuredOutput 完整接入(本 example 用 prompt+JSON 解析,比核心真实路径弱)。
|
||||
- **错误恢复**:journal resume 只在 mock 验证过;真实中途崩溃的重放正确性未压测。
|
||||
|
||||
引擎核心逻辑(并发 / 预算 / journal / schema)有 99.65% 覆盖的 mock 测试兜底,可作为基础继续建。
|
||||
@@ -0,0 +1,124 @@
|
||||
// research-report.workflow.mjs
|
||||
// 技术研究报告 workflow。
|
||||
// 由 run.ts 通过 @claude-code-best/workflow-engine 的 runWorkflow() 直接执行——
|
||||
// 不经 Workflow 工具、不经核心 runAgent。脚本内的 agent / parallel / pipeline /
|
||||
// phase / log / args 均为引擎运行时注入的全局(见 src/engine/script.ts 的沙箱)。
|
||||
//
|
||||
// 编排:多角度并行调研(parallel 屏障)→ 逐条深挖(pipeline 无屏障)→ 综合成报告。
|
||||
|
||||
export const meta = {
|
||||
name: 'research-report',
|
||||
description:
|
||||
'Multi-angle tech research → deep-read → synthesize into a Markdown report',
|
||||
whenToUse: '调研一个技术主题:从多个角度并行研究、逐条深挖、综合成结构化报告',
|
||||
phases: [
|
||||
{ title: 'Research', detail: '多角度并行调研(parallel 屏障)' },
|
||||
{ title: 'DeepRead', detail: '逐条深挖(pipeline 无屏障)' },
|
||||
{ title: 'Synthesize', detail: '综合成 Markdown 报告' },
|
||||
],
|
||||
}
|
||||
|
||||
// agent(schema) 让子 agent 返回「校验对象」而非纯文本。
|
||||
const ANGLE_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['angle', 'findings'],
|
||||
properties: {
|
||||
angle: { type: 'string', description: '本次调研的角度名' },
|
||||
findings: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['claim', 'evidence'],
|
||||
properties: {
|
||||
claim: { type: 'string', description: '一句话结论' },
|
||||
evidence: { type: 'string', description: '依据/来源/理由' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const DEEP_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['claim', 'analysis', 'confidence'],
|
||||
properties: {
|
||||
claim: { type: 'string' },
|
||||
analysis: { type: 'string', description: '机理/前提/边界/反例' },
|
||||
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
|
||||
},
|
||||
}
|
||||
|
||||
// ---- 输入(由 run.ts 通过 args 透传)----
|
||||
const topic = args.topic
|
||||
if (typeof topic !== 'string' || topic.length === 0) {
|
||||
throw new Error('research-report 需要 args.topic(研究主题字符串)')
|
||||
}
|
||||
const angles =
|
||||
Array.isArray(args.angles) && args.angles.length > 0
|
||||
? args.angles
|
||||
: ['核心概念与原理', '主流方案与对比', '工程实践与权衡', '生态与趋势']
|
||||
|
||||
// ---- Phase 1:多角度并行调研。parallel = 屏障,等所有角度完成后才继续。----
|
||||
phase('Research')
|
||||
log(`主题「${topic}」:${angles.length} 个角度并行调研中`)
|
||||
const researched = await parallel(
|
||||
angles.map(
|
||||
a => () =>
|
||||
agent(
|
||||
`你是资深技术研究分析师。针对技术主题「${topic}」,从「${a}」角度调研,给出该角度下 2-4 条最关键的技术发现,每条须附依据。`,
|
||||
{ label: `research:${a}`, phase: 'Research', schema: ANGLE_SCHEMA },
|
||||
),
|
||||
),
|
||||
)
|
||||
// parallel 返回 (object|null)[]:skipped/dead 的角度为 null,过滤后展平
|
||||
const allFindings = researched
|
||||
.filter(Boolean)
|
||||
.flatMap(r => r.findings.map(f => ({ ...f, angle: r.angle })))
|
||||
log(`收集到 ${allFindings.length} 条发现,进入深挖`)
|
||||
|
||||
if (allFindings.length === 0) {
|
||||
return {
|
||||
topic,
|
||||
report: '(所有角度调研均失败,无可用发现)',
|
||||
anglesCovered: 0,
|
||||
findingsDeepened: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Phase 2:逐条深挖。pipeline = 无屏障,每条发现独立跑完所有 stage,互不等待。----
|
||||
phase('DeepRead')
|
||||
const deepened = await pipeline(
|
||||
allFindings,
|
||||
f =>
|
||||
agent(
|
||||
`针对以下技术发现,深入分析其机理、成立前提、适用边界与可能的反例:\n结论:${f.claim}\n依据:${f.evidence}\n角度:${f.angle}`,
|
||||
{ label: `deep:${f.angle}`, phase: 'DeepRead', schema: DEEP_SCHEMA },
|
||||
),
|
||||
// 第二个 stage:按置信度标注交叉价值(演示多 stage pipeline 链式传递)。
|
||||
// stage-1 若 dead 返回 null,这里显式守卫——避免对 null 取属性(否则被 pipeline
|
||||
// 的 per-item catch 吞掉、整条静默丢失)。
|
||||
d =>
|
||||
d
|
||||
? {
|
||||
...d,
|
||||
crossCutting:
|
||||
d.confidence === 'high' ? '可作为报告主干' : '需谨慎引用或佐证',
|
||||
}
|
||||
: null,
|
||||
)
|
||||
const deepFindings = deepened.filter(Boolean)
|
||||
log(`深挖完成 ${deepFindings.length}/${allFindings.length} 条`)
|
||||
|
||||
// ---- Phase 3:综合成 Markdown 报告(无 schema → 返回纯文本)----
|
||||
phase('Synthesize')
|
||||
const report = await agent(
|
||||
`你是首席技术分析师。基于以下经深挖的技术发现,综合一份结构化研究报告(纯 Markdown 叙述)。\n要求:含摘要、分角度分析、关键结论、落地建议与风险;用自然语言陈述每条发现并标注 confidence。\n禁止:在报告中粘贴 JSON 代码块或原样引用下方输入数据。\n\n主题:${topic}\n\n深度发现(JSON,仅供你理解,不要原样输出):\n${JSON.stringify(deepFindings)}`,
|
||||
{ label: 'synthesize', phase: 'Synthesize', maxTokens: 8192 },
|
||||
)
|
||||
|
||||
return {
|
||||
topic,
|
||||
report,
|
||||
anglesCovered: angles.length,
|
||||
findingsDeepened: deepFindings.length,
|
||||
}
|
||||
313
packages/workflow-engine/examples/research-report/run.ts
Normal file
313
packages/workflow-engine/examples/research-report/run.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* research-report runner —— 直接用 @claude-code-best/workflow-engine 运行 workflow,
|
||||
* 完全绕开 Workflow 工具与核心 runAgent。agent() 后端直连 Anthropic SDK
|
||||
* (@anthropic-ai/sdk):子 agent = 一次 messages.create。
|
||||
*
|
||||
* 用法:
|
||||
* ANTHROPIC_API_KEY=sk-... \
|
||||
* bun run packages/workflow-engine/examples/research-report/run.ts "Edge Computing"
|
||||
*
|
||||
* 可选环境变量:
|
||||
* ANTHROPIC_MODEL 模型名,默认 claude-sonnet-4-5
|
||||
* RESEARCH_RUNS_DIR journal 目录,默认 ~/.claude/workflow-runs(resume 复用)
|
||||
*/
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
createFileJournalStore,
|
||||
createHostHandle,
|
||||
runWorkflow,
|
||||
Semaphore,
|
||||
validateAgainstSchema,
|
||||
type AgentRunParams,
|
||||
type AgentRunResult,
|
||||
type ProgressEvent,
|
||||
type WorkflowPorts,
|
||||
} from '@claude-code-best/workflow-engine'
|
||||
|
||||
const SCRIPT_FILE = `${import.meta.dir}/research-report.workflow.mjs`
|
||||
const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5'
|
||||
const MAX_TOKENS = 4096
|
||||
|
||||
// 终端着色(无第三方依赖)
|
||||
const paint = {
|
||||
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
||||
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
||||
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
||||
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
||||
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
|
||||
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
||||
}
|
||||
|
||||
// client 由 main() 构造,llmAgent 闭包引用。null 守卫使 import 时不触发真实调用。
|
||||
const clientRef: { client: Anthropic | null } = { client: null }
|
||||
|
||||
// API 并发上限(独立于引擎的 CPU semaphore——LLM API 对并发远比 CPU 敏感,默认 3)。
|
||||
// 用 WORKFLOW_API_CONCURRENCY 调整。
|
||||
const apiSem = new Semaphore(
|
||||
Math.max(1, Number(process.env.WORKFLOW_API_CONCURRENCY) || 3),
|
||||
)
|
||||
|
||||
/** 429/5xx/连接错误指数退避重试(500ms → 1s → 2s → 4s),最多 4 次。 */
|
||||
async function withRetry<T>(fn: () => Promise<T>, retries = 4): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (e) {
|
||||
if (!isRetryable(e) || attempt >= retries) throw e
|
||||
const wait = Math.min(500 * 2 ** attempt, 8000)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, wait)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryable(e: unknown): boolean {
|
||||
const err = e as { status?: number; name?: string }
|
||||
if (err.status === 429) return true
|
||||
if (typeof err.status === 'number' && err.status >= 500) return true
|
||||
if (typeof err.name === 'string' && /Connection|Timeout/i.test(err.name)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 精简错误摘要(避免打印整个含 request body 的 message)。 */
|
||||
function errSummary(e: unknown): string {
|
||||
const err = e as {
|
||||
status?: number
|
||||
error?: { type?: string }
|
||||
message?: string
|
||||
}
|
||||
if (err.status) return `HTTP ${err.status} ${err.error?.type ?? ''}`.trim()
|
||||
return (err.message ?? 'unknown').slice(0, 120)
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实 LLM agentRunner:一次 messages.create(经 API 并发信号量 + 重试)。
|
||||
* schema 模式:prompt 追加 JSON 指令 → 取文本 → 提取 JSON → Ajv 校验 → 失败返回 dead。
|
||||
* 非 schema:返回纯文本。
|
||||
*/
|
||||
async function llmAgent(params: AgentRunParams): Promise<AgentRunResult> {
|
||||
const client = clientRef.client
|
||||
if (client === null) return { kind: 'dead' }
|
||||
|
||||
const schemaInstruction = params.schema
|
||||
? '\n\n你必须以一个【单独的 JSON 对象】作为整段回答(不要 Markdown 代码围栏、不要任何解释),该对象须匹配如下 JSON Schema:\n' +
|
||||
JSON.stringify(params.schema)
|
||||
: ''
|
||||
|
||||
const release = await apiSem.acquire()
|
||||
try {
|
||||
const resp = await withRetry(() =>
|
||||
client.messages.create({
|
||||
model: params.model ?? DEFAULT_MODEL,
|
||||
max_tokens: params.maxTokens ?? MAX_TOKENS,
|
||||
messages: [
|
||||
{ role: 'user', content: params.prompt + schemaInstruction },
|
||||
],
|
||||
}),
|
||||
)
|
||||
const outputTokens = resp.usage.output_tokens
|
||||
const truncated = resp.stop_reason === 'max_tokens'
|
||||
|
||||
if (params.schema) {
|
||||
// 截断的 JSON 几乎必然不完整 → 直接判 dead(而非让解析模糊失败)
|
||||
if (truncated) return { kind: 'dead' }
|
||||
const text = resp.content
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
const parsed = extractJsonObject(text)
|
||||
if (parsed === null) return { kind: 'dead' }
|
||||
const { valid } = validateAgainstSchema(parsed, params.schema)
|
||||
if (!valid) return { kind: 'dead' }
|
||||
return { kind: 'ok', output: parsed as object, usage: { outputTokens } }
|
||||
}
|
||||
const text = resp.content
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
if (truncated) {
|
||||
console.error(
|
||||
paint.yellow(` ⚠ 输出被 max_tokens 截断(${outputTokens} tokens)`),
|
||||
)
|
||||
}
|
||||
return { kind: 'ok', output: text, usage: { outputTokens } }
|
||||
} catch (e) {
|
||||
console.error(paint.red(` ✗ ${errSummary(e)}`))
|
||||
return { kind: 'dead' }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 容错 JSON 提取:去代码围栏 → 从首个 { 起做括号深度匹配(跳过字符串字面量与
|
||||
* 转义,仿 src/engine/script.ts 的 extractMeta),取配对的 {…} → JSON.parse。
|
||||
* 比 lastIndexOf('}') 稳健:正确处理 JSON 后散文里含 }、第二个对象、字符串内 }。
|
||||
*/
|
||||
function extractJsonObject(text: string): unknown | null {
|
||||
const stripped = text.replace(/```(?:json)?/gi, '').trim()
|
||||
const start = stripped.indexOf('{')
|
||||
if (start < 0) {
|
||||
try {
|
||||
return JSON.parse(stripped)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
let depth = 0
|
||||
let inStr: string | null = null
|
||||
for (let i = start; i < stripped.length; i++) {
|
||||
const ch = stripped[i]
|
||||
if (inStr) {
|
||||
if (ch === '\\') i++
|
||||
else if (ch === inStr) inStr = null
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === '`') inStr = ch
|
||||
else if (ch === '{') depth++
|
||||
else if (ch === '}') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
try {
|
||||
return JSON.parse(stripped.slice(start, i + 1))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 内存版 taskRegistrar:不经核心 LocalWorkflowTask,仅维护 runId → AbortController。 */
|
||||
function makeTaskRegistrar(): WorkflowPorts['taskRegistrar'] {
|
||||
const controllers = new Map<string, AbortController>()
|
||||
return {
|
||||
register(opts) {
|
||||
const ac = new AbortController()
|
||||
const runId = opts.runId ?? `research-${controllers.size + 1}`
|
||||
controllers.set(runId, ac)
|
||||
return { runId, signal: ac.signal }
|
||||
},
|
||||
complete() {},
|
||||
fail() {},
|
||||
kill(runId) {
|
||||
controllers.get(runId)?.abort()
|
||||
},
|
||||
pendingAction() {
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 进度事件 → 终端实时打印。 */
|
||||
function printProgress(e: ProgressEvent): void {
|
||||
switch (e.type) {
|
||||
case 'run_started':
|
||||
console.log(paint.bold(paint.cyan(`\n▶ ${e.workflowName}`)))
|
||||
break
|
||||
case 'phase_started':
|
||||
console.log(paint.cyan(`\n━ phase: ${e.phase}`))
|
||||
break
|
||||
case 'phase_done':
|
||||
break
|
||||
case 'agent_started':
|
||||
console.log(` ${paint.dim('→')} ${e.label ?? 'agent'}`)
|
||||
break
|
||||
case 'agent_done': {
|
||||
const tag =
|
||||
e.result.kind === 'ok'
|
||||
? paint.green('✓')
|
||||
: e.result.kind === 'skipped'
|
||||
? paint.yellow('⊘')
|
||||
: paint.red('✗')
|
||||
console.log(
|
||||
` ${tag} ${e.label ?? 'agent'} ${paint.dim(`[${e.result.kind}]`)}`,
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'log':
|
||||
console.log(` ${paint.dim('·')} ${e.message}`)
|
||||
break
|
||||
case 'run_done':
|
||||
console.log(paint.bold(`\n■ ${e.status}`))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** 组装端口:agent 后端直连 SDK,其余为自包含实现,不触达核心层。 */
|
||||
function makePorts(runsDir: string): WorkflowPorts {
|
||||
return {
|
||||
agentRunner: { runAgentToResult: llmAgent },
|
||||
progressEmitter: { emit: printProgress },
|
||||
taskRegistrar: makeTaskRegistrar(),
|
||||
journalStore: createFileJournalStore(runsDir),
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: process.cwd(),
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const topic = process.argv[2]
|
||||
if (!topic) {
|
||||
console.error(paint.red('✗ 用法:run.ts <研究主题>'))
|
||||
console.error(paint.dim(' 例:bun run run.ts "Edge Computing"'))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
clientRef.client = new Anthropic({ logLevel: 'off' })
|
||||
const runsDir =
|
||||
process.env.RESEARCH_RUNS_DIR ?? join(homedir(), '.claude', 'workflow-runs')
|
||||
const script = await readFile(SCRIPT_FILE, 'utf-8')
|
||||
|
||||
const result = await runWorkflow({
|
||||
script,
|
||||
args: { topic },
|
||||
runId: `research-${Date.now()}`,
|
||||
ports: makePorts(runsDir),
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: process.cwd(),
|
||||
budgetTotal: null,
|
||||
})
|
||||
|
||||
if (result.status !== 'completed') {
|
||||
console.error(
|
||||
paint.red(`✗ workflow ${result.status}:${result.error ?? ''}`),
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
const ret = result.returnValue as {
|
||||
report?: string
|
||||
topic?: string
|
||||
anglesCovered?: number
|
||||
findingsDeepened?: number
|
||||
}
|
||||
console.log(
|
||||
paint.bold(
|
||||
paint.green(`\n════════ 技术研究报告:${ret.topic ?? topic} ════════`),
|
||||
),
|
||||
)
|
||||
console.log(
|
||||
paint.dim(
|
||||
`角度数=${ret.anglesCovered ?? '?'} 深挖=${ret.findingsDeepened ?? '?'}`,
|
||||
),
|
||||
)
|
||||
console.log(ret.report ?? '(无报告输出)')
|
||||
}
|
||||
|
||||
// 仅作为脚本直接运行时启动(import 不触发,便于冒烟/复用端口工厂)
|
||||
if (import.meta.main) {
|
||||
await main()
|
||||
}
|
||||
251
packages/workflow-engine/examples/smoke.ts
Normal file
251
packages/workflow-engine/examples/smoke.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 冒烟端到端入口 —— 真实 SDK + 引擎,最小验证端到端通路。
|
||||
* 3 次模型调用(2 角度并行 schema + 1 综合),秒级完成、低成本。
|
||||
* 覆盖:runWorkflow、parallel(屏障)、agent(schema) 结构化、agent 文本、进度事件。
|
||||
*
|
||||
* 用法:
|
||||
* ANTHROPIC_API_KEY=sk-... \
|
||||
* bun run packages/workflow-engine/examples/smoke.ts
|
||||
*
|
||||
* 可选:ANTHROPIC_MODEL(默认 claude-sonnet-4-5)
|
||||
*/
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
createFileJournalStore,
|
||||
createHostHandle,
|
||||
runWorkflow,
|
||||
Semaphore,
|
||||
validateAgainstSchema,
|
||||
type AgentRunParams,
|
||||
type AgentRunResult,
|
||||
type ProgressEvent,
|
||||
type WorkflowPorts,
|
||||
} from '@claude-code-best/workflow-engine'
|
||||
|
||||
const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5'
|
||||
const clientRef: { client: Anthropic | null } = { client: null }
|
||||
|
||||
const POINT_SCHEMA = {
|
||||
type: 'object',
|
||||
required: ['point'],
|
||||
properties: { point: { type: 'string' } },
|
||||
}
|
||||
|
||||
// 最小 workflow:2 角度并行(schema 结构化)→ 综合(文本)。脚本内用 + 拼接避免 ${}。
|
||||
const SMOKE_SCRIPT =
|
||||
`
|
||||
export const meta = { name: 'smoke', description: 'minimal end-to-end smoke' }
|
||||
phase('Smoke')
|
||||
const angles = ['一句话定义', '一个最核心价值']
|
||||
const points = await parallel(
|
||||
angles.map(a => () =>
|
||||
agent('用简短一句话(30 字内)说明 workflow 编排的「' + a + '」。', {
|
||||
label: 'p:' + a,
|
||||
schema: ` +
|
||||
JSON.stringify(POINT_SCHEMA) +
|
||||
`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const clean = points.filter(Boolean)
|
||||
const joined = clean.map(p => p.point).join(';')
|
||||
const summary = await agent('把以下要点综合成一句中文结论。要点:' + joined, {
|
||||
label: 'summary',
|
||||
})
|
||||
return { points: clean, summary }
|
||||
`
|
||||
|
||||
// API 并发上限(独立于引擎的 CPU semaphore——LLM API 对并发远比 CPU 敏感,默认 3)。
|
||||
const apiSem = new Semaphore(
|
||||
Math.max(1, Number(process.env.WORKFLOW_API_CONCURRENCY) || 3),
|
||||
)
|
||||
|
||||
/** 429/5xx/连接错误指数退避重试,最多 4 次。 */
|
||||
async function withRetry<T>(fn: () => Promise<T>, retries = 4): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (e) {
|
||||
if (!isRetryable(e) || attempt >= retries) throw e
|
||||
const wait = Math.min(500 * 2 ** attempt, 8000)
|
||||
await new Promise(r => {
|
||||
setTimeout(r, wait)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRetryable(e: unknown): boolean {
|
||||
const err = e as { status?: number; name?: string }
|
||||
if (err.status === 429) return true
|
||||
if (typeof err.status === 'number' && err.status >= 500) return true
|
||||
if (typeof err.name === 'string' && /Connection|Timeout/i.test(err.name)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function errSummary(e: unknown): string {
|
||||
const err = e as {
|
||||
status?: number
|
||||
error?: { type?: string }
|
||||
message?: string
|
||||
}
|
||||
if (err.status) return `HTTP ${err.status} ${err.error?.type ?? ''}`.trim()
|
||||
return (err.message ?? 'unknown').slice(0, 120)
|
||||
}
|
||||
|
||||
async function llmAgent(params: AgentRunParams): Promise<AgentRunResult> {
|
||||
const client = clientRef.client
|
||||
if (client === null) return { kind: 'dead' }
|
||||
const schemaInstruction = params.schema
|
||||
? '\n\n以单独 JSON 对象回答(无围栏无解释),匹配 schema:\n' +
|
||||
JSON.stringify(params.schema)
|
||||
: ''
|
||||
const release = await apiSem.acquire()
|
||||
try {
|
||||
const resp = await withRetry(() =>
|
||||
client.messages.create({
|
||||
model: params.model ?? DEFAULT_MODEL,
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
messages: [
|
||||
{ role: 'user', content: params.prompt + schemaInstruction },
|
||||
],
|
||||
}),
|
||||
)
|
||||
const outputTokens = resp.usage.output_tokens
|
||||
if (resp.stop_reason === 'max_tokens') return { kind: 'dead' }
|
||||
const text = resp.content
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
if (params.schema) {
|
||||
const parsed = extractJsonObject(text)
|
||||
if (parsed === null) return { kind: 'dead' }
|
||||
if (!validateAgainstSchema(parsed, params.schema).valid) {
|
||||
return { kind: 'dead' }
|
||||
}
|
||||
return { kind: 'ok', output: parsed as object, usage: { outputTokens } }
|
||||
}
|
||||
return { kind: 'ok', output: text, usage: { outputTokens } }
|
||||
} catch (e) {
|
||||
console.error(` ✗ ${errSummary(e)}`)
|
||||
return { kind: 'dead' }
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
function extractJsonObject(text: string): unknown | null {
|
||||
const stripped = text.replace(/```(?:json)?/gi, '').trim()
|
||||
const start = stripped.indexOf('{')
|
||||
if (start < 0) {
|
||||
try {
|
||||
return JSON.parse(stripped)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
let depth = 0
|
||||
let inStr: string | null = null
|
||||
for (let i = start; i < stripped.length; i++) {
|
||||
const ch = stripped[i]
|
||||
if (inStr) {
|
||||
if (ch === '\\') i++
|
||||
else if (ch === inStr) inStr = null
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'" || ch === '`') inStr = ch
|
||||
else if (ch === '{') depth++
|
||||
else if (ch === '}') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
try {
|
||||
return JSON.parse(stripped.slice(start, i + 1))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function makePorts(runsDir: string): WorkflowPorts {
|
||||
return {
|
||||
agentRunner: { runAgentToResult: llmAgent },
|
||||
progressEmitter: {
|
||||
emit: (e: ProgressEvent) => {
|
||||
if (e.type === 'phase_started') console.log(`\n━ phase: ${e.phase}`)
|
||||
else if (e.type === 'agent_started')
|
||||
console.log(` → ${e.label ?? 'agent'}`)
|
||||
else if (e.type === 'agent_done')
|
||||
console.log(
|
||||
` ${e.result.kind === 'ok' ? '✓' : '✗'} ${e.label ?? ''} [${e.result.kind}]`,
|
||||
)
|
||||
else if (e.type === 'log') console.log(` · ${e.message}`)
|
||||
},
|
||||
},
|
||||
taskRegistrar: {
|
||||
register: () => ({
|
||||
runId: 'smoke',
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
complete() {},
|
||||
fail() {},
|
||||
kill() {},
|
||||
pendingAction: () => null,
|
||||
},
|
||||
journalStore: createFileJournalStore(runsDir),
|
||||
permissionGate: { isAborted: () => false },
|
||||
logger: { debug: () => {}, event: () => {} },
|
||||
hostFactory: () => ({
|
||||
handle: createHostHandle(null),
|
||||
cwd: process.cwd(),
|
||||
budgetTotal: null,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (!apiKey) {
|
||||
console.error('✗ 缺少 ANTHROPIC_API_KEY 环境变量')
|
||||
process.exit(1)
|
||||
}
|
||||
clientRef.client = new Anthropic({ apiKey, logLevel: 'off' })
|
||||
const runsDir =
|
||||
process.env.RESEARCH_RUNS_DIR ?? join(homedir(), '.claude', 'workflow-runs')
|
||||
|
||||
const result = await runWorkflow({
|
||||
script: SMOKE_SCRIPT,
|
||||
args: {},
|
||||
runId: `smoke-${Date.now()}`,
|
||||
ports: makePorts(runsDir),
|
||||
host: createHostHandle(null),
|
||||
signal: new AbortController().signal,
|
||||
cwd: process.cwd(),
|
||||
budgetTotal: null,
|
||||
})
|
||||
|
||||
if (result.status !== 'completed') {
|
||||
console.error(`\n✗ FAIL:${result.status} ${result.error ?? ''}`)
|
||||
process.exit(1)
|
||||
}
|
||||
const ret = result.returnValue as {
|
||||
points: Array<{ point: string }>
|
||||
summary: string
|
||||
}
|
||||
console.log('\n━━━━━━━━ 冒烟结果 ━━━━━━━━')
|
||||
for (const p of ret.points) console.log(`• ${p.point}`)
|
||||
console.log(`\n综合:${ret.summary}`)
|
||||
console.log(
|
||||
`\n✓ PASS:端到端通路正常(${ret.points.length} 要点 + 综合,3 次模型调用)`,
|
||||
)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await main()
|
||||
}
|
||||
Reference in New Issue
Block a user