Compare commits

...

15 Commits

Author SHA1 Message Date
李岩岩
1644731a5e feat: 拆分 Models Extension 2026-08-21 16:34:59 +08:00
李岩岩
59903d6d5e feat: 按 Extension ID 装载初始化配置 2026-08-21 16:34:59 +08:00
李岩岩
f728132c81 feat: 拆分输入解析功能;拓展输入命令;更新文档规划 2026-08-21 16:34:59 +08:00
李岩岩
d4b6728326 feat: 更新write skill 2026-08-21 16:34:59 +08:00
李岩岩
5d13ae4c3e del: 去除无用旧代码 2026-08-21 16:34:50 +08:00
李岩岩
8283ba604d feat: 接入 CLI 对话工作流 2026-08-21 16:33:23 +08:00
李岩岩
13d2494208 feat: 实现 Agent 与 DeepSeek 对话链路 2026-08-21 16:33:23 +08:00
李岩岩
a801f40bdc feat: 实现 Workspace 对话持久化 2026-08-21 16:33:23 +08:00
李岩岩
d87dad27db refactor: 集中管理 Extension 连接点 2026-08-21 16:33:23 +08:00
李岩岩
258de1a1cd init: 初始化开发目录 2026-08-21 16:33:23 +08:00
李岩岩
49f333b668 init: 初始化开发文档 2026-08-21 16:33:23 +08:00
李岩岩
c4cfbaf83a feat: 第三版文档上传 2026-08-21 16:33:23 +08:00
李岩岩
da87569131 feat: 第二版文档上传 2026-08-21 16:33:23 +08:00
李岩岩
e4b45c5971 feat: 第一版文档上传 2026-08-21 16:33:23 +08:00
李岩岩
ad9ee89586 refactor: 项目结构重构 2026-08-21 16:33:06 +08:00
68 changed files with 2639 additions and 822 deletions

View File

@ -0,0 +1,194 @@
---
name: write-agent-blog
description: 为 llm-to-agent 的《Agent进阶专题》系列创作中文文章。触发"为本次提交写篇文章""给这个功能写博客""记录这次实现"。不触发:修改 README、写代码注释、写 commit message。
---
# 创作 Agent 系列博客
为《Agent进阶专题》输出一篇 Hexo Markdown 文章到 `/Users/liyanyan/study/hexo-blog/source/_posts/agent/`。**源码是唯一事实来源,只写已实现的行为。**
---
## 必须(违反即错误)
### 工作流程
1. `git rev-parse --show-toplevel` 定位项目根目录。
2. 用 `git show` / `git diff` 读取目标变更;读取 `/Users/liyanyan/study/hexo-blog/source/_posts/agent/*.md` 确定编号、术语、避免重复。
3. 确定阶段P0/P1/P2、阶段内序号、中文标题、英文 slug。
4. 只在 `/Users/liyanyan/study/hexo-blog/source/_posts/agent/` 新建一个 `2026-PX-0X-slug.md`,不覆盖已有文件。
### 文件名与元信息
```
2026-PX-0X-slug.md
```
- `PX` = 路线图阶段,`0X` = 阶段内序号01 起),`slug` = 小写 ASCII + 短横线。
- 如果无法判断阶段或序号,暂停询问用户。
```yaml
---
title: 【Agent进阶专题】中文标题
tags: [AI, Agent]
categories:
- AI
date: YYYY-MM-DD HH:mm:ss
permalink: /YYYY/agent/slug/
---
```
- date 用当前本地时间适当加工避开工作时间permalink的YYYY从date中提取。
- 标题具体、体现结果,不用"一些思考"等模糊标题。
### 事实约束
- 只写代码中已存在的行为。未实现的能力标注"计划中",不写成已实现。
- 禁止虚构:调试经历、性能数据、设计争论、命令输出、用户反馈。
- 代码片段必须与仓库一致,附近标注仓库相对路径。
- 脚手架/配置类提交:解释目的和后续价值,不夸大现有能力。
### 文章结构
```
元信息
摘要1-2 句)
<!-- more -->
正文
```
- `<!-- more -->` 恰好出现一次,前后各空一行。
- 摘要说明"做了什么、为什么有价值"。
- 正文标题从 `##` 开始,不重复一级标题。
---
## 应该(尽量做到)
### 内容边界
- 标题就是边界。不讲无关协议、远期能力、实现细节。
- 导读/概念类 ~1000 中文字符;实现类按需,讲清即止。
- 2-4 个短章节即可,不为完整而堆章节。
### Extension 实现类文章
默认按以下主线组织:
```text
能力介绍 → 实现思路 → setup 行为 → start 行为 → stop 行为 → 抓手 → 下节引子
```
- 这是写作顺序,不是固定模板;根据源码实际行为扩展、合并或删除小节。
- 没有 `start` / `stop` 时直接省略,不创建空章节。
- “能力介绍”说明当前插件解决什么问题、提供什么结果,以文字为主,不在这里罗列接口和方法。
- “实现思路”说明数据模型、关键取舍和整体流程,以文字为主;路径、数据结构、内部方法等细节不要各自拆节。
- 代码尽量集中在 `setup``start``stop` 和“抓手”中。前两节只在没有代码就难以说清时放一段短示意。
- “抓手”专门说明当前插件**对外暴露**的命名能力。先用“对外暴露一个名为 `xxx` 的能力,它的契约是 `XxxService`”点题,再展示契约并逐项解释其中的字段和方法。
- 不重复介绍所有插件通用的注册、获取和消费方式;除非某个插件的连接方式确有特殊之处,否则省略 `context.add/get` 等样板代码。
- 解释契约时只写当前文章已经建立的概念。某个字段或方法涉及后续主题时,只说明它现在提供的结果,不点名尚未介绍的插件,也不展开后续流程。
- 当前插件没有对外能力时,省略“抓手”,不要为了凑结构扩展概念。
- 下节引子只承接一个最自然的后续能力。
### 叙事方式
- 从读者能理解的场景切入,不假定读者读过前文。
- "原本有什么 → 遇到什么问题 → 做出什么选择"推进。
- 读者已知的概念直接引用跳过,如"和 Express 中间件机制一致,略过"。
- 结尾自然引出下一篇的问题,不罗列阶段和术语。
- 首次出现 P0/P1 等编号时用自然语言解释。
### 文风
面向懂 TS 和基础 LLM、刚接触 Agent 工程的开发者。**笔记体**:结论先行,解释从简。
- ❌ "我们需要先回答一个不那么显眼、却会影响整个项目的问题"
- ✅ "先确定一件事Kernel 不认业务概念。"
- ❌ "经过反复思考和持续调整,我们最终选择了一种简洁优雅的方案"
- ✅ "架构收缩为两个核心概念Kernel + Extensions。"
- 每段一个重点,通常不超过三句话。
- 删除过渡句、重复总结、设问、修饰语。
- 可用"我们",重心在技术推理。
- 不用营销语言、泛泛 AI 介绍、夸张结论。
- 不写散文,不用文学化铺垫。
### 格式
- 围栏代码块标注语言;标题/列表/代码块/`<!-- more -->` 前后空行。
- 代码尽量短,展示核心思路即可。省略处用 `...` 且确保不会误解。
- 不自行添加目录。
---
## 避免
- 假设读者读过系列其他文章("如前所述""大家已经知道")。
- 提前讲完后续文章的主题。
- 把计划/路线图写成已实现的能力。
- 流水账式叙述或填充式总结。
- 为同一概念反复更换中文译名。
---
## 最终检查
- [ ] 只新增/修改了一篇 `.md`
- [ ] 文件名匹配 `^2026-P[0-9]+-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$`
- [ ] 元信息字段完整,`<!-- more -->` 恰好一次
- [ ] 无占位内容残留
- [ ] 代码与仓库一致
- [ ] 回复含文件链接 + 一句话主题说明
---
## 参考:本作者已上线系列的共性特征
以下从 Java 专题和 AI 基础专题中提取,是已被验证有效的写作习惯。
### 开头
一句话交代本篇做什么,不做铺垫:
- "本篇简单记录常量和变量。"
- "开始之前,我们需要搭建一个基本的项目环境,基于 TypeScript 和 Node.js且尽量简洁。"
### 系列衔接
可引用前文建立上下文,但只提直接相关的一篇:
- "上一节我们已经可以和大模型进行连续对话了,这节我们通过简单的例子来演示一下提示词管理的能力。"
### 代码呈现
- 实现类文章中,前两节以文字为主,代码集中到生命周期与“抓手”。
- 按生命周期或对外能力拆分代码块,每块配一行解释。
- 可在代码块之间穿插截图验证运行结果。
### 结尾
两种方式,按主题选择:
- **引出下一篇**"到此,我们的项目环境就搭建完成了,下一节我们将进行一个简单的对话。"
- **发散点列表**:列出 3-5 个后续可探索的方向,标注为"发散点"而非"计划"。
```md
发散点:
* 上下文长度管理 - 监控上下文的长度,并在超过限制时进行裁剪。
* 持久化 - 将上下文保存到文件或者数据库中。
```
### 跨知识引用
读者已知的概念直接跳过,不重复解释:
- "`if` 语句和 `javascript` 完全一致,略过。"
- "和 Express 中间件机制一致,略过。"
### 语气
- 用"我们"但不煽情,重心始终在技术本身。
- 笔记语气:记录过程、防止遗忘,不假装客观权威。
- [ ] 无占位内容残留
- [ ] 代码与仓库一致
- [ ] 回复含文件链接 + 一句话主题说明

View File

@ -0,0 +1,4 @@
interface:
display_name: "Agent 专题博客创作"
short_description: "根据提交与项目文档创作统一风格的 Hexo Markdown 文章"
default_prompt: "使用 $write-agent-blog 为本次提交写一篇文章。"

View File

@ -0,0 +1,40 @@
---
title: 【Agent进阶专题】{{中文标题}}
tags: [AI, Agent]
categories:
- AI
date: {{年-月-日 时:分:秒}}
permalink: /agent/{{四位年份}}/{{两位月份}}/{{英文别名}}/
---
{{面向首次接触项目的读者,从一个具体场景或问题切入,用一至两个短段落说明本篇主题及其价值,作为 Hexo 列表页摘要。}}
<!-- more -->
## {{问题或限制}}
{{说明需要解决的具体问题或此前存在的限制。}}
## {{目标与边界}}
{{说明本篇交付什么,以及刻意暂不处理什么。}}
## {{核心设计或理解模型}}
{{先解释核心思路,再进入实现细节。}}
## {{实现过程}}
{{配合精选代码片段说明关键实现路径。}}
## {{验证结果}}
{{说明实际使用的验证方式和可观察结果。}}
## {{取舍与下一步}}
{{记录当前限制、实现取舍和下一项自然演进能力。}}
## {{结语}}
{{将这个小功能点连接到从 LLM 走向 Agent 的系列主线。}}

17
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,17 @@
# 开发准则
## 驾驭约束
* **开始任务前先明确任务内容**:不允许为了不报错正确而随意修改架构/目录/接口,不允许为了结果不报错而随意修改测试用例
* **先读文档再动手**:涉及架构/目录/接口的修改,必须先读 `docs/design.md`
* **只实现当前要求的**:禁止预建未来可能需要的模块、方法、字段
* **最小改动范围**:每次只改最少的文件,改完一批确认一批,不要一口气创建大量文件
* **改前先读**:编辑任何文件前必须先 `read_file` 确认当前内容(用户可能已手动修改)
* **禁止 `npx tsx -e` 内联测试**:测试代码统一写到 `packages/tests/src/` 下,用 `pnpm test` 运行
* **改后必验证**:代码改动后运行 `pnpm test`,确保用例全通过
## 代码风格
* 不得为简单的需求过度抽象和设计
* 简单功能不需要写注释
* 临时文件统一放在 `tmp` 目录下,且必须在 `.gitignore` 中忽略

7
.gitignore vendored
View File

@ -1,3 +1,6 @@
node_modules
node_modules/
.env
pnpm-lock.yaml
.DS_Store
tmp/
.agent/
.llm-to-agent

28
README.md Normal file
View File

@ -0,0 +1,28 @@
# llm-to-agent
个人使用、本地优先的开发助手与电脑管家。
项目坚持简单的 `Kernel + Extensions`Kernel 只提供运行机制,具体能力全部由 Extension 实现;所有专属数据集中在用户目录,不写入绑定的项目。
运行 CLI
```bash
pnpm dev
```
启动时自动加载项目根目录的 `.env`
其中设置 `DEEPSEEK_API_KEY`
也可用 `LLM_TO_AGENT_HOME` 修改数据根目录,用 `DEEPSEEK_MODEL` 修改模型。
CLI 支持 `/new` 新建对话、`/switch <id>` 切换对话、`/history` 查看当前对话、`/exit` 退出;输入 `//` 可以发送以 `/` 开头的普通消息。
Runtime 会读取 `<LLM_TO_AGENT_HOME>/config/extensions.json` 和当前 Workspace 下同结构的 `config/extensions.json`;文件缺失时继续使用默认值,不会自动创建。
文档:
- [架构总览](docs/architecture.md)
- [源码目录](docs/source-layout.md)
- [数据目录](docs/data-layout.md)
- [Extension 规划](docs/extensions.md)
- [路线图](docs/roadmap.md)

94
docs/architecture.md Normal file
View File

@ -0,0 +1,94 @@
# 架构总览
## 目标
llm-to-agent 是个人使用、本地优先的 Agent。架构优先级是
```text
简单易懂 > 容易扩展和修改 > 架构完整 > 通用性
```
Runtime 长期保持:
```text
Kernel + Extensions
```
Kernel 管 Extension 怎样连接和运行Extension 管系统具体能做什么。
## Kernel
Kernel 只提供所有 Extension 共用的运行机制:
- 按顺序装入 Extension
- 依次执行 `setup``start`,停止时倒序执行 `stop`
- 通过命名抓手提供和取得能力;
- 通过命名事件发布和监听事实。
Kernel 不认识 Workspace、Agent、模型、Tool 或其他业务概念。
## Extensions
Extension 是 Runtime 的独立装配单位,分为三类:
- 共享能力:拥有明确状态、资源或业务流程;
- Provider接入可替换的模型、协议或外部系统
- 产品入口:处理 CLI、Web、Desktop 的输入、输出和平台资源。
规划可以列出尚未实现的 Extension源码、Catalog 和产品装配只包含已经开始实现的部分。Extension 内部文件和目录不设统一结构。
最终能力归属见 [Extension 规划](extensions.md)。
## 配置
Runtime 启动时读取全局和当前 Workspace 的可选配置Workspace 配置按字段覆盖全局配置。产品装配向 Kernel 提供 Extension 工厂函数,每个工厂函数用自身的静态 `id` 表明身份:
```ts
createDeepSeekExtension.id = ExtensionId.DeepSeek;
```
`kernel.use(createDeepSeekExtension)` 根据这个 `id` 取得对应配置,调用 `createDeepSeekExtension(options)`,再保存创建出的 Extension 实例。配置文件位置、作用域和合并规则都不进入 Kernel。
Extension 负责解释和校验自己的片段。第一版配置只在启动时读取,不创建缺失文件、不写入默认值,也不支持热更新。
## 扩展点
多个实现向能力所有者登记,不再为扩展点增加新的架构层:
```text
models.providers
tools.providers
agent.contextContributors
automation.triggers
security.approvalChannels
health.checks
```
模型 Provider 由 `models` 选择,具体 Tool 由 `tools` 统一执行,上下文来源由 `agent` 组合。
## 协作
- 控制流程使用明确调用;
- 事件只表示已经发生的事实;
- Extension 只依赖其他 Extension 的公开契约;
- Provider 向扩展点注册实现,能力所有者不引用 Provider 实现;
- Tool 是能力 Extension 暴露给 Agent 的操作,不是新的 Extension
- 高风险操作统一经过 `security`,凭据统一通过 `secrets` 的受控引用取得。
事件不能承担顺序、返回值、审批、事务或回滚。
## Runtime 与产品
Runtime 持有一套 Kernel、Extension 实例和本地数据。CLI、Web、Desktop 是不同产品入口,共用共享能力和数据。
版本切换、进程守护、离线恢复和失败回退由 Runtime 外的 Supervisor 负责。
## 长期约束
- 保持单用户、本地优先和单 Runtime
- 保持一个 TypeScript package直到出现独立构建、发布或进程边界
- 使用静态装配,不提前建设插件平台、热加载或依赖图;
- 规划高风险边界但不创建占位目录、文件、ID 或 Hook
- 普通函数、页面、命令、Parser、Prompt 和单个 Tool 不拆成 Extension
- 新增 Kernel 机制必须有多个具体使用者;
- 不提前建设 Command Bus、Middleware、事件溯源或分布式状态。

90
docs/data-layout.md Normal file
View File

@ -0,0 +1,90 @@
# 数据目录
## 唯一数据根
所有 llm-to-agent 专属数据集中在 `LLM_TO_AGENT_HOME`。未设置时默认使用:
```text
~/.llm-to-agent/
```
开发环境可以指定其他数据根,测试使用临时目录。
## 物理目录
```text
<LLM_TO_AGENT_HOME>/
├── config/
│ └── extensions.json
├── workspaces/
│ └── <workspace-id>/
│ ├── workspace.json
│ ├── config/
│ │ └── extensions.json
│ ├── files/
│ ├── conversations/
│ ├── artifacts/
│ └── extensions/
│ └── <extension-id>/
├── extensions/
│ └── <extension-id>/
├── releases/
├── runtime/
└── trash/
```
目录按需要创建,不预建尚未使用的层级。
## Extension 配置
全局和 Workspace 配置使用相同结构:
```json
{
"version": 1,
"extensions": {
"models": {
"defaultProvider": "deepseek"
},
"deepseek": {
"model": "deepseek-v4-flash"
}
}
}
```
Runtime 先读取全局配置,再使用 Workspace 中同 Extension、同字段的值覆盖它。两个文件都是可选的第一版只读不自动创建、补全或修改配置文件。
## 数据归属
- `workspace` 管理 Workspace、Conversation、Message 和 Artifact
- `agent` 管理 Agent Run
- `tasks` 管理 Goal、Plan 和任务执行状态;
- `memory``knowledge` 分别管理自己的长期数据和索引;
- `automation` 管理 Workflow、Schedule 和执行历史;
- `security` 管理审批和安全记录;
- 每个 Extension 只能通过公开契约访问其他 Extension 的数据;
- 每个数据所有者负责自己的迁移、校验、导出和清理。
Workspace 范围的私有数据写入 `workspaces/<workspace-id>/extensions/<extension-id>/`,全局私有数据写入 `extensions/<extension-id>/`
凭据明文不写入数据根,由 `secrets` 使用系统 Keychain 或受控加密存储管理。
## Workspace
- `managed` Workspace 的工作文件位于自己的 `files/`
- `linked` Workspace 只记录用户已有目录的绑定关系;
- 产品入口各自管理当前选择的 Workspace 和 Conversation
- 删除 linked Workspace 绝不能删除外部项目源码。
## 项目目录零落地
绑定外部项目时,项目源码保留在原位置,所有 llm-to-agent 专属数据仍保存在数据根中。项目中不创建 `.llm-to-agent/` 或其他专属元数据。
## 数据规则
- Runtime 公共库只提供安全路径、原子写入和基础存储工具,不理解业务数据;
- 缓存、索引和临时数据必须可以清理重建;
- 删除重要数据默认先进入 `trash/`
- 迁移、修复和恢复前保留可恢复副本;
- 全局备份、离线恢复和版本回退由 Supervisor 协调。

78
docs/extensions.md Normal file
View File

@ -0,0 +1,78 @@
# Extension 规划
## 共享能力
| Extension | 职责 | 公开能力与 Tool |
|---|---|---|
| `workspace` | Workspace、Conversation、Message、Artifact | `WorkspaceService`;默认不提供 Tool |
| `models` | 模型注册、选择、流式调用、Embedding 和 Fallback | `ModelService`;接收 `models.providers` |
| `tools` | Tool 注册、Schema 校验、执行、取消和安全拦截 | `ToolService`;可提供 `tools.search``tools.describe` |
| `agent` | 一次 Agent Run、上下文构建、模型与 Tool 循环 | `AgentService`;接收 `agent.contextContributors` |
| `tasks` | Goal、Plan、子 Agent、任务依赖、等待和恢复 | `TaskService``tasks.update_plan``tasks.delegate``tasks.wait` |
| `memory` | 用户偏好、事实、经验和长期记忆 | `MemoryService``memory.recall``memory.propose``memory.forget` |
| `knowledge` | 知识来源、索引、检索和引用 | `KnowledgeService``knowledge.search``knowledge.open_source` |
| `project` | 项目路径、文件、搜索、Patch、项目识别和验证 | `ProjectService``project.read``project.search``project.apply_patch``project.run_checks` |
| `shell` | 命令、PTY、输出流、超时和取消 | `ShellService``shell.run``shell.read``shell.cancel` |
| `git` | Repository、Diff、分支、Commit 和 Worktree | `GitService``git.status``git.diff``git.log``git.create_worktree` |
| `automation` | Workflow、Scheduler、提醒、重试和执行历史 | `AutomationService``automation.create``automation.schedule``automation.pause``automation.run_now` |
| `security` | 风险规则、授权、审批和安全记录 | `SecurityService`;接收 `security.approvalChannels`,不提供 Tool |
| `secrets` | Keychain、凭据引用、授权范围和轮换 | `SecretsService`;不向模型提供明文凭据 |
| `browser` | 浏览器 Session 和页面操作 | `BrowserService``browser.open``browser.read``browser.click``browser.type` |
| `computer` | 屏幕、窗口、应用、键鼠和剪贴板 | `ComputerService``computer.screenshot``computer.open_app``computer.click``computer.type` |
| `evolution` | 自身修改候选、验证和发布提案 | `EvolutionService``evolution.prepare``evolution.validate` |
`browser``computer``evolution` 在实现对应能力时创建。
## Provider
- 模型 Provider`deepseek` 以及实际接入的其他模型;
- Tool ProviderGitHub、MCP 和其他实际接入的外部系统;
- Provider 管理自己的协议、配置、资源和故障,向所属扩展点注册实现;
- 一个 Provider 可以提供多个 Tool一个 Tool 不对应一个 Extension。
## 产品入口
- `cli`:终端输入、命令、展示和审批通道;
- `web`HTTP、WebSocket、页面和 Web 会话;
- `desktop`:窗口、快捷键、通知和原生平台集成。
产品入口不拥有共享业务能力。
## 协作关系
```text
模型 Provider ────────────────> models
workspace / memory / knowledge ─> agent.contextContributors
project / shell / git
browser / computer / 外部 Provider ─> tools.providers
tasks ────────────────> agent
automation ───────────> tasks / agent / tools
agent ────────────────> workspace / models / tools / memory / knowledge
tools ────────────────> security
外部 Provider ────────> secrets
evolution ────────────> project / shell / git
cli / web / desktop ──> workspace / agent / tasks / automation / security
```
## 不单独拆分
- Conversation、Message、Artifact 归 `workspace`
- Context、Agent Profile 和声明式 Skill 归 `agent`
- Workflow、Scheduler 和 Notification 请求归 `automation`
- Policy、Approval 和 Audit 归 `security`
- 项目文件归 `project`,进程归 `shell`,仓库归 `git`
- Run 由实际执行者拥有,不创建通用 Run Extension
- 页面、命令、Parser、Prompt、模板和单个 Tool 不是 Extension
- 数据迁移、校验和清理由数据所有者实现。
## 创建规则
- 规划中的 Extension 只有开始实现时才创建源码;
- `catalog.ts` 只登记已实现并发生跨 Extension 协作的 ID、Hook 和 Event
- 不创建占位目录、文件、Factory、ID 或 Hook
- Extension 内部文件划分不作统一限制;
- 当前保持单包,只有独立构建、发布或进程隔离时才拆包。

45
docs/roadmap.md Normal file
View File

@ -0,0 +1,45 @@
# 路线图
## P1持续对话
建立 Kernel、`workspace``models``secrets``agent`、模型 Provider 和 `cli`
完成标志CLI 可以管理 Workspace 与 Conversation流式聊天并在重启后继续历史。
## P2项目闭环
实现 `tools``security``project``shell``git`
完成标志Agent 可以在受控范围内读取和修改项目、运行验证、检查 Git 变更,并处理审批。
## P3自身演进
实现 `evolution` 和 Runtime 外的 Supervisor。
完成标志Agent 可以生成隔离候选、完成验证和发布提案Supervisor 可以安全切换版本并失败回退。
## P4复杂任务
实现 `tasks``memory``knowledge` 和更多需要的模型 Provider。
完成标志:复杂目标可以持续执行、使用子 Agent、恢复任务并检索长期上下文。
## P5电脑与自动化
实现 `browser``computer``automation`,完善 `secrets` 的 Keychain、轮换和恢复能力。
完成标志Agent 可以操作浏览器和本机应用,管理提醒与自动化,并安全使用外部凭据。
## P6长期使用
实现 `web``desktop`,完善远程访问、诊断、备份迁移、资源清理和版本维护。
完成标志CLI、Web、Desktop 共用一套稳定 Runtime 和数据,可以长期日常使用。
## 执行原则
- 每个阶段开始时再拆提交级任务;
- 只创建当前阶段实际使用的 Extension
- 优先跑通最短闭环,再补充实现细节;
- 安全、取消、日志和数据保护随能力一起实现;
- 路线图只记录阶段和完成标志,不维护小功能状态表。

46
docs/source-layout.md Normal file
View File

@ -0,0 +1,46 @@
# 源码目录
项目保持一个 TypeScript package
```text
llm-to-agent/
├── src/
│ ├── kernel/
│ ├── extensions/
│ │ ├── catalog.ts
│ │ ├── shared/
│ │ ├── cli/
│ │ ├── web/
│ │ └── desktop/
│ ├── products/
│ ├── config.ts
│ └── main.ts
├── docs/
├── tests/
├── tooling/
├── package.json
└── tsconfig.json
```
## 目录职责
- `kernel/`Extension 生命周期、能力注册和事实事件;
- `extensions/catalog.ts`:已经实现的跨 Extension ID、Hook 和 Event
- `extensions/shared/`:跨产品使用的能力和 Provider
- `extensions/cli|web|desktop/`:产品输入、展示和平台集成;
- `products/`:静态选择产品启用的 Extension不写业务逻辑
- `config.ts`:读取并合并全局与 Workspace Extension 配置;
- `tests/`:跨 Extension、跨进程或跨版本测试
- `tooling/`:构建、开发和发布辅助。
Extension 的最终职责见 [Extension 规划](extensions.md)。
## 放置规则
- 源码目录只在对应能力开始实现时创建;
- Extension 内部文件和目录不设统一模板;
- 业务概念和流程进入 Extension不进入 Kernel
- 产品私有实现不能互相依赖;
- Provider 只通过公开契约和扩展点接入能力所有者;
- 普通复用代码不因此升级为 Extension
- 当前保持单包,只有独立构建、发布或进程隔离时才拆包。

View File

@ -1,60 +0,0 @@
# LLM-to-Agent 框架知识库
## Agent 工具调用流程
Agent 的 `run()` 方法执行以下循环:
1. 将用户消息加入上下文
2. 调用 LLM传入可用工具列表
3. 如果 LLM 返回 `tool_calls`,逐个执行工具,将结果加入上下文,回到步骤 2
4. 如果 LLM 直接返回文本,结束循环,返回最终回复
工具定义需包含 `name``description``parameters`JSON Schema`execute` 函数。
## 子 Agent 管理
通过 `spawn_agent` 工具可以创建具有独立人设的子 Agent如销售、顾客
每个子 Agent 拥有独立的上下文和基础工具集(不含 sub-agent 管理工具,防止无限递归)。
通过 `run_conversation` 工具可以在两个子 Agent 之间运行多轮对话。
## 知识库功能
知识库位于 `knowledge/` 目录,支持 `.md` 格式文档。
文档按 `##` 二级标题自动分块,通过 `search_knowledge` 工具进行关键词检索。
检索算法基于关键词命中次数,标题命中加权 ×3。
也提供 `list_knowledge` 工具查看知识库全貌。
## 常用命令
- 启动框架:`pnpm dev`
- 指定提示词模式:`pnpm dev orchestrator`
- 可用模式:`default``toxic``json``agent``reAct``orchestrator`
## 项目结构
```
src/
index.ts -- CLI 入口
llm.ts -- LLM 客户端OpenAI 兼容)
context.ts -- 上下文管理器
types/index.ts -- 类型定义
agents/
agent.ts -- Agent 核心类
manager.ts -- 子 Agent 管理器
tools.ts -- spawn_agent / run_conversation 工具
tools/
registry.ts -- 工具注册表
weather.ts -- 天气查询工具
calculator.ts -- 计算器工具
guess.ts -- 猜数字游戏工具
knowledge/
knowledge-base.ts -- 知识库类
search-tool.ts -- 知识库搜索工具
hooks/
index.ts -- Hook 总线
log.hooks.ts -- 日志 Hook
registry.ts -- Hook 注册
prompts/
system.ts -- 系统提示词
orchestrator.ts -- Orchestrator 提示词
reAct.ts -- ReAct 提示词
```

View File

@ -1,17 +1,20 @@
{
"name": "llm-to-agent",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx src/index.ts"
},
"dependencies": {
"dotenv": "^16.x",
"openai": "^4.x"
"dev": "node --import tsx src/main.ts cli",
"dev:runtime": "node --import tsx src/main.ts cli web desktop",
"dev:cli": "node --import tsx src/main.ts cli",
"dev:web": "node --import tsx src/main.ts web",
"dev:desktop": "node --import tsx src/main.ts desktop",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test \"src/**/*.test.ts\""
},
"devDependencies": {
"@types/node": "^25.9.1",
"tsx": "^4.x",
"typescript": "^5.x"
}
}
}

329
pnpm-lock.yaml generated Normal file
View File

@ -0,0 +1,329 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
'@types/node':
specifier: ^25.9.1
version: 25.9.5
tsx:
specifier: ^4.x
version: 4.23.1
typescript:
specifier: ^5.x
version: 5.9.3
packages:
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.1':
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.1':
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.1':
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.1':
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.1':
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.1':
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.1':
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.1':
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.1':
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.1':
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.1':
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.1':
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.1':
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.1':
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.1':
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.1':
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.1':
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.1':
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.1':
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.1':
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.1':
resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.1':
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.1':
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.1':
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.1':
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@types/node@25.9.5':
resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==}
esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
tsx@4.23.1:
resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==}
engines: {node: '>=18.0.0'}
hasBin: true
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
snapshots:
'@esbuild/aix-ppc64@0.28.1':
optional: true
'@esbuild/android-arm64@0.28.1':
optional: true
'@esbuild/android-arm@0.28.1':
optional: true
'@esbuild/android-x64@0.28.1':
optional: true
'@esbuild/darwin-arm64@0.28.1':
optional: true
'@esbuild/darwin-x64@0.28.1':
optional: true
'@esbuild/freebsd-arm64@0.28.1':
optional: true
'@esbuild/freebsd-x64@0.28.1':
optional: true
'@esbuild/linux-arm64@0.28.1':
optional: true
'@esbuild/linux-arm@0.28.1':
optional: true
'@esbuild/linux-ia32@0.28.1':
optional: true
'@esbuild/linux-loong64@0.28.1':
optional: true
'@esbuild/linux-mips64el@0.28.1':
optional: true
'@esbuild/linux-ppc64@0.28.1':
optional: true
'@esbuild/linux-riscv64@0.28.1':
optional: true
'@esbuild/linux-s390x@0.28.1':
optional: true
'@esbuild/linux-x64@0.28.1':
optional: true
'@esbuild/netbsd-arm64@0.28.1':
optional: true
'@esbuild/netbsd-x64@0.28.1':
optional: true
'@esbuild/openbsd-arm64@0.28.1':
optional: true
'@esbuild/openbsd-x64@0.28.1':
optional: true
'@esbuild/openharmony-arm64@0.28.1':
optional: true
'@esbuild/sunos-x64@0.28.1':
optional: true
'@esbuild/win32-arm64@0.28.1':
optional: true
'@esbuild/win32-ia32@0.28.1':
optional: true
'@esbuild/win32-x64@0.28.1':
optional: true
'@types/node@25.9.5':
dependencies:
undici-types: 7.24.6
esbuild@0.28.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.1
'@esbuild/android-arm': 0.28.1
'@esbuild/android-arm64': 0.28.1
'@esbuild/android-x64': 0.28.1
'@esbuild/darwin-arm64': 0.28.1
'@esbuild/darwin-x64': 0.28.1
'@esbuild/freebsd-arm64': 0.28.1
'@esbuild/freebsd-x64': 0.28.1
'@esbuild/linux-arm': 0.28.1
'@esbuild/linux-arm64': 0.28.1
'@esbuild/linux-ia32': 0.28.1
'@esbuild/linux-loong64': 0.28.1
'@esbuild/linux-mips64el': 0.28.1
'@esbuild/linux-ppc64': 0.28.1
'@esbuild/linux-riscv64': 0.28.1
'@esbuild/linux-s390x': 0.28.1
'@esbuild/linux-x64': 0.28.1
'@esbuild/netbsd-arm64': 0.28.1
'@esbuild/netbsd-x64': 0.28.1
'@esbuild/openbsd-arm64': 0.28.1
'@esbuild/openbsd-x64': 0.28.1
'@esbuild/openharmony-arm64': 0.28.1
'@esbuild/sunos-x64': 0.28.1
'@esbuild/win32-arm64': 0.28.1
'@esbuild/win32-ia32': 0.28.1
'@esbuild/win32-x64': 0.28.1
fsevents@2.3.3:
optional: true
tsx@4.23.1:
dependencies:
esbuild: 0.28.1
optionalDependencies:
fsevents: 2.3.3
typescript@5.9.3: {}
undici-types@7.24.6: {}

View File

@ -1,92 +0,0 @@
import { ContextManager } from '../context.js';
import { chat } from '../llm.js';
import { getTools } from '../tools/registry.js';
import { Tool } from '../types/index.js';
import { hooks } from '../hooks/index.js';
export interface AgentConfig {
name: string;
systemPrompt: string;
/** 该 Agent 可用的工具列表,默认使用全局注册的所有工具 */
tools?: Tool[];
}
export class Agent {
public name: string;
private context: ContextManager;
private tools: Tool[];
constructor(config: AgentConfig) {
this.name = config.name;
this.tools = config.tools ?? getTools();
this.context = new ContextManager();
this.context.add({ role: 'system', content: config.systemPrompt });
}
/**
* Agent tool_calls
*/
async run(userMessage: string): Promise<string> {
this.context.add({ role: 'user', content: userMessage });
const openaiTools = this.tools.length > 0
? this.tools.map((t) => ({
type: 'function' as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}))
: undefined;
let reply = await chat(this.context.getMessages(), openaiTools);
// 工具调用循环
while (reply.tool_calls && reply.tool_calls.length > 0) {
this.context.add({
role: 'assistant',
content: '',
tool_calls: reply.tool_calls,
} as any);
for (const tc of reply.tool_calls) {
const tool = this.tools.find((t) => t.name === tc.function.name);
if (!tool) {
this.context.add({
role: 'tool',
tool_call_id: tc.id,
content: `错误: 未知工具 ${tc.function.name}`,
} as any);
continue;
}
hooks.emit('tool:before', { toolCall: tc, agentName: this.name });
try {
const args = JSON.parse(tc.function.arguments);
const result = await tool.execute(args);
this.context.add({
role: 'tool',
tool_call_id: tc.id,
content: result,
} as any);
hooks.emit('tool:after', { toolCall: tc, result, agentName: this.name });
} catch (e: any) {
this.context.add({
role: 'tool',
tool_call_id: tc.id,
content: `工具执行错误: ${e.message}`,
} as any);
hooks.emit('tool:after', { toolCall: tc, result: `错误: ${e.message}`, agentName: this.name });
}
}
reply = await chat(this.context.getMessages(), openaiTools);
}
const content = reply.content!;
this.context.add({ role: 'assistant', content });
return content;
}
}

View File

@ -1,4 +0,0 @@
export { Agent } from './agent.js';
export type { AgentConfig } from './agent.js';
export { SubAgentManager, subAgentManager } from './manager.js';
export { spawnAgentTool, runConversationTool, subAgentTools } from './tools.js';

View File

@ -1,90 +0,0 @@
import { Agent } from './agent.js';
import { getTools } from '../tools/registry.js';
/**
* SubAgentManager Agent
*/
export class SubAgentManager {
private agents: Map<string, Agent> = new Map();
/**
* Agent sub-agent
*/
spawn(name: string, systemPrompt: string): Agent {
if (this.agents.has(name)) {
throw new Error(`子 Agent "${name}" 已存在`);
}
const agent = new Agent({
name,
systemPrompt,
tools: getTools(), // 只有全局注册的普通工具
});
this.agents.set(name, agent);
return agent;
}
get(name: string): Agent | undefined {
return this.agents.get(name);
}
list(): Agent[] {
return Array.from(this.agents.values());
}
/**
* Agent
* @param agent1Name Agent
* @param agent2Name Agent
* @param maxTurns = agent1 + agent2
* @param topic
* @returns
*/
async runConversation(
agent1Name: string,
agent2Name: string,
maxTurns: number,
topic: string,
): Promise<string> {
const agent1 = this.agents.get(agent1Name);
const agent2 = this.agents.get(agent2Name);
if (!agent1) {
return `错误: 子 Agent "${agent1Name}" 不存在。可用的 Agent: ${this.listNames()}`;
}
if (!agent2) {
return `错误: 子 Agent "${agent2Name}" 不存在。可用的 Agent: ${this.listNames()}`;
}
const transcript: string[] = [];
transcript.push(`=== 对话开始: ${agent1Name} vs ${agent2Name},主题: ${topic},轮次: ${maxTurns} ===\n`);
// 第一轮agent1 发起对话
let currentMessage = `请就以下话题开始对话:${topic}。你是对话的发起方,请先发言。`;
let speaker = agent1;
let listener = agent2;
for (let turn = 1; turn <= maxTurns; turn++) {
// 当前发言者回复
const response = await speaker.run(currentMessage);
const line = `[${speaker.name}]: ${response}`;
console.log(line);
transcript.push(line);
// 将回复传给另一方
currentMessage = `[${speaker.name}]: ${response}\n请回复。`;
// 交换发言者
[speaker, listener] = [listener, speaker];
}
transcript.push(`\n=== 对话结束 ===`);
return transcript.join('\n');
}
private listNames(): string {
return Array.from(this.agents.keys()).join(', ') || '(无)';
}
}
/** 全局单例 */
export const subAgentManager = new SubAgentManager();

View File

@ -1,57 +0,0 @@
import { Tool } from '../types/index.js';
import { subAgentManager } from './manager.js';
/**
* spawn_agent Agent
*/
export const spawnAgentTool: Tool = {
name: 'spawn_agent',
description: '创建一个子Agent指定其名称和角色/系统提示词。用于创建具有特定人设的对话角色(如销售、顾客等)。',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: '子Agent的唯一名称如"sales"、"customer"' },
role_prompt: { type: 'string', description: '子Agent的角色描述/系统提示词,如"你是一个热情的汽车销售"' },
},
required: ['name', 'role_prompt'],
},
execute: async (args) => {
const { name, role_prompt } = args;
try {
const agent = subAgentManager.spawn(name as string, role_prompt as string);
return `子Agent "${agent.name}" 创建成功。`;
} catch (e: any) {
return `创建失败: ${e.message}`;
}
},
};
/**
* run_conversation Agent
*/
export const runConversationTool: Tool = {
name: 'run_conversation',
description: '在两个已创建的子Agent之间运行多轮对话。一方先发起另一方回应交替进行。返回完整对话记录。',
parameters: {
type: 'object',
properties: {
agent1: { type: 'string', description: '发起方Agent名称先说话的那个' },
agent2: { type: 'string', description: '回应方Agent名称' },
max_turns: { type: 'number', description: '最大对话轮次如5表示agent1发起 + 4轮交替 = 共5次发言' },
topic: { type: 'string', description: '对话主题/场景描述,如"汽车购买谈判"' },
},
required: ['agent1', 'agent2', 'max_turns', 'topic'],
},
execute: async (args) => {
const { agent1, agent2, max_turns, topic } = args;
return await subAgentManager.runConversation(
agent1 as string,
agent2 as string,
max_turns as number,
topic as string,
);
},
};
/** 所有 sub-agent 管理工具(仅供主 Agent 使用) */
export const subAgentTools: Tool[] = [spawnAgentTool, runConversationTool];

79
src/config.test.ts Normal file
View File

@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { loadRuntimeConfig, workspaceIdFor } from "./config";
test("merges global and workspace extension configs", async (t) => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-config-"));
const home = join(temporaryDirectory, "home");
const projectPath = join(temporaryDirectory, "project");
const workspaceId = workspaceIdFor(projectPath);
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
await mkdir(join(home, "config"), { recursive: true });
await mkdir(join(home, "workspaces", workspaceId, "config"), {
recursive: true,
});
await writeFile(
join(home, "config", "extensions.json"),
JSON.stringify({
version: 1,
extensions: {
models: { defaultProvider: "deepseek" },
shell: { timeoutMs: 30_000, maxOutputLength: 50_000 },
},
}),
);
await writeFile(
join(home, "workspaces", workspaceId, "config", "extensions.json"),
JSON.stringify({
version: 1,
extensions: {
shell: { timeoutMs: 120_000 },
project: { checks: ["pnpm test"] },
},
}),
);
const config = await loadRuntimeConfig({ home, projectPath });
assert.equal(config.workspaceId, workspaceId);
assert.deepEqual(config.extensions, {
models: { defaultProvider: "deepseek" },
shell: { timeoutMs: 120_000, maxOutputLength: 50_000 },
project: { checks: ["pnpm test"] },
});
});
test("does not create missing config files", async (t) => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-config-"));
const home = join(temporaryDirectory, "home");
const projectPath = join(temporaryDirectory, "project");
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
const config = await loadRuntimeConfig({ home, projectPath });
assert.deepEqual(config.extensions, {});
assert.deepEqual(await readdir(temporaryDirectory), []);
});
test("rejects invalid extension config sections", async (t) => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-config-"));
const home = join(temporaryDirectory, "home");
const projectPath = join(temporaryDirectory, "project");
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
await mkdir(join(home, "config"), { recursive: true });
await writeFile(
join(home, "config", "extensions.json"),
JSON.stringify({ version: 1, extensions: { models: "deepseek" } }),
);
await assert.rejects(
loadRuntimeConfig({ home, projectPath }),
/invalid "models" section/,
);
});

104
src/config.ts Normal file
View File

@ -0,0 +1,104 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { ExtensionConfig } from "./kernel";
export type ExtensionConfigs = Record<string, ExtensionConfig>;
export interface RuntimeConfigOptions {
home?: string;
projectPath?: string;
}
export interface RuntimeConfig {
home: string;
projectPath: string;
workspaceId: string;
extensions: ExtensionConfigs;
}
export function workspaceIdFor(projectPath: string): string {
return createHash("sha256").update(resolve(projectPath)).digest("hex").slice(0, 16);
}
export async function loadRuntimeConfig(
options: RuntimeConfigOptions = {},
): Promise<RuntimeConfig> {
const home = resolve(
options.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"),
);
const projectPath = resolve(options.projectPath ?? process.cwd());
const workspaceId = workspaceIdFor(projectPath);
const globalFile = join(home, "config", "extensions.json");
const workspaceFile = join(
home,
"workspaces",
workspaceId,
"config",
"extensions.json",
);
async function readExtensions(file: string): Promise<ExtensionConfigs> {
let content: string;
try {
content = await readFile(file, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch (error) {
throw new Error(`Invalid JSON in extension config "${file}".`, {
cause: error,
});
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Extension config "${file}" must be an object.`);
}
const document = parsed as Record<string, unknown>;
if (document.version !== 1) {
throw new Error(`Extension config "${file}" must use version 1.`);
}
if (
!document.extensions ||
typeof document.extensions !== "object" ||
Array.isArray(document.extensions)
) {
throw new Error(`Extension config "${file}" must contain extensions.`);
}
const extensions: ExtensionConfigs = {};
for (const [id, config] of Object.entries(document.extensions)) {
if (!config || typeof config !== "object" || Array.isArray(config)) {
throw new Error(
`Extension config "${file}" contains an invalid "${id}" section.`,
);
}
extensions[id] = config as ExtensionConfig;
}
return extensions;
}
const globalExtensions = await readExtensions(globalFile);
const workspaceExtensions = await readExtensions(workspaceFile);
const extensions: ExtensionConfigs = {};
for (const [id, config] of Object.entries(globalExtensions)) {
extensions[id] = { ...config };
}
for (const [id, config] of Object.entries(workspaceExtensions)) {
extensions[id] = { ...extensions[id], ...config };
}
return { home, projectPath, workspaceId, extensions };
}

View File

@ -1,16 +0,0 @@
type Message = { role: 'system' | 'user' | 'assistant'; content: string };
export class ContextManager {
private messages: Message[] = [];
constructor() {
}
add(message: Message) {
this.messages.push(message);
}
getMessages(): Message[] {
return [...this.messages];
}
}

10
src/extensions/README.md Normal file
View File

@ -0,0 +1,10 @@
# Extensions
除 Kernel 的稳定运行机制外,所有具体能力都在这里实现。
- `shared/` 保存不依赖产品界面的公共能力;
- `cli/``web/``desktop/` 保存三条产品线各自的输入、展示、协议和系统集成;
- 产品线私有扩展之间不能互相依赖;两个产品需要的实现应提升到 `shared/`
- Kernel 不导入本目录中的任何具体扩展。
新增扩展时直接实现 `Extension`。只有真实能力开始开发时才创建对应源码目录,不再预建空 package 或 `export {}` 文件。

22
src/extensions/catalog.ts Normal file
View File

@ -0,0 +1,22 @@
export const ExtensionId = {
Workspace: "workspace",
Models: "models",
DeepSeek: "deepseek",
Agent: "agent",
Cli: "cli",
};
export const Hook = {
Workspace: "workspace",
Models: "models",
Agent: "agent",
ModelProviders: "models.providers",
};
export const Event = {
RuntimeStopRequested: "runtime.stop.requested",
RunStarted: "run.started",
RunCompleted: "run.completed",
RunFailed: "run.failed",
MessageAdded: "message.added",
};

156
src/extensions/chat.test.ts Normal file
View File

@ -0,0 +1,156 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { Kernel } from "../kernel";
import { ExtensionId, Hook } from "./catalog";
import {
createAgentExtension,
type AgentService,
} from "./shared/agent";
import { createDeepSeekExtension } from "./shared/deepseek";
import { createModelsExtension } from "./shared/models";
import {
createWorkspaceExtension,
type WorkspaceService,
} from "./shared/workspace";
test("streams a reply, saves it, and restores the conversation after restart", async (t) => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-"));
const home = join(temporaryDirectory, "home");
const projectPath = join(temporaryDirectory, "project");
await mkdir(projectPath);
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
const requests: Array<{
model: string;
messages: Array<{ role: string; content: string }>;
stream: boolean;
}> = [];
const answers = ["第一次回答", "第二次回答"];
const request = async (url: string, init: RequestInit) => {
assert.equal(url, "https://api.deepseek.com/chat/completions");
assert.equal(init.method, "POST");
const body = JSON.parse(String(init.body));
requests.push(body);
const answer = answers[requests.length - 1] as string;
const middle = Math.ceil(answer.length / 2);
const stream = [
`data: ${JSON.stringify({ choices: [{ delta: { content: answer.slice(0, middle) } }] })}`,
`data: ${JSON.stringify({ choices: [{ delta: { content: answer.slice(middle) } }] })}`,
"data: [DONE]",
"",
].join("\n\n");
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
};
const extensionConfigs = {
[ExtensionId.Workspace]: { home, projectPath },
[ExtensionId.DeepSeek]: { apiKey: "test-key", request },
};
const firstKernel = new Kernel({ extensionConfigs }).use(
createWorkspaceExtension,
createModelsExtension,
createDeepSeekExtension,
createAgentExtension,
);
await firstKernel.start();
let firstAnswer = "";
for await (const chunk of firstKernel.get<AgentService>(Hook.Agent).chat("第一次问题")) {
firstAnswer += chunk;
}
assert.equal(firstAnswer, "第一次回答");
await firstKernel.stop();
const secondKernel = new Kernel({ extensionConfigs }).use(
createWorkspaceExtension,
createModelsExtension,
createDeepSeekExtension,
createAgentExtension,
);
await secondKernel.start();
let secondAnswer = "";
for await (const chunk of secondKernel.get<AgentService>(Hook.Agent).chat("第二次问题")) {
secondAnswer += chunk;
}
assert.equal(secondAnswer, "第二次回答");
assert.deepEqual(requests[1]?.messages, [
{ role: "user", content: "第一次问题" },
{ role: "assistant", content: "第一次回答" },
{ role: "user", content: "第二次问题" },
]);
const workspace = secondKernel.get<WorkspaceService>(Hook.Workspace);
assert.deepEqual(
(await workspace.messages()).map(({ role, content }) => ({ role, content })),
[
{ role: "user", content: "第一次问题" },
{ role: "assistant", content: "第一次回答" },
{ role: "user", content: "第二次问题" },
{ role: "assistant", content: "第二次回答" },
],
);
assert.deepEqual(await readdir(projectPath), []);
await secondKernel.stop();
});
test("creates a conversation and restores it after restart", async (t) => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "llm-to-agent-"));
const home = join(temporaryDirectory, "home");
const projectPath = join(temporaryDirectory, "project");
await mkdir(projectPath);
t.after(() => rm(temporaryDirectory, { recursive: true, force: true }));
const extensionConfigs = {
[ExtensionId.Workspace]: { home, projectPath },
};
const firstKernel = new Kernel({ extensionConfigs }).use(
createWorkspaceExtension,
);
await firstKernel.start();
const firstWorkspace = firstKernel.get<WorkspaceService>(Hook.Workspace);
await firstWorkspace.append("user", "旧对话消息");
const conversationId = await firstWorkspace.newConversation();
assert.deepEqual(await firstWorkspace.messages(), []);
await firstWorkspace.append("user", "新对话消息");
assert.equal(await firstWorkspace.switchConversation("default"), true);
assert.deepEqual(
(await firstWorkspace.messages()).map(({ role, content }) => ({ role, content })),
[{ role: "user", content: "旧对话消息" }],
);
assert.equal(await firstWorkspace.switchConversation("missing"), false);
assert.equal(firstWorkspace.conversationId, "default");
assert.equal(await firstWorkspace.switchConversation(conversationId), true);
await firstKernel.stop();
const secondKernel = new Kernel({ extensionConfigs }).use(
createWorkspaceExtension,
);
await secondKernel.start();
const secondWorkspace = secondKernel.get<WorkspaceService>(Hook.Workspace);
assert.equal(secondWorkspace.conversationId, conversationId);
assert.deepEqual(
(await secondWorkspace.messages()).map(({ role, content }) => ({ role, content })),
[{ role: "user", content: "新对话消息" }],
);
assert.deepEqual(await readdir(projectPath), []);
await secondKernel.stop();
});

View File

@ -0,0 +1,3 @@
# CLI extensions
这里保存终端输入解析、斜杠命令、展示和 TUI 等 CLI 私有能力。CLI Extension 通过公开抓手调用共享能力,并把运行结果转换成终端输出。

127
src/extensions/cli/index.ts Normal file
View File

@ -0,0 +1,127 @@
import {
createInterface,
type Interface as ReadlineInterface,
} from "node:readline/promises";
import type { Extension } from "../../kernel";
import { Event, ExtensionId, Hook } from "../catalog";
import type { AgentService } from "../shared/agent";
import type { WorkspaceService } from "../shared/workspace";
import { parseInput } from "./input";
export function createCliExtension(): Extension {
let terminal: ReadlineInterface | undefined;
let loop: Promise<void> | undefined;
let currentRequest: AbortController | undefined;
return {
setup() {},
start(context) {
const agent = context.get<AgentService>(Hook.Agent);
const workspace = context.get<WorkspaceService>(Hook.Workspace);
const roleNames = {
user: "用户",
assistant: "助手",
system: "系统",
};
terminal = createInterface({
input: process.stdin,
output: process.stdout,
});
terminal.on("SIGINT", () => {
if (currentRequest) {
currentRequest.abort();
} else {
terminal?.close();
}
});
loop = (async () => {
try {
process.stdout.write(
`llm-to-agent\nWorkspace: ${process.cwd()}\n当前对话: ${workspace.conversationId}\n命令: /new、/switch <id>、/history、/exitCtrl+C 取消当前回复。\n\n`,
);
terminal?.setPrompt("> ");
terminal?.prompt();
for await (const line of terminal!) {
const input = parseInput(line);
if (!input) {
terminal?.prompt();
continue;
}
if (input.type === "command") {
if (input.name === "exit") break;
if (input.name === "new") {
const conversationId = await workspace.newConversation();
process.stdout.write(`已新建对话: ${conversationId}\n\n`);
} else if (input.name === "switch") {
if (!input.argument) {
process.stdout.write("用法: /switch <conversation-id>\n\n");
} else if (await workspace.switchConversation(input.argument)) {
process.stdout.write(`已切换到对话: ${workspace.conversationId}\n\n`);
} else {
process.stdout.write(`对话不存在: ${input.argument}\n\n`);
}
} else if (input.name === "history") {
const messages = await workspace.messages();
if (messages.length === 0) {
process.stdout.write("当前对话暂无消息。\n\n");
} else {
process.stdout.write(`对话 ${workspace.conversationId}\n`);
for (const message of messages) {
process.stdout.write(
`${roleNames[message.role]}: ${message.content}\n`,
);
}
process.stdout.write("\n");
}
} else {
process.stdout.write(`未知命令: /${input.name}\n\n`);
}
terminal?.prompt();
continue;
}
currentRequest = new AbortController();
try {
for await (const chunk of agent.chat(input.content, currentRequest.signal)) {
process.stdout.write(chunk);
}
process.stdout.write("\n\n");
} catch (error) {
if (currentRequest.signal.aborted) {
process.stdout.write("\n[已取消]\n\n");
} else {
process.stderr.write(`\n${String(error)}\n\n`);
}
} finally {
currentRequest = undefined;
}
terminal?.prompt();
}
} finally {
terminal?.close();
await context.emit(Event.RuntimeStopRequested, { source: "cli" });
}
})();
},
async stop() {
currentRequest?.abort();
terminal?.close();
await loop;
},
};
}
createCliExtension.id = ExtensionId.Cli;

View File

@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseInput } from "./input";
test("parses CLI messages and commands", () => {
assert.equal(parseInput(" "), undefined);
assert.deepEqual(parseInput(" 你好 "), {
type: "message",
content: "你好",
});
assert.deepEqual(parseInput("/new\tdemo"), {
type: "command",
name: "new",
argument: "demo",
});
assert.deepEqual(parseInput("/switch default"), {
type: "command",
name: "switch",
argument: "default",
});
assert.deepEqual(parseInput("//path"), {
type: "message",
content: "/path",
});
});

View File

@ -0,0 +1,19 @@
type ParsedInput =
| { type: "message"; content: string }
| { type: "command"; name: string; argument: string };
export function parseInput(value: string): ParsedInput | undefined {
const input = value.trim();
if (!input) return;
if (!input.startsWith("/")) return { type: "message", content: input };
if (input.startsWith("//")) {
return { type: "message", content: input.slice(1) };
}
const separator = input.search(/\s/);
const name = input.slice(1, separator < 0 ? undefined : separator).toLowerCase();
const argument = separator < 0 ? "" : input.slice(separator + 1).trim();
return { type: "command", name, argument };
}

View File

@ -0,0 +1,3 @@
# Desktop extensions
这里保存原生桥接、窗口、托盘、快捷键、通知与更新等 Desktop 私有能力。与 Web 共用的界面逻辑应放入 `extensions/shared/`

View File

@ -0,0 +1,5 @@
# Shared extensions
这里保存模型、Workspace、Shell、Git、记忆、规划、多 Agent、浏览器、自动化和 System Evolution 等跨产品能力。
共享扩展的源码只依赖 Kernel 契约;需要协作时通过其他共享扩展注册的公开能力运行,不能认识 CLI、Web、Desktop 的界面与传输细节。

View File

@ -0,0 +1,84 @@
import { randomUUID } from "node:crypto";
import type {
Extension,
ExtensionRuntimeContext,
} from "../../../kernel";
import { Event, ExtensionId, Hook } from "../../catalog";
import type { ModelService } from "../models";
import type { WorkspaceService } from "../workspace";
export interface AgentService {
chat(input: string, signal?: AbortSignal): AsyncIterable<string>;
}
export function createAgentExtension(): Extension {
let runtime: ExtensionRuntimeContext | undefined;
let workspace: WorkspaceService | undefined;
let models: ModelService | undefined;
const agent: AgentService = {
async *chat(input, signal) {
if (!runtime || !workspace || !models) {
throw new Error("Agent is not running.");
}
const activeRuntime = runtime;
const activeWorkspace = workspace;
const activeModels = models;
const runId = randomUUID();
const userMessage = await activeWorkspace.append("user", input);
await activeRuntime.emit(Event.MessageAdded, {
workspaceId: activeWorkspace.workspaceId,
conversationId: activeWorkspace.conversationId,
message: userMessage,
});
await activeRuntime.emit(Event.RunStarted, { runId, input });
let answer = "";
try {
const messages = (await activeWorkspace.messages()).map(
({ role, content }) => ({ role, content }),
);
for await (const chunk of activeModels.chat(messages, signal)) {
answer += chunk;
yield chunk;
}
const assistantMessage = await activeWorkspace.append("assistant", answer);
await activeRuntime.emit(Event.MessageAdded, {
workspaceId: activeWorkspace.workspaceId,
conversationId: activeWorkspace.conversationId,
message: assistantMessage,
});
await activeRuntime.emit(Event.RunCompleted, { runId });
} catch (error) {
await activeRuntime.emit(Event.RunFailed, { runId, error: String(error) });
throw error;
}
},
};
return {
setup(context) {
context.add(Hook.Agent, agent);
},
start(context) {
runtime = context;
workspace = context.get<WorkspaceService>(Hook.Workspace);
models = context.get<ModelService>(Hook.Models);
},
stop() {
runtime = undefined;
workspace = undefined;
models = undefined;
},
};
}
createAgentExtension.id = ExtensionId.Agent;

View File

@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Kernel } from "../../../kernel";
import { ExtensionId, Hook } from "../../catalog";
import { createModelsExtension, type ModelService } from "../models";
import { createDeepSeekExtension } from ".";
test("reads DeepSeek settings from extension config", async (t) => {
const environment = {
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL,
DEEPSEEK_MODEL: process.env.DEEPSEEK_MODEL,
};
delete process.env.DEEPSEEK_API_KEY;
delete process.env.DEEPSEEK_BASE_URL;
delete process.env.DEEPSEEK_MODEL;
t.after(() => {
for (const [name, value] of Object.entries(environment)) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
});
let requestUrl = "";
let requestInit: RequestInit | undefined;
const request = async (url: string, init: RequestInit) => {
requestUrl = url;
requestInit = init;
return new Response("data: [DONE]\n\n");
};
const kernel = new Kernel({
extensionConfigs: {
[ExtensionId.DeepSeek]: {
apiKey: "config-key",
baseUrl: "https://example.test/v1/",
model: "config-model",
request,
},
},
}).use(createModelsExtension, createDeepSeekExtension);
await kernel.start();
for await (const _ of kernel
.get<ModelService>(Hook.Models)
.chat([{ role: "user", content: "你好" }])) {
// The test response contains no text chunks.
}
assert.equal(requestUrl, "https://example.test/v1/chat/completions");
assert.equal(
(requestInit?.headers as Record<string, string>).authorization,
"Bearer config-key",
);
assert.equal(JSON.parse(String(requestInit?.body)).model, "config-model");
await kernel.stop();
});

View File

@ -0,0 +1,111 @@
import type { Extension, ExtensionConfig } from "../../../kernel";
import { ExtensionId, Hook } from "../../catalog";
import type { ModelProvider } from "../models";
export interface DeepSeekOptions {
apiKey?: string;
baseUrl?: string;
model?: string;
request?: (url: string, init: RequestInit) => Promise<Response>;
}
export function createDeepSeekExtension(options: ExtensionConfig = {}): Extension {
if (options.apiKey !== undefined && typeof options.apiKey !== "string") {
throw new Error("deepseek.apiKey must be a string.");
}
if (options.baseUrl !== undefined && typeof options.baseUrl !== "string") {
throw new Error("deepseek.baseUrl must be a string.");
}
if (options.model !== undefined && typeof options.model !== "string") {
throw new Error("deepseek.model must be a string.");
}
if (options.request !== undefined && typeof options.request !== "function") {
throw new Error("deepseek.request must be a function.");
}
const config = options as DeepSeekOptions;
const apiKey = process.env.DEEPSEEK_API_KEY ?? config.apiKey;
const baseUrl = (
process.env.DEEPSEEK_BASE_URL ??
config.baseUrl ??
"https://api.deepseek.com"
).replace(/\/+$/, "");
const model =
process.env.DEEPSEEK_MODEL ?? config.model ?? "deepseek-v4-flash";
const request = config.request ?? fetch;
const provider: ModelProvider = {
id: "deepseek",
async *chat(messages, signal) {
const response = await request(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
model,
messages: messages.map(({ role, content }) => ({ role, content })),
thinking: { type: "disabled" },
stream: true,
}),
signal,
});
if (!response.ok) {
throw new Error(
`DeepSeek request failed (${response.status}): ${await response.text()}`,
);
}
if (!response.body) {
throw new Error("DeepSeek returned an empty response body.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const events = buffer.split(/\r?\n\r?\n/);
buffer = events.pop() ?? "";
for (const entry of events) {
for (const line of entry.split(/\r?\n/)) {
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") return;
if (!data) continue;
const chunk = JSON.parse(data);
const content = chunk.choices?.[0]?.delta?.content;
if (content) yield content;
}
}
if (done) break;
}
} finally {
reader.releaseLock();
}
},
};
return {
setup(context) {
if (!apiKey) {
throw new Error("DEEPSEEK_API_KEY is required.");
}
context.add(Hook.ModelProviders, provider);
},
};
}
createDeepSeekExtension.id = ExtensionId.DeepSeek;

View File

@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Kernel, type ExtensionSetupContext } from "../../../kernel";
import { ExtensionId, Hook } from "../../catalog";
import {
createModelsExtension,
type ModelProvider,
type ModelService,
} from ".";
test("selects a provider and streams its response", async () => {
const requests: Array<{ role: string; content: string }[]> = [];
const ignoredProvider: ModelProvider = {
id: "ignored",
async *chat() {
yield "错误";
},
};
const provider: ModelProvider = {
id: "test",
async *chat(messages) {
requests.push(messages);
yield "你";
yield "好";
},
};
function createProviderExtension() {
return {
setup(context: ExtensionSetupContext) {
context.add(Hook.ModelProviders, ignoredProvider);
context.add(Hook.ModelProviders, provider);
},
};
}
createProviderExtension.id = "test-model-provider";
const kernel = new Kernel({
extensionConfigs: {
[ExtensionId.Models]: { defaultProvider: "test" },
},
}).use(createModelsExtension, createProviderExtension);
await kernel.start();
let answer = "";
for await (const chunk of kernel
.get<ModelService>(Hook.Models)
.chat([{ role: "user", content: "你好" }])) {
answer += chunk;
}
assert.equal(answer, "你好");
assert.deepEqual(requests, [[{ role: "user", content: "你好" }]]);
await kernel.stop();
});
test("requires at least one model provider", async () => {
const kernel = new Kernel().use(createModelsExtension);
await assert.rejects(kernel.start(), /at least one provider/);
});

View File

@ -0,0 +1,63 @@
import type { Extension, ExtensionConfig } from "../../../kernel";
import { ExtensionId, Hook } from "../../catalog";
export interface ModelMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface ModelProvider {
id: string;
chat(messages: ModelMessage[], signal?: AbortSignal): AsyncIterable<string>;
}
export interface ModelService {
chat(messages: ModelMessage[], signal?: AbortSignal): AsyncIterable<string>;
}
export function createModelsExtension(options: ExtensionConfig = {}): Extension {
if (
options.defaultProvider !== undefined &&
typeof options.defaultProvider !== "string"
) {
throw new Error("models.defaultProvider must be a string.");
}
const defaultProvider = options.defaultProvider;
let provider: ModelProvider | undefined;
const models: ModelService = {
chat(messages, signal) {
if (!provider) throw new Error("Models is not running.");
return provider.chat(messages, signal);
},
};
return {
setup(context) {
context.add(Hook.Models, models);
},
start(context) {
const providers = context.all<ModelProvider>(Hook.ModelProviders);
if (providers.length === 0) {
throw new Error("Models needs at least one provider.");
}
provider = defaultProvider
? providers.find(({ id }) => id === defaultProvider)
: providers[0];
if (!provider) {
throw new Error(`Model provider "${defaultProvider}" is not registered.`);
}
},
stop() {
provider = undefined;
},
};
}
createModelsExtension.id = ExtensionId.Models;

View File

@ -0,0 +1,166 @@
import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { workspaceIdFor } from "../../../config";
import type { Extension, ExtensionConfig } from "../../../kernel";
import { ExtensionId, Hook } from "../../catalog";
export type MessageRole = "system" | "user" | "assistant";
export interface ChatMessage {
role: MessageRole;
content: string;
createdAt: string;
}
export interface WorkspaceService {
home: string;
projectPath: string;
workspaceId: string;
conversationId: string;
messages(): Promise<ChatMessage[]>;
append(role: MessageRole, content: string): Promise<ChatMessage>;
newConversation(): Promise<string>;
switchConversation(conversationId: string): Promise<boolean>;
}
export interface WorkspaceOptions {
home?: string;
projectPath?: string;
conversationId?: string;
}
export function createWorkspaceExtension(options: ExtensionConfig = {}): Extension {
if (options.home !== undefined && typeof options.home !== "string") {
throw new Error("workspace.home must be a string.");
}
if (
options.projectPath !== undefined &&
typeof options.projectPath !== "string"
) {
throw new Error("workspace.projectPath must be a string.");
}
if (
options.conversationId !== undefined &&
typeof options.conversationId !== "string"
) {
throw new Error("workspace.conversationId must be a string.");
}
const config = options as WorkspaceOptions;
const home = resolve(
config.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"),
);
const projectPath = resolve(config.projectPath ?? process.cwd());
const workspaceId = workspaceIdFor(projectPath);
const workspaceDirectory = join(home, "workspaces", workspaceId);
const conversationsDirectory = join(workspaceDirectory, "conversations");
const workspaceFile = join(workspaceDirectory, "workspace.json");
let conversationId = config.conversationId ?? "default";
async function saveWorkspace() {
await writeFile(
workspaceFile,
`${JSON.stringify(
{
id: workspaceId,
kind: "linked",
projectPath,
activeConversationId: conversationId,
},
null,
2,
)}\n`,
"utf8",
);
}
const workspace: WorkspaceService = {
home,
projectPath,
workspaceId,
get conversationId() {
return conversationId;
},
async messages() {
try {
const content = await readFile(
join(conversationsDirectory, conversationId, "messages.jsonl"),
"utf8",
);
return content
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as ChatMessage);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
},
async append(role, content) {
const message = {
role,
content,
createdAt: new Date().toISOString(),
};
await appendFile(
join(conversationsDirectory, conversationId, "messages.jsonl"),
`${JSON.stringify(message)}\n`,
"utf8",
);
return message;
},
async newConversation() {
const newConversationId = randomUUID();
await mkdir(join(conversationsDirectory, newConversationId), { recursive: true });
conversationId = newConversationId;
await saveWorkspace();
return conversationId;
},
async switchConversation(nextConversationId) {
const conversations = await readdir(conversationsDirectory, {
withFileTypes: true,
});
const exists = conversations.some(
(entry) => entry.isDirectory() && entry.name === nextConversationId,
);
if (!exists) return false;
conversationId = nextConversationId;
await saveWorkspace();
return true;
},
};
return {
async setup(context) {
await mkdir(workspaceDirectory, { recursive: true });
if (!config.conversationId) {
try {
const savedWorkspace = JSON.parse(await readFile(workspaceFile, "utf8"));
if (savedWorkspace.activeConversationId) {
conversationId = savedWorkspace.activeConversationId;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
await mkdir(join(conversationsDirectory, conversationId), { recursive: true });
await saveWorkspace();
context.add(Hook.Workspace, workspace);
},
};
}
createWorkspaceExtension.id = ExtensionId.Workspace;

View File

@ -0,0 +1,3 @@
# Web extensions
这里保存 HTTP 接入、事件流、路由与 Web 页面等 Web 私有能力。Web 扩展负责在网络交互和 Kernel 运行接口之间转换。

View File

@ -1,23 +0,0 @@
type EventName = 'process:start' | 'agent:start' | 'step:before' | 'tool:before' | 'tool:after' | 'agent:end';
type Listener = (data: any) => void | Promise<void>;
export class HookBus {
private listeners = new Map<EventName, Listener[]>();
on(event: EventName, fn: Listener) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(fn);
}
async emit(event: EventName, data: any) {
const fns = this.listeners.get(event);
if (!fns) return;
for (const fn of fns) {
await fn(data);
}
}
}
export const hooks = new HookBus();

View File

@ -1,30 +0,0 @@
import type { HookBus } from './index.js';
export default (hooks: HookBus) => {
// hooks.on('agent:start', async (data) => {
// console.log('🚀 ~ agent:start ~ data:', data);
// });
// hooks.on('step:before', async (data) => {
// console.log('🚀 ~ step:before ~ data:', data);
// });
hooks.on('tool:before', async (data) => {
const { toolCall: tc, agentName } = data;
const prefix = agentName ? `[${agentName}] ` : '';
console.log(` ${prefix}[工具调用] ${tc.function.name}(${tc.function.arguments})`);
});
hooks.on('tool:after', async (data) => {
const { toolCall: tc, result, agentName } = data;
const prefix = agentName ? `[${agentName}] ` : '';
console.log(` ${prefix}[工具] ${tc.function.name}(${tc.function.arguments}) -> ${result}`);
});
hooks.on('agent:end', async (data) => {
const { reply } = data;
// reply 可能是字符串Agent.run 返回值)或 OpenAI message 对象
const content = typeof reply === 'string' ? reply : reply.content;
console.log('助手:', content);
});
}

View File

@ -1,29 +0,0 @@
// 从内置hooks目录中获取所有hooks并注册到HookBus
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { hooks } from './index.js';
// 这里可以自动扫描hooks目录下的所有文件并导入它们假设每个文件都默认导出一个函数来注册hook
const getAllHooks = async () => {
// 动态读取hooks目录下的所有 .hooks.ts 结尾的文件
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const hooksDir = path.join(__dirname); // hooks 目录即当前目录
const hookFiles = fs.readdirSync(hooksDir).filter(file => file.endsWith('.hooks.ts'));
return Promise.all(
hookFiles.map(async (file) => {
const mod = await import(pathToFileURL(path.join(hooksDir, file)).href);
return mod.default;
})
);
}
export const registerHooks = async () => {
const hookModules = await getAllHooks();
for (const hookModule of hookModules) {
// 每个hook模块默认导出一个函数调用它并传入hooks实例
hookModule(hooks);
}
}

View File

@ -1,46 +0,0 @@
import { Agent } from './agents/index.js';
import { getOrchestratorTools } from './tools/registry.js';
import { PROMPTS } from './prompts/system.js';
import * as readline from 'node:readline/promises';
import { hooks } from './hooks/index.js';
import { registerHooks } from './hooks/registry.js';
import { knowledgeBase } from './knowledge/knowledge-base.js';
await registerHooks();
await knowledgeBase.load();
const promptName = process.argv[2] || 'orchestrator';
const systemPrompt = PROMPTS[promptName as keyof typeof PROMPTS];
if (!systemPrompt) {
console.error(`未知提示词: ${promptName},可选: ${Object.keys(PROMPTS).join(', ')}`);
process.exit(1);
}
// 主 orchestrator Agent拥有全部工具包括 spawn_agent / run_conversation
const mainAgent = new Agent({
name: 'Orchestrator',
systemPrompt,
tools: getOrchestratorTools(),
});
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log(`提示词模式: ${promptName},输入 "exit" 退出。\n`);
while (true) {
const userInput = await rl.question('我: ');
if (userInput.toLowerCase() === 'exit') break;
hooks.emit('agent:start', { userInput });
const reply = await mainAgent.run(userInput);
hooks.emit('agent:end', { userInput, reply });
// agent:end hook 会打印回复,这里不需要重复打印
}
rl.close();

35
src/kernel/events.ts Normal file
View File

@ -0,0 +1,35 @@
export type EventHandler<T> = (payload: T) => void | Promise<void>;
export type Unsubscribe = () => void;
export class EventBus {
#listeners = new Map<string, Set<EventHandler<any>>>();
on<T>(event: string, handler: EventHandler<T>): Unsubscribe {
const listeners = this.#listeners.get(event) ?? new Set();
listeners.add(handler);
this.#listeners.set(event, listeners);
return () => {
listeners.delete(handler);
if (listeners.size === 0) this.#listeners.delete(event);
};
}
async emit<T>(event: string, payload: T): Promise<unknown[]> {
const errors: unknown[] = [];
for (const listener of this.#listeners.get(event) ?? []) {
try {
await listener(payload);
} catch (error) {
errors.push(error);
}
}
return errors;
}
clear(): void {
this.#listeners.clear();
}
}

27
src/kernel/extension.ts Normal file
View File

@ -0,0 +1,27 @@
import type { EventHandler, Unsubscribe } from "./events";
export type Awaitable<T> = T | Promise<T>;
export type ExtensionConfig = Record<string, unknown>;
export interface ExtensionSetupContext {
add<T>(name: string, value: T): void;
on<T>(event: string, handler: EventHandler<T>): Unsubscribe;
}
export interface ExtensionRuntimeContext {
get<T>(name: string): T;
all<T>(name: string): T[];
on<T>(event: string, handler: EventHandler<T>): Unsubscribe;
emit<T>(event: string, payload: T): Promise<unknown[]>;
}
export interface Extension {
setup(context: ExtensionSetupContext): Awaitable<void>;
start?(context: ExtensionRuntimeContext): Awaitable<void>;
stop?(context: ExtensionRuntimeContext): Awaitable<void>;
}
export interface ExtensionFactory {
id: string;
(options?: ExtensionConfig): Extension;
}

4
src/kernel/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from "./events";
export * from "./extension";
export * from "./kernel";
export * from "./registry";

177
src/kernel/kernel.test.ts Normal file
View File

@ -0,0 +1,177 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
EventBus,
Kernel,
type Extension,
type ExtensionFactory,
} from "./index";
function extensionFactory(
id: string,
createExtension: () => Extension,
): ExtensionFactory {
function create() {
return createExtension();
}
create.id = id;
return create;
}
test("sets up every extension before starting them and stops in reverse order", async () => {
const calls: string[] = [];
const first = extensionFactory("first", () => ({
setup(context) {
calls.push("first.setup");
context.add("test.values", "first");
},
start(context) {
calls.push(`first.start:${context.all("test.values").join(",")}`);
},
stop() {
calls.push("first.stop");
},
}));
const second = extensionFactory("second", () => ({
setup(context) {
calls.push("second.setup");
context.add("test.values", "second");
},
start(context) {
calls.push(`second.start:${context.all("test.values").join(",")}`);
},
stop() {
calls.push("second.stop");
},
}));
const kernel = new Kernel().use(first, second);
await kernel.start();
await kernel.stop();
assert.deepEqual(calls, [
"first.setup",
"second.setup",
"first.start:first,second",
"second.start:first,second",
"second.stop",
"first.stop",
]);
});
test("rejects duplicate extension ids without partially installing a batch", () => {
const first = extensionFactory("first", () => ({ setup() {} }));
const duplicate = extensionFactory("first", () => ({ setup() {} }));
const kernel = new Kernel();
assert.throws(() => kernel.use(first, duplicate), /already installed/);
assert.deepEqual(kernel.installedExtensionIds, []);
});
test("passes extension config by id when constructing", async () => {
const extensionConfigs = {
first: { value: "configured" },
second: { enabled: true },
};
const received: Record<string, unknown>[] = [];
function createFirst(options: Record<string, unknown> = {}) {
received.push(options);
return { setup() {} };
}
createFirst.id = "first";
function createUnconfigured(options: Record<string, unknown> = {}) {
received.push(options);
return { setup() {} };
}
createUnconfigured.id = "unconfigured";
const kernel = new Kernel({ extensionConfigs }).use(
createFirst,
createUnconfigured,
);
extensionConfigs.first.value = "changed after use";
await kernel.start();
assert.deepEqual(received, [{ value: "configured" }, {}]);
await kernel.stop();
});
test("gets one value or collects multiple values", async () => {
const kernel = new Kernel().use(extensionFactory("values", () => ({
setup(context) {
context.add("single", "one");
context.add("multiple", "one");
context.add("multiple", "two");
},
})));
await kernel.start();
assert.equal(kernel.get("single"), "one");
assert.deepEqual(kernel.all("multiple"), ["one", "two"]);
assert.throws(() => kernel.get("missing"), /found 0/);
assert.throws(() => kernel.get("multiple"), /found 2/);
});
test("clears registrations when setup fails", async () => {
const kernel = new Kernel().use(extensionFactory("broken-setup", () => ({
setup(context) {
context.add("temporary", "value");
throw new Error("setup failed");
},
})));
await assert.rejects(kernel.start(), /setup failed/);
assert.equal(kernel.state, "stopped");
assert.deepEqual(kernel.all("temporary"), []);
});
test("stops every active extension after cleanup errors", async () => {
const calls: string[] = [];
const kernel = new Kernel().use(
extensionFactory("first", () => ({
setup() {},
stop() {
calls.push("first.stop");
},
})),
extensionFactory("second", () => ({
setup() {},
stop() {
calls.push("second.stop");
throw new Error("cleanup failed");
},
})),
);
await kernel.start();
await assert.rejects(kernel.stop(), /failed to stop/);
assert.equal(kernel.state, "stopped");
assert.deepEqual(calls, ["second.stop", "first.stop"]);
});
test("publishes events to every listener and reports listener errors", async () => {
const events = new EventBus();
const received: string[] = [];
events.on<string>("test.delivery", () => {
throw new Error("listener failed");
});
events.on<string>("test.delivery", (payload) => {
received.push(payload);
});
const errors = await events.emit("test.delivery", "delivered");
assert.deepEqual(received, ["delivered"]);
assert.equal(errors.length, 1);
assert.match(String(errors[0]), /listener failed/);
});

173
src/kernel/kernel.ts Normal file
View File

@ -0,0 +1,173 @@
import { EventBus, type EventHandler, type Unsubscribe } from "./events";
import type {
Extension,
ExtensionConfig,
ExtensionFactory,
ExtensionRuntimeContext,
ExtensionSetupContext,
} from "./extension";
import { ExtensionRegistry } from "./registry";
export interface KernelOptions {
extensionConfigs?: Record<string, ExtensionConfig>;
}
interface InstalledExtension {
id: string;
extension: Extension;
}
export class Kernel {
#extensions: InstalledExtension[] = [];
#activeExtensions: Extension[] = [];
#registry = new ExtensionRegistry();
#events = new EventBus();
#setupContext: ExtensionSetupContext;
#runtimeContext: ExtensionRuntimeContext;
#extensionConfigs: Record<string, ExtensionConfig>;
#state = "created";
constructor(options: KernelOptions = {}) {
const registry = this.#registry;
const events = this.#events;
this.#extensionConfigs = options.extensionConfigs ?? {};
this.#setupContext = {
add(name, value) {
registry.add(name, value);
},
on(event, handler) {
return events.on(event, handler);
},
};
this.#runtimeContext = {
get(name) {
return registry.get(name);
},
all(name) {
return registry.all(name);
},
on(event, handler) {
return events.on(event, handler);
},
emit(event, payload) {
return events.emit(event, payload);
},
};
}
get state(): string {
return this.#state;
}
get installedExtensionIds(): string[] {
return this.#extensions.map(({ id }) => id);
}
use(...factories: ExtensionFactory[]): this {
if (this.#state !== "created") {
throw new Error(`Cannot install extensions while kernel is ${this.#state}.`);
}
const ids = new Set(this.#extensions.map(({ id }) => id));
for (const factory of factories) {
if (ids.has(factory.id)) {
throw new Error(`Extension "${factory.id}" is already installed.`);
}
ids.add(factory.id);
}
const extensions = factories.map((factory) => ({
id: factory.id,
extension: factory({ ...this.#extensionConfigs[factory.id] }),
}));
this.#extensions.push(...extensions);
return this;
}
get<T>(name: string): T {
return this.#registry.get(name);
}
all<T>(name: string): T[] {
return this.#registry.all(name);
}
on<T>(event: string, handler: EventHandler<T>): Unsubscribe {
return this.#events.on(event, handler);
}
emit<T>(event: string, payload: T): Promise<unknown[]> {
return this.#events.emit(event, payload);
}
async start(): Promise<void> {
if (this.#state !== "created") {
throw new Error(`Cannot start kernel while it is ${this.#state}.`);
}
this.#state = "starting";
try {
for (const { extension } of this.#extensions) {
await extension.setup(this.#setupContext);
}
for (const { extension } of this.#extensions) {
this.#activeExtensions.push(extension);
await extension.start?.(this.#runtimeContext);
}
this.#state = "running";
} catch (error) {
const cleanupErrors = await this.#stopActiveExtensions();
this.#events.clear();
this.#registry.clear();
this.#state = "stopped";
if (cleanupErrors.length > 0) {
throw new AggregateError(
[error, ...cleanupErrors],
"Kernel failed to start and one or more extensions failed to clean up.",
);
}
throw error;
}
}
async stop(): Promise<void> {
if (this.#state === "stopped") return;
if (this.#state !== "running") {
throw new Error(`Cannot stop kernel while it is ${this.#state}.`);
}
this.#state = "stopping";
const errors = await this.#stopActiveExtensions();
this.#events.clear();
this.#registry.clear();
this.#state = "stopped";
if (errors.length > 0) {
throw new AggregateError(errors, "One or more extensions failed to stop.");
}
}
async #stopActiveExtensions(): Promise<unknown[]> {
const errors: unknown[] = [];
for (const extension of [...this.#activeExtensions].reverse()) {
try {
await extension.stop?.(this.#runtimeContext);
} catch (error) {
errors.push(error);
}
}
this.#activeExtensions = [];
return errors;
}
}

31
src/kernel/registry.ts Normal file
View File

@ -0,0 +1,31 @@
export class ExtensionRegistry {
#values = new Map<string, unknown[]>();
add<T>(name: string, value: T): void {
const values = this.#values.get(name);
if (values) {
values.push(value);
} else {
this.#values.set(name, [value]);
}
}
get<T>(name: string): T {
const values = this.all<T>(name);
if (values.length !== 1) {
throw new Error(`"${name}" needs exactly one value, found ${values.length}.`);
}
return values[0] as T;
}
all<T>(name: string): T[] {
return [...(this.#values.get(name) ?? [])] as T[];
}
clear(): void {
this.#values.clear();
}
}

View File

@ -1,128 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
/** 知识库中的一个文档块 */
interface Chunk {
/** 来源文件名 */
source: string;
/** 块内文本 */
content: string;
}
/**
* knowledge/ .md ##
*
*
*
* const kb = new KnowledgeBase('./knowledge');
* await kb.load();
* const results = kb.search('Agent 工具调用');
*/
export class KnowledgeBase {
private chunks: Chunk[] = [];
private knowledgeDir: string;
constructor(knowledgeDir: string) {
this.knowledgeDir = knowledgeDir;
}
/** 加载 knowledgeDir 下所有 .md 文件并分块 */
async load(): Promise<void> {
this.chunks = [];
if (!fs.existsSync(this.knowledgeDir)) {
console.warn(`知识库目录不存在: ${this.knowledgeDir}`);
return;
}
const files = fs
.readdirSync(this.knowledgeDir)
.filter((f) => f.endsWith('.md'));
for (const file of files) {
const filePath = path.join(this.knowledgeDir, file);
const raw = fs.readFileSync(filePath, 'utf-8');
const fileChunks = this.splitChunks(raw, file);
this.chunks.push(...fileChunks);
}
console.log(`知识库已加载: ${this.chunks.length} 个块,来自 ${files.length} 个文件`);
}
/** 按 ## 标题将文档拆分为块 */
private splitChunks(raw: string, source: string): Chunk[] {
const blocks = raw.split(/(?=^## )/m);
return blocks
.map((b) => b.trim())
.filter(Boolean)
.map((content) => ({ source, content }));
}
/**
*
*
*
*
*/
search(query: string, topK: number = 3): Chunk[] {
const keywords = this.tokenize(query);
if (keywords.length === 0) return [];
const scored = this.chunks.map((chunk) => {
const lower = chunk.content.toLowerCase();
let score = 0;
for (const kw of keywords) {
// 标题行命中加权 ×3
const headlineRegex = /^## .+$/gm;
let match: RegExpExecArray | null;
while ((match = headlineRegex.exec(chunk.content)) !== null) {
if (match[0].toLowerCase().includes(kw)) {
score += 3;
}
}
// 正文命中
const count = (lower.match(new RegExp(this.escapeRegex(kw), 'gi')) || []).length;
score += count;
}
// 标题匹配额外加分
return { chunk, score };
});
return scored
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map((s) => s.chunk);
}
/** 中文 + 英文简单分词 */
private tokenize(text: string): string[] {
// 按空白/标点拆分,过滤长度 ≤1 的词
return text
.split(/[\s,,。.!?:;、]+/)
.map((t) => t.toLowerCase().trim())
.filter((t) => t.length > 1);
}
private escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** 获取知识库摘要(供 Agent 概览) */
summary(): string {
if (this.chunks.length === 0) return '知识库为空';
const sources = [...new Set(this.chunks.map((c) => c.source))];
const titles = this.chunks
.map((c) => {
const m = c.content.match(/^## (.+)$/m);
return m ? ` - ${m[1]} (${c.source})` : null;
})
.filter(Boolean);
return `知识库包含 ${sources.length} 个文档:\n${titles.join('\n')}`;
}
}
/** 全局单例 */
export const knowledgeBase = new KnowledgeBase(
path.join(process.cwd(), 'knowledge'),
);

View File

@ -1,53 +0,0 @@
import { Tool } from '../types/index.js';
import { knowledgeBase } from './knowledge-base.js';
/**
* search_knowledge
*/
export const searchKnowledgeTool: Tool = {
name: 'search_knowledge',
description:
'在本地知识库中搜索与查询相关的文档片段。当你需要查找项目文档、技术说明、业务规则等存储在知识库中的信息时使用此工具。',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: '搜索关键词或问题,如"Agent 工具调用流程"、"如何创建子Agent"',
},
},
required: ['query'],
},
execute: async (args) => {
const { query } = args;
const results = knowledgeBase.search(query as string, 3);
if (results.length === 0) {
return `未找到与 "${query}" 相关的知识。当前知识库摘要:\n${knowledgeBase.summary()}`;
}
return results
.map(
(r, i) =>
`--- 结果 ${i + 1} (来源: ${r.source}) ---\n${r.content}`,
)
.join('\n\n');
},
};
/**
* list_knowledge
*/
export const listKnowledgeTool: Tool = {
name: 'list_knowledge',
description: '列出知识库中所有文档及其章节标题,用于了解知识库包含哪些内容。',
parameters: {
type: 'object',
properties: {},
required: [],
},
execute: async () => {
return knowledgeBase.summary();
},
};
/** 知识库相关工具集 */
export const knowledgeTools: Tool[] = [searchKnowledgeTool, listKnowledgeTool];

View File

@ -1,20 +0,0 @@
import OpenAI from 'openai';
import 'dotenv/config';
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
});
export async function chat(
messages: { role: string; content: string }[],
tools?: any[] // OpenAI 格式的工具定义数组
) {
const response = await client.chat.completions.create({
model: 'deepseek-v4-pro',
messages: messages as any,
temperature: 0.7,
...(tools && { tools }),
});
return response.choices[0]!.message!;
}

41
src/main.ts Normal file
View File

@ -0,0 +1,41 @@
import { existsSync } from "node:fs";
import { loadEnvFile } from "node:process";
import { loadRuntimeConfig } from "./config";
import { Event, ExtensionId } from "./extensions/catalog";
import { Kernel } from "./kernel";
import { getProduct, sharedExtensions } from "./products";
if (existsSync(".env")) {
loadEnvFile();
}
const runtimeConfig = await loadRuntimeConfig();
const productIds = process.argv.slice(2);
const products = (productIds.length > 0 ? productIds : ["cli"]).map(getProduct);
const factories = [
...sharedExtensions,
...products.flatMap((product) => product.extensions),
];
const extensionConfigs = {
...runtimeConfig.extensions,
[ExtensionId.Workspace]: {
...runtimeConfig.extensions[ExtensionId.Workspace],
home: runtimeConfig.home,
projectPath: runtimeConfig.projectPath,
},
};
const kernel = new Kernel({ extensionConfigs }).use(...factories);
const stopped = new Promise<void>((resolve) => {
kernel.on(Event.RuntimeStopRequested, resolve);
process.once("SIGINT", resolve);
process.once("SIGTERM", resolve);
});
try {
await kernel.start();
await stopped;
} finally {
await kernel.stop();
}

7
src/products/cli.ts Normal file
View File

@ -0,0 +1,7 @@
import { createCliExtension } from "../extensions/cli";
import type { ProductDefinition } from "./product";
export const cliProduct: ProductDefinition = {
id: "cli",
extensions: [createCliExtension],
};

6
src/products/desktop.ts Normal file
View File

@ -0,0 +1,6 @@
import type { ProductDefinition } from "./product";
export const desktopProduct: ProductDefinition = {
id: "desktop",
extensions: [],
};

22
src/products/index.ts Normal file
View File

@ -0,0 +1,22 @@
import { cliProduct } from "./cli";
import { desktopProduct } from "./desktop";
import { webProduct } from "./web";
import type { ProductDefinition, ProductId } from "./product";
export * from "./product";
export { sharedExtensions } from "./shared";
const products: Record<ProductId, ProductDefinition> = {
cli: cliProduct,
web: webProduct,
desktop: desktopProduct,
};
export function getProduct(id: string): ProductDefinition {
if (id === "cli" || id === "web" || id === "desktop") {
return products[id];
}
throw new Error(`Unknown product "${id}". Expected cli, web, or desktop.`);
}

8
src/products/product.ts Normal file
View File

@ -0,0 +1,8 @@
import type { ExtensionFactory } from "../kernel";
export type ProductId = "cli" | "web" | "desktop";
export interface ProductDefinition {
id: ProductId;
extensions: ExtensionFactory[];
}

12
src/products/shared.ts Normal file
View File

@ -0,0 +1,12 @@
import type { ExtensionFactory } from "../kernel";
import { createAgentExtension } from "../extensions/shared/agent";
import { createDeepSeekExtension } from "../extensions/shared/deepseek";
import { createModelsExtension } from "../extensions/shared/models";
import { createWorkspaceExtension } from "../extensions/shared/workspace";
export const sharedExtensions: ExtensionFactory[] = [
createWorkspaceExtension,
createModelsExtension,
createDeepSeekExtension,
createAgentExtension,
];

6
src/products/web.ts Normal file
View File

@ -0,0 +1,6 @@
import type { ProductDefinition } from "./product";
export const webProduct: ProductDefinition = {
id: "web",
extensions: [],
};

View File

@ -1,18 +0,0 @@
export const ORCHESTRATOR_PROMPT = `你是一个智能编排助手能够创建子Agent并编排它们之间的多轮对话。
##
1. **spawn_agent** Agent
2. **run_conversation** Agent之间运行多轮对话
##
Agent并进行对话时
1.
2. 使 spawn_agent Agent/
3. 使 run_conversation
4.
##
- Agent的角色提示词要具体
- 5-10
-
- 使`;

View File

@ -1,16 +0,0 @@
export const REACT_SYSTEM_PROMPT = `你是一个自主智能体,能够使用工具来完成目标。
##
1. ****
2. ****
##
- JSON
##
-
-
-
- 使`;

View File

@ -1,17 +0,0 @@
import { REACT_SYSTEM_PROMPT } from './reAct.js';
import { ORCHESTRATOR_PROMPT } from './orchestrator.js';
export const PROMPTS = {
default: '你是一个直爽的代码审查员,回答尽量简洁。',
toxic: '你是一个毒舌代码审查员,用讽刺的语气表达。',
json: `你是一个 API 格式化助手。你的回答必须是纯 JSON不要加任何解释。
{
"answer": "你的回答",
"confidence": 0.0-1.0
}`,
agent: `你是一个有工具调用能力的助手。当需要查询信息/执行计算或者玩猜谜游戏时,请使用提供的工具。
`,
reAct: REACT_SYSTEM_PROMPT,
orchestrator: ORCHESTRATOR_PROMPT,
} as const;

View File

@ -1,22 +0,0 @@
import { Tool } from '../types/index.js';
export const calculatorTool: Tool = {
name: 'calculate',
description: '执行数学计算,支持加减乘除和括号',
parameters: {
type: 'object',
properties: {
expression: { type: 'string', description: '数学表达式,如"2+3*4"' },
},
required: ['expression'],
},
execute: async (args) => {
try {
// 安全警告:生产环境绝不可以用 eval
const result = eval(args.expression);
return `${args.expression} = ${result}`;
} catch (e: any) {
return `计算错误: ${e.message}`;
}
},
};

View File

@ -1,20 +0,0 @@
import { Tool } from '../types/index.js';
export const guessTool: Tool = {
name: 'guess_number',
description: '猜一个1到100之间的整数。返回“大了”、“小了”或“猜对了”。',
parameters: {
type: 'object',
properties: {
number: { type: 'number', description: '你猜的数字' },
},
required: ['number'],
},
execute: async (args) => {
// 答案写死在工具内部,模型绝对不知道
const answer = 67;
const guess = args.number;
if (guess === answer) return '猜对了!';
return guess > answer ? '大了' : '小了';
},
};

View File

@ -1,36 +0,0 @@
import { weatherTool } from './weather.js';
import { calculatorTool } from './calculator.js';
import { guessTool } from './guess.js';
import { Tool } from '../types/index.js';
import { subAgentTools } from '../agents/tools.js';
import { knowledgeTools } from '../knowledge/search-tool.js';
const baseTools: Tool[] = [weatherTool, calculatorTool, guessTool, ...knowledgeTools];
/** 所有工具(基础工具 + sub-agent 管理工具),供主 orchestrator Agent 使用 */
const allTools: Tool[] = [...baseTools, ...subAgentTools];
/** 返回基础工具列表(供子 Agent 使用,不含 sub-agent 管理工具以防递归) */
export function getTools(): Tool[] {
return baseTools;
}
/** 返回全部工具列表(供主 orchestrator Agent 使用) */
export function getOrchestratorTools(): Tool[] {
return allTools;
}
export function getOpenAITools() {
return baseTools.map((t) => ({
type: 'function' as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}));
}
export function findTool(name: string): Tool | undefined {
return allTools.find((t) => t.name === name);
}

View File

@ -1,19 +0,0 @@
import { Tool } from '../types/index.js';
export const weatherTool: Tool = {
name: 'get_weather',
description: '获取指定城市的当前天气信息',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: '城市名称,如"北京"、"上海"' },
},
required: ['city'],
},
execute: async (args) => {
// 模拟异步 API 调用
const weathers = ['晴', '多云', '小雨', '阴天'];
const picked = weathers[Math.floor(Math.random() * weathers.length)];
return `城市:${args.city},天气:${picked},温度:${Math.floor(Math.random() * 15 + 15)}°C`;
},
};

View File

@ -1,14 +0,0 @@
export interface Tool {
name: string;
description: string;
parameters: {
type: 'object';
properties: Record<string, {
type: string;
description: string;
enum?: string[];
}>;
required: string[];
};
execute: (args: Record<string, any>) => Promise<string> | string;
}

1
tests/e2e/.gitkeep Normal file
View File

@ -0,0 +1 @@

1
tooling/.gitkeep Normal file
View File

@ -0,0 +1 @@

View File

@ -1,7 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "bundler",
"types": ["node"]
}
}
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"],
"noEmit": true
},
"include": ["src/**/*.ts"]
}