feat(core,cli,server,web): add goal mode — loop Tasks on one Session until an objective completes (#66)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rank-Yu
2026-07-27 23:43:24 +08:00
committed by GitHub
parent e1141ca010
commit 46463bee26
61 changed files with 3411 additions and 188 deletions
+57
View File
@@ -0,0 +1,57 @@
---
title: Goal Mode
description: Give the Agent an objective instead of a message — the system loops Tasks on one Session until the goal is complete, blocked, or out of token budget.
---
## What it is
A normal Task ends when the model stops calling tools and replies. Goal mode inverts the contract: you state an **objective**, and the system keeps driving Tasks on the same Session — each round re-injecting the objective and checking a control file — until the goal reaches a terminal state. The model never decides to stop by simply going quiet; it must *claim* completion (or a genuine impasse) through the protocol below, and everything else loops.
Start a goal from any of the three surfaces:
| Surface | How |
| --- | --- |
| Web App | The composer's `+` menu → **Goal mode** (or type `/goal`); the chip takes an optional token budget (`500k`, `2m`, empty = unlimited). Skills selected in the composer prefix the first round's message as a `[use_skills]` block, exactly like a normal send |
| CLI chat | `/goal[:<budget>] <objective>`, e.g. `/goal:500k make all tests pass` |
| CLI one-shot | `penguin run --goal [budget] -m "<objective>"`; exit code 0 only when the goal completes |
| Server API | `POST /api/sessions/:id/tasks` with `{ input, goal: { budget } }` (budget `-1` or omitted = unlimited) |
In the SDK, goal mode is an option of the one `run` call — `session.run(input, { goal: { budget } })` — not a separate API: the input's text becomes the objective, rounds loop inside the call, and the stream's final message is a `goal_finished` event carrying the outcome.
## The control file: GOAL.yaml
The loop's state channel is a file at `<agent_dir>/scratchpad/<session_id>/GOAL.yaml` (sibling of the model's `PLAN.md` convention), created by the system when the goal starts:
```yaml
objective: make all tests pass
status: active
```
The system writes this file **exactly once**, at creation; afterwards it only reads `status`:
| Field | Writer | Notes |
| --- | --- | --- |
| `objective` | system, at creation | the canonical value lives in the loop's memory and is re-stated in every round's block, so a tampered file changes nothing |
| `status` | model | only to `complete` or `blocked` — the model's mailbox back to the loop, read after every round |
Budget numbers ride each round's `[goal]` block, not the file; system-side endings (`budget_limited`, `aborted`) exist only as the `goal_finished` outcome and in server state — the file always keeps the model's own last write, which is exactly the resume point an interrupted goal wants. Reads are tolerant: a missing file, unparseable YAML, or an out-of-protocol status all normalize to `blocked` — a broken control channel stops the loop instead of spinning it forever.
## The loop
Each round's user message is a `[goal]` protocol block followed by a plain body — round 1 carries your original message verbatim (skill-invocation blocks and all); later rounds re-inject the objective. The Web App collapses the block into a "Goal · round N" notice under a regular user bubble; the Trace shows it verbatim. The block embeds the `GOAL.yaml` content (the model sees exactly the file it is asked to edit, composed from the same values it was created with), carries the current budget numbers on its own line, and states the working rules — evidence-based verification before claiming completion, no shrinking the objective to an easier subset, and key progress recorded in `PLAN.md` so it survives context compaction. After the Task ends, the system reads `status`:
- `complete` → the goal is done; the loop stops.
- `blocked` → the loop stops; what the model needs from you is in its final reply. The injected rules require the **same blocking condition to persist for three consecutive rounds** before the model may claim `blocked`, so a transient obstacle doesn't end the goal.
- `active` → budget permitting, the next round fires.
A round that ends in an abort (user stop, LLM failure) ends the whole goal without re-firing — on-disk state stays `active`, so the workspace and goal file remain a clean resume point. In the Web App the regular stop button aborts the entire loop; in the CLI, Ctrl-C does. The same applies to a round the engine cut off at the per-Task turn cap (`max_turns`): the model never got to write the goal file, so the loop ends as `aborted` instead of re-firing the same cutoff forever.
## Token budget
Accounting is incremental — **uncached input + output** (`request.total − cache_read`), summed over every request of every round, *including subagent sessions* spawned by `run_subagent`. `used` starts at 0. The sum is a spend estimate, not a bill: cache reads cost money too, just a small fraction of the uncached-input price, so leaving them out keeps the number an honest approximation without per-model price tables.
The budget is checked between rounds. When it is exhausted the goal is not cut off mid-thought: one final wrap-up round is injected — summarize progress, list remaining work, leave a clear next step, and no claiming `complete` just because the money ran out — after which the system ends the goal as `budget_limited` (the `goal_finished` outcome; nothing is written to the file). Because the check runs between rounds only, a round already in flight is never cut short: actual spend can overshoot the budget by up to one round, plus the wrap-up round. With no budget set, the loop runs until `complete` or `blocked` — bounded by the model's honesty about the two terminal states, plus a hard backstop of 100 rounds so a model that simply never writes the goal file cannot loop forever.
## Server state and events
The Web server records each goal run in a `goal_state` row (objective, status, budget, used, rounds) — the chat page's goal banner restores from the latest row on load, and live progress arrives as `goal_started` / `goal_round` / `goal_finished` events on the session's SSE channel. System-side terminal statuses (`aborted`, `budget_limited`) exist in this row and on the stream only; the on-disk file keeps the model's last write for resuming. Deleting the Session removes its goal rows along with the scratchpad (and `GOAL.yaml` with it).
+57
View File
@@ -0,0 +1,57 @@
---
title: 目标模式
description: 给 Agent 一个目标而不是一条消息——系统在同一 Session 上循环驱动 Task,直到目标完成、受阻或 token 预算耗尽。
---
## 是什么
普通 Task 在模型不再调用工具、给出回复时就结束了。目标模式反转了这个契约:你给出一个**目标(objective)**,系统在同一个 Session 上持续驱动 Task——每一轮重新注入目标并检查控制文件——直到目标进入终态。模型不能靠"不说话"来停下:它必须通过下述协议**声明**完成(或真正的僵局),否则循环继续。
三个入口都能发起目标:
| 入口 | 用法 |
| --- | --- |
| Web App | 输入框的 `+` 菜单 →「目标模式」(或输入 `/goal`);chip 上可填 token 预算(`500k`、`2m`,留空不限)。输入框选中的技能以 `[use_skills]` 块前缀在第一轮消息上,与普通发送完全一致 |
| CLI chat | `/goal[:<预算>] <目标>`,例如 `/goal:500k 让所有测试通过` |
| CLI 单次运行 | `penguin run --goal [预算] -m "<目标>"`;仅目标完成时退出码为 0 |
| Server API | `POST /api/sessions/:id/tasks`,body 带 `{ input, goal: { budget } }`(budget 为 `-1` 或缺省 = 不限额) |
在 SDK 中,目标模式是唯一入口 `run` 的一个选项——`session.run(input, { goal: { budget } })`——而不是独立 API:输入文本即目标,轮次在这一次调用内部循环,流的最后一条消息是携带结局的 `goal_finished` 事件。
## 控制文件:GOAL.yaml
循环的状态通道是一个文件,位于 `<agent_dir>/scratchpad/<session_id>/GOAL.yaml`(与模型的 `PLAN.md` 约定同级),目标启动时由系统创建:
```yaml
objective: 让所有测试通过
status: active
```
系统对这个文件**只写一次**(创建时),此后只读 `status`:
| 字段 | 写入方 | 说明 |
| --- | --- | --- |
| `objective` | 系统,创建时 | 正典值在循环内存里、每轮协议块中重申——文件被改动也不影响任何行为 |
| `status` | 模型 | 只允许改为 `complete` 或 `blocked`——模型回传循环的信箱,每轮结束后被读取 |
预算数字随每轮的 `[goal]` 块给出,不在文件里;系统侧终局(`budget_limited`、`aborted`)只存在于 `goal_finished` 事件与服务端状态——文件永远保持模型自己最后写下的样子,这正是中断目标想要的断点。读取是容错的:文件缺失、YAML 解析失败、协议外的 status 一律归一化为 `blocked`——控制通道坏了就停下循环,而不是无限空转。
## 循环
每一轮的 user 消息是一个 `[goal]` 协议块加纯文本正文——第一轮原样携带你的原始消息(含技能调用块等前缀);后续轮重新注入目标文本。Web App 把协议块折叠为普通用户气泡下方的「目标 · 第 N 轮」提示;Trace 中原样保留。协议块内嵌 `GOAL.yaml` 的内容(模型看到的就是它要编辑的那个文件,按创建时的同一份值组合)、自带一行当前预算数字,并附工作规则——声明完成前必须基于证据逐项核验、不许把目标缩水成更容易的子集、关键进展写入 `PLAN.md` 以跨越上下文压缩。Task 结束后系统读取 `status`:
- `complete` → 目标完成,循环停止。
- `blocked` → 循环停止;模型缺什么写在它最后一条回复里。注入规则要求**同一阻塞条件持续三个连续轮次**后才允许声明 `blocked`,临时性障碍不会终结目标。
- `active` → 预算允许则进入下一轮。
某一轮以中断结束(用户停止、LLM 故障)时整个目标随之结束、不再续推——磁盘上的状态保持 `active`,工作区与目标文件就是干净的断点。Web App 中常规停止按钮即中止整个循环;CLI 中是 Ctrl-C。被单 Task 轮次上限(`max_turns`)掐断的轮同理:模型没来得及写目标文件,循环以 `aborted` 结束,而不是永远重演同一次掐断。
## Token 预算
计数是增量制——**非缓存 input + output**(`request.total − cache_read`),对每一轮的每个请求累加,*包括 `run_subagent` 派生的子 Session*。`used` 从 0 开始。这个累加值是**花费的估算而非账单**:缓存读取并非免费,只是单价远低于非缓存 input,忽略它既不失真,也免去了依赖各模型价目表。
预算在轮与轮之间检查。耗尽时不会把模型拦腰斩断:系统注入最后一个收尾轮——总结进展、列出剩余工作、给出明确的下一步,并且不许因为钱花完了就标 `complete`——之后系统以 `budget_limited` 终局(`goal_finished` 事件;不写文件)。正因为只在轮间检查,进行中的一轮不会被截断:实际花费最多可超出预算一轮,外加收尾轮。未设预算时循环一直跑到 `complete` 或 `blocked`——边界是模型对两个终态的诚实,外加 100 轮的硬性兜底上限,防止一个从不写目标文件的模型无限循环。
## 服务端状态与事件
Web 服务端把每次目标运行记入 `goal_state` 表(objective、status、budget、used、rounds)——聊天页的目标 banner 加载时从最新一行恢复,实时进度通过会话 SSE 通道的 `goal_started` / `goal_round` / `goal_finished` 事件到达。系统侧终态(`aborted`、`budget_limited`)仅存在于表与流事件中;磁盘文件保持模型最后写下的内容以便续跑。删除 Session 会连同 scratchpad(包括 `GOAL.yaml`)一起清除其目标记录。
+1 -1
View File
@@ -28,7 +28,7 @@ export const DOCS_NAV: DocsSectionDef[] = [
"sessions-and-traces",
],
},
{ id: "guides", slugs: ["web-app", "self-improvement"] },
{ id: "guides", slugs: ["web-app", "goal-mode", "self-improvement"] },
{ id: "reference", slugs: ["cli", "server-api", "configuration"] },
];