监督
用于建筑的无头图书馆 控制循环: 计划抽查一个重播
类异常,用编码代理修正几个实例,并打开一个PR. 没有服务器或
UI - 你打电话 loop.run() 从您已有的调度器( cron, CI, 无服务器) 中
每个电话都有一个完整的周期:
sense → filter claimed → pick → actuator acts
└─ default: agent per signal → verify (re-sense) → commit → PR
一切都在新鲜中运行 代顿a 沙盒:repo被复制在, 一个代理CLI(Claude Code或Codex)被安装和驱动非交互,而沙盒 在运行结束时被撕毁.
安装
pnpm add @bpinternal/overwatch # or: npm install / yarn add
船舶作为汇编的建筑物(CommonJS, 附有类型申报),从两者一起运作 require 财务报告和审定财务报表
import 在节点18+。 要黑进图书馆本身 复制回放并运行 pnpm install
(pnpm是支持的软件包管理器;该版本通过 packageManager) (中文(简体) ).
起码你需要:
- 代托纳 API 密钥——通过时为
config.sandbox.apiKey每个循环,或通过DAYTONA_API_KEYenv var (法语)DAYTONA_API_URL编号 :DAYTONA_TARGET如有需要,也可作为 vars 工作) - Anthropic 或 OpenAI API 密钥 — 以您使用的代理
- GitHub 令牌, 如果想要打开循环, 在目标 repo 上带有 push + PR 权限 公关而不是仅仅报告
快开始
import { ControlLoop, Github, Codex, AgentPrActuator, sensors } from "@bpinternal/overwatch";
const loop = new ControlLoop({
// Slugified and applied as a label on every PR this loop opens; also what
// config.maxOpenPrCount counts against.
label: "Naked Error Should be ServiceError",
config: {
maxOpenPrCount: 1,
git: new Github({
repo: "https://github.com/my-org/my-repo.git",
branch: "main",
key: process.env.GITHUB_TOKEN,
}),
agent: new Codex({ apiKey: process.env.OPENAI_API_KEY!, model: "gpt-5.5" }),
hooks: {
setup: "bun install",
preCommit: "bun run fix:format",
},
sandbox: { apiKey: process.env.DAYTONA_API_KEY! },
},
// Detects anomalies. Scans src/ for raw `throw new Error(...)`.
sensor: sensors.astGrep({
pattern: "throw new Error($$$)",
language: "ts",
paths: ["src/"],
}),
// The actuator decides what to do with the picked signals. The default AgentPrActuator
// runs the agent per signal, verifies by re-sensing, then commits and opens a PR. Its
// `instructions` builder turns one signal into the agent's prompt — see "Writing a
// custom actuator" below for the built-in builders and for how to swap in an actuator
// that does something other than open a PR.
actuator: new AgentPrActuator({
instructions: (signal) => ({
instructions: [
`Fix this in ${signal.location?.file ?? "the codebase"}.`,
`Issue: ${signal.message}`,
"Make the smallest change that resolves it — no unrelated refactors.",
].join("\n"),
}),
}),
});
// Expose the built-in CLI (see "Running from CI" below). Or call loop.run() directly.
loop.cli();
用您的 TypeScript 跑取器运行它—— 例如 . pnpm dlx tsx my-loop.ts run - 随便怎么说
您喜欢( 线圈触发器, 时间表上的 GitHub 动作等) ; 循环本身不会停留
在运行之间居住.
见 examples/ 更完整、更现实的配置(共享配置于 examples/common.ts, (中文(简体) ),
上面的循环.
从中央情报局逃跑
循环是一个平版脚本,所以任何TypeScript跑者都可以运行. 而不是把论据串起来
处理您自己,结束文件 loop.cli() 它把剧本变成一个小
带有两个子命令的命令行工具 :
const loop = new ControlLoop({ /* ... */ });
loop.cli(); // parses process.argv and runs the matching subcommand
tsx my-loop.ts run # one full cycle: sense → pick → actuate (→ PR)
tsx my-loop.ts apply-comments 1234 # apply new review comments on PR #1234, push in place
tsx my-loop.ts --help # usage (works on each subcommand too)
run输出 非零 如果一个固定点无法被验证(fix-failed)或任何掷出 错误, 因此 CI 步骤会大声失败 。 所有其他结果——clean, (中文(简体) ),skipped, (中文(简体) ),pr-opened—— 说 输出0。结果也作为一行摘要印发.apply-comments <pr>阅读比公关头条更新的评论并将其应用于 公关的分支(参见: 公关审查意见 (见上文)。 把它连接到一个pull_request_review_comment触发,通过PR号码。 它首先检查 PR 带有这个循环的标签;如果它 不——来自另一个循环或人类的公关—— 它什么都不做,而且退出 非零 (单位:千美元)wrong-loop),所以错误的接线触发器会响亮地失败,而不是推动这个循环的配置 在外国分支.
帮助和论证 指挥官这样
--help,未知命令错误,每个命令的使用都免费.
GitHub Action 工作流程运行回路时
name: control-loop
on:
schedule:
- cron: "0 * * * *" # hourly
pull_request_review_comment:
types: [created]
jobs:
loop:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install
- name: run
if: github.event_name == 'schedule'
run: pnpm dlx tsx my-loop.ts run
env:
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GH_PUSH_TOKEN }}
- name: apply review comments
if: github.event_name == 'pull_request_review_comment'
run: pnpm dlx tsx my-loop.ts apply-comments ${{ github.event.pull_request.number }}
env:
DAYTONA_API_KEY: ${{ secrets.DAYTONA_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GH_PUSH_TOKEN }}
过 config.maxOpenPrCount 保持预定时间 run 从堆积起来的公关 比它们合并的速度快.
多个循环, 一个切入点
运行许多循环意味着许多接近相同的工作流程——a pull_request_review_comment
触发器不能知道 哪个 循环打开正在评论的 PR . LoopOrchestrator 崩溃
将它们编成单一的脚本: 记录循环, 结尾为 orchestrator.run(),和一个CLI战线
他们全部.
import { LoopOrchestrator } from "@bpinternal/overwatch";
import { nakedErrorLoop, reactDoctorLoop } from "./loops";
new LoopOrchestrator()
.register(nakedErrorLoop)
.register(reactDoctorLoop)
.run();
tsx loops.ts list # the registered loops and their routing slugs
tsx loops.ts run react-doctor-issues # one cycle of a specific loop (by slug)
tsx loops.ts apply-comments 1234 # route the event to whichever loop owns PR #1234
apply-comments 将 PR 与每个循环的标签相匹配,并将注释应用到该循环的标签上
拥有它——所以一个评论的webhook服务于每一个循环. 如果没有注册的循环拥有 PR,它
输出 非零 (单位:千美元)no-matching-loop) (中文(简体) ). 循环被他们的标签弹出键入,所以他们的标签必须
以相隔(登记相撞抛出物),它们应瞄准相同的还原物(a)
评论 Webhook 是每repo 。 在以上工作流程中,将两个评论/时间表步骤指向:
loops.ts apply-comments … 编号 : loops.ts run <slug> 而不是每页脚本.
核心概念
控制回路是五块有线的配置 ControlLoop数字 :
| 块 | 类型 | 目的 |
|---|---|---|
sensor | Sensor | 扫描 repo, 返回 Signal[] 发现的异常现象 |
picker | Picker (可选) | 选择要操作的信号 此运行 |
actuator | Actuator | 对所选信号采取行动; 默认运行代理并打开 PR |
config.agent | Agent | 在沙盒内驱动编码代理 CLI |
config.git | GitSource | 在哪里复制,在哪里推和打开 PR |
A 类 Signal 单位是所有其他操作:
interface Signal<TData = unknown> {
// Where the anomaly is. Optional — some anomalies are repo-wide. When present,
// `location.file` anchors the signal's identity (see below).
location?: { file: string; line?: number };
message: string;
priority?: "low" | "medium" | "high";
data?: TData; // anything sensor-specific; carried through to picker and actuator untouched
}
信号通过运行来匹配 location.file + message 仅限 —— 说 line 是信息和
自然改变为固定土地,所以永远不要烤出行号或时间戳入 message这个
身份是让循环在验证时从"固定"中分辨出"仍然被打破,重试"的,以及
是什么让它跳过信号 一个已经打开的公关声称.
哪个 run() 已经,顺序
- 如果
config.maxOpenPrCount打开的 PR 已经带有此循环的标签 . - 旋转沙盒,复制回波,运行
config.hooks.setup。 。 。 . - 运行传感器。 无信号 ~ 完成 (
status: "clean") (中文(简体) ). - 丢弃此循环的另一个开放 PR 已声称的信号( 见 内存声称( C) 页:1.
- 运行拾取器( 默认 ):
pickers.count(1),即传感器顺序的第一个信号). 没有 信号已选中 . - 把选中的信号交给
actuator.act(...)并归还它所报告的一切。 步骤6a-6c 下面是 默认AgentPrActuator是否 —— 由不同的驱动器拥有此步骤 他们中的任何人不得:- 第6条a款. 检查一个分支,运行一个代理 每一个选定的信号使用该信号
指示,然后重新运行传感器,以检查哪些信号已经消失。 还有什么
重试(用“ 尝试不同的方法” ) 至
config.maxFixAttempts时间( 默认 3). - 第6条b款. 如果在一切尝试之后 事情仍未解决
status: "fix-failed", (中文(简体) ), 无所取舍. - 第6条c款. 否则:运行
config.hooks.preCommit,承诺一切--no-verify, (中文(简体) ), 推出,打开公关(贴上标签,在正文中贴有索赔标记)status: "pr-opened"。 。 。 .
- 第6条a款. 检查一个分支,运行一个代理 每一个选定的信号使用该信号
指示,然后重新运行传感器,以检查哪些信号已经消失。 还有什么
重试(用“ 尝试不同的方法” ) 至
run() 决定: ControlLoopRunResult - 总是检查 .status 而不是假设
成功 干净的感应器 和"我们尝试和失败"都是正常的结果
例外情况.
公关审查意见
loop.applyPrComments(prNumber) 是另一个切入点——将它接通您的 GitHub webhook(或
民意调查),用于对打开的环路的公关评论活动. 它获取的评论比
PR的头部承诺,让特工在PR现有的分支上应用,并推进到位.
比头部年长的评论被当做已经处理过, 所以重新触发在同一方面
线程是安全的 (status: "no-new-comments") (中文(简体) ).
以 /feedback 与其他注释一样应用,但文本(前缀)
(被剥去) .github/control-loop/memory-<label>.md 在回购。 那个
文件在同一个循环的每个未来运行中被读取,并附在每一个代理指令之后,作为
常年指导——这是你如何纠正一个循环的一次行为,并把它粘住,因为
记忆文件船与 PR 记录它.
通知
config.notifications 是循环报告最终结果的目的地列表.
Slack 带有 lib 的船舶; 抽象的下级 Notification (一个) send(text, state?)
方法),用于其他一切.
import { Slack } from "@bpinternal/overwatch";
notifications: [
new Slack({
auth: { token: process.env.SLACK_BOT_TOKEN! }, // bot token with chat:write
channel: "#overwatch",
onSuccess: (context) => `A new PR was open: ${context.url}`,
onFailure: (reason) => reason,
}),
],
语言完全属于你——每个处理者将消息发回,然后返回
null编号 :undefined 所以一个目的地只说起事件
它有一个处理器:
| 处理器 | 着火了 | 接收 |
|---|---|---|
onSuccess | run() 打开 PR | { label, url, signals } |
onFailure | run() 已返回 fix-failed输入点 | reason: string (已经命名循环) |
onCommentsApplied | applyPrComments() 被推入公关 | { label, branch, comments } |
clean 财务报告和审定财务报表 skipped 运行——并评论意味着另一个循环的事件——不通知:
大部分滴滴的循环回报率是多少 并宣布这些滴滴值
看着 无法到达的目的地( 已撤销的令牌, 不在频道中) 是
登录为警告并跳过; 它从未失败过运行, 也从未替换运行本身的错误 .
在第一次公关之后,每条关于同一公关的信息都与该公关相接: Slack 员额
“应用的评论”更新(以及任何后来的失败) 在线索中回复 信息内容
所以公关读作谈话.
这在程序上是可行的 打开公关的运行 以及更新它的评论
除了公关之外 NotifyState: 随便 send
返回存储在 PR 正文中的隐形标记(紧接索赔标记)并移交
回到同一个目的地 在下一个活动。 斯莱克只是第一个信息 ts。 。 。 .
它从来没有到达你的处理器; 它是交付力学,不是措辞。 为了运送你
本身: 返回状态来记录它, 不返回任何内容来保存已存储的内容( 这就是 Slack 如何保存)
持续回答线根而不是自己最后的回覆).
索赔(安全货币)
每个打开的PR嵌入了一个隐形标记,列出它修补的信号密钥. 在接下来的比赛中, 已声明的信号 打开 同一循环的PR在选择之前被排除在外——所以 在先前的 PR 合并之前再次运行回路不会产生相同的重复修复 问题。 合并的PR删除源信号(传感器停止报告); a 关闭的不合并的PR会自动释放其索赔,因为它不再开放.
写入自定义传感器
传感器要么是一种平庸的功能,要么是一个上传到沙盒中的脚本. 使用 sensors.* 页:1
普通案件; 不适合时写自己的案件.
主机侧函数 (单位:千美元)SensorFn)——运行在你的机器/CI跑道上,有一个 RepoHandle 那个
代理人 exec, (中文(简体) ), readFile, (中文(简体) ), glob,以及 grep 进入沙盒:
import type { RepoHandle } from "@bpinternal/overwatch";
import type { Signal } from "@bpinternal/overwatch";
const myTodoSensor = async (repo: RepoHandle): Promise<Signal[]> => {
const matches = await repo.grep("FIXME:", ["src/"]);
return matches.map((match) => ({
location: { file: match.file, line: match.line },
message: `unresolved FIXME: ${match.content.trim()}`,
priority: "low",
}));
};
const loop = new ControlLoop({ /* ... */ sensor: myTodoSensor, /* ... */ });
当检查便宜且可表达时使用此形状 exec编号 :grep编号 :glob ——关闭
和东道方的进口正常工作,因为它永远不会离开你的流程.
沙盒脚本 (单位:千美元)sensors.script(localPath)——用于更重的扫描仪(海关静态)
应运行的项目工具 内部 以还原根作为沙盒
cwd, 而不是通过主机进行圆通:
// sensors/find-unhandled-promises.ts
export default async function (): Promise<import("@bpinternal/overwatch").Signal[]> {
// this file is uploaded and run with `bun` inside the sandbox; cwd is the repo root
// ... your scanning logic ...
return [];
}
sensor: sensors.script("./sensors/find-unhandled-promises.ts"),
默认导出必须是 () => Promise<Signal[]>循环将它包裹在一个小跑道上
将结果写入 JSON 文件, 而不是 stdout, 因此脚本可以自由使用 console.log 页:1
它自己的调试.
内置传感器 (单位:千美元)sensors.grep, (中文(简体) ), sensors.astGrep, (中文(简体) ), sensors.reactDoctor) 覆盖正文
搜索,结构AST搜索,以及 反应医生 诊断
检查 src/sensors/ 用于在写自定义形状前的完整选项;
(单位:千美元) grep编号 :astGrep 用自定义调用 message 函数经常取代原本的
一个感应器.
无论你选择的形状: 保留 paths (或等同范围)故意. 无瞄准传感器
将"修复这件事情"变成"修复一切,无所不在,永远"
写入自定义拾取器
一个拾取者将感知(和无感知)信号缩小到运行时应该采取的行动:
type Picker = (signals: Signal[]) => Promise<Signal[]> | Signal[];
import type { Signal } from "@bpinternal/overwatch";
const highPriorityFirst = (max: number) => (signals: Signal[]): Signal[] =>
[...signals]
.sort((a, b) => rank(b.priority) - rank(a.priority))
.slice(0, max);
function rank(p: Signal["priority"]): number {
return { high: 2, medium: 1, low: 0 }[p];
}
内设: pickers.count(n) (第一届) n 按传感器顺序排列——默认)和
pickers.busiestFile(maxCount?) (无论哪个文件的信号都最多,所以运行
在一个连贯的公关中共同修复土地。 摸摸感应器,或者写一个像那个
改变"第一"的意思.
演员
这个 演员 选择后拥有所有的东西——它决定了实际是什么 发生 页:1 并报告结果。 这是一个抽象的阶级 只有一个方法:
abstract class Actuator<TData = unknown> {
abstract act(input: ActuatorInput<TData>): Promise<ControlLoopRunResult<TData>>;
}
act 接收选中的 signals,提供 sandbox,一个 AgentContext (单位:千美元)ctx用于
运行命令/ 在其中写入文件, a sense() 回调以重运行传感器,循环
config页:1 label编号 :displayLabel运行 log - 做这些事的所有事情
返回 a ControlLoopRunResult。 。 。 .
默认 : AgentPrActuator
大多数循环想要内置行为:在每个信号上运行代理,通过再感应(与
重复,然后承诺并打开一个公关。 这是 AgentPrActuator,它需要一个
指令构建器 函数将一个信号变成代理的提示:
type InstructionBuilder<TData = unknown> =
(signal: Signal<TData>) => Promise<{ instructions: string }> | { instructions: string };
import { AgentPrActuator, actuators } from "@bpinternal/overwatch";
new AgentPrActuator({
instructions: (signal) => ({
instructions: [
`Fix this in ${signal.location?.file ?? "the codebase"}.`,
`Issue: ${signal.message}`,
"Make the smallest change that resolves it — no unrelated refactors.",
].join("\n"),
}),
});
到达 signal.data 用于任何特定传感器——例如:. sensors.reactDoctor 附上
全面诊断(包括其 help 这里,和 sensors.astGrep编号 :sensors.grep 附 录
原始的比赛.
两艘建造船 actuators (通过一个作为 instructions,或者省略它——默认是
fromSignal():
actuators.fromSignal()——仅从信号中创建的最小指令;一个好的默认.actuators.fromFile(localPath)——读取本地标记下/文本文件一次(已刻出)并用作 说明书,并附上信号的细节。 当固定跟随一个 作为医生写作比重建每个符号更容易的房子风格——见:examples/blank-errors-instructions.md真实的(带有地图的完整移徙指南) 表格和片断前后.
AgentPrActuator 在每个指令中自动附加两件事: 重试调整
重复尝试,这个循环积累 /feedback 内存( 见 公关审查意见
(见上文)。 不要重复 任何关心 在您的指令构建者.
写入自定义驱动程序
子类 Actuator 当你想逃跑的时候 其他 打开 PR — 张贴报告到
Slack,写一个摘要文件,呼叫一个API,打开一个问题而不是一个PR. 开动器不需要
触摸编码代理; 它只需要返回一个 ControlLoopRunResult数字 :
import { Actuator, type ActuatorInput, type ControlLoopRunResult } from "@bpinternal/overwatch";
class ReportOnly extends Actuator {
async act({ signals, log }: ActuatorInput): Promise<ControlLoopRunResult> {
log.info(`found ${signals.length} signal(s) — reporting without touching the repo`);
await postToSlack(signals.map((s) => `• ${s.location?.file ?? "repo"}: ${s.message}`).join("\n"));
return { status: "clean" }; // nothing was changed or opened
}
}
循环仍然处理感知, 索赔过滤,采摘 maxOpenPrCount 大门周围
您的驱动器 —— 带有循环标签的 PR 键, 所以它们只是不给驱动器
从来没有打开一个.
配置引用( E)ControlLoopConfig(中文(简体) )
静态的,每升起的设置 config数字 :
git(单位:千美元)GitSource——使用new Github({ repo, branch, key })。 。 。 .key实值 用于克隆/计算公共回购的PR的可选性,要求推取或打开PR.agent(单位:千美元)Agent——new Claude({ apiKey, model?, timeoutSec? })或者说new Codex({ apiKey, model?, timeoutSec? })扩展抽象Agent类 (单位:千美元)prepare, (中文(简体) ),executeAgent插入另一个CLI.maxOpenPrCount——跳出直接跑道,如果这些带有循环标签的PR是 已经打开.notifications(单位:千美元)Notification[]——目的地告知跑道结果 通知 见上文.branchPrefix——默认"control-loop";被推的树枝看起来像<prefix>/<label-slug>-<run-id>。 。 。 .hooks.setup—— shell 命令在克隆后立即运行(例如."bun install"非零级 退出中止运行 .hooks.preCommit——贝壳命令追随代理人,在犯罪前(例如: repo的自动处理器). 规范代理编辑和任何循环,所以 PR 通过 CI 格式化。 非零退出中止运行 .env- 创建时在沙盒上设置了环境 vars(可用hooks和代理人.maxFixAttempts——默认3;见上文步骤6.sandbox.apiKey—— Daytona API 密钥沙盒是用创建的,所以在 一个过程可以使用不同的代托纳账户. 掉回原点DAYTONA_API_KEY环境 var 当省略时.sandbox.snapshot来创建沙盒而非默认的 Daytona 快照 图像。 预装工具链和代理CLI在此跳过每个运行的安装. 说明: 资源大小必须被烤入快照本身——无法在创建时要求它 时间用一个.
日志
每跑步都会说出步道 RunLog (沙盒的创造、克隆、感知、,
每个信号代理运行,承诺/推出,公关打开)——无可配置,仅当观察stderr时
手动运行一个循环。 设定 CONTROL_LOOP_SILENT=1 变音; 否则颜色会随之而来
NO_COLOR 和TTY检测.
扩展
- 新传感器口味: 将文件添加到
src/sensors/返回时SensorFn再从src/sensors/index.ts(或使用)sensors.script原始函数内含——你不需要 用于一次性扫描的内置). - 新拾取器: 任意
(signals: Signal[]) => Signal[] | Promise<Signal[]>工作内置; 添加一个 文件为src/pickers/(从其index.ts),但需广泛再用. - 新指令样式: 添加一个
InstructionBuilder工厂改为src/actuators/instructions.ts(比如说)fromFile编号 :fromSignal),或通过一个内线AgentPrActuator。 。 。 . - 新建起动器类型 (不打开 PR / 不运行代理): 添加文件到
src/actuators/一个分类抽象的类Actuator,从src/actuators/index.ts。 。 。 . - 新剂CLI: 扩展抽象
Agent类src/agents.ts。 。 。 . - 新的通知目的地: 将文件添加到
src/notifications/与一个类 子分类抽象Notification执行send(text, state?),从src/notifications/index.ts。基地处理哪些事件起火和它们说什么;返回 aNotifyState从send在下一次关于公关的事件上 将循环的手拿回来. - 新伪造 (吉特拉布等):执行
GitSource界面从src/github.ts。 。 。 .
免责声明
此软件包将在 @bpinternal 组织。 这个组织的所有软件包都用于 瓶装 内部的团队 并不适合我们的社区。 由于这些软件包适合我们自己的使用情况,它们可能不稳定的API,在没有警告的情况下接受中断的更改,文件很少,缺乏以社区为重点的支持。 然而,这些软件包仍然被故意公开,其重要原因如下: 我们爱开源. 因此,如果您想要安装或叉出这个软件包,就完全可以自由操作。 我们强烈建议你正确标记你的版本.
博讯工程组.