diff --git a/.agents/skills/write-agent-blog/SKILL.md b/.agents/skills/write-agent-blog/SKILL.md index ed953f7..bcd5e6c 100644 --- a/.agents/skills/write-agent-blog/SKILL.md +++ b/.agents/skills/write-agent-blog/SKILL.md @@ -1,43 +1,31 @@ --- name: write-agent-blog -description: 为 llm-to-agent 的《Agent进阶专题》系列创作中文文章,并在项目 blog 目录输出单个 Markdown 文件。当用户要求为某次提交、代码变更、小功能点、路线图事项、里程碑或实现过程撰写、生成、修改博客或文章时使用,包括“为本次提交写篇文章”“给这个功能写博客”“记录这次实现”等表达。 +description: 为 llm-to-agent 的《Agent进阶专题》系列创作中文文章。触发:"为本次提交写篇文章""给这个功能写博客""记录这次实现"。不触发:修改 README、写代码注释、写 commit message。 --- # 创作 Agent 系列博客 -为《Agent进阶专题》系列创作一篇以项目事实为依据的 Hexo 文章,并将成果保存为 `<项目根目录>/blog/` 下的单个 Markdown 文件。 +为《Agent进阶专题》输出一篇 Hexo Markdown 文章到 `blog/` 下。**源码是唯一事实来源,只写已实现的行为。** -## 工作流程 +--- -1. 使用 `git rev-parse --show-toplevel` 定位项目根目录。 -2. 确定用户要求的写作范围。对于“本次提交”等表达,使用 `git show` 检查 `HEAD`;用户明确指向尚未提交的工作时,还要检查工作区变更。 -3. 阅读相关实现、测试、包脚本、路线图事项和功能文档。阅读已有的 `blog/*.md`,延续编号、术语和内容深度,并避免重复。 -4. 确定文章对应的单个小功能点、所属阶段(如 `P1`、`P2`)、阶段内篇号、标题和仅含 ASCII 小写字符的短横线式别名。 -5. 以 `assets/article-template.md` 为写作骨架。替换所有占位内容并删除模板说明。 -6. 如果 `blog/` 不存在则创建它。除非用户明确要求修改已有文章,否则只新建一个 `.md` 文件。 -7. 检查元信息、文件名、永久链接、``、事实陈述、代码片段和 Git 差异。除非用户明确要求,否则不要创建提交。 +## 必须(违反即错误) -## 事实依据 +### 工作流程 -- 将源代码、测试和实际差异视为事实依据;使用文档和提交信息理解设计意图与背景。 -- 只描述选定变更已经体现的行为。明确标注计划和未来工作,不得将其写成已经实现的能力。 -- 不得虚构调试经历、性能数据、设计争论、执行命令、命令输出或用户反馈。 -- 优先选用能够揭示核心思路的短代码片段。确保片段与仓库内容一致,并在附近正文中注明仓库相对路径。 -- 如果目标提交主要是脚手架、配置或文档,应解释这项基础工作的目的和后续价值,但不得夸大现有能力。 +1. `git rev-parse --show-toplevel` 定位项目根目录。 +2. 用 `git show` / `git diff` 读取目标变更;读已有 `blog/*.md` 确定编号、术语、避免重复。 +3. 确定:阶段(P0/P1/P2)、阶段内序号、中文标题、英文 slug。 +4. 只新建一个 `blog/2026-PX-0X-slug.md`,不覆盖已有文件。 -## 文件名与元信息 +### 文件名与元信息 -文件名使用 `2026-PX-0X-xxx.md` 格式: +``` +2026-PX-0X-slug.md +``` -- 除非用户修改约定,否则本系列固定使用四位年份 `2026`。 -- 将 `PX` 替换为路线图阶段,例如 `P1`。 -- 将 `0X` 替换为该阶段内的两位文章序号,从 `01` 开始。 -- 将 `xxx` 替换为描述该功能的简短英文别名,只使用小写 ASCII 字母、数字和短横线。 -- 示例:`2026-P1-03-streaming-chat.md`。 -- 根据已有文章推断下一个未使用的序号。如果无法可靠判断所属阶段,应暂停并询问用户,不要自行猜测。 -- 不得覆盖已有文件。如果目标名称已经存在,先判断它是否对应同一功能;否则递增序号。 - -严格使用以下元信息结构: +- `PX` = 路线图阶段,`0X` = 阶段内序号(01 起),`slug` = 小写 ASCII + 短横线。 +- 如果无法判断阶段或序号,暂停询问用户。 ```yaml --- @@ -46,80 +34,142 @@ tags: [AI, Agent] categories: - AI date: YYYY-MM-DD HH:mm:ss -permalink: /agent/YYYY/MM/slug/ +permalink: /YYYY/agent/slug/ --- ``` -- 新建文件时使用当前本地日期和时间。 -- 标题应具体并体现结果,不使用章节编号,也不要使用“一些思考”之类含糊标题。 -- 文件名和永久链接使用相同的英文别名。 -- 永久链接中的 `YYYY/MM` 从 `date` 推导。 +- date 用当前本地时间,适当加工避开工作时间,permalink的YYYY从date中提取。 +- 标题具体、体现结果,不用"一些思考"等模糊标题。 -## 文章固定结构 +### 事实约束 -保持以下三部分顺序: +- 只写代码中已存在的行为。未实现的能力标注"计划中",不写成已实现。 +- 禁止虚构:调试经历、性能数据、设计争论、命令输出、用户反馈。 +- 代码片段必须与仓库一致,附近标注仓库相对路径。 +- 脚手架/配置类提交:解释目的和后续价值,不夸大现有能力。 -1. Hexo 元信息。 -2. 位于 `` 之前的简洁简介,用于列表页摘要。 -3. 位于 `` 之后的完整正文。 +### 文章结构 -使用自然且能够说明内容的标题,不要直接使用“简介部分”或“正文部分”等模板化标题。简介由一至两个短段落组成,需要说明问题、此次交付的能力及其价值。 +``` +元信息 +摘要(1-2 句) + +正文 +``` -正文应围绕具体功能组织,不要机械套用完全相同的标题。根据主题只选择必要内容,不要求每篇全部覆盖: +- `` 恰好出现一次,前后各空一行。 +- 摘要说明"做了什么、为什么有价值"。 +- 正文标题从 `##` 开始,不重复一级标题。 -- 变更前存在的问题或限制; -- 当前小功能点的目标与边界; -- 核心设计或理解模型; -- 配合精选代码片段说明实现路径; -- 验证方式与可观察结果; -- 取舍、当前限制和下一项自然演进能力; -- 用简短结语将本篇内容连接到整个系列的演进主线。 +--- -- 导读、概念介绍和设计点题类文章建议控制在 1000 个中文字符左右;这是保持简洁的参考值,不是硬性上限。根据章节范围和必要信息调整篇幅,优先保证主题讲清且没有无关扩展。 -- 标题给出的主题就是内容边界。只讲清当前主题,不借机扩展相关协议、实现细节或远期能力。 +## 应该(尽量做到) -## 读者视角与系列衔接 +### 内容边界 -- 面向第一次接触本项目的外部读者写作,不要把作者已经掌握的项目背景当作读者常识。 -- 阶段编号和文章编号用于组织系列,不是正文的叙事前提。首次出现 `P0`、`P1` 等编号时必须用自然语言解释其含义;如果编号对理解当前内容没有帮助,就不要在正文中使用。 -- 每篇文章先从读者能够理解的场景、问题或一次自然的需求变化切入,再逐步引出项目术语和设计结论。 -- 用“原本有什么—遇到什么问题—做出什么选择”推进文章,但不要为了讲故事虚构场景或增加文学化铺垫。 -- 不得使用“如前所述”“大家已经知道”等措辞假设读者读过其他文章。即使文章位于系列中间,也应提供理解当前主题所需的最少背景。 -- 相邻文章应各守边界,避免提前讲完后续主题。结尾只需自然提出下一篇的问题,不要罗列尚未解释的阶段和术语。 -- 当前导读部分暂定为:`P0-00` 项目介绍与目录导航、`P0-01` 架构设计、`P0-02` 功能划分、`P0-03` 阶段规划、`P0-04` 目录规划。创作其中一篇时,不要侵占其他篇目的主要内容。 +- 标题就是边界。不讲无关协议、远期能力、实现细节。 +- 导读/概念类 ~1000 中文字符;实现类按需,讲清即止。 +- 2-4 个短章节即可,不为完整而堆章节。 -## 系列文风 +### 叙事方式 -- 使用清晰的简体中文,面向理解 TypeScript 和基本 LLM 概念、但可能刚接触 Agent 工程的开发者。 -- 直接、口语化、克制。先给结论,再补必要原因;能用一句话说清的内容不要扩成一段。 -- 删除不推动观点的过渡句、重复总结、设问和修饰语。每个段落只表达一个重点,通常不超过三句话。 -- 优先使用具体动词和短句,避免“我们需要先回答一个不那么显眼、却会影响整个项目的问题”一类绕弯表达。 -- 适度使用“我们”营造共同实践感,重点仍应放在技术推理和可复现的工程过程上。 -- 按照“动机—原理—实现—验证”的顺序展开。在进入密集实现细节前,先解释为什么这样做。 -- 保持段落简短、标题明确,并与项目文档使用一致的术语。 -- 技术术语首次出现时给出简要解释。代码中的英文标识符保持原样;不要为同一概念反复更换译名。 -- 避免营销语言、泛泛的 AI 背景介绍、夸张结论、填充式总结和流水账式叙述。 -- 每篇文章都应能够独立阅读,同时在概念上承接前一项能力并引出下一项能力。 -- 用最短的具体情境引出矛盾和选择,避免从项目内部术语或文档摘要直接起笔,也避免把技术文章写成散文。 -- 不为追求完整而增加章节。点题类文章通常使用两至四个短章节即可。 +- 从读者能理解的场景切入,不假定读者读过前文。 +- "原本有什么 → 遇到什么问题 → 做出什么选择"推进。 +- 读者已知的概念直接引用跳过,如"和 Express 中间件机制一致,略过"。 +- 结尾自然引出下一篇的问题,不罗列阶段和术语。 +- 首次出现 P0/P1 等编号时用自然语言解释。 -## Markdown 与代码 +### 文风 -- 使用标准 Markdown,围栏代码块必须标注语言。 -- 正文标题从 `##` 开始;页面标题由 Hexo 元信息提供,不要在正文中重复一级标题。 -- 标题、列表、代码块和 `` 前后保留空行。 -- 代码片段应尽量短且与主题直接相关。只有在省略范围明显且不会造成误解时才使用 `...`。 -- 展示命令时,应区分命令和示例输出。只有命令确实存在或实际执行过,才能将其作为验证命令写入文章。 -- 除非用户明确要求,否则不要添加目录。 +面向懂 TS 和基础 LLM、刚接触 Agent 工程的开发者。**笔记体**:结论先行,解释从简。 + +- ❌ "我们需要先回答一个不那么显眼、却会影响整个项目的问题" +- ✅ "先确定一件事:Kernel 不认业务概念。" + +- ❌ "经过反复思考和持续调整,我们最终选择了一种简洁优雅的方案" +- ✅ "架构收缩为两个核心概念:Kernel + Extensions。" + +- 每段一个重点,通常不超过三句话。 +- 删除过渡句、重复总结、设问、修饰语。 +- 可用"我们",重心在技术推理。 +- 不用营销语言、泛泛 AI 介绍、夸张结论。 +- 不写散文,不用文学化铺垫。 + +### 格式 + +- 围栏代码块标注语言;标题/列表/代码块/`` 前后空行。 +- 代码尽量短,展示核心思路即可。省略处用 `...` 且确保不会误解。 +- 不自行添加目录。 + +--- + +## 避免 + +- 假设读者读过系列其他文章("如前所述""大家已经知道")。 +- 提前讲完后续文章的主题。 +- 把计划/路线图写成已实现的能力。 +- 流水账式叙述或填充式总结。 +- 为同一概念反复更换中文译名。 + +--- ## 最终检查 -回复用户前确认: +- [ ] 只新增/修改了一篇 `.md` +- [ ] 文件名匹配 `^2026-P[0-9]+-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$` +- [ ] 元信息字段完整,`` 恰好一次 +- [ ] 无占位内容残留 +- [ ] 代码与仓库一致 +- [ ] 回复含文件链接 + 一句话主题说明 -- 只新增或修改了一篇目标 Markdown 文章。 -- 文件名符合 `^2026-P[0-9]+-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$`。 -- 元信息结构有效;除非用户要求,否则只包含本系列规定的字段。 -- 有意义的简介之后恰好出现一次 ``。 -- 没有遗留任何占位内容。 -- 事实陈述和代码片段符合当前仓库状态。 -- 最终回复包含文章文件链接,并用一句话说明文章主题。 +--- + +## 参考:本作者已上线系列的共性特征 + +以下从 Java 专题和 AI 基础专题中提取,是已被验证有效的写作习惯。 + +### 开头 + +一句话交代本篇做什么,不做铺垫: + +- "本篇简单记录常量和变量。" +- "开始之前,我们需要搭建一个基本的项目环境,基于 TypeScript 和 Node.js,且尽量简洁。" + +### 系列衔接 + +可引用前文建立上下文,但只提直接相关的一篇: + +- "上一节我们已经可以和大模型进行连续对话了,这节我们通过简单的例子来演示一下提示词管理的能力。" + +### 代码呈现 + +- 代码是正文主体,文字只是必要说明。 +- 按实现步骤拆分代码块,每块配一行解释。 +- 可在代码块之间穿插截图验证运行结果。 + +### 结尾 + +两种方式,按主题选择: + +- **引出下一篇**:"到此,我们的项目环境就搭建完成了,下一节我们将进行一个简单的对话。" +- **发散点列表**:列出 3-5 个后续可探索的方向,标注为"发散点"而非"计划"。 + ```md + 发散点: + * 上下文长度管理 - 监控上下文的长度,并在超过限制时进行裁剪。 + * 持久化 - 将上下文保存到文件或者数据库中。 + ``` + +### 跨知识引用 + +读者已知的概念直接跳过,不重复解释: + +- "`if` 语句和 `javascript` 完全一致,略过。" +- "和 Express 中间件机制一致,略过。" + +### 语气 + +- 用"我们"但不煽情,重心始终在技术本身。 +- 笔记语气:记录过程、防止遗忘,不假装客观权威。 +- [ ] 无占位内容残留 +- [ ] 代码与仓库一致 +- [ ] 回复含文件链接 + 一句话主题说明 diff --git a/src/extensions/chat.test.ts b/src/extensions/chat.test.ts new file mode 100644 index 0000000..9de3118 --- /dev/null +++ b/src/extensions/chat.test.ts @@ -0,0 +1,103 @@ +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 { Hook } from "./catalog"; +import { + createAgentExtension, + type AgentService, +} from "./shared/agent"; +import { createDeepSeekExtension } from "./shared/deepseek"; +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 firstKernel = new Kernel().use( + createWorkspaceExtension({ home, projectPath }), + createDeepSeekExtension({ apiKey: "test-key", request }), + createAgentExtension(), + ); + + await firstKernel.start(); + + let firstAnswer = ""; + for await (const chunk of firstKernel.get(Hook.Agent).chat("第一次问题")) { + firstAnswer += chunk; + } + + assert.equal(firstAnswer, "第一次回答"); + await firstKernel.stop(); + + const secondKernel = new Kernel().use( + createWorkspaceExtension({ home, projectPath }), + createDeepSeekExtension({ apiKey: "test-key", request }), + createAgentExtension(), + ); + + await secondKernel.start(); + + let secondAnswer = ""; + for await (const chunk of secondKernel.get(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(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(); +}); diff --git a/src/extensions/cli/index.ts b/src/extensions/cli/index.ts new file mode 100644 index 0000000..6fbc4c8 --- /dev/null +++ b/src/extensions/cli/index.ts @@ -0,0 +1,84 @@ +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"; + +export function createCliExtension(): Extension { + let terminal: ReadlineInterface | undefined; + let loop: Promise | undefined; + let currentRequest: AbortController | undefined; + + return { + id: ExtensionId.Cli, + + setup() {}, + + start(context) { + const agent = context.get(Hook.Agent); + 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输入 /exit 退出,Ctrl+C 取消当前回复。\n\n`, + ); + terminal?.setPrompt("> "); + terminal?.prompt(); + + for await (const line of terminal!) { + const input = line.trim(); + + if (input === "/exit") break; + if (!input) { + terminal?.prompt(); + continue; + } + + currentRequest = new AbortController(); + + try { + for await (const chunk of agent.chat(input, 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; + }, + }; +} diff --git a/src/products/cli.ts b/src/products/cli.ts index bc757d2..898f578 100644 --- a/src/products/cli.ts +++ b/src/products/cli.ts @@ -1,6 +1,7 @@ +import { createCliExtension } from "../extensions/cli"; import type { ProductDefinition } from "./product"; export const cliProduct: ProductDefinition = { id: "cli", - extensions: [], + extensions: [createCliExtension], }; diff --git a/src/products/shared.ts b/src/products/shared.ts index d7a2c3f..48e7a8d 100644 --- a/src/products/shared.ts +++ b/src/products/shared.ts @@ -1,4 +1,10 @@ import type { ExtensionFactory } from "../kernel"; +import { createAgentExtension } from "../extensions/shared/agent"; +import { createDeepSeekExtension } from "../extensions/shared/deepseek"; +import { createWorkspaceExtension } from "../extensions/shared/workspace"; -// 公共扩展在实现真实能力时加入这里。 -export const sharedExtensions: ExtensionFactory[] = []; +export const sharedExtensions: ExtensionFactory[] = [ + createWorkspaceExtension, + createDeepSeekExtension, + createAgentExtension, +];