From deac2336b6acc63f511372be0bcff18e078d3f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Sat, 23 May 2026 15:28:40 +0800 Subject: [PATCH 1/2] feat: add @uncaged/workflow-agent-builtin package Built-in role agent that uses workflow config models directly, with its own tool-calling run loop. No external agent dependency. - OpenAI-compatible chat completion client with tool_calls support - P0 toolkit: read_file, write_file, run_command - Integrates via createAgent factory from workflow-agent-kit - CAS detail recording for each turn - Path sandboxing and shell opt-in (UWF_BUILTIN_ALLOW_SHELL) --- docs/builtin-agent-research.md | 779 ++++++++++++++++++ .../__tests__/llm-parse.test.ts | 16 + .../__tests__/path.test.ts | 17 + .../__tests__/prompt.test.ts | 59 ++ packages/workflow-agent-builtin/package.json | 34 + packages/workflow-agent-builtin/src/agent.ts | 123 +++ packages/workflow-agent-builtin/src/cli.ts | 6 + packages/workflow-agent-builtin/src/detail.ts | 115 +++ packages/workflow-agent-builtin/src/index.ts | 14 + .../workflow-agent-builtin/src/llm/index.ts | 7 + .../workflow-agent-builtin/src/llm/llm.ts | 135 +++ .../workflow-agent-builtin/src/llm/types.ts | 29 + packages/workflow-agent-builtin/src/loop.ts | 110 +++ packages/workflow-agent-builtin/src/prompt.ts | 36 + .../workflow-agent-builtin/src/schemas.ts | 46 ++ .../workflow-agent-builtin/src/tools/index.ts | 44 + .../workflow-agent-builtin/src/tools/path.ts | 12 + .../src/tools/read-file.ts | 44 + .../src/tools/run-command.ts | 103 +++ .../workflow-agent-builtin/src/tools/types.ts | 13 + .../src/tools/write-file.ts | 39 + packages/workflow-agent-builtin/src/types.ts | 50 ++ packages/workflow-agent-builtin/tsconfig.json | 9 + scripts/publish-all.mjs | 1 + tsconfig.json | 1 + 25 files changed, 1842 insertions(+) create mode 100644 docs/builtin-agent-research.md create mode 100644 packages/workflow-agent-builtin/__tests__/llm-parse.test.ts create mode 100644 packages/workflow-agent-builtin/__tests__/path.test.ts create mode 100644 packages/workflow-agent-builtin/__tests__/prompt.test.ts create mode 100644 packages/workflow-agent-builtin/package.json create mode 100644 packages/workflow-agent-builtin/src/agent.ts create mode 100644 packages/workflow-agent-builtin/src/cli.ts create mode 100644 packages/workflow-agent-builtin/src/detail.ts create mode 100644 packages/workflow-agent-builtin/src/index.ts create mode 100644 packages/workflow-agent-builtin/src/llm/index.ts create mode 100644 packages/workflow-agent-builtin/src/llm/llm.ts create mode 100644 packages/workflow-agent-builtin/src/llm/types.ts create mode 100644 packages/workflow-agent-builtin/src/loop.ts create mode 100644 packages/workflow-agent-builtin/src/prompt.ts create mode 100644 packages/workflow-agent-builtin/src/schemas.ts create mode 100644 packages/workflow-agent-builtin/src/tools/index.ts create mode 100644 packages/workflow-agent-builtin/src/tools/path.ts create mode 100644 packages/workflow-agent-builtin/src/tools/read-file.ts create mode 100644 packages/workflow-agent-builtin/src/tools/run-command.ts create mode 100644 packages/workflow-agent-builtin/src/tools/types.ts create mode 100644 packages/workflow-agent-builtin/src/tools/write-file.ts create mode 100644 packages/workflow-agent-builtin/src/types.ts create mode 100644 packages/workflow-agent-builtin/tsconfig.json diff --git a/docs/builtin-agent-research.md b/docs/builtin-agent-research.md new file mode 100644 index 0000000..8a9cc00 --- /dev/null +++ b/docs/builtin-agent-research.md @@ -0,0 +1,779 @@ +# Built-in Role Agent 调研 + +## 目标 + +实现一个内置的 role agent(暂称 `uwf-builtin`),不依赖 hermes/openclaw 等外部 agent 进程。 +直接使用 workflow config 中配置的 model,自己实现 agent run loop 和关键 toolkit。 + +--- + +## 关键问题 + +### Q1: Agent 接口协议 + +现有 agent 是怎么被 CLI 调用的?输入(argv、环境变量)和输出(stdout、CAS)格式是什么? + +**调研要点:** +- `cli-workflow` 里 `spawnAgent` 的完整实现 +- AgentConfig 类型定义 +- agent 进程的 exit code 约定 +- 环境变量传递(UWF_STORAGE_ROOT 等) + +**答案:** + +#### 调用链 + +`uwf thread step` → `cmdThreadStepOnce` → moderator 求值下一 role → `resolveAgentConfig` → `spawnAgent`。 + +#### AgentConfig 类型 + +```146:149:packages/workflow-protocol/src/types.ts +export type AgentConfig = { + command: string; + args: string[]; +}; +``` + +在 `config.yaml` 的 `agents` 段注册,例如 `hermes: { command: "uwf-hermes", args: [] }`。 + +#### spawnAgent 行为 + +```627:653:packages/cli-workflow/src/commands/thread.ts +function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef { + const argv = [...agent.args, threadId, role]; + let stdout: string; + try { + stdout = execFileSync(agent.command, argv, { + encoding: "utf8", + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (e) { + // ... stderr 拼进 fail 消息 + } + + const line = stdout.trim().split("\n").pop()?.trim() ?? ""; + if (!isCasRef(line)) { + fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`); + } + return line; +} +``` + +| 项目 | 约定 | +|------|------| +| **argv** | `[...agent.args, , ]`,即 `process.argv[2]`=threadId,`process.argv[3]`=role(与 `createAgent` 的 `parseArgv` 一致) | +| **stdin** | 忽略 | +| **stdout** | 纯文本,**最后一行**必须是新 `StepNode` 的 CAS hash(13 字符 Crockford Base32) | +| **stderr** | 失败时 CLI 会附带 stderr;成功时无约定 | +| **exit code** | `0` = 成功;非 0 时 `execFileSync` 抛错,step 失败 | +| **环境变量** | 继承父进程 `process.env`(含 storage root、API key 等) | +| **链头更新** | **不由 agent 负责**;agent 只写 CAS StepNode,CLI 在拿到 stdout hash 后更新 `threads.yaml` | + +Agent 解析优先级(`resolveAgentConfig`): + +1. CLI `--agent` override(整段 command + args 字符串) +2. `config.agentOverrides[workflow.name][role]` +3. `config.defaultAgent` + +#### 环境变量:Storage Root + +文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-agent-kit` / `cli-workflow` 一致): + +```33:43:packages/workflow-agent-kit/src/storage.ts +export function resolveStorageRoot(): string { + const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT; + if (internal !== undefined && internal !== "") { + return internal; + } + const userOverride = process.env.WORKFLOW_STORAGE_ROOT; + if (userOverride !== undefined && userOverride !== "") { + return userOverride; + } + return getDefaultStorageRoot(); +} +``` + +Agent 子进程通过继承的 `process.env` 与父 CLI 共享同一 storage root;`createAgent` 内还会 `loadDotenv({ path: getEnvPath(storageRoot) })` 加载 `~/.uncaged/workflow/.env`。 + +#### Agent 侧职责(设计文档 + 实现) + +- 读 `threads.yaml` 链头,构建 context,执行 role +- 将 `StepNode` 写入 CAS(`output` / `detail` / `agent` / `prev` / `start`) +- stdout 打印 step hash +- **不**更新 `threads.yaml` + +--- + +### Q2: createAgent 工厂 + +workflow-agent-kit 的 `createAgent` 做了什么?它的完整生命周期是什么? + +**调研要点:** +- `AgentOptions` 类型的 `run` 和 `continue` 回调签名 +- `AgentRunResult` 的完整定义 +- retry 逻辑(frontmatter 校验失败后的重试机制) +- `persistStep` 写入 CAS 的 StepNode 结构 + +**答案:** + +#### 类型定义 + +```4:35:packages/workflow-agent-kit/src/types.ts +export type AgentContext = ModeratorContext & { + threadId: ThreadId; + role: string; + store: Store; + workflow: WorkflowPayload; + outputFormatInstruction: string; +}; + +export type AgentRunResult = { + output: string; + detailHash: CasRef; + sessionId: string; +}; + +export type AgentContinueFn = ( + sessionId: string, + message: string, + store: AgentContext["store"], +) => Promise; + +export type AgentRunFn = (ctx: AgentContext) => Promise; + +export type AgentOptions = { + name: string; + run: AgentRunFn; + continue: AgentContinueFn; +}; +``` + +- **`run(ctx)`**:首次执行,返回原始 agent 文本 `output`、审计用 `detailHash`、用于续聊的 `sessionId`。 +- **`continue(sessionId, message, store)`**:在同一 session 上追加用户消息(用于 frontmatter 纠错),再次返回 `AgentRunResult`。 + +`createAgent(options)` 返回 `() => Promise`,作为 agent CLI 的 `main`(见 `uwf-hermes` 的 `cli.ts`)。 + +#### 生命周期(按执行顺序) + +```101:152:packages/workflow-agent-kit/src/run.ts +export function createAgent(options: AgentOptions): () => Promise { + return async function main(): Promise { + const { threadId, role } = parseArgv(process.argv); + const storageRoot = resolveStorageRoot(); + loadDotenv({ path: getEnvPath(storageRoot) }); + + const ctx = await buildContextWithMeta(threadId, role); + // 1. 校验 role 存在 + // 2. 从 CAS 取 frontmatter JSON Schema → buildOutputFormatInstruction → ctx.outputFormatInstruction + + let agentResult = await options.run(ctx); + + let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx); + + for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) { + const correctionMessage = "Your previous response did not contain valid YAML frontmatter..."; + agentResult = await options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store); + outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx); + } + + if (outputHash === null) { fail(...); } + + const stepHash = await persistStep({ ctx, outputHash, detailHash: agentResult.detailHash, agentName }); + process.stdout.write(`${stepHash}\n`); + }; +} +``` + +| 阶段 | 行为 | +|------|------| +| 解析 argv | `argv[2]=threadId`, `argv[3]=role`,缺失则 `stderr` + `exit(1)` | +| Context | `buildContextWithMeta` + 可选 `outputFormatInstruction` | +| Run | `options.run(ctx)` | +| Extract | **仅** `tryFrontmatterFastPath`(见 Q4);**不**调用 `extract()` LLM fallback | +| Retry | 最多 `MAX_FRONTMATTER_RETRIES = 2` 次 `continue` + 再试 fast-path | +| Persist | `persistStep` → `writeStepNode` | +| 输出 | stdout 一行 step CAS hash | + +#### StepNode 写入结构 + +```44:68:packages/workflow-agent-kit/src/run.ts +async function writeStepNode(options: { + store: AgentStore["store"]; + schemas: AgentStore["schemas"]; + startHash: CasRef; + prevHash: CasRef | null; + role: string; + outputHash: CasRef; + detailHash: CasRef; + agentName: string; +}): Promise { + const payload: StepNodePayload = { + start: options.startHash, + prev: options.prevHash, + role: options.role, + output: options.outputHash, + detail: options.detailHash, + agent: options.agentName, + }; + // store.put(stepNode schema) + validate +} +``` + +`agentName` 经 `agentLabel(name)` 规范化:已有 `uwf-` 前缀则原样,否则加 `uwf-`(如 `hermes` → `uwf-hermes`)。 + +`prevHash`:若链头仍是 `StartNode` 则为 `null`,否则为当前 head step hash。 + +--- + +### Q3: Context Builder + +`buildContextWithMeta` 构建了什么上下文给 agent? + +**调研要点:** +- `AgentContext` 完整类型定义(所有字段) +- context 构建过程(CAS chain walk) +- `outputFormatInstruction` 怎么生成的 +- role definition 怎么获取(从 workflow YAML) + +**答案:** + +#### AgentContext 字段 + +继承 `ModeratorContext`: + +```60:68:packages/workflow-protocol/src/types.ts +export type ModeratorContext = { + start: StartNodePayload; + steps: StepContext[]; +}; +``` + +```48:51:packages/workflow-protocol/src/types.ts +export type StartNodePayload = { + workflow: CasRef; + prompt: string; +}; +``` + +```61:63:packages/workflow-protocol/src/types.ts +export type StepContext = Omit & { + output: unknown; +}; +``` + +`AgentContext` 额外字段: + +| 字段 | 类型 | 含义 | +|------|------|------| +| `threadId` | `ThreadId` | 当前线程 | +| `role` | `string` | 本步要执行的角色名 | +| `store` | `Store` | CAS store(读写节点) | +| `workflow` | `WorkflowPayload` | 已从 CAS 加载的 workflow 定义 | +| `outputFormatInstruction` | `string` | 由 `createAgent` 根据 role 的 frontmatter schema 生成;`buildContext*` 初始为 `""` | + +`buildContextWithMeta` 还返回 `meta`: + +```148:154:packages/workflow-agent-kit/src/context.ts +export type BuildContextMeta = { + storageRoot: string; + store: Store; + schemas: AgentStore["schemas"]; + headHash: CasRef; + chain: ChainState; +}; +``` + +#### CAS chain walk + +1. 从 `threads.yaml[threadId]` 取 `headHash` +2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first +3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / JSONata 使用) +4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload` + +#### Role definition 来源 + +- 作者写在 workflow YAML 的 `roles.`(`goal`, `capabilities`, `procedure`, `output`, `frontmatter` 等) +- `uwf workflow put` 时 `frontmatter` 内联 JSON Schema 经 `putSchema` 存入 CAS,workflow 里存的是 **CasRef** +- Agent 运行时:`ctx.workflow.roles[ctx.role]` → `RoleDefinition` + +#### outputFormatInstruction + +在 `createAgent` 中,若 `getSchema(store, roleDef.frontmatter)` 非空,则: + +```typescript +ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema); +``` + +`buildOutputFormatInstruction` 根据 JSON Schema 的 `properties` 生成「必须以 `---` YAML frontmatter 开头」的说明和示例字段列表(见 `build-output-format-instruction.ts`)。 + +各 agent 实现(Hermes / Claude Code)在组装 prompt 时把该块放在最前,再接 `buildRolePrompt(roleDef)`。 + +--- + +### Q4: Extract Pipeline + +agent 输出怎么被处理成结构化数据? + +**调研要点:** +- frontmatter fast-path 的完整逻辑 +- LLM extract fallback 的实现(`extract.ts`) +- frontmatter schema 从哪里来(role 定义里的 `frontmatter` 字段) +- 校验失败时的 correction prompt 是什么 + +**答案:** + +#### Schema 来源 + +Workflow YAML 中每个 role 的 `frontmatter:` 段是 JSON Schema 对象;注册时: + +```66:76:packages/cli-workflow/src/commands/workflow.ts +async function resolveFrontmatterRef(..., frontmatter: unknown): Promise { + // 校验为 JSON Schema → putSchema → 返回 CasRef +} +``` + +运行时 `roleDef.frontmatter` 即该 schema 的 CAS hash;structured `output` 节点用**同一 schema** 写入 CAS。 + +#### Frontmatter fast-path(createAgent 实际使用的路径) + +```148:195:packages/workflow-agent-kit/src/frontmatter.ts +export async function tryFrontmatterFastPath( + raw: string, + outputSchema: CasRef, + store: Store, +): Promise +``` + +流程: + +1. `parseFrontmatterMarkdown(raw)` → 标准 agent 字段(`status`, `next`, `confidence`, `artifacts`, `scope`)+ body +2. `validateFrontmatter` 失败 → `null` +3. `getSchema(store, outputSchema)` + `extractSchemaFields` 得到 role 需要的属性名 +4. `buildCandidate`:从标准 frontmatter + YAML 原始字段拼出符合 schema 的对象 +5. `store.put(outputSchema, candidate)` + `validate` → 成功则 `{ body, outputHash }` + +**永不抛错**,失败返回 `null`。 + +#### LLM extract fallback(已实现但未接入 createAgent) + +```135:181:packages/workflow-agent-kit/src/extract.ts +export async function extract( + rawOutput: string, + outputSchema: CasRef, + config: WorkflowConfig, +): Promise +``` + +- 模型:`resolveExtractModelAlias(config)` → `modelOverrides.extract` → `models.extract` → `models.default` → `defaultModel` +- HTTP:`POST {baseUrl}/chat/completions`,`response_format: { type: "json_object" }` +- System:要求按 JSON Schema 从 agent 输出提取单个 JSON 对象 +- 校验通过后 `store.put(outputSchema, structured)` + +**重要:`createAgent` 当前未调用 `extract()`**。fast-path 失败且 2 次 `continue` 仍失败则直接 `fail()`。builtin agent 若希望无 frontmatter 也能跑,需在 kit 或 builtin 层显式接入 `extract()`。 + +#### Correction prompt(retry) + +```125:128:packages/workflow-agent-kit/src/run.ts +const correctionMessage = + "Your previous response did not contain valid YAML frontmatter matching the role schema.\n" + + "You MUST begin your response with a YAML frontmatter block (--- delimited).\n" + + "Please output ONLY the corrected frontmatter block followed by your work."; +``` + +通过 `options.continue(sessionId, correctionMessage, store)` 发给外部 agent;builtin 需在自有 message 历史里 append 同等语义的 user 消息。 + +--- + +### Q5: Model 配置与 LLM 调用 + +workflow 怎么配置和使用 model? + +**调研要点:** +- `WorkflowConfig` 中 providers/models/defaultModel/modelOverrides 的完整定义 +- `resolveModel` 函数的实现 +- `chatCompletionText` 的实现(OpenAI 兼容 HTTP 客户端) +- 有没有 streaming 支持?tool calling 支持? + +**答案:** + +#### WorkflowConfig + +```136:160:packages/workflow-protocol/src/types.ts +export type ProviderConfig = { + baseUrl: string; + apiKeyEnv: string; +}; + +export type ModelConfig = { + provider: ProviderAlias; + name: string; +}; + +export type WorkflowConfig = { + providers: Record; + models: Record; + agents: Record; + defaultAgent: AgentAlias; + agentOverrides: Record> | null; + defaultModel: ModelAlias; + modelOverrides: Record | null; +}; +``` + +示例见 `docs/architecture.md`(`providers` / `models` / `defaultModel` / `modelOverrides.extract`)。 + +#### resolveModel + +```32:50:packages/workflow-agent-kit/src/extract.ts +export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider { + const modelEntry = config.models[alias]; + const providerEntry = config.providers[modelEntry.provider]; + const apiKey = process.env[providerEntry.apiKeyEnv]; + return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name }; +} +``` + +`ResolvedLlmProvider = { baseUrl, apiKey, model }`。 + +Extract 专用别名解析: + +```18:30:packages/workflow-agent-kit/src/extract.ts +export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias { + return config.modelOverrides?.extract ?? (config.models.extract ? "extract" : config.models.default ? "default" : config.defaultModel); +} +``` + +**尚无** `modelOverrides` 按 role/workflow 解析 agent 主模型的函数;builtin 首版可用 `config.defaultModel`,扩展时可加 `modelOverrides.agent` 或与 `agentOverrides` 对称的表。 + +#### chatCompletionText + +```87:124:packages/workflow-agent-kit/src/extract.ts +async function chatCompletionText( + provider: ResolvedLlmProvider, + messages: Array<{ role: "system" | "user"; content: string }>, +): Promise +``` + +| 能力 | 现状 | +|------|------| +| 协议 | OpenAI 兼容 `POST /chat/completions` | +| Streaming | **无**(一次性 `response.text()`) | +| Tool calling | **无**(无 `tools` / `tool_calls` 字段) | +| 多模态 | **无**(仅 text `content`) | +| Extract 专用 | `response_format: { type: "json_object" }` | + +builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-agent-kit` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。 + +--- + +### Q6: Hermes Agent 参考实现 + +`uwf-hermes` 是怎么实现 `run` 和 `continue` 的? + +**调研要点:** +- prompt 怎么组装的(outputFormatInstruction + rolePrompt + task + history) +- hermes CLI 的调用参数 +- session management(resume) +- 输出怎么捕获 + +**答案:** + +#### Prompt 组装 + +```40:53:packages/workflow-agent-hermes/src/hermes.ts +export function buildHermesPrompt(ctx: AgentContext): string { + const roleDef = ctx.workflow.roles[ctx.role]; + const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : ""; + const parts: string[] = []; + if (ctx.outputFormatInstruction !== "") { + parts.push(ctx.outputFormatInstruction, ""); + } + parts.push(rolePrompt, "", "## Task", ctx.start.prompt); + const historyBlock = buildHistorySummary(ctx.steps); + if (historyBlock !== "") { + parts.push("", historyBlock); + } + return parts.join("\n"); +} +``` + +`buildRolePrompt` 生成 `## Goal` / `## Capabilities` / `## Prepare`(含 `generateCliReference()`)/ `## Procedure` / `## Output`。 + +`buildHistorySummary`:每步 `role`、`JSON.stringify(step.output)`、`agent`。 + +Hermes 把**整段 prompt 作为单条 user 消息**传给 `hermes chat -q`(无独立 system channel)。 + +#### Hermes CLI 参数 + +首次: + +```88:97:packages/workflow-agent-hermes/src/hermes.ts +spawnHermes(["chat", "-q", prompt, "--yolo", "--max-turns", "90", "--quiet"]); +``` + +续聊: + +```100:114:packages/workflow-agent-hermes/src/hermes.ts +spawnHermes(["chat", "--resume", sessionId, "-q", message, "--yolo", "--max-turns", "90", "--quiet"]); +``` + +#### Session + +- stdout/stderr 中解析 `session_id: `(`parseSessionIdFromStdout`) +- 会话文件:`~/.hermes/sessions/session_.json` +- `loadHermesSession` → `storeHermesSessionDetail`:每 assistant/tool 消息写成 CAS turn 节点,汇总为 `detail`;**output 文本** = 最后一条非空 `assistant` 的 `content` + +#### 与 createAgent 的衔接 + +```157:164:packages/workflow-agent-hermes/src/hermes.ts +export function createHermesAgent(): () => Promise { + return createAgent({ name: "hermes", run: runHermes, continue: continueHermes }); +} +``` + +`uwf-hermes` 入口:`createHermesAgent()` 即 main。 + +Claude Code 包(`workflow-agent-claude-code`)结构相同:`buildClaudeCodePrompt` 同构,`claude -p` + `--resume` + JSON stdout 解析。 + +--- + +### Q7: Toolkit 需求分析 + +要实现一个自给自足的 agent,最少需要哪些 tool? + +**调研要点:** +- 现有 workflow example(solve-issue.yaml)里 role 都做什么任务 +- hermes agent 在 workflow 场景下常用哪些 tool +- 哪些 tool 是 agent loop 必须的(如 file read/write、shell exec、web fetch) + +**答案:** + +#### solve-issue.yaml 角色能力 + +| Role | capabilities | 隐含需求 | +|------|----------------|----------| +| planner | issue-analysis, planning | 读上下文/仓库、总结,通常不需写代码 | +| developer | file-edit, shell, testing | **读文件、写文件、执行命令** | +| reviewer | code-review, static-analysis | 读 diff/文件、静态分析(可读+可选 shell) | + +#### Hermes 侧 + +Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes 项目定义,workflow 不配置。从 session JSON 可见 `tool_calls` 被记入 detail,常见包括文件与 shell 类工具。 + +#### Builtin 最小 toolkit 建议 + +| 优先级 | Tool | 用途 | +|--------|------|------| +| P0 | `read_file` | 读仓库/配置/issue 上下文 | +| P0 | `write_file` / `edit_file` | developer 改代码 | +| P0 | `run_command` | 测试、构建、git(需 cwd + timeout + 输出截断) | +| P1 | `list_dir` / `glob` | 导航代码库 | +| P1 | `grep` | 搜索符号/引用 | +| P2 | `fetch_url` | 查文档(planner 偶尔需要) | + +**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + JSONata 负责。 + +#### Agent loop 必须能力 + +1. 多轮 LLM 调用 + **OpenAI-style tool_calls** 解析与执行 +2. 将 tool 结果 append 回 messages +3. 终止条件:模型不再请求 tool,或达到 `maxTurns` +4. 最终响应须含合法 YAML frontmatter(满足 Q4),供 `createAgent` fast-path + +--- + +## 方案草案 + +(调研完成后基于以上答案撰写) + +### 架构设计 + +```mermaid +flowchart TB + subgraph cli ["cli-workflow"] + Step["uwf thread step"] + Spawn["spawnAgent(uwf-builtin, threadId, role)"] + Step --> Spawn + end + + subgraph builtin_pkg ["@uncaged/workflow-agent-builtin"] + Main["createBuiltinAgent() = createAgent({...})"] + Prompt["buildBuiltinPrompt(ctx)"] + Loop["runBuiltinLoop(provider, messages, tools)"] + Tools["Toolkit: read/write/exec/..."] + Detail["storeBuiltinDetail(turns)"] + Main --> Prompt + Main --> Loop + Loop --> Tools + Loop --> Detail + end + + subgraph kit ["workflow-agent-kit"] + Ctx["buildContextWithMeta"] + FM["tryFrontmatterFastPath"] + Persist["persistStep"] + Ctx --> Main + Main --> FM + FM --> Persist + end + + subgraph cas ["CAS / config"] + Config["config.yaml models/providers"] + CAS["cas/ + threads.yaml"] + end + + Spawn --> Main + Config --> Loop + CAS --> Ctx + Persist --> CAS + Spawn -->|"stdout: step hash"| Step +``` + +**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-agent-kit`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。 + +**分层**: + +| 层 | 职责 | +|----|------| +| `createAgent`(kit) | argv、context、frontmatter extract、StepNode、stdout 协议 — **不变** | +| `builtin/agent.ts` | `run` / `continue` 实现 | +| `builtin/llm.ts` | OpenAI 兼容 chat + tools(可后续抽到 kit) | +| `builtin/tools/*.ts` | 各 tool 的 JSON Schema + handler | +| `builtin/prompt.ts` | 复用 Hermes 的 prompt 拼接逻辑(或抽到 kit 的 `buildAgentPrompt`) | +| `builtin/detail.ts` | 类似 Hermes:每轮 assistant/tool 写入 CAS detail | + +**配置集成**: + +```yaml +agents: + builtin: + command: "uwf-builtin" + args: [] +defaultAgent: "builtin" # 或 agentOverrides 按 role 指定 +``` + +模型:首版 `resolveModel(config, config.defaultModel)`;后续可增加 `modelOverrides.agent` 或 per-role 映射。 + +--- + +### Agent Run Loop + +伪代码(单次 `run(ctx)`): + +``` +1. provider ← resolveModel(loadWorkflowConfig(), defaultModel) +2. system ← buildBuiltinPrompt(ctx) // outputFormatInstruction + buildRolePrompt + Task + History +3. messages ← [{ role: "system", content: system }] +4. sessionId ← newULID() // 内存或临时目录,供 continue 使用 +5. turns ← [] + +6. for turn in 1..MAX_TURNS: + response ← chatCompletionWithTools(provider, messages, TOOL_DEFINITIONS) + record assistant message + tool_calls in turns + + if response has no tool_calls: + finalText ← response.content + break + + for each tool_call: + result ← executeTool(tool_call, { cwd: process.cwd() }) + messages.push tool result + record in turns + +7. if no finalText with valid frontmatter after loop: + optionally one-shot "finalize" message without tools + +8. detailHash ← storeBuiltinDetail(store, sessionId, turns, metadata) +9. return { output: finalText, detailHash, sessionId } +``` + +**`continue(sessionId, message, store)`**: + +- 从内存/磁盘恢复 `messages` + `turns` +- `messages.push({ role: "user", content: message })`(correction 或续聊) +- 从步骤 6 继续,步数上限可单独设小一点(如 3) +- 返回新的 `AgentRunResult` + +**与 frontmatter 的配合**: + +- system prompt 已含 `outputFormatInstruction`;最后一轮可强制 user:`Now output your final answer with YAML frontmatter only if you have not yet.` +- 仍依赖 `createAgent` 的 fast-path + 最多 2 次 continue + +**安全**: + +- `run_command`:白名单或需 `UWF_BUILTIN_ALLOW_SHELL=1`,默认工作区限定在 `process.cwd()` 或 `start` 中将来扩展的 `workspace` 字段 +- 路径:禁止 `..` 逃逸出 workspace root + +--- + +### Toolkit 设计 + +统一注册表: + +```typescript +type BuiltinTool = { + name: string; + description: string; + parameters: JSONSchema; // object type + execute: (args: unknown, ctx: ToolContext) => Promise; +}; + +type ToolContext = { + cwd: string; + storageRoot: string; +}; +``` + +| Tool name | OpenAI function | 行为摘要 | +|-----------|-----------------|----------| +| `read_file` | `read_file` | `{ path }` → UTF-8 文本,大小上限 | +| `write_file` | `write_file` | `{ path, content }` → 写盘,返回确认 | +| `edit_file` | 可选 | search/replace 块,减少 token | +| `run_command` | `run_command` | `{ command, cwd? }` → stdout/stderr 截断 | +| `list_dir` | `list_dir` | `{ path }` → 条目列表 | +| `grep` | `grep` | `{ pattern, path? }` → 匹配行 | + +**LLM 请求形状**(扩展 extract 客户端): + +```json +{ + "model": "...", + "messages": [...], + "tools": [{ "type": "function", "function": { "name", "description", "parameters" } }], + "tool_choice": "auto" +} +``` + +解析 `choices[0].message.tool_calls`,执行后以 `{ role: "tool", tool_call_id, content }` 回传。 + +**不提供** streaming 首版;detail CAS 记录每轮 tool 名/参数/结果摘要供 `uwf thread step-details` 调试。 + +--- + +### 与现有架构的集成 + +| 集成点 | 方式 | +|--------|------| +| CLI 协议 | 实现标准 agent CLI:`uwf-builtin `,stdout 一行 step hash,exit 0/1 | +| 工厂 | `export function createBuiltinAgent()` → `createAgent({ name: "builtin", run, continue })` | +| Context / Prompt | 复用 `buildContextWithMeta`、`buildRolePrompt`、`buildOutputFormatInstruction`;prompt 布局对齐 `buildHermesPrompt` | +| 结构化输出 | 优先 YAML frontmatter fast-path;可选后续在 `createAgent` 增加 `extract()` fallback 开关 | +| 配置 | `config.yaml` 增加 `agents.builtin`;`uwf setup` 可选默认 agent | +| 存储 | `resolveStorageRoot()` + `loadWorkflowConfig` + `getEnvPath`;与 Hermes 相同,**不**改 `threads.yaml` 写入方 | +| 测试 | 单元测试:tool handlers、prompt 组装、mock LLM tool loop;集成测试:临时 storage root + fake provider | +| 发布 | 新包 `@uncaged/workflow-agent-builtin`,bin `uwf-builtin`,加入 `scripts/publish-all.mjs` | + +**明确不做**: + +- 不替代 moderator / 不在 agent 内调用 `uwf thread step` +- 不依赖 Hermes/OpenClaw/Claude Code 二进制 +- 首版不实现 streaming、不实现 MCP + +**建议实现顺序**: + +1. `llm.ts`:tool calling HTTP 客户端 + 单测 +2. P0 tools + `runBuiltinLoop` +3. `createBuiltinAgent` + detail CAS +4. `config` / docs / `examples` 可选 `agentOverrides` 演示 +5. (可选)`createAgent` 接入 `extract()` fallback diff --git a/packages/workflow-agent-builtin/__tests__/llm-parse.test.ts b/packages/workflow-agent-builtin/__tests__/llm-parse.test.ts new file mode 100644 index 0000000..ecf38bd --- /dev/null +++ b/packages/workflow-agent-builtin/__tests__/llm-parse.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; + +import type { LlmToolCall } from "../src/llm/types.js"; + +/** Mirror OpenAI response shape for parser coverage via chatCompletionWithTools integration later. */ +describe("LlmToolCall shape", () => { + test("tool call record fields", () => { + const call: LlmToolCall = { + id: "call_1", + name: "read_file", + arguments: '{"path":"README.md"}', + }; + expect(call.name).toBe("read_file"); + expect(JSON.parse(call.arguments)).toEqual({ path: "README.md" }); + }); +}); diff --git a/packages/workflow-agent-builtin/__tests__/path.test.ts b/packages/workflow-agent-builtin/__tests__/path.test.ts new file mode 100644 index 0000000..d39ad79 --- /dev/null +++ b/packages/workflow-agent-builtin/__tests__/path.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; + +import { resolvePathInWorkspace } from "../src/tools/path.js"; + +describe("resolvePathInWorkspace", () => { + const root = join("/tmp", "uwf-workspace"); + + test("resolves relative paths inside root", () => { + const resolved = resolvePathInWorkspace(root, "src/foo.ts"); + expect(resolved).toBe(join(root, "src/foo.ts")); + }); + + test("rejects parent traversal", () => { + expect(resolvePathInWorkspace(root, "../etc/passwd")).toBeNull(); + }); +}); diff --git a/packages/workflow-agent-builtin/__tests__/prompt.test.ts b/packages/workflow-agent-builtin/__tests__/prompt.test.ts new file mode 100644 index 0000000..70eb75a --- /dev/null +++ b/packages/workflow-agent-builtin/__tests__/prompt.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; + +import type { AgentContext } from "@uncaged/workflow-agent-kit"; + +import { buildBuiltinPrompt } from "../src/prompt.js"; + +function minimalContext(overrides: Partial = {}): AgentContext { + return { + threadId: "00000000000000000000000000" as AgentContext["threadId"], + role: "developer", + store: {} as AgentContext["store"], + workflow: { + name: "test", + roles: { + developer: { + goal: "Ship the fix", + capabilities: ["file-edit"], + procedure: ["Edit files"], + output: "A patch", + frontmatter: "schema-hash", + }, + }, + conditions: {}, + graph: {}, + }, + start: { workflow: "wf-hash", prompt: "Fix the bug" }, + steps: [], + outputFormatInstruction: "---\nstatus: done\n---", + ...overrides, + }; +} + +describe("buildBuiltinPrompt", () => { + test("includes output format, task, and role goal", () => { + const prompt = buildBuiltinPrompt(minimalContext()); + expect(prompt).toContain("status: done"); + expect(prompt).toContain("## Goal"); + expect(prompt).toContain("Ship the fix"); + expect(prompt).toContain("## Task"); + expect(prompt).toContain("Fix the bug"); + }); + + test("includes history when steps exist", () => { + const prompt = buildBuiltinPrompt( + minimalContext({ + steps: [ + { + role: "planner", + output: { plan: "step 1" }, + agent: "uwf-builtin", + detail: "detail-hash", + }, + ], + }), + ); + expect(prompt).toContain("## Previous Steps"); + expect(prompt).toContain("planner"); + }); +}); diff --git a/packages/workflow-agent-builtin/package.json b/packages/workflow-agent-builtin/package.json new file mode 100644 index 0000000..ebc0a4e --- /dev/null +++ b/packages/workflow-agent-builtin/package.json @@ -0,0 +1,34 @@ +{ + "name": "@uncaged/workflow-agent-builtin", + "version": "0.5.0", + "files": [ + "src", + "dist", + "package.json" + ], + "type": "module", + "bin": { + "uwf-builtin": "./src/cli.ts" + }, + "exports": { + ".": { + "bun": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "test": "bun test" + }, + "dependencies": { + "@uncaged/json-cas": "^0.4.0", + "@uncaged/workflow-agent-kit": "workspace:^", + "@uncaged/workflow-util": "workspace:^" + }, + "devDependencies": { + "typescript": "^5.8.3" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/workflow-agent-builtin/src/agent.ts b/packages/workflow-agent-builtin/src/agent.ts new file mode 100644 index 0000000..cc738b4 --- /dev/null +++ b/packages/workflow-agent-builtin/src/agent.ts @@ -0,0 +1,123 @@ +import type { Store } from "@uncaged/json-cas"; +import { + type AgentContext, + type AgentRunResult, + createAgent, + loadWorkflowConfig, + resolveModel, + resolveStorageRoot, +} from "@uncaged/workflow-agent-kit"; +import { generateUlid } from "@uncaged/workflow-util"; + +import { storeBuiltinDetail } from "./detail.js"; +import type { ChatMessage } from "./llm/index.js"; +import { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js"; +import { buildBuiltinPrompt } from "./prompt.js"; +import type { BuiltinSessionState } from "./types.js"; + +const sessions = new Map(); + +function getSession(sessionId: string): BuiltinSessionState { + const session = sessions.get(sessionId); + if (session === undefined) { + throw new Error(`builtin session not found: ${sessionId}`); + } + return session; +} + +function buildToolContext(storageRoot: string): { cwd: string; storageRoot: string } { + return { + cwd: process.cwd(), + storageRoot, + }; +} + +async function runBuiltinWithMessages( + storageRoot: string, + provider: ReturnType, + messages: ChatMessage[], + session: BuiltinSessionState, + store: Store, + maxTurns: number, +): Promise { + const loopResult = await runBuiltinLoop({ + provider, + messages, + toolCtx: buildToolContext(storageRoot), + maxTurns, + existingTurns: session.turns, + }); + + session.messages = loopResult.messages; + session.turns = loopResult.turns; + + const { detailHash, output } = await storeBuiltinDetail( + store, + session.sessionId, + session.model, + session.startedAtMs, + session.turns, + ); + + const finalOutput = output !== "" ? output : loopResult.finalText; + return { output: finalOutput, detailHash, sessionId: session.sessionId }; +} + +async function runBuiltin(ctx: AgentContext): Promise { + const storageRoot = resolveStorageRoot(); + const config = await loadWorkflowConfig(storageRoot); + const provider = resolveModel(config, config.defaultModel); + + const sessionId = generateUlid(Date.now()); + const systemPrompt = buildBuiltinPrompt(ctx); + const messages: ChatMessage[] = [{ role: "system", content: systemPrompt }]; + + const session: BuiltinSessionState = { + sessionId, + model: provider.model, + startedAtMs: Date.now(), + messages, + turns: [], + }; + sessions.set(sessionId, session); + + return runBuiltinWithMessages( + storageRoot, + provider, + messages, + session, + ctx.store, + BUILTIN_MAX_TURNS, + ); +} + +async function continueBuiltin( + sessionId: string, + message: string, + store: Store, +): Promise { + const session = getSession(sessionId); + const storageRoot = resolveStorageRoot(); + const config = await loadWorkflowConfig(storageRoot); + const provider = resolveModel(config, config.defaultModel); + + const messages: ChatMessage[] = [...session.messages, { role: "user", content: message }]; + + return runBuiltinWithMessages( + storageRoot, + provider, + messages, + session, + store, + BUILTIN_CONTINUE_MAX_TURNS, + ); +} + +/** Agent CLI factory: built-in LLM loop with file/shell tools. */ +export function createBuiltinAgent(): () => Promise { + return createAgent({ + name: "builtin", + run: runBuiltin, + continue: continueBuiltin, + }); +} diff --git a/packages/workflow-agent-builtin/src/cli.ts b/packages/workflow-agent-builtin/src/cli.ts new file mode 100644 index 0000000..d3fda1e --- /dev/null +++ b/packages/workflow-agent-builtin/src/cli.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env bun + +import { createBuiltinAgent } from "./agent.js"; + +const main = createBuiltinAgent(); +void main(); diff --git a/packages/workflow-agent-builtin/src/detail.ts b/packages/workflow-agent-builtin/src/detail.ts new file mode 100644 index 0000000..ab49fbf --- /dev/null +++ b/packages/workflow-agent-builtin/src/detail.ts @@ -0,0 +1,115 @@ +import { bootstrap, putSchema, type Store } from "@uncaged/json-cas"; + +import { BUILTIN_DETAIL_SCHEMA, BUILTIN_TURN_SCHEMA } from "./schemas.js"; +import type { + BuiltinDetailPayload, + BuiltinLoopTurn, + BuiltinToolCall, + BuiltinTurnPayload, + BuiltinTurnRole, +} from "./types.js"; + +function mapToolCalls(calls: NonNullable): BuiltinToolCall[] { + return calls.map((call) => ({ + name: call.name, + args: call.args, + })); +} + +function loopTurnToAssistantPayload(turn: BuiltinLoopTurn, index: number): BuiltinTurnPayload { + return { + index, + role: "assistant", + content: turn.assistantContent ?? "", + toolCalls: + turn.toolCalls !== null && turn.toolCalls.length > 0 ? mapToolCalls(turn.toolCalls) : null, + reasoning: null, + }; +} + +function loopTurnToToolPayloads(turn: BuiltinLoopTurn, startIndex: number): BuiltinTurnPayload[] { + if (turn.toolResults === null || turn.toolResults.length === 0) { + return []; + } + const payloads: BuiltinTurnPayload[] = []; + let index = startIndex; + for (const result of turn.toolResults) { + payloads.push({ + index, + role: "tool" as BuiltinTurnRole, + content: result.content, + toolCalls: null, + reasoning: null, + }); + index += 1; + } + return payloads; +} + +/** Last assistant message with non-empty text. */ +export function extractFinalAssistantText(turns: BuiltinLoopTurn[]): string { + for (let i = turns.length - 1; i >= 0; i--) { + const turn = turns[i]; + if (turn === undefined) { + continue; + } + const text = turn.assistantContent; + if (text !== null && text.trim() !== "") { + return text; + } + } + return ""; +} + +type BuiltinSchemaHashes = { + turn: string; + detail: string; +}; + +async function registerBuiltinSchemas(store: Store): Promise { + await bootstrap(store); + const [turn, detail] = await Promise.all([ + putSchema(store, BUILTIN_TURN_SCHEMA), + putSchema(store, BUILTIN_DETAIL_SCHEMA), + ]); + return { turn, detail }; +} + +export async function storeBuiltinDetail( + store: Store, + sessionId: string, + model: string, + startedAtMs: number, + turns: BuiltinLoopTurn[], + nowMs: number = Date.now(), +): Promise<{ detailHash: string; output: string }> { + const schemas = await registerBuiltinSchemas(store); + const turnHashes: string[] = []; + let turnIndex = 0; + + for (const loopTurn of turns) { + const assistant = loopTurnToAssistantPayload(loopTurn, turnIndex); + const assistantHash = await store.put(schemas.turn, assistant); + turnHashes.push(assistantHash); + turnIndex += 1; + + const toolPayloads = loopTurnToToolPayloads(loopTurn, turnIndex); + for (const toolPayload of toolPayloads) { + const toolHash = await store.put(schemas.turn, toolPayload); + turnHashes.push(toolHash); + turnIndex += 1; + } + } + + const duration = Math.max(0, nowMs - startedAtMs); + const detail: BuiltinDetailPayload = { + sessionId, + model, + duration, + turnCount: turnHashes.length, + turns: turnHashes, + }; + const detailHash = await store.put(schemas.detail, detail); + const output = extractFinalAssistantText(turns); + return { detailHash, output }; +} diff --git a/packages/workflow-agent-builtin/src/index.ts b/packages/workflow-agent-builtin/src/index.ts new file mode 100644 index 0000000..4bfe337 --- /dev/null +++ b/packages/workflow-agent-builtin/src/index.ts @@ -0,0 +1,14 @@ +export { createBuiltinAgent } from "./agent.js"; +export { extractFinalAssistantText, storeBuiltinDetail } from "./detail.js"; +export type { ChatMessage, LlmAssistantResponse, LlmToolCall } from "./llm/index.js"; +export { chatCompletionWithTools } from "./llm/index.js"; +export { BUILTIN_CONTINUE_MAX_TURNS, BUILTIN_MAX_TURNS, runBuiltinLoop } from "./loop.js"; +export { buildBuiltinPrompt } from "./prompt.js"; +export type { BuiltinTool, ToolContext } from "./tools/index.js"; +export { executeBuiltinTool, getBuiltinTools } from "./tools/index.js"; +export type { + BuiltinDetailPayload, + BuiltinLoopTurn, + BuiltinSessionState, + BuiltinTurnPayload, +} from "./types.js"; diff --git a/packages/workflow-agent-builtin/src/llm/index.ts b/packages/workflow-agent-builtin/src/llm/index.ts new file mode 100644 index 0000000..64d66b8 --- /dev/null +++ b/packages/workflow-agent-builtin/src/llm/index.ts @@ -0,0 +1,7 @@ +export { chatCompletionWithTools } from "./llm.js"; +export type { + ChatMessage, + LlmAssistantResponse, + LlmToolCall, + OpenAiToolDefinition, +} from "./types.js"; diff --git a/packages/workflow-agent-builtin/src/llm/llm.ts b/packages/workflow-agent-builtin/src/llm/llm.ts new file mode 100644 index 0000000..3beb102 --- /dev/null +++ b/packages/workflow-agent-builtin/src/llm/llm.ts @@ -0,0 +1,135 @@ +import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit"; + +import type { + ChatMessage, + LlmAssistantResponse, + LlmToolCall, + OpenAiToolDefinition, +} from "./types.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function chatUrl(baseUrl: string): string { + const trimmed = baseUrl.replace(/\/+$/, ""); + return `${trimmed}/chat/completions`; +} + +function parseToolCalls(raw: unknown): LlmToolCall[] | null { + if (!Array.isArray(raw) || raw.length === 0) { + return null; + } + const calls: LlmToolCall[] = []; + for (const entry of raw) { + if (!isRecord(entry)) { + continue; + } + const id = entry.id; + const fn = entry.function; + if (typeof id !== "string" || !isRecord(fn)) { + continue; + } + const name = fn.name; + const args = fn.arguments; + if (typeof name !== "string" || typeof args !== "string") { + continue; + } + calls.push({ id, name, arguments: args }); + } + return calls.length > 0 ? calls : null; +} + +function parseAssistantMessage(parsed: unknown): LlmAssistantResponse { + if (!isRecord(parsed)) { + throw new Error("LLM response is not an object"); + } + const choices = parsed.choices; + if (!Array.isArray(choices) || choices.length === 0) { + throw new Error("LLM response has no choices"); + } + const c0 = choices[0]; + if (!isRecord(c0)) { + throw new Error("LLM choice is not an object"); + } + const messageObj = c0.message; + if (!isRecord(messageObj)) { + throw new Error("LLM message is not an object"); + } + const contentRaw = messageObj.content; + const content = + typeof contentRaw === "string" + ? contentRaw + : contentRaw === null || contentRaw === undefined + ? null + : null; + const toolCalls = parseToolCalls(messageObj.tool_calls); + return { content, toolCalls }; +} + +function serializeMessage(message: ChatMessage): Record { + if (message.role === "tool") { + return { + role: "tool", + tool_call_id: message.tool_call_id, + content: message.content, + }; + } + if (message.role === "assistant") { + const base: Record = { + role: "assistant", + content: message.content, + }; + if (message.tool_calls !== null && message.tool_calls.length > 0) { + base.tool_calls = message.tool_calls.map((call) => ({ + id: call.id, + type: "function", + function: { name: call.name, arguments: call.arguments }, + })); + } + return base; + } + return { role: message.role, content: message.content }; +} + +/** OpenAI-compatible chat completion with tool calling (non-streaming). */ +export async function chatCompletionWithTools( + provider: ResolvedLlmProvider, + messages: ChatMessage[], + tools: OpenAiToolDefinition[], +): Promise { + let response: Response; + try { + response = await fetch(chatUrl(provider.baseUrl), { + method: "POST", + headers: { + Authorization: `Bearer ${provider.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: provider.model, + messages: messages.map(serializeMessage), + tools, + tool_choice: "auto", + }), + }); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new Error(`LLM network error: ${message}`); + } + + const responseText = await response.text(); + if (!response.ok) { + throw new Error(`LLM HTTP ${response.status}: ${responseText.slice(0, 2000)}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(responseText) as unknown; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new Error(`LLM invalid JSON response: ${message}`); + } + + return parseAssistantMessage(parsed); +} diff --git a/packages/workflow-agent-builtin/src/llm/types.ts b/packages/workflow-agent-builtin/src/llm/types.ts new file mode 100644 index 0000000..088673d --- /dev/null +++ b/packages/workflow-agent-builtin/src/llm/types.ts @@ -0,0 +1,29 @@ +export type LlmToolCall = { + id: string; + name: string; + arguments: string; +}; + +export type LlmAssistantResponse = { + content: string | null; + toolCalls: LlmToolCall[] | null; +}; + +export type ChatMessage = + | { role: "system"; content: string } + | { role: "user"; content: string } + | { + role: "assistant"; + content: string | null; + tool_calls: LlmToolCall[] | null; + } + | { role: "tool"; tool_call_id: string; content: string }; + +export type OpenAiToolDefinition = { + type: "function"; + function: { + name: string; + description: string; + parameters: Record; + }; +}; diff --git a/packages/workflow-agent-builtin/src/loop.ts b/packages/workflow-agent-builtin/src/loop.ts new file mode 100644 index 0000000..91b92de --- /dev/null +++ b/packages/workflow-agent-builtin/src/loop.ts @@ -0,0 +1,110 @@ +import type { ResolvedLlmProvider } from "@uncaged/workflow-agent-kit"; +import { createLogger } from "@uncaged/workflow-util"; + +import { type ChatMessage, chatCompletionWithTools, type LlmToolCall } from "./llm/index.js"; +import { + builtinToolsToOpenAi, + executeBuiltinTool, + getBuiltinTools, + type ToolContext, +} from "./tools/index.js"; +import type { BuiltinLoopTurn, BuiltinToolCallRecord, BuiltinToolResultRecord } from "./types.js"; + +const log = createLogger({ sink: { kind: "stderr" } }); + +export const BUILTIN_MAX_TURNS = 30; +export const BUILTIN_CONTINUE_MAX_TURNS = 5; + +export type RunBuiltinLoopOptions = { + provider: ResolvedLlmProvider; + messages: ChatMessage[]; + toolCtx: ToolContext; + maxTurns: number; + existingTurns: BuiltinLoopTurn[]; +}; + +export type RunBuiltinLoopResult = { + finalText: string; + messages: ChatMessage[]; + turns: BuiltinLoopTurn[]; +}; + +function mapToolCalls(calls: LlmToolCall[]): BuiltinToolCallRecord[] { + return calls.map((call) => ({ + id: call.id, + name: call.name, + args: call.arguments, + })); +} + +/** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */ +export async function runBuiltinLoop( + options: RunBuiltinLoopOptions, +): Promise { + const messages = [...options.messages]; + const turns = [...options.existingTurns]; + const openAiTools = builtinToolsToOpenAi(getBuiltinTools()); + let finalText = ""; + + for (let turn = 0; turn < options.maxTurns; turn++) { + log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`); + const response = await chatCompletionWithTools(options.provider, messages, openAiTools); + + const assistantMessage: ChatMessage = { + role: "assistant", + content: response.content, + tool_calls: response.toolCalls, + }; + messages.push(assistantMessage); + + if (response.toolCalls === null || response.toolCalls.length === 0) { + finalText = response.content ?? ""; + turns.push({ + assistantContent: response.content, + toolCalls: null, + toolResults: null, + }); + break; + } + + const toolCallRecords = mapToolCalls(response.toolCalls); + const toolResults: BuiltinToolResultRecord[] = []; + + for (const call of response.toolCalls) { + const result = await executeBuiltinTool(call.name, call.arguments, options.toolCtx); + toolResults.push({ + toolCallId: call.id, + name: call.name, + content: result, + }); + messages.push({ + role: "tool", + tool_call_id: call.id, + content: result, + }); + } + + turns.push({ + assistantContent: response.content, + toolCalls: toolCallRecords, + toolResults, + }); + } + + if (finalText === "" && messages.length > 0) { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if ( + msg !== undefined && + msg.role === "assistant" && + msg.content !== null && + msg.content.trim() !== "" + ) { + finalText = msg.content; + break; + } + } + } + + return { finalText, messages, turns }; +} diff --git a/packages/workflow-agent-builtin/src/prompt.ts b/packages/workflow-agent-builtin/src/prompt.ts new file mode 100644 index 0000000..37861ab --- /dev/null +++ b/packages/workflow-agent-builtin/src/prompt.ts @@ -0,0 +1,36 @@ +import { type AgentContext, buildRolePrompt } from "@uncaged/workflow-agent-kit"; + +function buildHistorySummary(steps: AgentContext["steps"]): string { + if (steps.length === 0) { + return ""; + } + + const lines: string[] = ["## Previous Steps"]; + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + if (step === undefined) { + continue; + } + lines.push(""); + lines.push(`### Step ${i + 1}: ${step.role}`); + lines.push(`Output: ${JSON.stringify(step.output)}`); + lines.push(`Agent: ${step.agent}`); + } + return lines.join("\n"); +} + +/** Assemble output format, role prompt, task, and history (aligned with buildHermesPrompt). */ +export function buildBuiltinPrompt(ctx: AgentContext): string { + const roleDef = ctx.workflow.roles[ctx.role]; + const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : ""; + const parts: string[] = []; + if (ctx.outputFormatInstruction !== "") { + parts.push(ctx.outputFormatInstruction, ""); + } + parts.push(rolePrompt, "", "## Task", ctx.start.prompt); + const historyBlock = buildHistorySummary(ctx.steps); + if (historyBlock !== "") { + parts.push("", historyBlock); + } + return parts.join("\n"); +} diff --git a/packages/workflow-agent-builtin/src/schemas.ts b/packages/workflow-agent-builtin/src/schemas.ts new file mode 100644 index 0000000..273d3df --- /dev/null +++ b/packages/workflow-agent-builtin/src/schemas.ts @@ -0,0 +1,46 @@ +import type { JSONSchema } from "@uncaged/json-cas"; + +const BUILTIN_TOOL_CALL_SCHEMA: JSONSchema = { + type: "object", + required: ["name", "args"], + properties: { + name: { type: "string" }, + args: { type: "string" }, + }, + additionalProperties: false, +}; + +export const BUILTIN_TURN_SCHEMA: JSONSchema = { + title: "builtin-turn", + type: "object", + required: ["index", "role", "content"], + properties: { + index: { type: "integer" }, + role: { type: "string", enum: ["assistant", "tool"] }, + content: { type: "string" }, + toolCalls: { + anyOf: [{ type: "array", items: BUILTIN_TOOL_CALL_SCHEMA }, { type: "null" }], + }, + reasoning: { + anyOf: [{ type: "string" }, { type: "null" }], + }, + }, + additionalProperties: false, +}; + +export const BUILTIN_DETAIL_SCHEMA: JSONSchema = { + title: "builtin-detail", + type: "object", + required: ["sessionId", "model", "duration", "turnCount", "turns"], + properties: { + sessionId: { type: "string" }, + model: { type: "string" }, + duration: { type: "integer" }, + turnCount: { type: "integer" }, + turns: { + type: "array", + items: { type: "string", format: "cas_ref" }, + }, + }, + additionalProperties: false, +}; diff --git a/packages/workflow-agent-builtin/src/tools/index.ts b/packages/workflow-agent-builtin/src/tools/index.ts new file mode 100644 index 0000000..87fe1c1 --- /dev/null +++ b/packages/workflow-agent-builtin/src/tools/index.ts @@ -0,0 +1,44 @@ +import type { OpenAiToolDefinition } from "../llm/index.js"; + +import { readFileTool } from "./read-file.js"; +import { runCommandTool } from "./run-command.js"; +import type { BuiltinTool, ToolContext } from "./types.js"; +import { writeFileTool } from "./write-file.js"; + +export { resolvePathInWorkspace } from "./path.js"; +export type { BuiltinTool, ToolContext } from "./types.js"; + +const BUILTIN_TOOLS: BuiltinTool[] = [readFileTool, writeFileTool, runCommandTool]; + +export function getBuiltinTools(): readonly BuiltinTool[] { + return BUILTIN_TOOLS; +} + +export function builtinToolsToOpenAi(tools: readonly BuiltinTool[]): OpenAiToolDefinition[] { + return tools.map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters as Record, + }, + })); +} + +export async function executeBuiltinTool( + name: string, + argsJson: string, + ctx: ToolContext, +): Promise { + const tool = BUILTIN_TOOLS.find((t) => t.name === name); + if (tool === undefined) { + return `Error: unknown tool ${name}`; + } + let args: unknown; + try { + args = JSON.parse(argsJson) as unknown; + } catch { + return "Error: tool arguments must be valid JSON"; + } + return tool.execute(args, ctx); +} diff --git a/packages/workflow-agent-builtin/src/tools/path.ts b/packages/workflow-agent-builtin/src/tools/path.ts new file mode 100644 index 0000000..6a34704 --- /dev/null +++ b/packages/workflow-agent-builtin/src/tools/path.ts @@ -0,0 +1,12 @@ +import { isAbsolute, relative, resolve } from "node:path"; + +/** Reject paths that escape the workspace root via `..` segments. */ +export function resolvePathInWorkspace(cwd: string, inputPath: string): string | null { + const root = resolve(cwd); + const target = resolve(root, inputPath); + const rel = relative(root, target); + if (rel.startsWith("..") || isAbsolute(rel)) { + return null; + } + return target; +} diff --git a/packages/workflow-agent-builtin/src/tools/read-file.ts b/packages/workflow-agent-builtin/src/tools/read-file.ts new file mode 100644 index 0000000..79178b1 --- /dev/null +++ b/packages/workflow-agent-builtin/src/tools/read-file.ts @@ -0,0 +1,44 @@ +import { readFile, stat } from "node:fs/promises"; +import { resolvePathInWorkspace } from "./path.js"; +import type { BuiltinTool } from "./types.js"; + +const MAX_READ_BYTES = 512 * 1024; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export const readFileTool: BuiltinTool = { + name: "read_file", + description: "Read a UTF-8 text file from the workspace.", + parameters: { + type: "object", + required: ["path"], + properties: { + path: { type: "string", description: "Relative or absolute path within the workspace." }, + }, + additionalProperties: false, + }, + execute: async (args, ctx) => { + if (!isRecord(args) || typeof args.path !== "string") { + return "Error: path must be a string"; + } + const resolved = resolvePathInWorkspace(ctx.cwd, args.path); + if (resolved === null) { + return "Error: path escapes workspace root"; + } + try { + const info = await stat(resolved); + if (!info.isFile()) { + return "Error: not a file"; + } + if (info.size > MAX_READ_BYTES) { + return `Error: file exceeds ${MAX_READ_BYTES} byte limit`; + } + return await readFile(resolved, "utf8"); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + return `Error: ${message}`; + } + }, +}; diff --git a/packages/workflow-agent-builtin/src/tools/run-command.ts b/packages/workflow-agent-builtin/src/tools/run-command.ts new file mode 100644 index 0000000..e5e7289 --- /dev/null +++ b/packages/workflow-agent-builtin/src/tools/run-command.ts @@ -0,0 +1,103 @@ +import { spawn } from "node:child_process"; +import { resolvePathInWorkspace } from "./path.js"; +import type { BuiltinTool } from "./types.js"; + +const COMMAND_TIMEOUT_MS = 60_000; +const MAX_OUTPUT_CHARS = 32_000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function truncate(text: string, maxChars: number): string { + if (text.length <= maxChars) { + return text; + } + return `${text.slice(0, maxChars)}\n...(truncated)`; +} + +function runShell( + command: string, + cwd: string, +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(command, { + cwd, + env: process.env, + shell: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + const timer = setTimeout(() => { + child.kill("SIGTERM"); + }, COMMAND_TIMEOUT_MS); + + child.on("error", (cause) => { + clearTimeout(timer); + const message = cause instanceof Error ? cause.message : String(cause); + reject(new Error(message)); + }); + + child.on("close", (code) => { + clearTimeout(timer); + resolve({ stdout, stderr, code: code ?? 1 }); + }); + }); +} + +export const runCommandTool: BuiltinTool = { + name: "run_command", + description: + "Run a shell command in the workspace. Requires UWF_BUILTIN_ALLOW_SHELL=1. Output is truncated.", + parameters: { + type: "object", + required: ["command"], + properties: { + command: { type: "string", description: "Shell command to execute." }, + cwd: { + type: "string", + description: "Optional working directory relative to workspace root.", + }, + }, + additionalProperties: false, + }, + execute: async (args, ctx) => { + if (process.env.UWF_BUILTIN_ALLOW_SHELL !== "1") { + return "Error: run_command disabled. Set UWF_BUILTIN_ALLOW_SHELL=1 to enable."; + } + if (!isRecord(args) || typeof args.command !== "string") { + return "Error: command must be a string"; + } + let workDir = ctx.cwd; + if (args.cwd !== undefined && args.cwd !== null) { + if (typeof args.cwd !== "string") { + return "Error: cwd must be a string"; + } + const resolved = resolvePathInWorkspace(ctx.cwd, args.cwd); + if (resolved === null) { + return "Error: cwd escapes workspace root"; + } + workDir = resolved; + } + try { + const { stdout, stderr, code } = await runShell(args.command, workDir); + const out = truncate( + `exit_code: ${code}\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, + MAX_OUTPUT_CHARS, + ); + return out; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + return `Error: ${message}`; + } + }, +}; diff --git a/packages/workflow-agent-builtin/src/tools/types.ts b/packages/workflow-agent-builtin/src/tools/types.ts new file mode 100644 index 0000000..a96573b --- /dev/null +++ b/packages/workflow-agent-builtin/src/tools/types.ts @@ -0,0 +1,13 @@ +import type { JSONSchema } from "@uncaged/json-cas"; + +export type ToolContext = { + cwd: string; + storageRoot: string; +}; + +export type BuiltinTool = { + name: string; + description: string; + parameters: JSONSchema; + execute: (args: unknown, ctx: ToolContext) => Promise; +}; diff --git a/packages/workflow-agent-builtin/src/tools/write-file.ts b/packages/workflow-agent-builtin/src/tools/write-file.ts new file mode 100644 index 0000000..e34b5df --- /dev/null +++ b/packages/workflow-agent-builtin/src/tools/write-file.ts @@ -0,0 +1,39 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { resolvePathInWorkspace } from "./path.js"; +import type { BuiltinTool } from "./types.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export const writeFileTool: BuiltinTool = { + name: "write_file", + description: "Write UTF-8 text to a file in the workspace (creates parent directories).", + parameters: { + type: "object", + required: ["path", "content"], + properties: { + path: { type: "string", description: "Relative or absolute path within the workspace." }, + content: { type: "string", description: "File contents to write." }, + }, + additionalProperties: false, + }, + execute: async (args, ctx) => { + if (!isRecord(args) || typeof args.path !== "string" || typeof args.content !== "string") { + return "Error: path and content must be strings"; + } + const resolved = resolvePathInWorkspace(ctx.cwd, args.path); + if (resolved === null) { + return "Error: path escapes workspace root"; + } + try { + await mkdir(dirname(resolved), { recursive: true }); + await writeFile(resolved, args.content, "utf8"); + return `Wrote ${args.content.length} bytes to ${args.path}`; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + return `Error: ${message}`; + } + }, +}; diff --git a/packages/workflow-agent-builtin/src/types.ts b/packages/workflow-agent-builtin/src/types.ts new file mode 100644 index 0000000..679d83c --- /dev/null +++ b/packages/workflow-agent-builtin/src/types.ts @@ -0,0 +1,50 @@ +import type { ChatMessage } from "./llm/index.js"; + +export type BuiltinToolCallRecord = { + id: string; + name: string; + args: string; +}; + +export type BuiltinToolResultRecord = { + toolCallId: string; + name: string; + content: string; +}; + +export type BuiltinLoopTurn = { + assistantContent: string | null; + toolCalls: BuiltinToolCallRecord[] | null; + toolResults: BuiltinToolResultRecord[] | null; +}; + +export type BuiltinSessionState = { + sessionId: string; + model: string; + startedAtMs: number; + messages: ChatMessage[]; + turns: BuiltinLoopTurn[]; +}; + +export type BuiltinTurnRole = "assistant" | "tool"; + +export type BuiltinToolCall = { + name: string; + args: string; +}; + +export type BuiltinTurnPayload = { + index: number; + role: BuiltinTurnRole; + content: string; + toolCalls: BuiltinToolCall[] | null; + reasoning: string | null; +}; + +export type BuiltinDetailPayload = { + sessionId: string; + model: string; + duration: number; + turnCount: number; + turns: string[]; +}; diff --git a/packages/workflow-agent-builtin/tsconfig.json b/packages/workflow-agent-builtin/tsconfig.json new file mode 100644 index 0000000..de22a37 --- /dev/null +++ b/packages/workflow-agent-builtin/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"], + "references": [{ "path": "../workflow-agent-kit" }, { "path": "../workflow-util" }] +} diff --git a/scripts/publish-all.mjs b/scripts/publish-all.mjs index 28de67c..fdb8de7 100644 --- a/scripts/publish-all.mjs +++ b/scripts/publish-all.mjs @@ -21,6 +21,7 @@ const publishOrder = [ "workflow-moderator", "workflow-agent-kit", "workflow-agent-hermes", + "workflow-agent-builtin", "cli-workflow", ]; diff --git a/tsconfig.json b/tsconfig.json index faeeb95..a5e606b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ { "path": "packages/workflow-moderator" }, { "path": "packages/workflow-agent-kit" }, { "path": "packages/workflow-agent-hermes" }, + { "path": "packages/workflow-agent-builtin" }, { "path": "packages/cli-workflow" } ] } From cef4db9a877c6fdfee133a21c4153e758c5591dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Sat, 23 May 2026 15:50:30 +0800 Subject: [PATCH 2/2] refactor: remove workspace path sandbox and shell gate - Replace resolvePathInWorkspace with simple resolvePath (no boundary check) - Remove UWF_BUILTIN_ALLOW_SHELL env gate from run_command - Update tests accordingly Per review: sandbox was false security with shell=true, and path restrictions are unnecessary for a trusted agent environment. --- .../__tests__/path.test.ts | 26 +++++++++++-------- .../workflow-agent-builtin/src/tools/index.ts | 2 +- .../workflow-agent-builtin/src/tools/path.ts | 14 +++------- .../src/tools/read-file.ts | 7 ++--- .../src/tools/run-command.ts | 13 +++------- .../src/tools/write-file.ts | 7 ++--- 6 files changed, 27 insertions(+), 42 deletions(-) diff --git a/packages/workflow-agent-builtin/__tests__/path.test.ts b/packages/workflow-agent-builtin/__tests__/path.test.ts index d39ad79..063475c 100644 --- a/packages/workflow-agent-builtin/__tests__/path.test.ts +++ b/packages/workflow-agent-builtin/__tests__/path.test.ts @@ -1,17 +1,21 @@ import { describe, expect, test } from "bun:test"; -import { join } from "node:path"; +import { resolvePath } from "../src/tools/path.js"; +import { resolve } from "node:path"; -import { resolvePathInWorkspace } from "../src/tools/path.js"; - -describe("resolvePathInWorkspace", () => { - const root = join("/tmp", "uwf-workspace"); - - test("resolves relative paths inside root", () => { - const resolved = resolvePathInWorkspace(root, "src/foo.ts"); - expect(resolved).toBe(join(root, "src/foo.ts")); +describe("resolvePath", () => { + test("resolves relative paths against cwd", () => { + const root = "/workspace/project"; + const resolved = resolvePath(root, "src/foo.ts"); + expect(resolved).toBe(resolve(root, "src/foo.ts")); }); - test("rejects parent traversal", () => { - expect(resolvePathInWorkspace(root, "../etc/passwd")).toBeNull(); + test("resolves absolute paths as-is", () => { + const resolved = resolvePath("/workspace", "/etc/hosts"); + expect(resolved).toBe("/etc/hosts"); + }); + + test("resolves parent traversal normally", () => { + const resolved = resolvePath("/workspace/project", "../other/file.ts"); + expect(resolved).toBe(resolve("/workspace/project", "../other/file.ts")); }); }); diff --git a/packages/workflow-agent-builtin/src/tools/index.ts b/packages/workflow-agent-builtin/src/tools/index.ts index 87fe1c1..c2e62a4 100644 --- a/packages/workflow-agent-builtin/src/tools/index.ts +++ b/packages/workflow-agent-builtin/src/tools/index.ts @@ -5,7 +5,7 @@ import { runCommandTool } from "./run-command.js"; import type { BuiltinTool, ToolContext } from "./types.js"; import { writeFileTool } from "./write-file.js"; -export { resolvePathInWorkspace } from "./path.js"; +export { resolvePath } from "./path.js"; export type { BuiltinTool, ToolContext } from "./types.js"; const BUILTIN_TOOLS: BuiltinTool[] = [readFileTool, writeFileTool, runCommandTool]; diff --git a/packages/workflow-agent-builtin/src/tools/path.ts b/packages/workflow-agent-builtin/src/tools/path.ts index 6a34704..ceb8014 100644 --- a/packages/workflow-agent-builtin/src/tools/path.ts +++ b/packages/workflow-agent-builtin/src/tools/path.ts @@ -1,12 +1,6 @@ -import { isAbsolute, relative, resolve } from "node:path"; +import { resolve } from "node:path"; -/** Reject paths that escape the workspace root via `..` segments. */ -export function resolvePathInWorkspace(cwd: string, inputPath: string): string | null { - const root = resolve(cwd); - const target = resolve(root, inputPath); - const rel = relative(root, target); - if (rel.startsWith("..") || isAbsolute(rel)) { - return null; - } - return target; +/** Resolve a path relative to the working directory. */ +export function resolvePath(cwd: string, inputPath: string): string { + return resolve(cwd, inputPath); } diff --git a/packages/workflow-agent-builtin/src/tools/read-file.ts b/packages/workflow-agent-builtin/src/tools/read-file.ts index 79178b1..b3683e7 100644 --- a/packages/workflow-agent-builtin/src/tools/read-file.ts +++ b/packages/workflow-agent-builtin/src/tools/read-file.ts @@ -1,5 +1,5 @@ import { readFile, stat } from "node:fs/promises"; -import { resolvePathInWorkspace } from "./path.js"; +import { resolvePath } from "./path.js"; import type { BuiltinTool } from "./types.js"; const MAX_READ_BYTES = 512 * 1024; @@ -23,10 +23,7 @@ export const readFileTool: BuiltinTool = { if (!isRecord(args) || typeof args.path !== "string") { return "Error: path must be a string"; } - const resolved = resolvePathInWorkspace(ctx.cwd, args.path); - if (resolved === null) { - return "Error: path escapes workspace root"; - } + const resolved = resolvePath(ctx.cwd, args.path); try { const info = await stat(resolved); if (!info.isFile()) { diff --git a/packages/workflow-agent-builtin/src/tools/run-command.ts b/packages/workflow-agent-builtin/src/tools/run-command.ts index e5e7289..2f34843 100644 --- a/packages/workflow-agent-builtin/src/tools/run-command.ts +++ b/packages/workflow-agent-builtin/src/tools/run-command.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { resolvePathInWorkspace } from "./path.js"; +import { resolvePath } from "./path.js"; import type { BuiltinTool } from "./types.js"; const COMMAND_TIMEOUT_MS = 60_000; @@ -57,7 +57,7 @@ function runShell( export const runCommandTool: BuiltinTool = { name: "run_command", description: - "Run a shell command in the workspace. Requires UWF_BUILTIN_ALLOW_SHELL=1. Output is truncated.", + "Run a shell command. Output is truncated to 32KB.", parameters: { type: "object", required: ["command"], @@ -71,9 +71,6 @@ export const runCommandTool: BuiltinTool = { additionalProperties: false, }, execute: async (args, ctx) => { - if (process.env.UWF_BUILTIN_ALLOW_SHELL !== "1") { - return "Error: run_command disabled. Set UWF_BUILTIN_ALLOW_SHELL=1 to enable."; - } if (!isRecord(args) || typeof args.command !== "string") { return "Error: command must be a string"; } @@ -82,11 +79,7 @@ export const runCommandTool: BuiltinTool = { if (typeof args.cwd !== "string") { return "Error: cwd must be a string"; } - const resolved = resolvePathInWorkspace(ctx.cwd, args.cwd); - if (resolved === null) { - return "Error: cwd escapes workspace root"; - } - workDir = resolved; + workDir = resolvePath(ctx.cwd, args.cwd); } try { const { stdout, stderr, code } = await runShell(args.command, workDir); diff --git a/packages/workflow-agent-builtin/src/tools/write-file.ts b/packages/workflow-agent-builtin/src/tools/write-file.ts index e34b5df..445f665 100644 --- a/packages/workflow-agent-builtin/src/tools/write-file.ts +++ b/packages/workflow-agent-builtin/src/tools/write-file.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; -import { resolvePathInWorkspace } from "./path.js"; +import { resolvePath } from "./path.js"; import type { BuiltinTool } from "./types.js"; function isRecord(value: unknown): value is Record { @@ -23,10 +23,7 @@ export const writeFileTool: BuiltinTool = { if (!isRecord(args) || typeof args.path !== "string" || typeof args.content !== "string") { return "Error: path and content must be strings"; } - const resolved = resolvePathInWorkspace(ctx.cwd, args.path); - if (resolved === null) { - return "Error: path escapes workspace root"; - } + const resolved = resolvePath(ctx.cwd, args.path); try { await mkdir(dirname(resolved), { recursive: true }); await writeFile(resolved, args.content, "utf8");