Standing Goal Loop (/goal)¶
让 Agent 持续向一个用户目标推进,每轮结束自动判断是否完成,没完成就续跑——直到 judge 判 done、预算耗尽、或用户主动停下。CLI 用户用 /goal xxx,SDK 用户一行 await agent.run_goal(...)。
主成本闸是
token_budget(默认DEFAULT_TOKEN_BUDGET = 500_000,CLI 与 SDK 一致)。turn_budget默认None(不限轮数),仅在显式传入时生效。wall_clock_budget_sec仍为可选 SLA 闸。本特性已包含 P0 基础环 + P1 S/A 档(Runner 锚点、token/wall-clock 预算、
update_goal受限工具、goal.*事件)。设计文档:docs/learn_cc/goal.md(内部)。
SDK 用法(推荐)¶
一行起飞¶
from agentica import Agent, DeepSeekChat
agent = Agent(
session_id="my-task",
# 主模型 + 便宜的 auxiliary 模型:judge / 压缩 / 记忆抽取等次要工作都走 auxiliary。
# 不区分也能跑,但 judge 每轮都调一次,分开后能省 5-10x 成本。
model=DeepSeekChat(id="deepseek-v4-pro"),
auxiliary_model=DeepSeekChat(
id="deepseek-v4-flash",
max_completion_tokens=4096, # judge JSON 输出预算
),
)
# 最简:默认 token_budget=500_000,turn_budget=None
result = await agent.run_goal("compute 17+9+16 and state the integer answer")
print(result.status) # "complete" / "paused" / "budget_limited"
print(result.reason)
print(result.response_content) # == result.run_response.content or ""
agent.run_goal() 内部会:
- 懒建
SessionLog+GoalManager(绑到agent._session_log,复用 CLI 同一套持久化路径) mgr.set(objective, ...)写持久化状态- 把
TaskAnchor钉到 objective(SDK 路径 + Runner S1 自动生效) - 默认挂
GoalTool,让模型可以update_goal(status="complete"|"paused")短路 judge - 循环
agent.run() → mgr.evaluate_after_turn(token_delta=..., elapsed_sec=...) - 终止时返回
GoalRunResult(status, reason, run_response, goal, turns_used)
GoalRunResult¶
| 字段 | 类型 | 说明 |
|---|---|---|
status |
str |
"complete" / "paused" / "budget_limited" |
reason |
str |
人类可读的原因(judge verdict / 预算消息 / 工具理由) |
run_response |
Optional[RunResponse] |
最后一次 agent.run() 的完整响应,含 content、cost_tracker、messages、tool_calls |
goal |
GoalState |
终态快照:objective / turns_used / tokens_used / wall_clock_used_sec / subgoals / last_verdict |
turns_used |
int |
实际跑了多少轮 |
response_content (property) |
str |
run_response.content or "" 的便捷访问 |
预算(hard caps)¶
三个预算互相独立、任一触发即停(取最严的那个先停)。
| 参数 | 默认 | 不传时 | 含义 |
|---|---|---|---|
token_budget |
DEFAULT_TOKEN_BUDGET = 500_000 |
fallback 到 500_000(主成本闸) | 累计输入+输出 token 上限 |
turn_budget |
None |
不限(不计轮数) | LLM 循环总轮数上限(可选) |
wall_clock_budget_sec |
None |
不限(不计时) | agent wall-clock 秒数上限 |
无限预算:显式传
-1(CLI:/goal --tokens -1 ...;SDK:token_budget=-1)会存成None,关闭该闸。这与「不传参数」不同——不传token_budget仍回落默认500_000。turn/wall同样认-1。0会被拒绝(避免和「零额度」混淆)。想要更大但仍有限的预算就传正整数,例如
token_budget=2_000_000或/goal --tokens 2000000 ...。turn_budget只有显式传入正整数时才生效。
判定优先级(在 evaluate_after_turn 里固定为):
即:即便模型自己通过 update_goal 标了 complete,只要 token / wall-clock 已超 cap,最终 status 仍是 budget_limited。budget 是 hard cap,模型短路改不了。
预算耗尽时 status 是独立的 budget_limited(不是 paused),语义"用户必须决定加额度或接受部分结果"。用 mgr.resume() 或 /goal resume 可从 budget_limited / paused 两种状态恢复。
预算耗尽的收尾轮(wrap-up turn)¶
超预算不是当场砍断。任一 cap 触发时,循环会再给一轮,喂一条 [Standing goal budget reached] 开头的收尾 prompt,要求模型交接而不是继续干活:做完了什么(点名文件 / 命令 / 结果)、还剩什么(按下一步的顺序)、有什么坑或阻塞。prompt 里明确禁止 verify_completion——已经没有预算去处理一次失败的校验了。
这一轮跑完,下一次 evaluate_after_turn 才落到 budget_limited 终态。几个刻意的设计点:
- 只给一次。 收尾轮自己也烧 token,所以
GoalState.budget_wrapup_sent持久化标记,避免"每轮都超预算 → 每轮都发收尾"变成死循环。 - 收尾轮期间 status 仍是
active。 CLI hook 和run_goal()都以is_active()为闸;提前翻成budget_limited会让这一轮完全不计账(token 不扣、轮数不加)。 - 因此最终会略微超出 cap(多一轮)。这是诚实的代价,
Budget:那一行会如实显示,比如turns 6/5。 resume()会重置这个标记,所以加大额度后再次超预算能拿到新的收尾轮。
CLI /goal status 用一行 Budget: 汇总三个预算(未设上限的只显示已用量);状态栏在 goal 执行期间实时显示 goal 12.3K/500K:
Best Practices:怎么设预算¶
| 场景 | token_budget |
turn_budget |
wall_clock_budget_sec |
|---|---|---|---|
| 试玩 / 调试 | 50_000 |
5 |
不传 |
| 一次性短任务(算个数、写一句话) | 不传 (默认 500k) | 不传 | 不传 |
| 修个 bug | 50_000–100_000 |
不传 | 600 (10 分钟) |
| 实现完整功能 + 测试 | 不传 (默认 500k) | 不传 | 1800 (30 分钟) |
| 长 refactor / migration | 1_000_000+ |
不传 | 3600 (1 小时) |
| 严控成本,定额执行 | 三个都传 | 按轮数兜底 | 按 SLA 算 |
经验法则:
- 默认就有 token 闸——CLI
/goal xxx与 SDKrun_goal()未传时都是 500k - 需要更紧/更松就显式传
token_budget——例如/goal --tokens 80000 ...;不限则/goal --tokens -1 ... turn_budget可选——只在你想额外限制轮数时用--turns Nwall_clock_budget_sec主要给 SLA 用——例如"30 分钟内出个结果"
示例:
# 真实编码任务的常见组合
result = await agent.run_goal(
"在 examples/ 下加一个 mcp_client 示例并跑通",
token_budget=200_000,
wall_clock_budget_sec=1800,
)
Judge 鲁棒性(自动启用,无需配置)¶
每轮 judge 调用会自动:
- 看 tool call 名字——
agent.run_goal()会把本轮RunResponse.tool_calls的(tool_name, is_error)列表传给 judge,让它分清"啥也没干就嘴硬"和"跑了 5 个工具实际产出"。零额外 LLM 调用,只是名字。 - 强制 evidence rule for subgoals——当存在 subgoals 时,judge prompt 自动加一句"为每条验收条件找具体证据(文件片段 / 命令输出 / 结果值),不接受 'all requirements met' 这种泛泛之言"。来自 hermes 的实战经验。
- JSON 解析容错——支持
{"done": "yes"}、{"done": "TRUE"}、{"done": 1}这类弱 judge 模型的偏差输出,以及 markdown fence 包裹。 - Tool-stuck 自动 pause——连续 N(默认 3)轮"所有 tool call 都失败"时,自动 pause 状态为
tool-stuck,避免烧穿整个 turn_budget。任意一次 tool 成功就重置计数;"光思考不调 tool"的轮不重置计数。
Reasoning judge 的特别注意¶
如果你想用 reasoning 模型当 judge(DeepSeek-Reasoner、o-series、qwq、GLM-4 Reasoning 等),hidden CoT 会先烧掉一大块 output token,必须在构造时显式给足 max_completion_tokens(推荐 ≥ 4096),否则 JSON 还没输出完就被截断,会被 GoalManager 当成 parse failure,连续 3 次后 auto-pause 为 judge-broken:
agent = Agent(
model=DeepSeekChat(id="deepseek-v4-pro"),
auxiliary_model=DeepSeekChat(
id="deepseek-reasoner",
max_completion_tokens=4096, # judge 必备
),
)
非 reasoning 的小 chat 模型(deepseek-v4-flash、gpt-4o-mini 等)默认输出预算就够用,不用设。
Power-user 路径¶
需要更细粒度控制(例如自己写循环、自定义 logging、与 streaming 配合)时:
mgr = agent.get_goal_manager(default_token_budget=50_000)
agent.enable_goal_tool()
mgr.set("xxx", token_budget=1000) # override for this goal
while True:
resp = await agent.run(mgr.next_continuation_prompt())
ct = resp.cost_tracker
delta = (ct.total_input_tokens + ct.total_output_tokens) if ct else 0
# Recommended: pass tool-call names so the judge sees what work
# actually happened + the tool-stuck counter advances correctly.
tool_pairs = [(t.tool_name, bool(t.is_error)) for t in resp.tool_calls if t.tool_name]
decision = await mgr.evaluate_after_turn(
resp.content or "", token_delta=delta, tool_calls=tool_pairs or None,
)
if not decision.should_continue:
break
agent.get_goal_manager() 返回的 GoalManager 是持久化到 SessionLog 的,下次进程启动 / /resume 后能拾回状态。
CLI 用法¶
进入交互式 CLI 后:
/goal 实现 xxx 功能并跑通 pytest # 设置目标,自动塞入首轮
/goal status # 显示当前状态、预算条、subgoals
/goal pause # 暂停自动续跑
/goal resume # 恢复续跑(含 budget_limited 也可恢复)
/goal clear # 清空目标
/subgoal 必须补单测 # 给目标加验收条件,会渲染到 continuation prompt
/subgoal list # 列出 subgoals
/subgoal clear # 清空 subgoals
/subgoal remove 2 # 按编号删除
/steer 别动公开 API # 中途纠偏当前这一轮
循环跑起来之后怎么干预¶
| 想做的事 | 用什么 | 生效范围 |
|---|---|---|
| 立刻纠偏正在跑的这一轮 | /steer <指引> |
当前 run,在下一次推理前注入 |
| 加一条长期约束 / 验收条件 | /subgoal <文本> |
持久化到 GoalState,每轮 continuation prompt 都带上 |
| 插一个完整的新任务 | 直接打字(普通消息) | 排到队列,抢在续跑之前执行 |
| 停下来 | /goal pause 或 Ctrl+C |
停止自动续跑,/goal resume 恢复 |
/steer 只影响当前这一轮:它以 user 消息的形式进入对话历史,后续轮次靠上下文延续。要让约束在整个循环里都成立,用 /subgoal——它会渲染进每一轮的 continuation prompt。
/steer 不会丢词。agent 不在 run 中时(典型场景:一轮刚结束、goal 正在跑 judge 的那几秒),它会自动降级成"下一轮消息",并插在待执行的 continuation prompt 之前,这样纠偏不会被一整轮无关工作挡在后面。
CLI 行为:
- 用户真实输入永远抢占 goal 循环(在队列里发现非续跑消息就让位)
- Ctrl+C 取消 agent 时自动
pause(reason="user-interrupted"),不会偷偷重启 /resume <session_id>重进旧 session 时,goal 强制改为paused(reason="resume-safety"),等用户显式/goal resume- judge 连续 3 次 JSON 解析失败 →
paused(reason="judge-broken"),避免 silent 死循环
事件(用于 tracing / 观测)¶
from agentica.run_events import RunEventType
def on_goal(event_type: RunEventType, payload: dict) -> None:
print(event_type.value, payload)
await agent.run_goal("xxx", event_callback=on_goal)
会触发的事件:goal.set、goal.continuing、goal.completed、goal.paused。Payload 包含 session_id / objective / status / turns_used / turn_budget / tokens_used / token_budget / wall_clock_used_sec / wall_clock_budget_sec 等。
update_goal 模型工具¶
agent.run_goal() 默认会挂 GoalTool,模型可以通过它自己标记 complete / paused:
| 参数 | 取值 | 说明 |
|---|---|---|
status |
"complete" |
任务真正完成;evaluator 会跳过 judge,状态置为 complete |
status |
"paused" |
被卡住需要用户输入(缺凭据、需澄清等),停止自动续跑 |
reason |
str |
一句话理由,会显示给用户 |
安全约束:工具只能改 status 和 reason,不能修改 objective、不能 clear、不能调预算。这些仍归用户控制。
如果不想让模型有这个能力(让 external judge 唯一裁判):
持久化与 /resume¶
GoalState 以 type="goal" 条目写在 SessionLog(JSONL)末尾,不会污染模型对话历史(SessionLog._build_messages() 只回放 user/assistant/system/tool 四类)。
- 重启进程、
/resume <session_id>后,Agent.get_goal_manager()会自动load()最近一条 goal 条目 - 出于安全考虑,resume 时 active 状态会被强制改为
paused(reason="resume-safety")——避免"打开旧会话偷偷自动跑" Runner._run_impl()在 anchor 初始化时也会load_goal(),若有 active goal 则把TaskAnchor绑到 objective 上,所有 SDK 路径(gateway / ACP / 脚本)都受益
失败 / 暂停时的行为对照¶
| 触发条件 | 终态 | paused_reason |
恢复方式 |
|---|---|---|---|
| Judge 判 done | complete |
— | — |
模型调 update_goal(complete) |
complete |
— | — |
| 用户 Ctrl+C | paused |
user-interrupted |
/goal resume |
用户 /goal pause |
paused |
user |
/goal resume |
模型调 update_goal(paused) |
paused |
agent-tool |
/goal resume |
/resume <sid> 拾回 active goal |
paused |
resume-safety |
/goal resume |
| Judge 连续 3 次 JSON 解析失败 | paused |
judge-broken |
/goal resume(先排查 judge) |
| 连续 3 轮所有 tool call 都失败 | paused |
tool-stuck |
看最后几轮 tool 报错 → 修 → /goal resume |
turn_budget 超 |
budget_limited(先跑一轮收尾) |
budget |
/goal resume |
token_budget / wall_clock_budget_sec 超 |
budget_limited(先跑一轮收尾) |
budget |
/goal resume |
完整可运行示例¶
examples/cli/03_goal_loop_demo.py— 4 个 SDK Example:one-liner、budgets、event_callback、手动循环(依赖DEEPSEEK_API_KEY)