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)
This commit is contained in:
@@ -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, <thread-id>, <role>]`,即 `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<AgentRunResult>;
|
||||||
|
|
||||||
|
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
|
||||||
|
|
||||||
|
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<void>`,作为 agent CLI 的 `main`(见 `uwf-hermes` 的 `cli.ts`)。
|
||||||
|
|
||||||
|
#### 生命周期(按执行顺序)
|
||||||
|
|
||||||
|
```101:152:packages/workflow-agent-kit/src/run.ts
|
||||||
|
export function createAgent(options: AgentOptions): () => Promise<void> {
|
||||||
|
return async function main(): Promise<void> {
|
||||||
|
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<CasRef> {
|
||||||
|
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<StepRecord, "output"> & {
|
||||||
|
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.<name>`(`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<CasRef> {
|
||||||
|
// 校验为 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<FrontmatterFastPathResult | null>
|
||||||
|
```
|
||||||
|
|
||||||
|
流程:
|
||||||
|
|
||||||
|
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<ExtractResult>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 模型:`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<ProviderAlias, ProviderConfig>;
|
||||||
|
models: Record<ModelAlias, ModelConfig>;
|
||||||
|
agents: Record<AgentAlias, AgentConfig>;
|
||||||
|
defaultAgent: AgentAlias;
|
||||||
|
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
|
||||||
|
defaultModel: ModelAlias;
|
||||||
|
modelOverrides: Record<Scenario, ModelAlias> | 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<string>
|
||||||
|
```
|
||||||
|
|
||||||
|
| 能力 | 现状 |
|
||||||
|
|------|------|
|
||||||
|
| 协议 | 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: <id>`(`parseSessionIdFromStdout`)
|
||||||
|
- 会话文件:`~/.hermes/sessions/session_<id>.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<void> {
|
||||||
|
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<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 <thread-id> <role>`,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
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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> = {}): 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, BuiltinSessionState>();
|
||||||
|
|
||||||
|
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<typeof resolveModel>,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
session: BuiltinSessionState,
|
||||||
|
store: Store,
|
||||||
|
maxTurns: number,
|
||||||
|
): Promise<AgentRunResult> {
|
||||||
|
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<AgentRunResult> {
|
||||||
|
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<AgentRunResult> {
|
||||||
|
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<void> {
|
||||||
|
return createAgent({
|
||||||
|
name: "builtin",
|
||||||
|
run: runBuiltin,
|
||||||
|
continue: continueBuiltin,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { createBuiltinAgent } from "./agent.js";
|
||||||
|
|
||||||
|
const main = createBuiltinAgent();
|
||||||
|
void main();
|
||||||
@@ -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<BuiltinLoopTurn["toolCalls"]>): 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<BuiltinSchemaHashes> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { chatCompletionWithTools } from "./llm.js";
|
||||||
|
export type {
|
||||||
|
ChatMessage,
|
||||||
|
LlmAssistantResponse,
|
||||||
|
LlmToolCall,
|
||||||
|
OpenAiToolDefinition,
|
||||||
|
} from "./types.js";
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
if (message.role === "tool") {
|
||||||
|
return {
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: message.tool_call_id,
|
||||||
|
content: message.content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (message.role === "assistant") {
|
||||||
|
const base: Record<string, unknown> = {
|
||||||
|
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<LlmAssistantResponse> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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<RunBuiltinLoopResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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<string, unknown>,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeBuiltinTool(
|
||||||
|
name: string,
|
||||||
|
argsJson: string,
|
||||||
|
ctx: ToolContext,
|
||||||
|
): Promise<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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<string>;
|
||||||
|
};
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "../workflow-agent-kit" }, { "path": "../workflow-util" }]
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ const publishOrder = [
|
|||||||
"workflow-moderator",
|
"workflow-moderator",
|
||||||
"workflow-agent-kit",
|
"workflow-agent-kit",
|
||||||
"workflow-agent-hermes",
|
"workflow-agent-hermes",
|
||||||
|
"workflow-agent-builtin",
|
||||||
"cli-workflow",
|
"cli-workflow",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
{ "path": "packages/workflow-moderator" },
|
{ "path": "packages/workflow-moderator" },
|
||||||
{ "path": "packages/workflow-agent-kit" },
|
{ "path": "packages/workflow-agent-kit" },
|
||||||
{ "path": "packages/workflow-agent-hermes" },
|
{ "path": "packages/workflow-agent-hermes" },
|
||||||
|
{ "path": "packages/workflow-agent-builtin" },
|
||||||
{ "path": "packages/cli-workflow" }
|
{ "path": "packages/cli-workflow" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user