Compare commits
15 Commits
4ca19bf301
...
1644731a5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1644731a5e | ||
|
|
59903d6d5e | ||
|
|
f728132c81 | ||
|
|
d4b6728326 | ||
|
|
5d13ae4c3e | ||
|
|
8283ba604d | ||
|
|
13d2494208 | ||
|
|
a801f40bdc | ||
|
|
d87dad27db | ||
|
|
258de1a1cd | ||
|
|
49f333b668 | ||
|
|
c4cfbaf83a | ||
|
|
da87569131 | ||
|
|
e4b45c5971 | ||
|
|
ad9ee89586 |
194
.agents/skills/write-agent-blog/SKILL.md
Normal file
194
.agents/skills/write-agent-blog/SKILL.md
Normal 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 中间件机制一致,略过。"
|
||||
|
||||
### 语气
|
||||
|
||||
- 用"我们"但不煽情,重心始终在技术本身。
|
||||
- 笔记语气:记录过程、防止遗忘,不假装客观权威。
|
||||
- [ ] 无占位内容残留
|
||||
- [ ] 代码与仓库一致
|
||||
- [ ] 回复含文件链接 + 一句话主题说明
|
||||
4
.agents/skills/write-agent-blog/agents/openai.yaml
Normal file
4
.agents/skills/write-agent-blog/agents/openai.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Agent 专题博客创作"
|
||||
short_description: "根据提交与项目文档创作统一风格的 Hexo Markdown 文章"
|
||||
default_prompt: "使用 $write-agent-blog 为本次提交写一篇文章。"
|
||||
40
.agents/skills/write-agent-blog/assets/article-template.md
Normal file
40
.agents/skills/write-agent-blog/assets/article-template.md
Normal 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
17
.github/copilot-instructions.md
vendored
Normal 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
7
.gitignore
vendored
@ -1,3 +1,6 @@
|
||||
node_modules
|
||||
node_modules/
|
||||
.env
|
||||
pnpm-lock.yaml
|
||||
.DS_Store
|
||||
tmp/
|
||||
.agent/
|
||||
.llm-to-agent
|
||||
28
README.md
Normal file
28
README.md
Normal 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
94
docs/architecture.md
Normal 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
90
docs/data-layout.md
Normal 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
78
docs/extensions.md
Normal 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 Provider:GitHub、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
45
docs/roadmap.md
Normal 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
46
docs/source-layout.md
Normal 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;
|
||||
- 当前保持单包,只有独立构建、发布或进程隔离时才拆包。
|
||||
@ -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 提示词
|
||||
```
|
||||
17
package.json
17
package.json
@ -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
329
pnpm-lock.yaml
generated
Normal 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: {}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
@ -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();
|
||||
@ -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
79
src/config.test.ts
Normal 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
104
src/config.ts
Normal 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 };
|
||||
}
|
||||
@ -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
10
src/extensions/README.md
Normal file
@ -0,0 +1,10 @@
|
||||
# Extensions
|
||||
|
||||
除 Kernel 的稳定运行机制外,所有具体能力都在这里实现。
|
||||
|
||||
- `shared/` 保存不依赖产品界面的公共能力;
|
||||
- `cli/`、`web/`、`desktop/` 保存三条产品线各自的输入、展示、协议和系统集成;
|
||||
- 产品线私有扩展之间不能互相依赖;两个产品需要的实现应提升到 `shared/`;
|
||||
- Kernel 不导入本目录中的任何具体扩展。
|
||||
|
||||
新增扩展时直接实现 `Extension`。只有真实能力开始开发时才创建对应源码目录,不再预建空 package 或 `export {}` 文件。
|
||||
22
src/extensions/catalog.ts
Normal file
22
src/extensions/catalog.ts
Normal 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
156
src/extensions/chat.test.ts
Normal 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();
|
||||
});
|
||||
3
src/extensions/cli/README.md
Normal file
3
src/extensions/cli/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# CLI extensions
|
||||
|
||||
这里保存终端输入解析、斜杠命令、展示和 TUI 等 CLI 私有能力。CLI Extension 通过公开抓手调用共享能力,并把运行结果转换成终端输出。
|
||||
127
src/extensions/cli/index.ts
Normal file
127
src/extensions/cli/index.ts
Normal 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、/exit,Ctrl+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;
|
||||
26
src/extensions/cli/input.test.ts
Normal file
26
src/extensions/cli/input.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
19
src/extensions/cli/input.ts
Normal file
19
src/extensions/cli/input.ts
Normal 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 };
|
||||
}
|
||||
3
src/extensions/desktop/README.md
Normal file
3
src/extensions/desktop/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Desktop extensions
|
||||
|
||||
这里保存原生桥接、窗口、托盘、快捷键、通知与更新等 Desktop 私有能力。与 Web 共用的界面逻辑应放入 `extensions/shared/`。
|
||||
5
src/extensions/shared/README.md
Normal file
5
src/extensions/shared/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Shared extensions
|
||||
|
||||
这里保存模型、Workspace、Shell、Git、记忆、规划、多 Agent、浏览器、自动化和 System Evolution 等跨产品能力。
|
||||
|
||||
共享扩展的源码只依赖 Kernel 契约;需要协作时通过其他共享扩展注册的公开能力运行,不能认识 CLI、Web、Desktop 的界面与传输细节。
|
||||
84
src/extensions/shared/agent/index.ts
Normal file
84
src/extensions/shared/agent/index.ts
Normal 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;
|
||||
57
src/extensions/shared/deepseek/index.test.ts
Normal file
57
src/extensions/shared/deepseek/index.test.ts
Normal 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();
|
||||
});
|
||||
111
src/extensions/shared/deepseek/index.ts
Normal file
111
src/extensions/shared/deepseek/index.ts
Normal 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;
|
||||
60
src/extensions/shared/models/index.test.ts
Normal file
60
src/extensions/shared/models/index.test.ts
Normal 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/);
|
||||
});
|
||||
63
src/extensions/shared/models/index.ts
Normal file
63
src/extensions/shared/models/index.ts
Normal 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;
|
||||
166
src/extensions/shared/workspace/index.ts
Normal file
166
src/extensions/shared/workspace/index.ts
Normal 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;
|
||||
3
src/extensions/web/README.md
Normal file
3
src/extensions/web/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Web extensions
|
||||
|
||||
这里保存 HTTP 接入、事件流、路由与 Web 页面等 Web 私有能力。Web 扩展负责在网络交互和 Kernel 运行接口之间转换。
|
||||
@ -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();
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
46
src/index.ts
46
src/index.ts
@ -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
35
src/kernel/events.ts
Normal 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
27
src/kernel/extension.ts
Normal 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
4
src/kernel/index.ts
Normal 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
177
src/kernel/kernel.test.ts
Normal 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
173
src/kernel/kernel.ts
Normal 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
31
src/kernel/registry.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
);
|
||||
@ -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];
|
||||
20
src/llm.ts
20
src/llm.ts
@ -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
41
src/main.ts
Normal 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
7
src/products/cli.ts
Normal 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
6
src/products/desktop.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { ProductDefinition } from "./product";
|
||||
|
||||
export const desktopProduct: ProductDefinition = {
|
||||
id: "desktop",
|
||||
extensions: [],
|
||||
};
|
||||
22
src/products/index.ts
Normal file
22
src/products/index.ts
Normal 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
8
src/products/product.ts
Normal 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
12
src/products/shared.ts
Normal 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
6
src/products/web.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { ProductDefinition } from "./product";
|
||||
|
||||
export const webProduct: ProductDefinition = {
|
||||
id: "web",
|
||||
extensions: [],
|
||||
};
|
||||
@ -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轮
|
||||
- 总结时聚焦关键转折点、各方策略和最终结果
|
||||
- 使用中文回复`;
|
||||
@ -1,16 +0,0 @@
|
||||
export const REACT_SYSTEM_PROMPT = `你是一个自主智能体,能够使用工具来完成目标。
|
||||
|
||||
## 工作方式
|
||||
你需要反复执行以下步骤,直到目标完成:
|
||||
|
||||
1. **思考**:分析当前状态,决定下一步行动。
|
||||
2. **行动**:调用一个工具,或者给出最终答案。
|
||||
|
||||
## 行动格式
|
||||
- 如果需要调用工具,只返回工具调用的 JSON,不要其他内容。
|
||||
|
||||
## 重要规则
|
||||
- 如果工具返回了错误,分析错误并尝试修复,不要重复相同的错误调用。
|
||||
- 如果连续三次调用没有进展,给出最终答案并说明遇到困难。
|
||||
- 诚实:如果无法完成,直接说明,不要编造。
|
||||
- 使用中文回复。`;
|
||||
@ -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;
|
||||
@ -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}`;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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 ? '大了' : '小了';
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
@ -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`;
|
||||
},
|
||||
};
|
||||
@ -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
1
tests/e2e/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
tooling/.gitkeep
Normal file
1
tooling/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user