mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
Merge pull request #8185 from SivanCola/fix/goal-evidence-progress
fix(goal): bound Runs instead of no-progress pauses / 用有界 Run 替代 Goal 无进展误暂停
This commit is contained in:
52 files changed
+1537
-587
No files matched your search
@@ -8,6 +8,21 @@ branch.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Goal progress now uses a bounded, persisted novelty ledger instead of
|
||||
treating all read-only turns as stalled or all successful reads as progress.
|
||||
New read/search results advance research, exact tool/argument/result repeats
|
||||
do not, while cross-turn no-progress streaks are observational rather than a
|
||||
4/6/10 stop condition. Each Goal Run now defaults to 16 model rounds (unless
|
||||
the user explicitly configured `max_steps`), followed by one summary-only
|
||||
response and a resumable `goal_run_budget` pause. Repeating the same host
|
||||
failure three times or completing six successful rounds without new evidence
|
||||
similarly yields a resumable `goal_stuck` pause. The public `update_goal:
|
||||
continue` status is normalized to
|
||||
the FSM's internal running state, so it carries `next_action` without being
|
||||
mistaken for terminal progress. The 10/20/40 cross-Run turn budgets remain an
|
||||
independent backstop, and Goal status now persists real provider request
|
||||
counts alongside observational tokens.
|
||||
|
||||
- Goal is now the sole long-task runtime. Historical AutoResearch sidecars
|
||||
migrate transactionally into research-budget Goals. Invalid archives block
|
||||
fail closed and remain read-only, retaining the task id and compatibility mode
|
||||
|
||||
+3
-3
@@ -6831,16 +6831,15 @@ type Meta struct {
|
||||
Goal string `json:"goal,omitempty"`
|
||||
GoalStatus string `json:"goalStatus,omitempty"`
|
||||
GoalRuntime *GoalRuntimeView `json:"goalRuntime,omitempty"`
|
||||
// A nil pointer means the controller cannot provide an authoritative snapshot;
|
||||
// a non-nil pointer preserves an empty list as an explicit panel clear.
|
||||
// Nil means no authoritative snapshot; non-nil empty means clear the panel.
|
||||
CanonicalTodos *[]evidence.TodoItem `json:"canonicalTodos,omitempty"`
|
||||
}
|
||||
|
||||
// GoalRuntimeView is the desktop-facing Goal budget/runtime summary.
|
||||
type GoalRuntimeView struct {
|
||||
TurnsUsed int `json:"turnsUsed"`
|
||||
TurnsLimit int `json:"turnsLimit"`
|
||||
TokensUsed int `json:"tokensUsed"`
|
||||
RequestsUsed int `json:"requestsUsed,omitempty"`
|
||||
TokensLimit int `json:"tokensLimit"` // Deprecated: always 0; retained for bridge compatibility.
|
||||
NoProgressTurns int `json:"noProgressTurns"`
|
||||
NoProgressLimit int `json:"noProgressLimit"`
|
||||
@@ -6858,6 +6857,7 @@ func goalRuntimeViewFromController(ctrl control.SessionAPI) *GoalRuntimeView {
|
||||
TurnsUsed: rt.TurnsUsed,
|
||||
TurnsLimit: rt.TurnsLimit,
|
||||
TokensUsed: rt.TokensUsed,
|
||||
RequestsUsed: rt.RequestsUsed,
|
||||
TokensLimit: rt.TokensLimit,
|
||||
NoProgressTurns: rt.NoProgressTurns,
|
||||
NoProgressLimit: rt.NoProgressLimit,
|
||||
|
||||
@@ -79,7 +79,8 @@ for (const path of localeChunks) {
|
||||
const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
|
||||
.reduce((total, path) => total + statSync(path).size, 0);
|
||||
// Native Web Animations and frame-batched scrolling avoid an eager animation
|
||||
// runtime. The shared reasoning display state adds a small always-available
|
||||
// contract; keep its raw allowance tightly ratcheted while gzip stays flat.
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_203 * 1024);
|
||||
// runtime. Shared reasoning-display state and Goal request observability add
|
||||
// small always-available contracts; keep their raw allowance tightly ratcheted
|
||||
// while gzip stays flat.
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_205 * 1024);
|
||||
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
|
||||
@@ -4009,8 +4009,8 @@ export function Composer({
|
||||
turnsUsed: goalRuntime.turnsUsed,
|
||||
turnsLimit: goalRuntime.turnsLimit,
|
||||
tokensUsed: formatTokens(goalRuntime.tokensUsed),
|
||||
requestsUsed: goalRuntime.requestsUsed ?? 0,
|
||||
noProgressTurns: goalRuntime.noProgressTurns,
|
||||
noProgressLimit: goalRuntime.noProgressLimit,
|
||||
extensions: goalRuntime.budgetExtensions,
|
||||
})}
|
||||
</span>
|
||||
|
||||
@@ -856,22 +856,22 @@ export type ToolApprovalMode = "ask" | "auto" | "yolo";
|
||||
// "full" is the persisted compatibility value for the Balanced runtime profile.
|
||||
export type TokenMode = "full" | "economy" | "delivery";
|
||||
export type GoalStatus = "running" | "complete" | "blocked" | "stopped";
|
||||
|
||||
// GoalRuntime is the optional Goal budget/runtime summary the backend attaches
|
||||
// to Meta. Absent for old hosts or when no goal is active.
|
||||
export interface GoalRuntime {
|
||||
turnsUsed: number;
|
||||
turnsLimit: number;
|
||||
tokensUsed: number;
|
||||
requestsUsed?: number;
|
||||
/** @deprecated Goal has no hard token limit; retained as 0 for old hosts/clients. */
|
||||
tokensLimit: number;
|
||||
noProgressTurns: number;
|
||||
/** @deprecated No longer enforced; retained for old hosts/clients. */
|
||||
noProgressLimit: number;
|
||||
lastReason?: string;
|
||||
stopCause?: string;
|
||||
budgetExtensions: number;
|
||||
}
|
||||
|
||||
export function normalizeCollaborationMode(mode?: string, goal?: string, legacyMode?: Mode): CollaborationMode {
|
||||
if (mode === "plan" || mode === "goal" || mode === "normal") return mode;
|
||||
if (legacyMode && modeHasPlan(legacyMode)) return "plan";
|
||||
|
||||
@@ -712,7 +712,7 @@ export const en = {
|
||||
"composer.taskModeResumeGoal": "Resume goal",
|
||||
"composer.goalPaused": "Goal paused",
|
||||
"composer.goalBlocked": "Goal blocked — resume to continue",
|
||||
"composer.goalRuntimeLine": "turns {turnsUsed}/{turnsLimit} · tokens {tokensUsed} · no-progress {noProgressTurns}/{noProgressLimit} · extensions {extensions}",
|
||||
"composer.goalRuntimeLine": "turns {turnsUsed}/{turnsLimit} · tokens {tokensUsed} · requests {requestsUsed} · no-progress {noProgressTurns} (observational) · extensions {extensions}",
|
||||
"composer.accessMenuTitle": "Tool access ({shortcut} toggles YOLO)",
|
||||
"composer.accessAsk": "Ask first",
|
||||
"composer.accessAskTitle": "Ask before approval-gated tool calls; Ask is not read-only",
|
||||
|
||||
@@ -2413,7 +2413,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"composer.taskModeResumeGoal": "恢復目標",
|
||||
"composer.goalPaused": "目標已暫停",
|
||||
"composer.goalBlocked": "目標被阻擋 — 恢復後繼續",
|
||||
"composer.goalRuntimeLine": "輪次 {turnsUsed}/{turnsLimit} · token {tokensUsed} · 無進展 {noProgressTurns}/{noProgressLimit} · 追加 {extensions}",
|
||||
"composer.goalRuntimeLine": "輪次 {turnsUsed}/{turnsLimit} · token {tokensUsed} · 請求 {requestsUsed} · 無進展 {noProgressTurns}(僅觀測)· 追加 {extensions}",
|
||||
"composer.accessMenuTitle": "工具權限({shortcut} 切換 Yolo)",
|
||||
"composer.accessAsk": "需要批准",
|
||||
"composer.accessAskTitle": "需審批的工具呼叫會先詢問;詢問不是唯讀模式",
|
||||
|
||||
@@ -713,7 +713,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"composer.taskModeResumeGoal": "恢复目标",
|
||||
"composer.goalPaused": "目标已暂停",
|
||||
"composer.goalBlocked": "目标被阻塞 — 恢复后继续",
|
||||
"composer.goalRuntimeLine": "轮次 {turnsUsed}/{turnsLimit} · token {tokensUsed} · 无进展 {noProgressTurns}/{noProgressLimit} · 追加 {extensions}",
|
||||
"composer.goalRuntimeLine": "轮次 {turnsUsed}/{turnsLimit} · token {tokensUsed} · 请求 {requestsUsed} · 无进展 {noProgressTurns}(仅观测)· 追加 {extensions}",
|
||||
"composer.accessMenuTitle": "工具权限({shortcut} 切换 Yolo)",
|
||||
"composer.accessAsk": "需要批准",
|
||||
"composer.accessAskTitle": "需审批的工具调用会先询问;询问不是只读模式",
|
||||
|
||||
+1
-1
@@ -435,7 +435,7 @@ the displayed list matches the commands the TUI accepts.
|
||||
| `/verbose` | Toggle expanded reasoning display. |
|
||||
| `/sandbox` | Inspect sandbox status. |
|
||||
| `/goal [objective]` | Start a long-running goal, or inspect the current goal and its budget runtime. |
|
||||
| `/goal status` | Show the active goal plus the turn/token/no-progress budget summary and the last continuation/evaluator reason. |
|
||||
| `/goal status` | Show the active goal plus outer turns, observational tokens/requests/no-progress, extensions, and the last continuation/evaluator reason. |
|
||||
| `/goal pause` | Pause the running goal (keeps todos, Delivery checkpoint, and budget). |
|
||||
| `/goal resume` | Resume a paused or blocked goal (budget pauses add one more budget slice). |
|
||||
| `/goal clear` | End goal mode permanently. |
|
||||
|
||||
@@ -10,7 +10,7 @@ Reasonix 的 Goal 模式(`/goal`)将目标推进(Goal)、验收(Delive
|
||||
| 完成校验 | 默认 | `complete` 声明必须通过 Delivery readiness(todos、验证、review、签收、能力门禁)才会真正完成;不满足时用缺失项开启下一轮 |
|
||||
| 完成自述与对账 | `update_goal` 的 `completion` | `complete` 可附带自述:`verified` 命令逐条与本会话真实 receipt 对账,没跑过 / 跑失败 / 早于最后一次改动都记为 unbacked claim;`unverified` 与 `risks` 是宿主推断不出的声明,只增不减,永远不阻塞完成 |
|
||||
| 独立评审 | 无报告时 | 模型未调用 `update_goal` 时,宿主调用一次独立 bounded evaluator 判定;评审不可用/出错/不确定时安全暂停,绝不默认继续 |
|
||||
| 执行预算 | 默认 | **轮次与无进展熔断**:简单 10 轮、写入型 20 轮、研究型 40 轮;连续 4 轮无宿主可验证进展则暂停。累计 token 只做观测展示,**没有 token 硬上限**,也没有 provider 请求前预算准入 |
|
||||
| 执行预算 | 默认 | **真实执行轮次 + 结构化卡死检测**:简单/写入/研究型跨 Run 总预算为 10/20/40 轮;用户未显式配置 `max_steps` 时,每次 Goal Run 默认 16 个模型轮次并提供一次仅总结响应。相同宿主失败 3 次或成功轮连续 6 次没有新证据时结构化暂停。累计 token、provider 请求数和跨 turn 无进展只做观测,**没有 token/请求数硬上限** |
|
||||
| 暂停/恢复 | `/goal pause` / `/goal resume` | 暂停保留 Goal、todo、Delivery checkpoint 与运行历史;轮次型暂停恢复时追加一档同类别**轮数**(`budget_extensions` 统计轮次追加次数) |
|
||||
| 立即阻塞 | `blocked` 报告 | 单个 blocked 报告立即结束目标,不再重复三轮确认 |
|
||||
| 并行调度 | `parallel_tasks` 工具 | 并发派发多个子 agent,各自独立显示结果 |
|
||||
@@ -53,21 +53,21 @@ Reasonix 的 Goal 模式(`/goal`)将目标推进(Goal)、验收(Delive
|
||||
|
||||
普通 Delivery 的只读/咨询分类不变;上述「裸故障默认 write」只作用于 Goal 轮数类别。
|
||||
|
||||
**Token 只观测、不设限**:executor、planner、subagent、compaction、router、reviewer、evaluator 等计费用量仍累计到 `tokensUsed` 并在 UI/CLI 展示,但:
|
||||
**Token 与请求数只观测、不设限**:executor、planner、subagent、compaction、router、reviewer、evaluator 等计费用量累计到 `tokensUsed`,真实 HTTP 请求(含重试)累计到 `requestsUsed`,并在 UI/CLI 展示,但:
|
||||
|
||||
- 不存在 `tokensLimit` 硬上限(对外字段固定为 `0`);
|
||||
- 没有 provider 请求前的 token 预留/准入;
|
||||
- 累计 token 再大也不会单独暂停 Goal。
|
||||
|
||||
可停止 Goal 的条件:轮次耗尽、连续 4 轮无宿主可验证进展、evaluator 故障、显式 `blocked`、账号额度或人工暂停。
|
||||
可停止 Goal 的条件:10/20/40 外层轮次耗尽、单次 Run 的 16 轮默认边界、结构化卡死、evaluator 故障、显式 `blocked`、账号额度或人工暂停。跨 Goal turn 的 `noProgressTurns` 继续记录新颖证据变化,但不再直接停止 Goal。
|
||||
|
||||
达到轮次预算后目标安全暂停(持久化层表现为 `blocked` + `stop_cause`,旧客户端安全显示为 blocked,不会误恢复自动运行)。`/goal status` 显示完整运行摘要:
|
||||
|
||||
```
|
||||
runtime: turns 12/20, tokens 214000, no-progress 0/4, extensions 0
|
||||
runtime: turns 12/20, tokens 214000, requests 37, no-progress 6 (observational), extensions 0
|
||||
```
|
||||
|
||||
`/goal resume` 恢复目标:轮次型暂停追加一档同类别轮数(累计 token 与 `budget_extensions` 保留,no-progress 计数归零);手动暂停或 evaluator 故障暂停不自动追加额度,除非原轮次预算已耗尽。旧版本因 `budget_tokens` 暂停的 sidecar 在新版本加载时会自动改为 `running` 并立即持久化。
|
||||
`/goal resume` 恢复目标:只有 10/20/40 外层轮次型暂停才追加一档同类别轮数;`goal_run_budget` 与 `goal_stuck` 只开启一个新的 Run,不增加外层额度。累计 token、请求数与 `budget_extensions` 保留。旧版本因 `budget_tokens` 或 `no_progress` 暂停的 sidecar 在新版本加载时会自动改为 `running` 并立即持久化;旧 `noProgressLimit` 字段继续兼容读写但不再参与决策。
|
||||
|
||||
上下文压缩继续使用全局既有策略(约 50% 提示、60% 工具结果清理、80% compact、90% 强制 compact)。Goal 开启本身不额外触发 summarizer,也不改变工具 Schema 或稳定 prompt 前缀。
|
||||
|
||||
@@ -135,7 +135,7 @@ Prometheus 会逐个问澄清问题:
|
||||
2. 获取结构化 Delivery readiness;
|
||||
3. 读取本轮的 `update_goal` 报告;
|
||||
4. 没有报告时调用一次独立 evaluator(readiness 已明确缺失项时直接继续,不调用);
|
||||
5. 应用 readiness、轮次预算与 no-progress 门禁;
|
||||
5. 应用 readiness、外层轮次预算、单 Run 预算与结构化卡死边界;
|
||||
6. 由 Goal FSM 独占决定 complete、continue、blocked 或 pause。
|
||||
|
||||
`complete` 只有在 readiness 通过时才被接受;`blocked` 立即停止;evaluator 超时、报错、JSON 非法或返回 `uncertain` 一律安全暂停。
|
||||
@@ -153,7 +153,7 @@ Delivery 不再自行注入隐藏模型消息做 3/6 次 readiness 重试:普
|
||||
|
||||
### 进展签名
|
||||
|
||||
只有宿主可验证信息才能重置停滞计数:todo 状态变化、新的有效 mutation/verification/review/signoff receipt、Delivery checkpoint 变化、终态 `update_goal` 报告。任意工具调用、重复读取、仅改变措辞的回答或重复 continue 理由都不能伪造进展。
|
||||
只有宿主可验证且对当前 Goal **新颖**的信息才能重置停滞计数:新的读取/搜索结果、todo 状态变化、新的有效 mutation/verification/review/signoff receipt、Delivery checkpoint 变化、终态 `update_goal` 报告。读取和查询由规范化工具名、参数及宿主观测到的结果摘要标识;完全相同的重复调用、仅改变措辞的回答或重复 continue 理由都不能伪造进展。证据摘要以有界窗口持久化,不保存工具输出正文。
|
||||
|
||||
### Todo 状态流
|
||||
|
||||
@@ -165,7 +165,9 @@ advanceGoalAfterTurn → 读取 update_goal 报告 + readiness + 预算
|
||||
├─ complete + readiness 缺失 → 拦截并列出缺失项,继续循环
|
||||
├─ blocked → 立即阻塞
|
||||
├─ 无报告 → evaluator 判定一次(失败则安全暂停)
|
||||
└─ 轮次/无进展耗尽 → 安全暂停(blocked + stop_cause)
|
||||
├─ 外层轮次耗尽 → 安全暂停(blocked + budget_turns)
|
||||
├─ 单 Run 16 轮耗尽 → 总结后安全暂停(blocked + goal_run_budget)
|
||||
└─ 3 次相同失败 / 6 次零证据成功轮 → 总结后安全暂停(blocked + goal_stuck)
|
||||
```
|
||||
|
||||
### 并行调度架构
|
||||
@@ -183,7 +185,7 @@ parallel_tasks Execute()
|
||||
|
||||
## 相关代码
|
||||
|
||||
- `internal/control/goal.go` — Goal FSM、轮次预算、turn recorder、暂停/恢复、token 观测
|
||||
- `internal/control/goal.go` — Goal FSM、外层轮次预算、turn recorder、暂停/恢复、token/请求数观测
|
||||
- `internal/control/turn_orchestrator.go` — 每轮决策流程、evaluator 调用
|
||||
- `internal/control/input.go` — `/goal` 命令解析与任务合约注入
|
||||
- `internal/goaleval/` — 独立 bounded evaluator
|
||||
|
||||
+19
-11
@@ -1136,16 +1136,24 @@ changes collaboration mode implicitly; choose Goal in the composer or use
|
||||
`/goal` to start a long-running objective.
|
||||
|
||||
Goal runs under a per-class **turn** budget: simple goals get 10 turns, write
|
||||
goals 20 turns, and research goals 40 turns; four consecutive turns without
|
||||
host-verifiable progress pause the goal. Cumulative token usage is still tracked
|
||||
and shown for diagnostics, but there is **no token hard limit** and no
|
||||
pre-provider request admission. In Goal mode, a bare bug/crash/exception
|
||||
statement defaults to the write turn class unless the user asks only for
|
||||
analysis/explanation or forbids changes. A paused goal keeps its todos, Delivery
|
||||
goals 20 turns, and research goals 40 turns. This is a cross-Run continuation
|
||||
backstop. Each Goal Run also defaults to 16 model rounds when no explicit
|
||||
`max_steps` is configured, then gets one summary-only response before a
|
||||
resumable `goal_run_budget` pause. Progress is goal-scoped and novelty based:
|
||||
new read/search results, mutations, verification, todo/signoff changes, and
|
||||
reviews advance the goal; an exact tool/argument/result repeat does not.
|
||||
Cumulative token and real provider request usage is tracked and shown for
|
||||
diagnostics, but there is
|
||||
**no token hard limit** and no pre-provider request admission. In Goal mode, a
|
||||
bare bug/crash/exception statement defaults to the write turn class unless the
|
||||
user asks only for analysis/explanation or forbids changes. A paused goal keeps its todos, Delivery
|
||||
checkpoint, and runtime history — use `/goal resume` to continue (turn-budget
|
||||
pauses add one more slice of turns of the same class), or `/goal pause` to pause
|
||||
pauses add one more slice of turns of the same class; Run-budget and structural
|
||||
stuck pauses start a fresh Run without extending the outer budget), or `/goal pause` to pause
|
||||
a running goal manually. `/goal status` shows the full runtime summary (turns
|
||||
used/limit, tokens used, no-progress, extensions). At the end of every goal turn
|
||||
used/limit, tokens, requests, observational no-progress streak, extensions).
|
||||
Within one Run, three repeated identical host failures or six successful
|
||||
zero-evidence rounds produce a resumable `goal_stuck` pause. At the end of every goal turn
|
||||
the model reports its disposition through the structured `update_goal` tool
|
||||
(continue/complete/blocked); when no report arrives, an independent bounded
|
||||
evaluator judges the turn once, and any evaluator failure pauses the goal
|
||||
@@ -1161,9 +1169,9 @@ information only the user can provide.
|
||||
Research budgets are selected automatically for goals with strong long-horizon
|
||||
signals or several distinct phases. There is no separate research mode or
|
||||
runtime to configure. Goal state stays in the normal session sidecar, progress
|
||||
comes only from host receipts, canonical todos, `complete_step`, review and the
|
||||
Delivery checkpoint, and completion is decided by Delivery readiness plus the
|
||||
bounded Goal evaluator. Legacy `.reasonix/autoresearch/<task-id>/` archives are
|
||||
comes only from novel host receipts, canonical todos, `complete_step`, review
|
||||
and the Delivery checkpoint, and completion is decided by Delivery readiness
|
||||
plus the bounded Goal evaluator. Legacy `.reasonix/autoresearch/<task-id>/` archives are
|
||||
read-only: an explicit old path can be recovered as an ordinary Goal, but new
|
||||
runs never create or update those directories. Deprecated budget flags are
|
||||
accepted for compatibility but are hidden from help and completion.
|
||||
|
||||
+11
-4
@@ -891,11 +891,18 @@ Goal 是长期目标的统一运行机制。Reasonix 会持续推进,直到完
|
||||
普通聊天不会隐式改变协作模式;需要长目标时,请在输入框中明确选择 Goal,或使用 `/goal` 启动。
|
||||
|
||||
Goal 按类别运行在**轮次**预算内:简单目标 10 轮,写入型 20 轮,研究型 40 轮;
|
||||
连续 4 轮没有宿主可验证进展会暂停。累计 token 仍会统计并展示(便于诊断),但**没有
|
||||
这是跨 Run 的 continuation backstop。用户未显式配置 `max_steps` 时,每次 Goal Run 默认
|
||||
最多 16 个模型轮次,随后获得一次仅总结响应;若仍未完成则以 `goal_run_budget` 可恢复暂停。
|
||||
进展按 Goal 范围的新颖性计算:新的读取/搜索
|
||||
结果、mutation、verification、todo/签收变化和 review 会推进目标;完全相同的工具、参数与
|
||||
结果重复不会推进。单次 Run 内,相同宿主失败连续 3 次,或成功工具轮连续 6 次没有新证据,
|
||||
会以 `goal_stuck` 可恢复暂停。跨 Goal turn 的无进展数只做观测,不再按 4/6/10 强制暂停。
|
||||
累计 token 与真实 provider 请求数仍会统计并展示(便于诊断),但**没有
|
||||
token 硬上限**,也不会在 provider 请求前做 token 准入拦截。Goal 中只陈述 BUG/崩溃/异常
|
||||
且未要求分析或禁止修改时,默认按写入型轮数类别。暂停会保留 Goal、todo、Delivery
|
||||
checkpoint 与运行历史——用 `/goal resume` 继续(轮次型暂停会追加一档同类别轮数),
|
||||
`/goal pause` 可手动暂停运行中的目标,`/goal status` 显示完整的轮次/累计 token/无进展
|
||||
checkpoint 与运行历史——用 `/goal resume` 继续(外层轮次型暂停会追加一档同类别轮数,
|
||||
Run 预算/结构化卡死暂停只开启新 Run,不增加外层额度),`/goal pause` 可手动暂停运行中的目标,
|
||||
`/goal status` 显示完整的轮次/累计 token/请求数/观测性无进展
|
||||
运行摘要。每个目标 turn 结束时,模型通过结构化的 `update_goal` 工具报告
|
||||
continue/complete/blocked;没有报告时由独立的有界 evaluator 判定一次,任何 evaluator
|
||||
故障都会安全暂停目标而不是静默继续。
|
||||
@@ -906,7 +913,7 @@ Output format、Constraints 和 Pause policy。Goal 模式会把这些部分当
|
||||
|
||||
带有明显长周期信号或多个独立阶段的目标会自动获得研究型预算,不需要配置单独的研究模式或
|
||||
运行时。Goal 状态只保存在普通会话 sidecar;进展只来自宿主工具 receipt、canonical todo、
|
||||
`complete_step`、review 与 Delivery checkpoint,最终由 Delivery readiness 和有界 Goal
|
||||
`complete_step`、review 与 Delivery checkpoint 中的新证据,最终由 Delivery readiness 和有界 Goal
|
||||
evaluator 判定。旧 `.reasonix/autoresearch/<task-id>/` 目录保持只读:显式引用旧路径时可恢复为
|
||||
普通 Goal,但新版本不会创建或改写这些目录。旧预算 flags 仅为兼容继续接受,不再出现在帮助和补全中。
|
||||
|
||||
|
||||
+9
-2
@@ -518,12 +518,19 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
|
||||
constraints, and relevant verification expectations to be satisfied or
|
||||
explicitly reported as unverified.
|
||||
Goal automatically selects a simple (10), write (20), or research (40) turn
|
||||
budget from the objective. All classes use the same Goal FSM, host receipts,
|
||||
Delivery readiness, and bounded evaluator; there is no second research
|
||||
continuation backstop from the objective. A Goal Run defaults to 16 model
|
||||
rounds when the user did not explicitly configure `max_steps`, followed by
|
||||
one summary-only response and a resumable pause. Goal-scoped novelty accepts new read/search results and state changes
|
||||
but rejects exact tool/argument/result repeats. All classes use the same Goal
|
||||
FSM, host receipts, Delivery readiness, and bounded evaluator; there is no second research
|
||||
protocol or writable sidecar runtime. Legacy `.reasonix/autoresearch/...`
|
||||
archives remain read-only and explicit old paths recover as ordinary Goals.
|
||||
Outside goal mode, ordinary prompts never change collaboration mode; the user
|
||||
must choose Goal or use `/goal` explicitly.
|
||||
Cross-turn no-progress streaks are observational. Within one Run, three
|
||||
repeated host failures or six successful zero-evidence rounds trigger a
|
||||
resumable structural-stuck pause. Token and provider-request totals remain
|
||||
observational and are not request-admission limits.
|
||||
`/goal clear` removes the active goal. Switching into plan/normal mode clears
|
||||
the active goal in the desktop UI so the collaboration mode remains one of
|
||||
the three choices, while the underlying tool approval posture is preserved.
|
||||
|
||||
+1
-1
@@ -216,7 +216,7 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec
|
||||
- 安装 MCP server 即授权其全部工具,不再有 server、raw tool、writer 或 destructive 的第二套审批策略;项目 `reasonix.toml` 与 `.mcp.json` 声明同样默认可信,不需要额外启动确认,显式全局 `deny` 仍然优先。全局安装写入用户 `config.toml`,项目声明保留在原项目文件;同名时项目覆盖全局,项目内部 `reasonix.toml` 高于 `.mcp.json`。编辑写回当前生效来源,删除高优先级声明后露出下一层。`readOnlyHint` 与 `destructiveHint` 仅用于调度、Plan/严格只读边界及缓存到实时安全分类复核,不会新增逐调用审批。严格只读子智能体 registry 仍仅暴露已授权且 `readOnlyHint: true`、无 `destructiveHint` 的 MCP;双模型 Planner 通过固定 `use_capability` 代理(从不暴露直接 `mcp__*` schema)调用已授权、非 destructive 的 MCP,不再要求 `readOnlyHint`,destructive 工具留给 Executor。Balanced 双模型的 Executor 使用独立 frontend 复用同一稳定代理,因此 Planner 发现的 capability ID 可在 handoff 后直接执行,同时保持两侧 ledger/audit 隔离。分发前代理会再次复核当前 controller 的 enable、授权和完整运行时连接身份;共享 Host 中仅 server 同名不构成复用权限。
|
||||
- Plan 是协作流程,不等于全工具只读。普通 built-in 与 Bash 仍走 Ask/Auto/YOLO 和 Sandbox;独立双模型 Planner 允许已授权、非 destructive 的 MCP(即使没有 `readOnlyHint`),但在规划阶段持续阻止 destructive 与未授权目标;没有独立 Planner 的单模型 Plan 仍阻止 MCP writer/destructive。
|
||||
- Plan 只能由用户显式选择进入,与当前工具审批姿态相互独立;普通聊天不会自动切换到 Plan。Auto/YOLO 不会回答 `ask`,也不会替用户批准 `exit_plan_mode`,获批计划的短期自动执行窗口也不会自动批准后续计划或嵌套/间接 Bash。
|
||||
- 桌面端协作模式分为 `normal`、`plan` 和 `goal`。Goal 会持续推进目标,直到完成、阻塞、用户停止或达到轮次/无进展安全边界,并按目标自动选择简单(10)、写入(20)或研究(40)轮预算。三类预算共用同一个 Goal FSM、宿主 receipt、Delivery readiness 和有界 evaluator,不再存在第二套研究协议或可写 sidecar。普通聊天不会隐式切换协作模式;旧 `.reasonix/autoresearch/.../` 目录只读,显式旧路径可恢复为普通 Goal。
|
||||
- 桌面端协作模式分为 `normal`、`plan` 和 `goal`。Goal 会持续推进目标,直到完成、阻塞、用户停止或达到执行安全边界,并按目标自动选择简单(10)、写入(20)或研究(40)轮的跨 Run continuation backstop。用户未显式配置 `max_steps` 时,每次 Goal Run 默认 16 个模型轮次并提供一次仅总结响应;未完成时以 `goal_run_budget` 可恢复暂停。跨 turn 无进展只做观测;单次 Run 内相同宿主失败连续 3 次或成功工具轮连续 6 次没有新证据时,以 `goal_stuck` 暂停。Goal 范围的新颖证据允许新的读取/搜索结果推进任务,但拒绝完全相同的工具、参数和结果重复。累计 token 和真实 provider 请求数只做观测。三类预算共用同一个 Goal FSM、宿主 receipt、Delivery readiness 和有界 evaluator,不再存在第二套研究协议或可写 sidecar。普通聊天不会隐式切换协作模式;旧 `.reasonix/autoresearch/.../` 目录只读,显式旧路径可恢复为普通 Goal。
|
||||
|
||||
### 3.8 Slash command
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ type ReasonixGoalRuntime struct {
|
||||
TurnsUsed int `json:"turnsUsed"`
|
||||
TurnsLimit int `json:"turnsLimit"`
|
||||
TokensUsed int `json:"tokensUsed"`
|
||||
RequestsUsed int `json:"requestsUsed,omitempty"`
|
||||
TokensLimit int `json:"tokensLimit"` // Deprecated: always 0; retained for protocol compatibility.
|
||||
NoProgressTurns int `json:"noProgressTurns"`
|
||||
NoProgressLimit int `json:"noProgressLimit"`
|
||||
@@ -647,6 +648,7 @@ func (s *acpSession) statusSnapshot() ReasonixSessionStatus {
|
||||
TurnsUsed: rt.TurnsUsed,
|
||||
TurnsLimit: rt.TurnsLimit,
|
||||
TokensUsed: rt.TokensUsed,
|
||||
RequestsUsed: rt.RequestsUsed,
|
||||
TokensLimit: rt.TokensLimit,
|
||||
NoProgressTurns: rt.NoProgressTurns,
|
||||
NoProgressLimit: rt.NoProgressLimit,
|
||||
|
||||
+12
-47
@@ -1422,11 +1422,15 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
|
||||
runMaxSteps := a.maxSteps
|
||||
runMaxStepsKey := a.maxStepsKey
|
||||
runLimitHostOwned := false
|
||||
runPauseAfterFinal := false
|
||||
if limit, ok := runStepLimitFromContext(ctx); ok {
|
||||
runMaxSteps = limit.steps
|
||||
runLimitHostOwned = true
|
||||
if limit.key != "" {
|
||||
runMaxStepsKey = limit.key
|
||||
if !limit.defaultOnly || a.maxSteps <= 0 {
|
||||
runMaxSteps = limit.steps
|
||||
runLimitHostOwned = true
|
||||
runPauseAfterFinal = limit.pauseAfterFinal
|
||||
if limit.key != "" {
|
||||
runMaxStepsKey = limit.key
|
||||
}
|
||||
}
|
||||
}
|
||||
a.recoveryRunSeq.Add(1)
|
||||
@@ -1479,6 +1483,7 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
|
||||
state.runMaxSteps = runMaxSteps
|
||||
state.runMaxStepsKey = runMaxStepsKey
|
||||
state.runLimitHostOwned = runLimitHostOwned
|
||||
state.runPauseAfterFinal = runPauseAfterFinal
|
||||
state.workDurationMs = workDurationMs
|
||||
return a.runToolLoop(ctx, state)
|
||||
}
|
||||
@@ -1571,34 +1576,6 @@ func (a *Agent) observeMissingToolCallReasoning(calls []provider.ToolCall, reaso
|
||||
return true, true
|
||||
}
|
||||
|
||||
// maxStepsPause is the deliberate stop when a positive tool-call budget runs
|
||||
// out: the session already holds the completed work and the user is asked to
|
||||
// continue. It is a control-flow signal, not a provider failure. Coordinator
|
||||
// treats planner research budgets specially: ordinary plan-and-execute work
|
||||
// falls back to the executor, while explicit execution boundaries fail closed.
|
||||
type maxStepsPause struct {
|
||||
steps int
|
||||
key string
|
||||
}
|
||||
|
||||
func (e *maxStepsPause) Error() string {
|
||||
return fmt.Sprintf("paused after %d tool-call rounds (%s) — the work so far is saved; send another message to continue, or set %s higher or to 0 for no limit", e.steps, e.key, e.key)
|
||||
}
|
||||
|
||||
type todoStallPause struct {
|
||||
rounds int
|
||||
}
|
||||
|
||||
func (e *todoStallPause) Error() string {
|
||||
return fmt.Sprintf("paused after %d tool-call rounds without advancing the current todo — the work so far is saved; inspect the blocker or send another message to continue", e.rounds)
|
||||
}
|
||||
|
||||
func isToolLoopPause(err error) bool {
|
||||
var maxPause *maxStepsPause
|
||||
var stallPause *todoStallPause
|
||||
return errors.As(err, &maxPause) || errors.As(err, &stallPause)
|
||||
}
|
||||
|
||||
// ReadinessResult is the host-consumable outcome of the Delivery final-answer
|
||||
// readiness check. The Controller reads it after each goal turn; plain turns
|
||||
// receive the same outcome as a FinalReadinessError.
|
||||
@@ -1631,19 +1608,6 @@ func (a *Agent) ReadinessResult() ReadinessResult {
|
||||
}
|
||||
}
|
||||
|
||||
// HostProgressSignature returns a compact signature of host-observable progress
|
||||
// across the current delivery scope: successful writes, commands, todo writes,
|
||||
// signoffs, and reviews. Identical signatures across consecutive goal turns
|
||||
// mean no host-verifiable progress was made — reads, reworded answers, and
|
||||
// repeated continue reasons never reset the stall counter.
|
||||
func (a *Agent) HostProgressSignature() string {
|
||||
if a == nil || a.evidence == nil {
|
||||
return ""
|
||||
}
|
||||
s := a.evidence.ReceiptProgressSummary()
|
||||
return fmt.Sprintf("w=%d;c=%d;t=%d;s=%d;r=%d", s.Writes, s.Commands, s.Todos, s.Signoffs, s.Reviews)
|
||||
}
|
||||
|
||||
type finalReadinessCheck struct {
|
||||
applies bool
|
||||
reason string
|
||||
@@ -2829,7 +2793,7 @@ const loopGuardBlockErrMsg = "blocked by loop guard"
|
||||
// blocker (see loopGuardAllowsFinal). The hard maxSteps guard remains the
|
||||
// ultimate backstop; this just keeps the loop from burning that whole budget
|
||||
// bouncing off the same host refusals.
|
||||
func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutcome, results []string, receiptMark int) {
|
||||
func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutcome, results []string, receiptMark int) string {
|
||||
allBlocked := len(outcomes) > 0
|
||||
for _, outcome := range outcomes {
|
||||
if !outcome.blocked {
|
||||
@@ -2861,7 +2825,7 @@ func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutc
|
||||
stormHit := ok && a.stormCount >= stormBreakThreshold
|
||||
streakHit := allBlocked && a.blockedTurnStreak >= stormBreakThreshold
|
||||
if !stormHit && !streakHit {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
|
||||
const blockedAdvice = "Change approach: do not keep retrying a blocked tool by changing the tool, command, or arguments. Respect the permission, plan-mode, hook, or loop-guard blocker; use an already-allowed tool, ask the user for the specific approval or choice if appropriate, or explain the blocker in your final answer."
|
||||
@@ -2903,6 +2867,7 @@ func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutc
|
||||
results[0] = outcomes[0].output + "\n\n" + guard
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeLoopGuard, Text: loopGuardNoticeText(), Detail: detail})
|
||||
a.armLoopGuardPass(receiptMark)
|
||||
return detail
|
||||
}
|
||||
|
||||
func loopGuardNoticeText() string {
|
||||
|
||||
@@ -18,6 +18,10 @@ func PauseClass(err error) string {
|
||||
if errors.As(err, &stall) {
|
||||
return "todo_stall"
|
||||
}
|
||||
var stuck *goalStuckPause
|
||||
if errors.As(err, &stuck) {
|
||||
return "goal_stuck"
|
||||
}
|
||||
var readiness *FinalReadinessError
|
||||
if errors.As(err, &readiness) {
|
||||
return "final_readiness"
|
||||
@@ -29,6 +33,34 @@ func PauseClass(err error) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// RunPauseInfo is the stable host-facing description of a deliberate Run
|
||||
// boundary. It keeps unexported control-flow error types private while allowing
|
||||
// Controller to distinguish a host default from an explicit user max_steps.
|
||||
type RunPauseInfo struct {
|
||||
Kind string
|
||||
Limit int
|
||||
Key string
|
||||
HostOwned bool
|
||||
Reason string
|
||||
}
|
||||
|
||||
// InspectRunPause unwraps a deliberate max-round or Goal-stuck pause.
|
||||
func InspectRunPause(err error) (RunPauseInfo, bool) {
|
||||
var maxSteps *maxStepsPause
|
||||
if errors.As(err, &maxSteps) {
|
||||
return RunPauseInfo{Kind: "max_steps", Limit: maxSteps.steps, Key: maxSteps.key, HostOwned: maxSteps.hostOwned}, true
|
||||
}
|
||||
var stuck *goalStuckPause
|
||||
if errors.As(err, &stuck) {
|
||||
return RunPauseInfo{Kind: "goal_stuck", Limit: stuck.limit, Key: stuck.key, HostOwned: true, Reason: stuck.reason}, true
|
||||
}
|
||||
var stall *todoStallPause
|
||||
if errors.As(err, &stall) {
|
||||
return RunPauseInfo{Kind: "todo_stall", Limit: stall.rounds, Key: "todo progress", HostOwned: true, Reason: "the current todo made no host-observed progress"}, true
|
||||
}
|
||||
return RunPauseInfo{}, false
|
||||
}
|
||||
|
||||
// FinalReadinessError reports that the model exhausted its recovery attempts
|
||||
// before satisfying the host-observed delivery checks.
|
||||
type FinalReadinessError struct {
|
||||
|
||||
@@ -20,6 +20,7 @@ type batchExecution struct {
|
||||
executions []*tool.ShellExecution
|
||||
recoveryStopTurn bool
|
||||
recoveryStopReason string
|
||||
goalStuck goalStuckSignal
|
||||
}
|
||||
|
||||
// executeBatch dispatches one model turn's tool calls. ToolDispatch events are
|
||||
@@ -333,9 +334,7 @@ func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) bat
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: o.truncMsg})
|
||||
}
|
||||
}
|
||||
if !cancelled {
|
||||
a.applyBatchGuards(calls, outcomes, results, receiptMark)
|
||||
}
|
||||
goalStuck := a.applyBatchGuards(ctx, cancelled, calls, outcomes, results, receiptMark)
|
||||
images := make([][]string, len(calls))
|
||||
executions := make([]*tool.ShellExecution, len(calls))
|
||||
for i := range outcomes {
|
||||
@@ -354,6 +353,7 @@ func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) bat
|
||||
executions: executions,
|
||||
recoveryStopTurn: recoveryBatchStop,
|
||||
recoveryStopReason: recoveryStopReason,
|
||||
goalStuck: goalStuck,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent/testutil"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func TestDefaultRunStepLimitYieldsAfterSummary(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "read_file", readOnly: true})
|
||||
prov := testutil.NewMock("m",
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "r1", Name: "read_file", Arguments: `{"path":"a"}`}}},
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "r2", Name: "read_file", Arguments: `{"path":"b"}`}}},
|
||||
testutil.Turn{Text: "Saved progress; more work remains."},
|
||||
)
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
err := a.Run(WithDefaultRunStepLimit(context.Background(), 2, "goal model rounds"), "work")
|
||||
info, ok := InspectRunPause(err)
|
||||
if !ok || info.Kind != "max_steps" || !info.HostOwned || info.Limit != 2 || info.Key != "goal model rounds" {
|
||||
t.Fatalf("pause = %+v ok=%v err=%v", info, ok, err)
|
||||
}
|
||||
if prov.CallCount() != 3 {
|
||||
t.Fatalf("provider calls = %d, want two work rounds plus one summary", prov.CallCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitMaxStepsOverridesGoalDefault(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "read_file", readOnly: true})
|
||||
prov := testutil.NewMock("m",
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "r1", Name: "read_file", Arguments: `{"path":"a"}`}}},
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "r2", Name: "read_file", Arguments: `{"path":"b"}`}}},
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "r3", Name: "read_file", Arguments: `{"path":"c"}`}}},
|
||||
testutil.Turn{Text: "Done."},
|
||||
)
|
||||
a := New(prov, reg, NewSession(""), Options{MaxSteps: 3}, event.Discard)
|
||||
if err := a.Run(WithDefaultRunStepLimit(context.Background(), 2, "goal model rounds"), "work"); err != nil {
|
||||
t.Fatalf("explicit MaxSteps should own the run: %v", err)
|
||||
}
|
||||
if prov.CallCount() != 4 {
|
||||
t.Fatalf("provider calls = %d, want explicit three rounds plus summary", prov.CallCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalSameFailurePausesAfterStructuralThreshold(t *testing.T) {
|
||||
turns := []testutil.Turn{
|
||||
{ToolCalls: []provider.ToolCall{{ID: "x1", Name: "missing_tool", Arguments: `{}`}}},
|
||||
{ToolCalls: []provider.ToolCall{{ID: "x2", Name: "missing_tool", Arguments: `{}`}}},
|
||||
{ToolCalls: []provider.ToolCall{{ID: "x3", Name: "missing_tool", Arguments: `{}`}}},
|
||||
{Text: "The same host failure repeated; a different approach is required."},
|
||||
}
|
||||
prov := testutil.NewMock("m", turns...)
|
||||
a := New(prov, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
|
||||
ctx := WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{ID: "goal-1", TaskText: "finish"})
|
||||
err := a.Run(ctx, "work")
|
||||
info, ok := InspectRunPause(err)
|
||||
if !ok || info.Kind != "goal_stuck" || info.Limit != stormBreakThreshold || info.Key != "goal repeated host outcome" {
|
||||
t.Fatalf("pause = %+v ok=%v err=%v", info, ok, err)
|
||||
}
|
||||
if prov.CallCount() != stormBreakThreshold+1 {
|
||||
t.Fatalf("provider calls = %d, want threshold plus one summary", prov.CallCount())
|
||||
}
|
||||
|
||||
ordinary := testutil.NewMock("m", turns...)
|
||||
plain := New(ordinary, tool.NewRegistry(), NewSession(""), Options{}, event.Discard)
|
||||
if err := plain.Run(context.Background(), "work"); err != nil {
|
||||
t.Fatalf("ordinary mode keeps the structural guard advisory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalZeroEvidencePausesAfterSixRepeatedSuccesses(t *testing.T) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "read_file", readOnly: true})
|
||||
turns := make([]testutil.Turn, 0, progressStopStreak+2)
|
||||
for i := range progressStopStreak + 1 {
|
||||
turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{
|
||||
ID: "same-" + string(rune('a'+i)), Name: "read_file", Arguments: `{"path":"same"}`,
|
||||
}}})
|
||||
}
|
||||
turns = append(turns, testutil.Turn{Text: "The repeated read produced no new evidence."})
|
||||
prov := testutil.NewMock("m", turns...)
|
||||
a := New(prov, reg, NewSession(""), Options{}, event.Discard)
|
||||
ctx := WithDeliveryExecutionScope(context.Background(), DeliveryExecutionScope{ID: "goal-1", TaskText: "research"})
|
||||
err := a.Run(ctx, "work")
|
||||
info, ok := InspectRunPause(err)
|
||||
if !ok || info.Kind != "goal_stuck" || info.Limit != progressStopStreak || info.Key != "goal zero-evidence rounds" {
|
||||
t.Fatalf("pause = %+v ok=%v err=%v", info, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalDefaultBudgetFlowsToChild(t *testing.T) {
|
||||
task := &TaskTool{}
|
||||
ctx := WithDefaultRunStepLimit(context.Background(), 16, "goal model rounds")
|
||||
if got := task.childMaxStepsForContext(ctx, 0); got != 8 {
|
||||
t.Fatalf("child steps = %d, want 8", got)
|
||||
}
|
||||
if got := task.childMaxStepsForContext(ctx, 3); got != 3 {
|
||||
t.Fatalf("explicit child steps = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalDefaultBudgetDoesNotChangeNormalRequestPrefix(t *testing.T) {
|
||||
plainProvider := testutil.NewMock("m", testutil.Turn{Text: "done"})
|
||||
boundedProvider := testutil.NewMock("m", testutil.Turn{Text: "done"})
|
||||
plain := New(plainProvider, tool.NewRegistry(), NewSession("stable system"), Options{}, event.Discard)
|
||||
bounded := New(boundedProvider, tool.NewRegistry(), NewSession("stable system"), Options{}, event.Discard)
|
||||
if err := plain.Run(context.Background(), "same input"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bounded.Run(WithDefaultRunStepLimit(context.Background(), 16, "goal model rounds"), "same input"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plainReqs, boundedReqs := plainProvider.Requests(), boundedProvider.Requests()
|
||||
if len(plainReqs) != 1 || len(boundedReqs) != 1 {
|
||||
t.Fatalf("request counts = %d/%d", len(plainReqs), len(boundedReqs))
|
||||
}
|
||||
if !reflect.DeepEqual(plainReqs[0], boundedReqs[0]) {
|
||||
t.Fatalf("host-only default budget changed the provider request\nplain=%+v\nbounded=%+v", plainReqs[0], boundedReqs[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// maxStepsPause is a resumable stop after a positive model-round budget.
|
||||
type maxStepsPause struct {
|
||||
steps int
|
||||
key string
|
||||
hostOwned bool
|
||||
}
|
||||
|
||||
func (e *maxStepsPause) Error() string {
|
||||
return fmt.Sprintf("paused after %d tool-call rounds (%s) — the work so far is saved; send another message to continue, or set %s higher or to 0 for no limit", e.steps, e.key, e.key)
|
||||
}
|
||||
|
||||
type goalStuckPause struct {
|
||||
limit int
|
||||
key string
|
||||
reason string
|
||||
}
|
||||
|
||||
func (e *goalStuckPause) Error() string {
|
||||
if e == nil || strings.TrimSpace(e.reason) == "" {
|
||||
return "goal paused after a structural no-progress loop; completed work is saved"
|
||||
}
|
||||
return "goal paused after a structural no-progress loop: " + e.reason
|
||||
}
|
||||
|
||||
type todoStallPause struct {
|
||||
rounds int
|
||||
}
|
||||
|
||||
func (e *todoStallPause) Error() string {
|
||||
return fmt.Sprintf("paused after %d tool-call rounds without advancing the current todo — the work so far is saved; inspect the blocker or send another message to continue", e.rounds)
|
||||
}
|
||||
|
||||
func isToolLoopPause(err error) bool {
|
||||
var maxPause *maxStepsPause
|
||||
var stallPause *todoStallPause
|
||||
var stuckPause *goalStuckPause
|
||||
return errors.As(err, &maxPause) || errors.As(err, &stallPause) || errors.As(err, &stuckPause)
|
||||
}
|
||||
|
||||
// HostProgressSignatures exposes successful evidence identities to the Goal FSM.
|
||||
func (a *Agent) HostProgressSignatures() []string {
|
||||
if a == nil || a.evidence == nil {
|
||||
return nil
|
||||
}
|
||||
return a.evidence.SuccessfulProgressSignaturesSince(0)
|
||||
}
|
||||
|
||||
func (a *Agent) resetStructuralRunGuards() {
|
||||
a.stormSig, a.stormCount, a.blockedTurnStreak = "", 0, 0
|
||||
a.progress.reset()
|
||||
}
|
||||
|
||||
func (a *Agent) prepareRepeatFailureScope(scoped bool, scopeID string) {
|
||||
if !scoped || a.repeatFailureScope != scopeID {
|
||||
a.repeatFailureCounts = nil
|
||||
} else {
|
||||
for sig, failure := range a.repeatFailureCounts {
|
||||
if !failure.stateRecheck {
|
||||
delete(a.repeatFailureCounts, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
if scoped {
|
||||
a.repeatFailureScope = scopeID
|
||||
} else {
|
||||
a.repeatFailureScope = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) stopUnexecutedBoundaryCalls(state *runLoopState, calls []provider.ToolCall, usage *provider.Usage) (error, bool) {
|
||||
switch {
|
||||
case state.graceRound:
|
||||
a.pairUnexecutedGraceCalls(calls, "blocked: the tool-call round budget is exhausted; no more tools will run in this turn")
|
||||
return &maxStepsPause{steps: state.runMaxSteps, key: state.runMaxStepsKey, hostOwned: state.runLimitHostOwned}, true
|
||||
case state.goalStuckGraceRound:
|
||||
a.pairUnexecutedGraceCalls(calls, "blocked: Goal structural progress guard paused this run; no more tools will run until the user resumes")
|
||||
return &goalStuckPause{limit: state.goalStuckLimit, key: state.goalStuckKey, reason: state.goalStuckReason}, true
|
||||
case state.recoveryGraceRound:
|
||||
if ctrl := a.recoveryEpisodeControl(); ctrl != nil {
|
||||
_, _ = ctrl.ConsumeFinalization(a.recoveryTaskID)
|
||||
}
|
||||
a.pairUnexecutedGraceCalls(calls, "blocked: Auto recovery already paused this turn. Do not call tools; the user will continue in the next message.")
|
||||
a.contextManager().ObserveUsage(usage)
|
||||
return &RecoveryPauseError{Message: "Automatic retries paused. Reasonix stopped repeated attempts and kept completed work. Send \"continue\" to start a fresh attempt, or add instructions to change direction."}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) trackTodoProgress(state *runLoopState, receiptMark int) error {
|
||||
if a.planMode.Load() {
|
||||
return nil
|
||||
}
|
||||
nextProgress, nextTracking := a.canonicalTodoProgress()
|
||||
hostProgress := false
|
||||
if a.evidence != nil {
|
||||
for _, sig := range a.evidence.SuccessfulProgressSignaturesSince(receiptMark) {
|
||||
if _, seen := state.seenTodoProgress[sig]; !seen {
|
||||
hostProgress = true
|
||||
state.seenTodoProgress[sig] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !nextTracking, !state.trackingTodoProgress || nextProgress > state.todoProgress || hostProgress:
|
||||
state.todoStallRounds = 0
|
||||
default:
|
||||
state.todoStallRounds++
|
||||
}
|
||||
state.todoProgress, state.trackingTodoProgress = nextProgress, nextTracking
|
||||
if state.todoStallRounds == todoProgressNudgeRounds {
|
||||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(todoProgressNudgeMessage(state.todoStallRounds))})
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeLoopGuard,
|
||||
Text: loopGuardNoticeText(), Detail: fmt.Sprintf("the current todo has no new completion, unique read, command, or mutation for %d consecutive tool-call rounds; asking the assistant to reassess", state.todoStallRounds)})
|
||||
}
|
||||
if state.todoStallRounds < maxTodoStallRounds {
|
||||
return nil
|
||||
}
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeLoopGuard,
|
||||
Text: "Task progress stalled; pausing before more tools are called.", Detail: fmt.Sprintf("the current todo has no new completion, unique read, command, or mutation for %d consecutive tool-call rounds after a host reassessment; work is saved and can be resumed", state.todoStallRounds)})
|
||||
return &todoStallPause{rounds: state.todoStallRounds}
|
||||
}
|
||||
|
||||
func (a *Agent) armGoalStuckFinalization(state *runLoopState, stuck goalStuckSignal) bool {
|
||||
if stuck.reason == "" || state.goalStuckGraceRound {
|
||||
return false
|
||||
}
|
||||
state.goalStuckGraceRound = true
|
||||
state.goalStuckLimit, state.goalStuckKey, state.goalStuckReason = stuck.limit, stuck.key, stuck.reason
|
||||
nudge := "The Goal is caught in a host-detected no-progress loop. Do not call any more tools. Summarize what was completed, what remains unresolved, and what should change before the user resumes."
|
||||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge)})
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Code: event.NoticeCodeLoopGuard,
|
||||
Text: "Goal progress is structurally stuck; pausing after one summary response.", Detail: stuck.reason})
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//go:build live
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/provider/openai"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// TestLiveGoalBoundaryMatrix runs the three acceptance scenarios from the Goal
|
||||
// boundary design against real OpenAI-compatible providers. Credentials are
|
||||
// accepted only through the process environment; response text and keys are
|
||||
// never logged or persisted. Deterministic unit tests cover exact internals.
|
||||
func TestLiveGoalBoundaryMatrix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, keyEnv, baseURL, model string
|
||||
extra map[string]any
|
||||
}{
|
||||
{name: "deepseek", keyEnv: "DEEPSEEK_API_KEY", baseURL: "https://api.deepseek.com", model: "deepseek-v4-flash", extra: map[string]any{"reasoning_protocol": "deepseek", "thinking": "enabled", "effort": "low"}},
|
||||
{name: "longcat", keyEnv: "LONGCAT_API_KEY", baseURL: "https://api.longcat.chat/openai/v1", model: "LongCat-2.0", extra: map[string]any{"thinking": "enabled", "effort": "enabled"}},
|
||||
{name: "zhipu-coding-plan", keyEnv: "GLM_PLAN_API_KEY", baseURL: "https://open.bigmodel.cn/api/coding/paas/v4", model: "glm-5.2", extra: map[string]any{"reasoning_protocol": "glm", "effort": "disabled"}},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
key := os.Getenv(tc.keyEnv)
|
||||
if key == "" {
|
||||
t.Skip(tc.keyEnv + " not set")
|
||||
}
|
||||
prov, err := openai.New(provider.Config{Name: tc.name, BaseURL: tc.baseURL, Model: tc.model, APIKey: key, Extra: tc.extra})
|
||||
if err != nil {
|
||||
t.Fatalf("create provider: %v", err)
|
||||
}
|
||||
if closer, ok := prov.(interface{ CloseIdleConnections() }); ok {
|
||||
t.Cleanup(closer.CloseIdleConnections)
|
||||
}
|
||||
|
||||
t.Run("normal-completion", func(t *testing.T) {
|
||||
metrics := &liveGoalMetrics{}
|
||||
a := newLiveGoalAgent(prov, tool.NewRegistry(), metrics)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
if err := a.Run(WithDefaultRunStepLimit(ctx, 16, "goal model rounds"), "Return one short final sentence now without calling tools."); err != nil {
|
||||
t.Fatalf("normal completion: %v", err)
|
||||
}
|
||||
if metrics.requests.Load() < 1 {
|
||||
t.Fatal("normal completion emitted no provider request usage")
|
||||
}
|
||||
logLiveGoalMetrics(t, tc.model, "normal-completion", 1, metrics, "complete", time.Since(started))
|
||||
})
|
||||
|
||||
t.Run("deterministic-stuck", func(t *testing.T) {
|
||||
metrics := &liveGoalMetrics{}
|
||||
var executions atomic.Int32
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(liveGoalFailureTool{executions: &executions})
|
||||
a := newLiveGoalAgent(prov, reg, metrics)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
ctx = WithDeliveryExecutionScope(ctx, DeliveryExecutionScope{ID: "live-goal-stuck", TaskText: "validate deterministic Goal stuck detection"})
|
||||
started := time.Now()
|
||||
err := a.Run(WithDefaultRunStepLimit(ctx, 16, "goal model rounds"), "This is a deterministic host-failure conformance probe requiring three failed tool rounds. Call live_goal_failure exactly once now. After each failure, call it exactly once again in the next assistant response. A prose response before the third failure is invalid. Do not switch tools, change arguments, or batch calls; only summarize when the host explicitly requests finalization.")
|
||||
info, ok := InspectRunPause(err)
|
||||
if !ok || info.Kind != "goal_stuck" || !info.HostOwned || info.Limit != 3 {
|
||||
t.Fatalf("stuck result = %+v ok=%v err=%v executions=%d requests=%d", info, ok, err, executions.Load(), metrics.requests.Load())
|
||||
}
|
||||
if executions.Load() != 3 {
|
||||
t.Fatalf("failure executions = %d, want 3", executions.Load())
|
||||
}
|
||||
logLiveGoalMetrics(t, tc.model, "deterministic-stuck", 3, metrics, "goal_stuck", time.Since(started))
|
||||
})
|
||||
|
||||
t.Run("sixteen-round-budget-and-resume", func(t *testing.T) {
|
||||
metrics := &liveGoalMetrics{}
|
||||
var executions atomic.Int32
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(liveGoalMarkerTool{executions: &executions})
|
||||
a := newLiveGoalAgent(prov, reg, metrics)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
|
||||
defer cancel()
|
||||
ctx = WithDeliveryExecutionScope(ctx, DeliveryExecutionScope{ID: "live-goal-budget", TaskText: "validate the 16-round Goal Run boundary"})
|
||||
started := time.Now()
|
||||
err := a.Run(WithDefaultRunStepLimit(ctx, 16, "goal model rounds"), "This is a 16-round tool conformance probe. Call live_goal_marker exactly once with index 1 now. After every accepted result, call it exactly once with the next integer index in your next assistant response. Never batch calls and never answer with prose; after index 16 the host will request a summary.")
|
||||
info, ok := InspectRunPause(err)
|
||||
if !ok || info.Kind != "max_steps" || !info.HostOwned || info.Limit != 16 {
|
||||
t.Fatalf("budget result = %+v ok=%v err=%v executions=%d requests=%d", info, ok, err, executions.Load(), metrics.requests.Load())
|
||||
}
|
||||
if executions.Load() < 16 {
|
||||
t.Fatalf("tool executions = %d, want at least 16 bounded rounds", executions.Load())
|
||||
}
|
||||
if err := a.Run(WithDefaultRunStepLimit(ctx, 16, "goal model rounds"), "The 16-round boundary probe is complete. Return one short final sentence now without calling tools."); err != nil {
|
||||
t.Fatalf("fresh continuation: %v", err)
|
||||
}
|
||||
if metrics.requests.Load() < 18 {
|
||||
t.Fatalf("provider requests = %d, want at least 16 work rounds, summary, and continuation", metrics.requests.Load())
|
||||
}
|
||||
logLiveGoalMetrics(t, tc.model, "sixteen-round-budget-and-resume", 16, metrics, "goal_run_budget->complete", time.Since(started))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type liveGoalMetrics struct {
|
||||
requests atomic.Int32
|
||||
tokens atomic.Int64
|
||||
}
|
||||
|
||||
func newLiveGoalAgent(prov provider.Provider, reg *tool.Registry, metrics *liveGoalMetrics) *Agent {
|
||||
sink := event.FuncSink(func(e event.Event) {
|
||||
if e.Kind != event.Usage || e.Usage == nil {
|
||||
return
|
||||
}
|
||||
if e.Usage.RequestCount > 0 {
|
||||
metrics.requests.Add(int32(e.Usage.RequestCount))
|
||||
}
|
||||
tokens := e.Usage.TotalTokens
|
||||
if tokens <= 0 {
|
||||
tokens = e.Usage.PromptTokens + e.Usage.CompletionTokens
|
||||
}
|
||||
if tokens > 0 {
|
||||
metrics.tokens.Add(int64(tokens))
|
||||
}
|
||||
})
|
||||
return New(prov, reg, NewSession("You are a tool-call conformance test agent. Tool calls requested by the user are mandatory. Never substitute prose for a requested tool call. Call at most one tool per assistant response. Follow host finalization instructions exactly."), Options{Temperature: 0}, sink)
|
||||
}
|
||||
|
||||
func logLiveGoalMetrics(t *testing.T, model, scenario string, rounds int, metrics *liveGoalMetrics, exit string, elapsed time.Duration) {
|
||||
t.Helper()
|
||||
t.Logf("model=%s scenario=%s rounds=%d requests=%d tokens=%d exit=%s latency=%s", model, scenario, rounds, metrics.requests.Load(), metrics.tokens.Load(), exit, elapsed.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
type liveGoalMarkerTool struct{ executions *atomic.Int32 }
|
||||
|
||||
func (t liveGoalMarkerTool) Name() string { return "live_goal_marker" }
|
||||
func (t liveGoalMarkerTool) Description() string {
|
||||
return "Accept one numbered live-test round. Call exactly once per assistant response, using the next integer index requested by the prior result."
|
||||
}
|
||||
func (t liveGoalMarkerTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","properties":{"index":{"type":"integer"}},"required":["index"],"additionalProperties":false}`)
|
||||
}
|
||||
func (t liveGoalMarkerTool) ReadOnly() bool { return true }
|
||||
func (t liveGoalMarkerTool) Execute(context.Context, json.RawMessage) (string, error) {
|
||||
n := t.executions.Add(1)
|
||||
if n < 16 {
|
||||
return "accepted live round " + strconv.Itoa(int(n)) + "; in the next assistant response call live_goal_marker exactly once with index " + strconv.Itoa(int(n+1)) + "; do not write prose", nil
|
||||
}
|
||||
return "accepted live round 16; wait for the host finalization instruction and do not call more tools", nil
|
||||
}
|
||||
|
||||
type liveGoalFailureTool struct{ executions *atomic.Int32 }
|
||||
|
||||
func (t liveGoalFailureTool) Name() string { return "live_goal_failure" }
|
||||
func (t liveGoalFailureTool) Description() string {
|
||||
return "Always return the same deterministic host failure. Call exactly once per assistant response when the user requests the failure probe."
|
||||
}
|
||||
func (t liveGoalFailureTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`)
|
||||
}
|
||||
func (t liveGoalFailureTool) ReadOnly() bool { return true }
|
||||
func (t liveGoalFailureTool) Execute(context.Context, json.RawMessage) (string, error) {
|
||||
t.executions.Add(1)
|
||||
return "", errors.New("deterministic live host failure; the conformance probe is not complete, so call live_goal_failure exactly once again in the next assistant response and do not write prose")
|
||||
}
|
||||
@@ -66,8 +66,11 @@ func normalizePlannerDecision(d PlannerDecision) PlannerDecision {
|
||||
}
|
||||
|
||||
type runStepLimit struct {
|
||||
steps int
|
||||
key string
|
||||
steps int
|
||||
key string
|
||||
defaultOnly bool
|
||||
inherit bool
|
||||
pauseAfterFinal bool
|
||||
}
|
||||
|
||||
type runStepLimitContextKey struct{}
|
||||
@@ -82,6 +85,24 @@ func withRunStepLimit(ctx context.Context, steps int, key string) context.Contex
|
||||
})
|
||||
}
|
||||
|
||||
// WithDefaultRunStepLimit installs a host-owned zero-configuration Run limit.
|
||||
// It applies only when the Agent has no explicit MaxSteps configuration and is
|
||||
// inherited by delegated sub-agents as their effective parent budget. Planner
|
||||
// limits continue to use withRunStepLimit, which is an unconditional boundary
|
||||
// and deliberately does not leak into executor children.
|
||||
func WithDefaultRunStepLimit(ctx context.Context, steps int, key string) context.Context {
|
||||
if steps <= 0 {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, runStepLimitContextKey{}, runStepLimit{
|
||||
steps: steps,
|
||||
key: strings.TrimSpace(key),
|
||||
defaultOnly: true,
|
||||
inherit: true,
|
||||
pauseAfterFinal: true,
|
||||
})
|
||||
}
|
||||
|
||||
func runStepLimitFromContext(ctx context.Context) (runStepLimit, bool) {
|
||||
if ctx == nil {
|
||||
return runStepLimit{}, false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"reasonix/internal/event"
|
||||
@@ -24,6 +25,12 @@ type progressGuard struct {
|
||||
streak int
|
||||
}
|
||||
|
||||
type goalStuckSignal struct {
|
||||
limit int
|
||||
key string
|
||||
reason string
|
||||
}
|
||||
|
||||
func (g *progressGuard) reset() {
|
||||
g.tracker = evidence.NewProgressTracker()
|
||||
g.streak = 0
|
||||
@@ -51,17 +58,31 @@ type Receipt = evidence.Receipt
|
||||
// applyBatchGuards runs both post-batch guards: the storm breaker (failure
|
||||
// fixation) and the progress guard (zero-gain repetition). The outcome shadow
|
||||
// observes the same receipts afterwards without influencing either guard.
|
||||
func (a *Agent) applyBatchGuards(calls []provider.ToolCall, outcomes []toolOutcome, results []string, receiptMark int) {
|
||||
a.applyStormBreaker(calls, outcomes, results, receiptMark)
|
||||
a.applyProgressGuard(results, outcomes, receiptMark)
|
||||
func (a *Agent) applyBatchGuards(ctx context.Context, cancelled bool, calls []provider.ToolCall, outcomes []toolOutcome, results []string, receiptMark int) goalStuckSignal {
|
||||
if cancelled {
|
||||
return goalStuckSignal{}
|
||||
}
|
||||
stormReason := a.applyStormBreaker(calls, outcomes, results, receiptMark)
|
||||
progressReason := a.applyProgressGuard(results, outcomes, receiptMark)
|
||||
a.observeOutcomeShadow(receiptMark, results, outcomes)
|
||||
a.observeDelegationAdmission(calls)
|
||||
if _, scoped := DeliveryExecutionScopeFromContext(ctx); !scoped {
|
||||
return goalStuckSignal{}
|
||||
}
|
||||
if stormReason != "" {
|
||||
return goalStuckSignal{limit: stormBreakThreshold, key: "goal repeated host outcome", reason: stormReason}
|
||||
}
|
||||
if progressReason != "" {
|
||||
return goalStuckSignal{limit: progressStopStreak, key: "goal zero-evidence rounds", reason: progressReason}
|
||||
}
|
||||
return goalStuckSignal{}
|
||||
}
|
||||
|
||||
// resetTurnEvidence clears the ledger and both progress scorers together.
|
||||
func (a *Agent) resetTurnEvidence() {
|
||||
a.evidence.Reset()
|
||||
a.progress.reset()
|
||||
a.stormSig, a.stormCount, a.blockedTurnStreak = "", 0, 0
|
||||
a.outcome = evidence.NewOutcomeTracker()
|
||||
a.ebm = ebmState{}
|
||||
a.governor = governorState{}
|
||||
@@ -90,9 +111,9 @@ func (a *Agent) observeOutcomeShadow(receiptMark int, results []string, outcomes
|
||||
// to the round's first tool result, a loop-guard notice for the frontend, and
|
||||
// at the stop tier the loop-guard pass so final readiness stands down and the
|
||||
// model can deliver its answer instead of being sent back for more receipts.
|
||||
func (a *Agent) applyProgressGuard(results []string, outcomes []toolOutcome, receiptMark int) {
|
||||
func (a *Agent) applyProgressGuard(results []string, outcomes []toolOutcome, receiptMark int) string {
|
||||
if a.evidence == nil || len(results) == 0 || len(outcomes) == 0 {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
receipts := a.evidence.ReceiptsSince(receiptMark)
|
||||
// Rounds where nothing succeeded are the storm breaker's jurisdiction
|
||||
@@ -106,7 +127,7 @@ func (a *Agent) applyProgressGuard(results []string, outcomes []toolOutcome, rec
|
||||
}
|
||||
}
|
||||
if !anySuccess {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
streak := a.progress.observe(receipts)
|
||||
var guard, detail string
|
||||
@@ -132,7 +153,7 @@ func (a *Agent) applyProgressGuard(results []string, outcomes []toolOutcome, rec
|
||||
streak)
|
||||
detail = fmt.Sprintf("progress guard: %d zero-gain rounds — nudging to narrow", streak)
|
||||
default:
|
||||
return
|
||||
return ""
|
||||
}
|
||||
results[0] = outcomes[0].output + "\n\n" + guard
|
||||
level := event.LevelInfo
|
||||
@@ -140,6 +161,10 @@ func (a *Agent) applyProgressGuard(results []string, outcomes []toolOutcome, rec
|
||||
level = event.LevelWarn
|
||||
}
|
||||
a.sink.Emit(event.Event{Kind: event.Notice, Level: level, Code: event.NoticeCodeProgressGuard, Text: progressGuardNoticeText(), Detail: detail})
|
||||
if streak >= progressStopStreak {
|
||||
return fmt.Sprintf("%d consecutive successful tool rounds produced no new host evidence", streak)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func progressGuardNoticeText() string {
|
||||
|
||||
+29
-113
@@ -16,34 +16,6 @@ import (
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
// runLoopState holds per-Run loop counters and flags. It is package-private and
|
||||
// not shared across goroutines; the first extraction keeps the existing lock
|
||||
// model and only structures the sequential turn state machine.
|
||||
type runLoopState struct {
|
||||
runMaxSteps int
|
||||
runMaxStepsKey string
|
||||
runLimitHostOwned bool
|
||||
|
||||
emptyFinalBlocks int
|
||||
handoffNudges int
|
||||
usedAnyTool bool
|
||||
contextToolRepairs int
|
||||
graceRound bool
|
||||
recoveryGraceRound bool
|
||||
|
||||
todoProgress int
|
||||
trackingTodoProgress bool
|
||||
todoStallRounds int
|
||||
seenTodoProgress map[string]struct{}
|
||||
|
||||
executorHandoff bool
|
||||
// input is the user turn text after withTurnPreferences (used by handoff
|
||||
// nudges that inspect the original request wording).
|
||||
input string
|
||||
|
||||
workDurationMs func() int64
|
||||
}
|
||||
|
||||
// streamedTurn is one provider completion collected by stream. Keeping the
|
||||
// result together makes the missing-reasoning recovery path explicit: the
|
||||
// first, malformed completion is never committed before a safe replacement is
|
||||
@@ -142,6 +114,7 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
|
||||
// values are computed below. Cross-turn state (checkpoint, scope, failure
|
||||
// budgets) lives directly on Agent and is reconciled field by field.
|
||||
a.perTurnState = perTurnState{turnInput: input}
|
||||
a.resetStructuralRunGuards()
|
||||
scope, scoped := DeliveryExecutionScopeFromContext(ctx)
|
||||
preserveEvidence := a.preserveEvidenceOnce
|
||||
// A run that starts with a pending readiness recovery (or an explicit
|
||||
@@ -220,23 +193,7 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
|
||||
// transcript tail. Fold its bounded facts into this new user turn exactly
|
||||
// once; the user's raw text remains the classifier source above.
|
||||
providerInput = withInterruptedRecovery(providerInput, a.pendingInterruptedRecovery())
|
||||
if !scoped || a.repeatFailureScope != scope.ID {
|
||||
a.repeatFailureCounts = nil
|
||||
} else {
|
||||
// Only stale-anchor failures have a side-effect-free state recheck.
|
||||
// Ordinary write failures may recover between Runs after user action or
|
||||
// an external state change, so do not carry their retry budget forward.
|
||||
for sig, failure := range a.repeatFailureCounts {
|
||||
if !failure.stateRecheck {
|
||||
delete(a.repeatFailureCounts, sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
if scoped {
|
||||
a.repeatFailureScope = scope.ID
|
||||
} else {
|
||||
a.repeatFailureScope = ""
|
||||
}
|
||||
a.prepareRepeatFailureScope(scoped, scope.ID)
|
||||
a.sink.Emit(event.Event{Kind: event.TurnStarted})
|
||||
input = a.withTurnPreferences(providerInput)
|
||||
userCreatedAt := time.Now().UnixMilli()
|
||||
@@ -274,7 +231,7 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
|
||||
// assistant turn into final-response or tool-round handling.
|
||||
func (a *Agent) runToolLoop(ctx context.Context, state *runLoopState) error {
|
||||
ctx = a.withAgentContext(ctx)
|
||||
for step := 0; state.runMaxSteps <= 0 || step < state.runMaxSteps || state.graceRound || state.recoveryGraceRound; step++ {
|
||||
for step := 0; state.runMaxSteps <= 0 || step < state.runMaxSteps || state.graceRound || state.recoveryGraceRound || state.goalStuckGraceRound; step++ {
|
||||
// Consume a queued steer and persist it to the session so it
|
||||
// survives tab switches and history replay. The model sees it as
|
||||
// guidance (with a prefix), not a new task. One cache miss per
|
||||
@@ -361,7 +318,7 @@ func (a *Agent) runToolLoop(ctx context.Context, state *runLoopState) error {
|
||||
// Only reached when a positive maxSteps guard is configured. The work so far
|
||||
// is already in the session, so the user can just send another message to pick
|
||||
// up where it left off.
|
||||
return &maxStepsPause{steps: state.runMaxSteps, key: state.runMaxStepsKey}
|
||||
return &maxStepsPause{steps: state.runMaxSteps, key: state.runMaxStepsKey, hostOwned: state.runLimitHostOwned}
|
||||
}
|
||||
|
||||
// streamWithSamplingRecovery coordinates Codex-style original-request replay
|
||||
@@ -816,10 +773,21 @@ func (a *Agent) handleFinalResponse(ctx context.Context, state *runLoopState, te
|
||||
StopReason: reason,
|
||||
}
|
||||
}
|
||||
if state.goalStuckGraceRound {
|
||||
a.contextManager().ObserveUsage(usage)
|
||||
return false, &goalStuckPause{limit: state.goalStuckLimit, key: state.goalStuckKey, reason: state.goalStuckReason}
|
||||
}
|
||||
readiness := a.finalReadinessCheckFor()
|
||||
if state.graceRound && (readiness.reason != "" || !hasVisibleFinalAnswer(text)) {
|
||||
a.contextManager().ObserveUsage(usage)
|
||||
return false, &maxStepsPause{steps: state.runMaxSteps, key: state.runMaxStepsKey}
|
||||
return false, &maxStepsPause{steps: state.runMaxSteps, key: state.runMaxStepsKey, hostOwned: state.runLimitHostOwned}
|
||||
}
|
||||
if state.graceRound && state.runPauseAfterFinal {
|
||||
// A host-owned limit is a real Goal yield boundary even when the model
|
||||
// produced a useful summary. Controller still evaluates that final text and
|
||||
// may complete the Goal; otherwise it persists a resumable budget pause.
|
||||
a.contextManager().ObserveUsage(usage)
|
||||
return false, &maxStepsPause{steps: state.runMaxSteps, key: state.runMaxStepsKey, hostOwned: true}
|
||||
}
|
||||
if readiness.reason != "" {
|
||||
// Delivery no longer retries readiness with hidden model messages: the
|
||||
@@ -894,34 +862,8 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
|
||||
return false, fmt.Errorf("model repeatedly called context-unavailable tools without a visible answer: %s", strings.Join(unavailableContextTools, ", "))
|
||||
}
|
||||
|
||||
// Grace round guard: if we already gave the model one extra response
|
||||
// and it still wants to call tools, stop here.
|
||||
if state.graceRound {
|
||||
return false, &maxStepsPause{steps: state.runMaxSteps, key: state.runMaxStepsKey}
|
||||
}
|
||||
// Recovery Episode exhausted: one finalization round only. Further tool
|
||||
// calls are not executed; return a typed pause so the host can surface
|
||||
// recovery_paused without treating it as a send failure.
|
||||
if state.recoveryGraceRound {
|
||||
reason := ""
|
||||
if ctrl := a.recoveryEpisodeControl(); ctrl != nil {
|
||||
_, _ = ctrl.ConsumeFinalization(a.recoveryTaskID)
|
||||
}
|
||||
// Pair tool-call / tool-result without executing.
|
||||
msg := "blocked: Auto recovery already paused this turn. Do not call tools; the user will continue in the next message."
|
||||
for _, call := range calls {
|
||||
a.session.Add(provider.Message{
|
||||
Role: provider.RoleTool,
|
||||
Content: msg,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
})
|
||||
}
|
||||
a.contextManager().ObserveUsage(usage)
|
||||
return false, &RecoveryPauseError{
|
||||
Message: "Automatic retries paused. Reasonix stopped repeated attempts and kept completed work. Send \"continue\" to start a fresh attempt, or add instructions to change direction.",
|
||||
StopReason: reason,
|
||||
}
|
||||
if boundaryErr, stop := a.stopUnexecutedBoundaryCalls(state, calls, usage); stop {
|
||||
return false, boundaryErr
|
||||
}
|
||||
|
||||
receiptMark := 0
|
||||
@@ -959,43 +901,11 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
|
||||
nudge := fmt.Sprintf("The following tools are unavailable in the current workflow phase: %s. Do not call them again. Respond to the user's request with visible answer text now; call a different tool only if it is still needed to complete the request.", strings.Join(unavailableContextTools, ", "))
|
||||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge)})
|
||||
}
|
||||
if !a.planMode.Load() {
|
||||
nextProgress, nextTracking := a.canonicalTodoProgress()
|
||||
hostProgress := false
|
||||
if a.evidence != nil {
|
||||
for _, sig := range a.evidence.SuccessfulProgressSignaturesSince(receiptMark) {
|
||||
if _, seen := state.seenTodoProgress[sig]; !seen {
|
||||
hostProgress = true
|
||||
state.seenTodoProgress[sig] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !nextTracking:
|
||||
state.todoStallRounds = 0
|
||||
case !state.trackingTodoProgress || nextProgress > state.todoProgress || hostProgress:
|
||||
state.todoStallRounds = 0
|
||||
default:
|
||||
state.todoStallRounds++
|
||||
}
|
||||
state.todoProgress, state.trackingTodoProgress = nextProgress, nextTracking
|
||||
if state.todoStallRounds == todoProgressNudgeRounds {
|
||||
nudge := todoProgressNudgeMessage(state.todoStallRounds)
|
||||
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge)})
|
||||
a.sink.Emit(event.Event{
|
||||
Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeLoopGuard,
|
||||
Text: loopGuardNoticeText(),
|
||||
Detail: fmt.Sprintf("the current todo has no new completion, unique read, command, or mutation for %d consecutive tool-call rounds; asking the assistant to reassess", state.todoStallRounds),
|
||||
})
|
||||
}
|
||||
if state.todoStallRounds >= maxTodoStallRounds {
|
||||
a.sink.Emit(event.Event{
|
||||
Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeLoopGuard,
|
||||
Text: "Task progress stalled; pausing before more tools are called.",
|
||||
Detail: fmt.Sprintf("the current todo has no new completion, unique read, command, or mutation for %d consecutive tool-call rounds after a host reassessment; work is saved and can be resumed", state.todoStallRounds),
|
||||
})
|
||||
return false, &todoStallPause{rounds: state.todoStallRounds}
|
||||
}
|
||||
if err := a.trackTodoProgress(state, receiptMark); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if a.armGoalStuckFinalization(state, batch.goalStuck) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// The prompt only grows from here; compact before the next turn so it
|
||||
@@ -1030,6 +940,12 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (a *Agent) pairUnexecutedGraceCalls(calls []provider.ToolCall, msg string) {
|
||||
for _, call := range calls {
|
||||
a.session.Add(provider.Message{Role: provider.RoleTool, Content: msg, ToolCallID: call.ID, Name: call.Name})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) unavailableContextualToolCalls(ctx context.Context, calls []provider.ToolCall) []string {
|
||||
if len(calls) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package agent
|
||||
|
||||
// runLoopState holds sequential state for one Agent.Run invocation.
|
||||
type runLoopState struct {
|
||||
runMaxSteps int
|
||||
runMaxStepsKey string
|
||||
runLimitHostOwned bool
|
||||
runPauseAfterFinal bool
|
||||
|
||||
emptyFinalBlocks int
|
||||
handoffNudges int
|
||||
usedAnyTool bool
|
||||
contextToolRepairs int
|
||||
graceRound bool
|
||||
recoveryGraceRound bool
|
||||
goalStuckGraceRound bool
|
||||
goalStuckLimit int
|
||||
goalStuckKey string
|
||||
goalStuckReason string
|
||||
|
||||
todoProgress int
|
||||
trackingTodoProgress bool
|
||||
todoStallRounds int
|
||||
seenTodoProgress map[string]struct{}
|
||||
|
||||
executorHandoff bool
|
||||
input string
|
||||
workDurationMs func() int64
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func decorateExecutionReceipt(rec *evidence.Receipt, result string, ex *tool.She
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
rec.OutputBytes = len(strings.TrimSpace(result))
|
||||
rec.ObserveOutput(result)
|
||||
if ex == nil {
|
||||
return
|
||||
}
|
||||
|
||||
+1
-19
@@ -614,24 +614,6 @@ func (r *ReadOnlyTaskTool) Execute(ctx context.Context, args json.RawMessage) (s
|
||||
return r.task.RunProfileSpec(ctx, spec)
|
||||
}
|
||||
|
||||
// childMaxSteps resolves a sub-agent's step budget. An explicit request wins.
|
||||
// Otherwise mirror the parent: a finite parent caps the child at half its
|
||||
// budget (min 5) so a delegated sub-task stays shorter than the whole turn; an
|
||||
// unbounded parent yields an unbounded child (it shares the parent's ctx, so
|
||||
// cancelling the turn stops it, and it compacts its own context — the same
|
||||
// bounds the parent has). Shared by task, read_only_task, and parallel_tasks
|
||||
// children so the default cannot drift per call site.
|
||||
func (t *TaskTool) childMaxSteps(requested int) int {
|
||||
if requested > 0 {
|
||||
return requested
|
||||
}
|
||||
if t.maxSteps <= 0 {
|
||||
return 0
|
||||
}
|
||||
half := max(t.maxSteps/2, 5)
|
||||
return half
|
||||
}
|
||||
|
||||
func (t *TaskTool) effectiveProfile(model, effort string) (string, string) {
|
||||
model = strings.TrimSpace(model)
|
||||
effort = strings.TrimSpace(effort)
|
||||
@@ -789,7 +771,7 @@ func (t *TaskTool) RunProfileSpec(ctx context.Context, spec ProfileExecSpec) (re
|
||||
spec.Worker.SystemPrompt = t.sysPrompt
|
||||
}
|
||||
|
||||
maxSteps := t.childMaxSteps(spec.Sched.MaxSteps)
|
||||
maxSteps := t.childMaxStepsForContext(ctx, spec.Sched.MaxSteps)
|
||||
childDepth, err := t.nextSubagentDepth(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package agent
|
||||
|
||||
import "context"
|
||||
|
||||
// childMaxSteps resolves a sub-agent budget; an explicit request always wins.
|
||||
func (t *TaskTool) childMaxSteps(requested int) int {
|
||||
return childMaxStepsForParent(t.maxSteps, requested)
|
||||
}
|
||||
|
||||
func childMaxStepsForParent(parent, requested int) int {
|
||||
if requested > 0 {
|
||||
return requested
|
||||
}
|
||||
if parent <= 0 {
|
||||
return 0
|
||||
}
|
||||
return max(parent/2, 5)
|
||||
}
|
||||
|
||||
func (t *TaskTool) childMaxStepsForContext(ctx context.Context, requested int) int {
|
||||
if requested > 0 || t.maxSteps > 0 {
|
||||
return t.childMaxSteps(requested)
|
||||
}
|
||||
if limit, ok := runStepLimitFromContext(ctx); ok && limit.inherit {
|
||||
return childMaxStepsForParent(limit.steps, requested)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -4984,8 +4984,8 @@ func (m *chatTUI) runGoalSubcommand(input string) tea.Cmd {
|
||||
m.notice(fmt.Sprintf(i18n.M.GoalCurrentFmt, goal))
|
||||
rt := m.ctrl.GoalRuntime()
|
||||
m.notice(fmt.Sprintf(i18n.M.GoalRuntimeFmt,
|
||||
rt.TurnsUsed, rt.TurnsLimit, rt.TokensUsed,
|
||||
rt.NoProgressTurns, rt.NoProgressLimit, rt.BudgetExtensions))
|
||||
rt.TurnsUsed, rt.TurnsLimit, rt.TokensUsed, rt.RequestsUsed,
|
||||
rt.NoProgressTurns, rt.BudgetExtensions))
|
||||
if rt.LastReason != "" {
|
||||
m.notice(fmt.Sprintf("%s: %s", i18n.M.GoalRuntimeLastReason, rt.LastReason))
|
||||
}
|
||||
|
||||
@@ -1586,8 +1586,8 @@ func (c *Controller) applyGoalCommand(input, display string) bool {
|
||||
rt := c.GoalRuntime()
|
||||
c.notice(fmt.Sprintf(i18n.M.GoalCurrentFmt, goal))
|
||||
c.notice(fmt.Sprintf(i18n.M.GoalRuntimeFmt,
|
||||
rt.TurnsUsed, rt.TurnsLimit, rt.TokensUsed,
|
||||
rt.NoProgressTurns, rt.NoProgressLimit, rt.BudgetExtensions))
|
||||
rt.TurnsUsed, rt.TurnsLimit, rt.TokensUsed, rt.RequestsUsed,
|
||||
rt.NoProgressTurns, rt.BudgetExtensions))
|
||||
if rt.LastReason != "" {
|
||||
c.noticeDetail(i18n.M.GoalRuntimeLastReason, rt.LastReason)
|
||||
}
|
||||
|
||||
+99
-138
@@ -23,9 +23,13 @@ const (
|
||||
goalContinueTurn = "Continue pursuing the active goal under its task contract. Do the next useful work, then call update_goal with your disposition: continue (include the next concrete step in next_action), complete (only when fully done and verified), or blocked (when only the user can unblock)."
|
||||
goalCompleteNotice = "goal complete"
|
||||
|
||||
// Default no-progress stall limit: four consecutive goal turns without any
|
||||
// host-verifiable progress pause the goal.
|
||||
// Historical simple-goal no-progress value. It remains in sidecars and
|
||||
// status DTOs for rolling compatibility, but current decisions ignore it.
|
||||
defaultNoProgressLimit = 4
|
||||
// Bound the persisted novelty window. Signatures are compact hashes, and
|
||||
// retaining the most recent window is enough to stop short repeat cycles
|
||||
// without allowing an unbounded Goal sidecar.
|
||||
maxGoalProgressEvidence = 512
|
||||
)
|
||||
|
||||
// Budget class aliases; classification and quotas live in taskintent.
|
||||
@@ -41,7 +45,9 @@ const (
|
||||
const (
|
||||
stopCauseBudgetTurns = "budget_turns"
|
||||
stopCauseBudgetTokens = "budget_tokens" // legacy; never written by current runtime
|
||||
stopCauseNoProgress = "no_progress"
|
||||
stopCauseNoProgress = "no_progress" // legacy; never written by current runtime
|
||||
stopCauseGoalRunBudget = "goal_run_budget"
|
||||
stopCauseGoalStuck = "goal_stuck"
|
||||
stopCauseEvaluator = "evaluator_unavailable"
|
||||
stopCauseLegacyArchive = "legacy_archive"
|
||||
stopCauseManual = "manual"
|
||||
@@ -91,6 +97,7 @@ type goalMachine struct {
|
||||
turnsUsed int
|
||||
turnsLimit int
|
||||
tokensUsed int
|
||||
requestsUsed int
|
||||
tokensLimit int // always 0 at runtime; deprecated hard limit
|
||||
noProgressTurns int
|
||||
noProgressLimit int
|
||||
@@ -98,6 +105,10 @@ type goalMachine struct {
|
||||
lastEvaluatorReason string
|
||||
stopCause string
|
||||
budgetExtensions int // turn extensions from resume (compat field name)
|
||||
progressEvidence []string
|
||||
// stateExtra preserves fields written by a newer peer during read/modify/
|
||||
// write cycles. Known current fields always win on serialization.
|
||||
stateExtra map[string]json.RawMessage
|
||||
// legacyTaskID is retained only while a historical AutoResearch archive is
|
||||
// awaiting migration. It is serialized on fail-closed blocked sidecars so a
|
||||
// restart can retry the migration without treating the raw archive path as a
|
||||
@@ -127,32 +138,35 @@ type goalState struct {
|
||||
Strict bool `json:"strict,omitempty"`
|
||||
Todos []evidence.TodoItem `json:"todos,omitempty"`
|
||||
|
||||
BudgetClass string `json:"budgetClass,omitempty"`
|
||||
TurnsUsed int `json:"turnsUsed,omitempty"`
|
||||
TurnsLimit int `json:"turnsLimit,omitempty"`
|
||||
TokensUsed int `json:"tokensUsed,omitempty"`
|
||||
TokensLimit int `json:"tokensLimit,omitempty"`
|
||||
NoProgressTurns int `json:"noProgressTurns,omitempty"`
|
||||
NoProgressLimit int `json:"noProgressLimit,omitempty"`
|
||||
LastContinuationReason string `json:"lastContinuationReason,omitempty"`
|
||||
LastEvaluatorReason string `json:"lastEvaluatorReason,omitempty"`
|
||||
StopCause string `json:"stopCause,omitempty"`
|
||||
BudgetExtensions int `json:"budgetExtensions,omitempty"`
|
||||
BudgetClass string `json:"budgetClass,omitempty"`
|
||||
TurnsUsed int `json:"turnsUsed,omitempty"`
|
||||
TurnsLimit int `json:"turnsLimit,omitempty"`
|
||||
TokensUsed int `json:"tokensUsed,omitempty"`
|
||||
RequestsUsed int `json:"requestsUsed,omitempty"`
|
||||
TokensLimit int `json:"tokensLimit,omitempty"`
|
||||
NoProgressTurns int `json:"noProgressTurns,omitempty"`
|
||||
NoProgressLimit int `json:"noProgressLimit,omitempty"`
|
||||
LastContinuationReason string `json:"lastContinuationReason,omitempty"`
|
||||
LastEvaluatorReason string `json:"lastEvaluatorReason,omitempty"`
|
||||
StopCause string `json:"stopCause,omitempty"`
|
||||
BudgetExtensions int `json:"budgetExtensions,omitempty"`
|
||||
ProgressEvidence []string `json:"progressEvidence,omitempty"`
|
||||
}
|
||||
|
||||
// goalAdvanceInput carries everything the FSM needs for one continuation step,
|
||||
// gathered by the caller off the machine's lock. The FSM is the exclusive
|
||||
// decision point: it applies readiness, budget, and no-progress gates and
|
||||
// decision point: it applies readiness, outer budget, and host-owned Run gates and
|
||||
// decides complete / continue / blocked / pause.
|
||||
type goalAdvanceInput struct {
|
||||
report *goalTurnReport // validated update_goal report; nil when none
|
||||
readiness agent.ReadinessResult
|
||||
evaluator *goalEvaluatorVerdict // evaluator verdict; nil when not run
|
||||
evaluatorFailed string // evaluator error/timeout text; pause fail-closed
|
||||
todos []evidence.TodoItem
|
||||
progressBefore string // host progress signature captured before the turn
|
||||
progressAfter string // host progress signature captured after the turn
|
||||
expectedEpoch *uint64
|
||||
report *goalTurnReport // validated update_goal report; nil when none
|
||||
readiness agent.ReadinessResult
|
||||
evaluator *goalEvaluatorVerdict // evaluator verdict; nil when not run
|
||||
evaluatorFailed string // evaluator error/timeout text; pause fail-closed
|
||||
todos []evidence.TodoItem
|
||||
progressEvidence []string // host evidence identities visible after this turn
|
||||
pauseCause string // host-owned Run boundary; empty for ordinary turns
|
||||
pauseReason string
|
||||
expectedEpoch *uint64
|
||||
}
|
||||
|
||||
// goalEvaluatorVerdict is the bounded evaluator's structured outcome.
|
||||
@@ -318,11 +332,12 @@ func (g *goalMachine) setLegacyArchiveBlockedWithTaskID(goal, preferredBudgetCla
|
||||
|
||||
func (g *goalMachine) installGoalLocked(goal, preferredBudgetClass string) {
|
||||
g.continuationEpoch++
|
||||
g.turnsUsed, g.tokensUsed, g.noProgressTurns = 0, 0, 0
|
||||
g.turnsUsed, g.tokensUsed, g.requestsUsed, g.noProgressTurns = 0, 0, 0, 0
|
||||
g.block = ""
|
||||
g.lastContinuationReason, g.lastEvaluatorReason = "", ""
|
||||
g.stopCause = ""
|
||||
g.budgetExtensions = 0
|
||||
g.progressEvidence = nil
|
||||
if goal == "" {
|
||||
g.goal, g.status = "", GoalStatusStopped
|
||||
g.budgetClass = ""
|
||||
@@ -335,7 +350,7 @@ func (g *goalMachine) installGoalLocked(goal, preferredBudgetClass string) {
|
||||
g.budgetClass = preferredBudgetClass
|
||||
g.turnsLimit = budgetQuota(g.budgetClass)
|
||||
g.tokensLimit = 0 // no token hard limit
|
||||
g.noProgressLimit = defaultNoProgressLimit
|
||||
g.noProgressLimit = noProgressQuota(g.budgetClass)
|
||||
}
|
||||
// Installing a normal Goal always abandons any pending legacy migration.
|
||||
g.legacyTaskID = ""
|
||||
@@ -397,7 +412,6 @@ func (g *goalMachine) resume(todos []evidence.TodoItem) (path string, data []byt
|
||||
// can resume without understanding the removed hard limit.
|
||||
extend := g.stopCause == stopCauseBudgetTurns ||
|
||||
g.stopCause == stopCauseBudgetTokens ||
|
||||
g.stopCause == stopCauseNoProgress ||
|
||||
(g.turnsLimit > 0 && g.turnsUsed >= g.turnsLimit)
|
||||
g.continuationEpoch++
|
||||
g.status = GoalStatusRunning
|
||||
@@ -483,7 +497,7 @@ func (g *goalMachine) admitContinuation(res goalAdvanceResult) (goalContinuation
|
||||
// 4. budget exhausted → safe pause (also vetoes complete claims rejected by
|
||||
// readiness: those would continue, and continuation past the budget is a
|
||||
// pause)
|
||||
// 5. no-progress limit reached → safe pause
|
||||
// 5. a host-owned Run budget/stuck boundary → safe pause
|
||||
// 6. otherwise continue, carrying the missing requirements (complete rejected
|
||||
// by readiness, or no report with an explicit missing list) or the report's
|
||||
// next_action as the next turn's prompt.
|
||||
@@ -500,17 +514,7 @@ func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
|
||||
// A top-level goal turn (the first turn or a synthetic continuation) counts
|
||||
// against the turn budget; the in-Run model/tool loop is never re-counted.
|
||||
g.turnsUsed++
|
||||
// No-progress bookkeeping: only host-verifiable changes reset the stall
|
||||
// counter. A terminal report is itself host-verifiable progress.
|
||||
progressed := in.progressAfter != in.progressBefore || in.progressBefore == ""
|
||||
if in.report != nil && in.report.status != GoalStatusRunning {
|
||||
progressed = true
|
||||
}
|
||||
if progressed {
|
||||
g.noProgressTurns = 0
|
||||
} else {
|
||||
g.noProgressTurns++
|
||||
}
|
||||
g.observeGoalProgress(in)
|
||||
var notice string
|
||||
var intercept string
|
||||
var interceptNotice string
|
||||
@@ -518,7 +522,7 @@ func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
|
||||
evaluatorBlocked := in.evaluator != nil && in.evaluator.outcome == goaleval.OutcomeBlocked
|
||||
// Terminal dispositions first (completing or blocking ends the goal, so the
|
||||
// budget gates never veto them); then evaluator fail-closed, then the
|
||||
// budget gates, then the no-progress gate; only then continue.
|
||||
// budget gates, then any host-owned Run boundary; only then continue.
|
||||
reportBlocked := in.report != nil && in.report.status == GoalStatusBlocked
|
||||
reportComplete := in.report != nil && in.report.status == GoalStatusComplete
|
||||
completeOK := (reportComplete || evaluatorComplete) && formatIncompleteTodos(in.todos, in.readiness.Reason) == ""
|
||||
@@ -550,6 +554,7 @@ func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
|
||||
g.status = GoalStatusComplete
|
||||
g.block = ""
|
||||
g.stopCause = ""
|
||||
g.progressEvidence = nil
|
||||
g.lastContinuationReason, g.lastEvaluatorReason = "", ""
|
||||
notice = goalCompleteNotice
|
||||
case in.evaluatorFailed != "" || (in.evaluator != nil && in.evaluator.outcome == goaleval.OutcomeUncertain):
|
||||
@@ -570,10 +575,13 @@ func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
|
||||
g.stopCause = stopCauseBudgetTurns
|
||||
g.block = clipGoalReason(reason)
|
||||
notice = "goal paused: " + reason
|
||||
case g.noProgressLimit > 0 && g.noProgressTurns >= g.noProgressLimit:
|
||||
reason := fmt.Sprintf("no host-verifiable progress in the last %d turns", g.noProgressTurns)
|
||||
case in.pauseCause != "":
|
||||
reason := strings.TrimSpace(in.pauseReason)
|
||||
if reason == "" {
|
||||
reason = "the current Goal run reached a recoverable execution boundary"
|
||||
}
|
||||
g.status = GoalStatusBlocked
|
||||
g.stopCause = stopCauseNoProgress
|
||||
g.stopCause = in.pauseCause
|
||||
g.block = clipGoalReason(reason)
|
||||
notice = "goal paused: " + reason
|
||||
default:
|
||||
@@ -619,13 +627,18 @@ func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
|
||||
// foldUsage attributes a turn's billable tokens to the goal, but only while the
|
||||
// goal lifecycle still matches the recorder's scope+epoch; stale or replaced
|
||||
// goals reject late usage.
|
||||
func (g *goalMachine) foldUsage(scopeID string, epoch uint64, tokens int) bool {
|
||||
func (g *goalMachine) foldUsage(scopeID string, epoch uint64, tokens, requests int) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if tokens <= 0 || g.scopeID != scopeID || g.continuationEpoch != epoch {
|
||||
if (tokens <= 0 && requests <= 0) || g.scopeID != scopeID || g.continuationEpoch != epoch {
|
||||
return false
|
||||
}
|
||||
g.tokensUsed += tokens
|
||||
if tokens > 0 {
|
||||
g.tokensUsed += tokens
|
||||
}
|
||||
if requests > 0 {
|
||||
g.requestsUsed += requests
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -650,6 +663,7 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string,
|
||||
TurnsUsed: g.turnsUsed,
|
||||
TurnsLimit: g.turnsLimit,
|
||||
TokensUsed: g.tokensUsed,
|
||||
RequestsUsed: g.requestsUsed,
|
||||
TokensLimit: g.tokensLimit,
|
||||
NoProgressTurns: g.noProgressTurns,
|
||||
NoProgressLimit: g.noProgressLimit,
|
||||
@@ -657,6 +671,7 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string,
|
||||
LastEvaluatorReason: g.lastEvaluatorReason,
|
||||
StopCause: g.stopCause,
|
||||
BudgetExtensions: g.budgetExtensions,
|
||||
ProgressEvidence: append([]string(nil), g.progressEvidence...),
|
||||
}
|
||||
// GoalResearchOff is a downgrade fence for ordinary Goal sidecars. A
|
||||
// fail-closed legacy migration keeps its task identity and compatibility mode
|
||||
@@ -667,7 +682,7 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string,
|
||||
} else {
|
||||
state.ResearchMode = GoalResearchOff
|
||||
}
|
||||
b, err := json.Marshal(state)
|
||||
b, err := marshalGoalState(state, g.stateExtra)
|
||||
if err != nil {
|
||||
slog.Warn("controller: marshal goal state", "err", err)
|
||||
return "", nil, false
|
||||
@@ -753,6 +768,7 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.stateExtra = goalStateUnknownFields(raw)
|
||||
g.goal = strings.TrimSpace(state.Goal)
|
||||
g.status = state.Status
|
||||
if g.status == "" {
|
||||
@@ -797,6 +813,7 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
|
||||
g.strict = state.Strict
|
||||
g.stopCause = state.StopCause
|
||||
g.budgetExtensions = state.BudgetExtensions
|
||||
g.progressEvidence, _ = mergeGoalProgressEvidence(nil, state.ProgressEvidence)
|
||||
g.lastContinuationReason = state.LastContinuationReason
|
||||
g.lastEvaluatorReason = state.LastEvaluatorReason
|
||||
// Budget defaults: old sidecars carry Turns (pre-budget counting); treat it
|
||||
@@ -806,6 +823,7 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
|
||||
g.turnsUsed = state.Turns
|
||||
}
|
||||
g.tokensUsed = state.TokensUsed
|
||||
g.requestsUsed = state.RequestsUsed
|
||||
g.budgetClass = normalizeBudgetClass(g.goal, state.BudgetClass, state.ResearchMode)
|
||||
g.turnsLimit = state.TurnsLimit
|
||||
g.noProgressTurns = state.NoProgressTurns
|
||||
@@ -826,18 +844,8 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
|
||||
if g.turnsLimit == 0 {
|
||||
g.turnsLimit = budgetQuota(g.budgetClass)
|
||||
}
|
||||
if g.noProgressLimit == 0 {
|
||||
g.noProgressLimit = defaultNoProgressLimit
|
||||
}
|
||||
// Auto-clear legacy token-budget pauses so the next user turn can
|
||||
// continue without a manual resume. Loading itself never calls a
|
||||
// provider.
|
||||
if g.status == GoalStatusBlocked && g.stopCause == stopCauseBudgetTokens {
|
||||
g.status = GoalStatusRunning
|
||||
g.stopCause = ""
|
||||
// The stop cause proves the block belongs to the removed token gate;
|
||||
// do not leave a stale blocked reason attached to a running goal.
|
||||
g.block = ""
|
||||
g.noProgressLimit = resolvedNoProgressLimit(g.noProgressLimit, g.budgetClass)
|
||||
if g.migrateRemovedGoalPause() {
|
||||
migrated = true
|
||||
}
|
||||
// Also rewrite sidecars that still store a non-zero tokensLimit so the
|
||||
@@ -952,73 +960,23 @@ func (c *Controller) restoreTerminalGoalTodos(sessionPath string) {
|
||||
c.executor.ReplaceTodoState(todos)
|
||||
}
|
||||
|
||||
// GoalRuntimeView is the host-side runtime summary exposed to frontends.
|
||||
type GoalRuntimeView struct {
|
||||
TurnsUsed int
|
||||
TurnsLimit int
|
||||
TokensUsed int
|
||||
TokensLimit int
|
||||
NoProgressTurns int
|
||||
NoProgressLimit int
|
||||
LastReason string
|
||||
StopCause string
|
||||
BudgetExtensions int
|
||||
}
|
||||
|
||||
// runtimeView returns the goal's budget/runtime summary.
|
||||
func (g *goalMachine) runtimeView() GoalRuntimeView {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
last := g.lastEvaluatorReason
|
||||
if last == "" {
|
||||
last = g.lastContinuationReason
|
||||
}
|
||||
return GoalRuntimeView{
|
||||
TurnsUsed: g.turnsUsed,
|
||||
TurnsLimit: g.turnsLimit,
|
||||
TokensUsed: g.tokensUsed,
|
||||
TokensLimit: 0, // deprecated: no hard token limit
|
||||
NoProgressTurns: g.noProgressTurns,
|
||||
NoProgressLimit: g.noProgressLimit,
|
||||
LastReason: last,
|
||||
StopCause: g.stopCause,
|
||||
BudgetExtensions: g.budgetExtensions,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *goalMachine) lastContinuationReasonText() string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.lastEvaluatorReason != "" {
|
||||
return g.lastEvaluatorReason
|
||||
}
|
||||
return g.lastContinuationReason
|
||||
}
|
||||
|
||||
func (g *goalMachine) budgetStatusText() string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return fmt.Sprintf("turns: %d/%d used, tokens: %d, no-progress turns: %d/%d",
|
||||
g.turnsUsed, g.turnsLimit, g.tokensUsed, g.noProgressTurns, g.noProgressLimit)
|
||||
}
|
||||
|
||||
// goalTurnRecorder is the per-turn recorder bound to one goal turn's scope and
|
||||
// epoch. update_goal calls land here as candidate state; the FSM commits them
|
||||
// only when the goal lifecycle still matches (scope + epoch), so late calls
|
||||
// from a replaced or cleared goal are rejected. Usage events emitted during the
|
||||
// turn are folded through the recorder into the goal's observational token total.
|
||||
type goalTurnRecorder struct {
|
||||
mu sync.Mutex
|
||||
machine *goalMachine
|
||||
scopeID string
|
||||
epoch uint64
|
||||
recorded bool
|
||||
terminal bool
|
||||
status string
|
||||
reason string
|
||||
nextAction string
|
||||
tokensUsed int
|
||||
progressBefore string
|
||||
mu sync.Mutex
|
||||
machine *goalMachine
|
||||
scopeID string
|
||||
epoch uint64
|
||||
recorded bool
|
||||
terminal bool
|
||||
status string
|
||||
reason string
|
||||
nextAction string
|
||||
tokensUsed int
|
||||
requestsUsed int
|
||||
}
|
||||
|
||||
func (g *goalMachine) newTurnRecorder(scopeID string, epoch uint64) *goalTurnRecorder {
|
||||
@@ -1035,17 +993,23 @@ func (r *goalTurnRecorder) RecordGoalReport(report tool.GoalReport) (string, err
|
||||
if !r.machine.turnActive(r.scopeID, r.epoch) {
|
||||
return "", fmt.Errorf("update_goal: the active goal changed during this turn — report ignored; no goal state was changed")
|
||||
}
|
||||
// The wire status "continue" maps to the FSM's internal "running" state;
|
||||
// retain the wire value for user-facing acknowledgements and errors.
|
||||
wireStatus := report.Status
|
||||
if report.Status == "continue" {
|
||||
report.Status = GoalStatusRunning
|
||||
}
|
||||
switch {
|
||||
case r.terminal:
|
||||
return "", fmt.Errorf("update_goal: this turn's disposition is already final (%s); conflicting %q report ignored", r.status, report.Status)
|
||||
return "", fmt.Errorf("update_goal: this turn's disposition is already final (%s); conflicting %q report ignored", r.status, wireStatus)
|
||||
case !r.recorded:
|
||||
// first record
|
||||
case r.status == report.Status && r.reason == report.Reason && r.nextAction == report.NextAction:
|
||||
return fmt.Sprintf("update_goal: %s already recorded for this turn (identical report).", report.Status), nil
|
||||
return fmt.Sprintf("update_goal: %s already recorded for this turn (identical report).", wireStatus), nil
|
||||
case r.status == GoalStatusRunning && (report.Status == GoalStatusComplete || report.Status == GoalStatusBlocked):
|
||||
// continue → terminal upgrade allowed.
|
||||
default:
|
||||
return "", fmt.Errorf("update_goal: conflicting reports this turn (%s then %s) — the later report was ignored", r.status, report.Status)
|
||||
return "", fmt.Errorf("update_goal: conflicting reports this turn (%s then %s) — the later report was ignored", r.status, wireStatus)
|
||||
}
|
||||
r.recorded = true
|
||||
r.status = report.Status
|
||||
@@ -1054,28 +1018,25 @@ func (r *goalTurnRecorder) RecordGoalReport(report tool.GoalReport) (string, err
|
||||
if report.Status != GoalStatusRunning {
|
||||
r.terminal = true
|
||||
}
|
||||
return fmt.Sprintf("update_goal: %s recorded for this turn.", report.Status), nil
|
||||
}
|
||||
|
||||
func (r *goalTurnRecorder) setProgressBefore(sig string) {
|
||||
r.mu.Lock()
|
||||
r.progressBefore = sig
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *goalTurnRecorder) progressBeforeText() string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.progressBefore
|
||||
return fmt.Sprintf("update_goal: %s recorded for this turn.", wireStatus), nil
|
||||
}
|
||||
|
||||
func (r *goalTurnRecorder) addUsage(tokens int) {
|
||||
if tokens <= 0 {
|
||||
r.addUsageWithRequests(tokens, 0)
|
||||
}
|
||||
|
||||
func (r *goalTurnRecorder) addUsageWithRequests(tokens, requests int) {
|
||||
if tokens <= 0 && requests <= 0 {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.machine.foldUsage(r.scopeID, r.epoch, tokens) {
|
||||
r.tokensUsed += tokens
|
||||
if r.machine.foldUsage(r.scopeID, r.epoch, tokens, requests) {
|
||||
if tokens > 0 {
|
||||
r.tokensUsed += tokens
|
||||
}
|
||||
if requests > 0 {
|
||||
r.requestsUsed += requests
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/store"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func TestNoProgressQuotaByBudgetClass(t *testing.T) {
|
||||
cases := map[string]int{budgetClassResearch: 10, budgetClassWrite: 6, budgetClassSimple: defaultNoProgressLimit}
|
||||
for class, want := range cases {
|
||||
if got := noProgressQuota(class); got != want {
|
||||
t.Fatalf("noProgressQuota(%q) = %d, want %d", class, got, want)
|
||||
}
|
||||
}
|
||||
if got := resolvedNoProgressLimit(defaultNoProgressLimit, budgetClassResearch); got != 10 {
|
||||
t.Fatalf("legacy research limit = %d, want 10", got)
|
||||
}
|
||||
if got := resolvedNoProgressLimit(7, budgetClassWrite); got != 7 {
|
||||
t.Fatalf("custom write limit = %d, want 7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeGoalProgressEvidenceIsNovelAndBounded(t *testing.T) {
|
||||
observed := make([]string, maxGoalProgressEvidence+25)
|
||||
for i := range observed {
|
||||
observed[i] = fmt.Sprintf("sig-%03d", i)
|
||||
}
|
||||
got, progressed := mergeGoalProgressEvidence([]string{"old"}, observed)
|
||||
if !progressed || len(got) != maxGoalProgressEvidence {
|
||||
t.Fatalf("merge = progressed:%v len:%d, want true/%d", progressed, len(got), maxGoalProgressEvidence)
|
||||
}
|
||||
if repeat, advanced := mergeGoalProgressEvidence(got, got); advanced || len(repeat) != maxGoalProgressEvidence {
|
||||
t.Fatalf("exact repeat = advanced:%v len:%d", advanced, len(repeat))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnTokenNoProgressPausesAndResumeExtendsBudget(t *testing.T) {
|
||||
newMachine := func() *goalMachine {
|
||||
g := &goalMachine{goal: "fix the parser", status: GoalStatusRunning}
|
||||
g.budgetClass = budgetClassWrite
|
||||
g.turnsLimit = budgetQuota(budgetClassWrite)
|
||||
g.noProgressLimit = noProgressQuota(g.budgetClass)
|
||||
return g
|
||||
}
|
||||
in := func(report *goalTurnReport, progress ...string) goalAdvanceInput {
|
||||
return goalAdvanceInput{report: report, progressEvidence: progress}
|
||||
}
|
||||
|
||||
t.Run("turn budget pauses", func(t *testing.T) {
|
||||
g := newMachine()
|
||||
for i := range g.turnsLimit {
|
||||
res := g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "keep going"}, "sig-"+fmt.Sprint(i)))
|
||||
if res.cont && i == g.turnsLimit-1 {
|
||||
t.Fatal("last turn must pause")
|
||||
}
|
||||
}
|
||||
if g.status != GoalStatusBlocked || g.stopCause != stopCauseBudgetTurns {
|
||||
t.Fatalf("machine = (%q, %q), want blocked+budget_turns", g.status, g.stopCause)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token usage never pauses", func(t *testing.T) {
|
||||
g := newMachine()
|
||||
g.tokensUsed = 900_000
|
||||
if res := g.advance(in(&goalTurnReport{status: GoalStatusRunning}, "new-evidence")); !res.cont {
|
||||
t.Fatal("token usage must not pause the goal")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no-progress is observational", func(t *testing.T) {
|
||||
g := newMachine()
|
||||
for range g.noProgressLimit {
|
||||
if res := g.advance(in(&goalTurnReport{status: GoalStatusRunning})); !res.cont {
|
||||
t.Fatalf("legacy 4/6/10 metadata paused Goal: %+v", g)
|
||||
}
|
||||
}
|
||||
if g.status != GoalStatusRunning || g.stopCause != "" || g.noProgressTurns != g.noProgressLimit {
|
||||
t.Fatalf("unexpected observational runtime: %+v", g)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("host-verifiable progress resets no-progress", func(t *testing.T) {
|
||||
g := newMachine()
|
||||
for range g.noProgressLimit - 1 {
|
||||
g.advance(in(&goalTurnReport{status: GoalStatusRunning}))
|
||||
}
|
||||
if res := g.advance(in(&goalTurnReport{status: GoalStatusRunning}, "new-evidence")); !res.cont || g.noProgressTurns != 0 {
|
||||
t.Fatalf("progress did not reset observational streak: %+v", g)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exact repeated evidence does not reset no-progress", func(t *testing.T) {
|
||||
g := newMachine()
|
||||
g.advance(in(&goalTurnReport{status: GoalStatusRunning}, "same-read"))
|
||||
for range g.noProgressLimit {
|
||||
g.advance(in(&goalTurnReport{status: GoalStatusRunning}, "same-read"))
|
||||
}
|
||||
if g.status != GoalStatusRunning || g.stopCause != "" || g.noProgressTurns != g.noProgressLimit {
|
||||
t.Fatalf("repeated evidence changed runtime: %+v", g)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("research accepts new read evidence but keeps a larger synthesis budget", func(t *testing.T) {
|
||||
g := &goalMachine{goal: "research", status: GoalStatusRunning, budgetClass: budgetClassResearch,
|
||||
turnsLimit: budgetQuota(budgetClassResearch), noProgressLimit: noProgressQuota(budgetClassResearch)}
|
||||
for i := range 8 {
|
||||
if res := g.advance(in(&goalTurnReport{status: GoalStatusRunning}, "read-"+fmt.Sprint(i))); !res.cont {
|
||||
t.Fatalf("new research evidence paused at turn %d", i+1)
|
||||
}
|
||||
}
|
||||
if g.noProgressTurns != 0 || g.noProgressLimit != 10 {
|
||||
t.Fatalf("research runtime = no-progress %d/%d", g.noProgressTurns, g.noProgressLimit)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume extension contract", func(t *testing.T) {
|
||||
g := newMachine()
|
||||
for range g.turnsLimit {
|
||||
g.advance(in(&goalTurnReport{status: GoalStatusRunning}))
|
||||
}
|
||||
before := g.turnsLimit
|
||||
_, _, _, resumed, extended := g.resume(nil)
|
||||
if !resumed || !extended || g.turnsLimit != before+budgetQuota(budgetClassWrite) || g.budgetExtensions != 1 {
|
||||
t.Fatalf("budget resume failed: %+v", g)
|
||||
}
|
||||
g.pauseFor(stopCauseManual, "user paused", nil)
|
||||
before = g.turnsLimit
|
||||
_, _, _, resumed, extended = g.resume(nil)
|
||||
if !resumed || extended || g.turnsLimit != before {
|
||||
t.Fatalf("manual resume extended budget: %+v", g)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWireContinueDoesNotKeepExactRepeatedEvidenceAlive(t *testing.T) {
|
||||
g := &goalMachine{goal: "repeat", status: GoalStatusRunning, budgetClass: budgetClassSimple,
|
||||
turnsLimit: budgetQuota(budgetClassSimple), noProgressLimit: noProgressQuota(budgetClassSimple), scopeID: newGoalScopeID()}
|
||||
advance := func() goalAdvanceResult {
|
||||
epoch := g.continuationEpoch
|
||||
rec := g.newTurnRecorder(g.scopeID, epoch)
|
||||
if _, err := rec.RecordGoalReport(tool.GoalReport{Status: "continue", Reason: "same", NextAction: "repeat"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g.advance(goalAdvanceInput{report: rec.validReport(epoch), progressEvidence: []string{"same-read"}, expectedEpoch: &epoch})
|
||||
}
|
||||
if res := advance(); !res.cont || g.noProgressTurns != 0 {
|
||||
t.Fatalf("first read = %+v runtime=%+v", res, g.runtimeView())
|
||||
}
|
||||
for range g.noProgressLimit {
|
||||
advance()
|
||||
}
|
||||
if g.status != GoalStatusRunning || g.stopCause != "" || g.noProgressTurns != g.noProgressLimit {
|
||||
t.Fatalf("wire continue changed runtime: %+v", g.runtimeView())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalLegacyNoProgressSidecarAutoResumesWithoutExtension(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
state := goalState{Goal: "finish", Status: GoalStatusBlocked, StopCause: stopCauseNoProgress,
|
||||
Block: "no progress", BudgetClass: budgetClassWrite, TurnsUsed: 6, TurnsLimit: 20,
|
||||
NoProgressTurns: 6, NoProgressLimit: 6, RequestsUsed: 12}
|
||||
raw, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(store.SessionGoalState(path), raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := &goalMachine{}
|
||||
_, data, migrated, _ := g.restoreFromState(path)
|
||||
if !migrated || g.status != GoalStatusRunning || g.stopCause != "" || g.block != "" {
|
||||
t.Fatalf("legacy pause did not migrate: %+v", g)
|
||||
}
|
||||
if g.turnsLimit != 20 || g.budgetExtensions != 0 || g.requestsUsed != 12 || g.noProgressTurns != 6 {
|
||||
t.Fatalf("migration changed counters: %+v", g.runtimeView())
|
||||
}
|
||||
var normalized goalState
|
||||
if err := json.Unmarshal(data, &normalized); err != nil || normalized.Status != GoalStatusRunning || normalized.RequestsUsed != 12 {
|
||||
t.Fatalf("normalized sidecar = %+v err=%v", normalized, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalSidecarPreservesUnknownFieldsAndOldReaderIgnoresRequests(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
raw := []byte(`{"goal":"ship","status":"running","turnsLimit":10,"requestsUsed":7,"futurePolicy":{"mode":"adaptive"}}`)
|
||||
if err := os.WriteFile(store.SessionGoalState(path), raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := &goalMachine{}
|
||||
g.restoreFromState(path)
|
||||
g.mu.Lock()
|
||||
_, data, ok := g.buildStateLocked(nil)
|
||||
g.mu.Unlock()
|
||||
if !ok {
|
||||
t.Fatal("expected persisted state")
|
||||
}
|
||||
var roundTrip map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &roundTrip); err != nil || string(roundTrip["futurePolicy"]) != `{"mode":"adaptive"}` {
|
||||
t.Fatalf("unknown field was lost: %s err=%v", data, err)
|
||||
}
|
||||
var oldReader struct {
|
||||
Goal string `json:"goal"`
|
||||
TurnsLimit int `json:"turnsLimit"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &oldReader); err != nil || oldReader.Goal != "ship" || oldReader.TurnsLimit != 10 {
|
||||
t.Fatalf("old reader = %+v err=%v", oldReader, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoalRunBoundariesPauseAfterTerminalDecisions(t *testing.T) {
|
||||
t.Run("continue pauses", func(t *testing.T) {
|
||||
g := &goalMachine{goal: "ship", status: GoalStatusRunning, turnsLimit: 10}
|
||||
res := g.advance(goalAdvanceInput{report: &goalTurnReport{status: GoalStatusRunning}, pauseCause: stopCauseGoalRunBudget, pauseReason: "16 rounds"})
|
||||
if res.cont || g.status != GoalStatusBlocked || g.stopCause != stopCauseGoalRunBudget {
|
||||
t.Fatalf("result=%+v runtime=%+v", res, g.runtimeView())
|
||||
}
|
||||
before := g.turnsLimit
|
||||
_, _, _, resumed, extended := g.resume(nil)
|
||||
if !resumed || extended || g.turnsLimit != before {
|
||||
t.Fatalf("resume extended outer budget: %+v", g)
|
||||
}
|
||||
})
|
||||
t.Run("completion wins", func(t *testing.T) {
|
||||
g := &goalMachine{goal: "ship", status: GoalStatusRunning, turnsLimit: 10}
|
||||
res := g.advance(goalAdvanceInput{report: &goalTurnReport{status: GoalStatusComplete}, readiness: agent.ReadinessResult{Ready: true}, pauseCause: stopCauseGoalStuck})
|
||||
if res.notice != goalCompleteNotice || g.status != GoalStatusComplete || g.stopCause != "" {
|
||||
t.Fatalf("result=%+v runtime=%+v", res, g.runtimeView())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGoalProgressEvidenceRestoresAsBoundedNoveltyState(t *testing.T) {
|
||||
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
state := goalState{Goal: "research", Status: GoalStatusRunning, BudgetClass: budgetClassResearch,
|
||||
TurnsLimit: budgetQuota(budgetClassResearch), NoProgressTurns: 2, NoProgressLimit: defaultNoProgressLimit,
|
||||
ProgressEvidence: []string{"read-a", "read-a", "read-b", strings.Repeat("x", 129)}}
|
||||
raw, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeGoalStateData(goalStatePath(sessionPath), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := &goalMachine{}
|
||||
_, data, migrated, _ := g.restoreFromState(sessionPath)
|
||||
if !migrated || g.noProgressLimit != 10 || len(g.progressEvidence) != 2 {
|
||||
t.Fatalf("restore failed: migrated=%v runtime=%+v", migrated, g)
|
||||
}
|
||||
var normalized goalState
|
||||
if err := json.Unmarshal(data, &normalized); err != nil || len(normalized.ProgressEvidence) != 2 {
|
||||
t.Fatalf("normalized sidecar = %+v err=%v", normalized, err)
|
||||
}
|
||||
g.advance(goalAdvanceInput{report: &goalTurnReport{status: GoalStatusRunning}, progressEvidence: []string{"read-a"}})
|
||||
if g.noProgressTurns != 3 {
|
||||
t.Fatalf("restored repeat reset streak to %d", g.noProgressTurns)
|
||||
}
|
||||
g.advance(goalAdvanceInput{report: &goalTurnReport{status: GoalStatusRunning}, progressEvidence: []string{"read-c"}})
|
||||
if g.noProgressTurns != 0 {
|
||||
t.Fatalf("new evidence left streak at %d", g.noProgressTurns)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -21,6 +22,7 @@ type goalMachineSnapshot struct {
|
||||
turnsUsed int
|
||||
turnsLimit int
|
||||
tokensUsed int
|
||||
requestsUsed int
|
||||
tokensLimit int
|
||||
noProgressTurns int
|
||||
noProgressLimit int
|
||||
@@ -28,6 +30,8 @@ type goalMachineSnapshot struct {
|
||||
lastEvaluatorReason string
|
||||
stopCause string
|
||||
budgetExtensions int
|
||||
progressEvidence []string
|
||||
stateExtra map[string]json.RawMessage
|
||||
}
|
||||
|
||||
func (g *goalMachine) capture() goalMachineSnapshot {
|
||||
@@ -39,11 +43,14 @@ func (g *goalMachine) capture() goalMachineSnapshot {
|
||||
block: g.block, strict: g.strict,
|
||||
budgetClass: g.budgetClass, turnsUsed: g.turnsUsed,
|
||||
turnsLimit: g.turnsLimit, tokensUsed: g.tokensUsed,
|
||||
tokensLimit: g.tokensLimit, noProgressTurns: g.noProgressTurns,
|
||||
requestsUsed: g.requestsUsed,
|
||||
tokensLimit: g.tokensLimit, noProgressTurns: g.noProgressTurns,
|
||||
noProgressLimit: g.noProgressLimit,
|
||||
lastContinuationReason: g.lastContinuationReason,
|
||||
lastEvaluatorReason: g.lastEvaluatorReason,
|
||||
stopCause: g.stopCause, budgetExtensions: g.budgetExtensions,
|
||||
progressEvidence: append([]string(nil), g.progressEvidence...),
|
||||
stateExtra: cloneGoalStateExtra(g.stateExtra),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +63,14 @@ func (g *goalMachine) restore(snapshot goalMachineSnapshot) {
|
||||
g.budgetClass = snapshot.budgetClass
|
||||
g.turnsUsed, g.turnsLimit = snapshot.turnsUsed, snapshot.turnsLimit
|
||||
g.tokensUsed, g.tokensLimit = snapshot.tokensUsed, snapshot.tokensLimit
|
||||
g.requestsUsed = snapshot.requestsUsed
|
||||
g.noProgressTurns, g.noProgressLimit = snapshot.noProgressTurns, snapshot.noProgressLimit
|
||||
g.lastContinuationReason = snapshot.lastContinuationReason
|
||||
g.lastEvaluatorReason = snapshot.lastEvaluatorReason
|
||||
g.stopCause = snapshot.stopCause
|
||||
g.budgetExtensions = snapshot.budgetExtensions
|
||||
g.progressEvidence = append([]string(nil), snapshot.progressEvidence...)
|
||||
g.stateExtra = cloneGoalStateExtra(snapshot.stateExtra)
|
||||
g.continuationEpoch++
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func TestSetGoalDurableRollsBackAllRuntimeStateOnWriteFailure(t *testing.T) {
|
||||
c.goals.noProgressTurns = 2
|
||||
c.goals.lastContinuationReason = "preserve this reason"
|
||||
c.goals.budgetExtensions = 1
|
||||
c.goals.progressEvidence = []string{"existing-read"}
|
||||
c.goals.mu.Unlock()
|
||||
want := c.GoalRuntime()
|
||||
|
||||
@@ -35,4 +36,9 @@ func TestSetGoalDurableRollsBackAllRuntimeStateOnWriteFailure(t *testing.T) {
|
||||
if got := c.GoalRuntime(); got != want {
|
||||
t.Fatalf("GoalRuntime() after failed durable write = %+v, want %+v", got, want)
|
||||
}
|
||||
c.goals.mu.Lock()
|
||||
defer c.goals.mu.Unlock()
|
||||
if len(c.goals.progressEvidence) != 1 || c.goals.progressEvidence[0] != "existing-read" {
|
||||
t.Fatalf("Goal evidence after rollback = %v, want preserved", c.goals.progressEvidence)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,8 @@ func goalStateNeedsMigration(state goalState, normalizedBudgetClass string) bool
|
||||
expectedMode = GoalResearchOn
|
||||
}
|
||||
return state.TokensLimit != 0 || state.ResearchMode != expectedMode ||
|
||||
(state.BudgetClass != "" && state.BudgetClass != normalizedBudgetClass)
|
||||
(state.BudgetClass != "" && state.BudgetClass != normalizedBudgetClass) ||
|
||||
(state.NoProgressLimit > 0 && state.NoProgressLimit != resolvedNoProgressLimit(state.NoProgressLimit, normalizedBudgetClass))
|
||||
}
|
||||
|
||||
// blockLegacyRestore fails closed only while the decoded sidecar still owns the
|
||||
@@ -141,7 +142,7 @@ func (g *goalMachine) fillGoalTextIfEmpty(expectedEpoch uint64, goal string) (ui
|
||||
g.turnsLimit = budgetQuota(g.budgetClass)
|
||||
}
|
||||
if g.noProgressLimit == 0 {
|
||||
g.noProgressLimit = defaultNoProgressLimit
|
||||
g.noProgressLimit = noProgressQuota(g.budgetClass)
|
||||
}
|
||||
if g.scopeID == "" {
|
||||
g.scopeID = newGoalScopeID()
|
||||
@@ -172,7 +173,7 @@ func (g *goalMachine) resumeLegacyArchive(expectedEpoch uint64, goal string) (ui
|
||||
g.turnsLimit = budgetQuota(g.budgetClass)
|
||||
}
|
||||
if g.noProgressLimit == 0 {
|
||||
g.noProgressLimit = defaultNoProgressLimit
|
||||
g.noProgressLimit = noProgressQuota(g.budgetClass)
|
||||
}
|
||||
if g.scopeID == "" {
|
||||
g.scopeID = newGoalScopeID()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package control
|
||||
|
||||
import "strings"
|
||||
|
||||
// noProgressQuota returns deprecated compatibility metadata for old clients.
|
||||
func noProgressQuota(class string) int {
|
||||
switch class {
|
||||
case budgetClassResearch:
|
||||
return 10
|
||||
case budgetClassWrite:
|
||||
return 6
|
||||
default:
|
||||
return defaultNoProgressLimit
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedNoProgressLimit(persisted int, class string) int {
|
||||
if persisted > 0 && persisted != defaultNoProgressLimit {
|
||||
return persisted
|
||||
}
|
||||
return noProgressQuota(class)
|
||||
}
|
||||
|
||||
// mergeGoalProgressEvidence updates the bounded Goal-scoped novelty window.
|
||||
func mergeGoalProgressEvidence(existing, observed []string) ([]string, bool) {
|
||||
if len(existing) > maxGoalProgressEvidence {
|
||||
existing = existing[len(existing)-maxGoalProgressEvidence:]
|
||||
}
|
||||
if len(observed) > maxGoalProgressEvidence {
|
||||
observed = observed[len(observed)-maxGoalProgressEvidence:]
|
||||
}
|
||||
out := make([]string, 0, min(len(existing)+len(observed), maxGoalProgressEvidence))
|
||||
seen := make(map[string]struct{}, min(len(existing)+len(observed), maxGoalProgressEvidence))
|
||||
appendValid := func(sig string) bool {
|
||||
sig = strings.TrimSpace(sig)
|
||||
if sig == "" || len(sig) > 128 {
|
||||
return false
|
||||
}
|
||||
if _, ok := seen[sig]; ok {
|
||||
return false
|
||||
}
|
||||
seen[sig] = struct{}{}
|
||||
out = append(out, sig)
|
||||
return true
|
||||
}
|
||||
for _, sig := range existing {
|
||||
appendValid(sig)
|
||||
}
|
||||
progressed := false
|
||||
for _, sig := range observed {
|
||||
if appendValid(sig) {
|
||||
progressed = true
|
||||
}
|
||||
}
|
||||
if len(out) > maxGoalProgressEvidence {
|
||||
out = append([]string(nil), out[len(out)-maxGoalProgressEvidence:]...)
|
||||
}
|
||||
return out, progressed
|
||||
}
|
||||
|
||||
func (g *goalMachine) observeGoalProgress(in goalAdvanceInput) {
|
||||
progressed := false
|
||||
g.progressEvidence, progressed = mergeGoalProgressEvidence(g.progressEvidence, in.progressEvidence)
|
||||
if in.report != nil && in.report.status != GoalStatusRunning {
|
||||
progressed = true
|
||||
}
|
||||
if progressed {
|
||||
g.noProgressTurns = 0
|
||||
return
|
||||
}
|
||||
g.noProgressTurns++
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package control
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -110,14 +109,13 @@ func TestEvaluatorOutcomesDriveFSM(t *testing.T) {
|
||||
c.Submit("/goal assess the impact")
|
||||
waitGoalTurnDone(t, events)
|
||||
if tc.wantStatus == GoalStatusRunning {
|
||||
// continue keeps the loop going; without host-verifiable
|
||||
// progress it eventually pauses on the no-progress gate rather
|
||||
// than stopping at turn 1.
|
||||
// Continue keeps the loop going until the recoverable outer turn
|
||||
// backstop; cross-turn no-progress is observational only.
|
||||
if got := c.GoalStatus(); got != GoalStatusBlocked {
|
||||
t.Fatalf("GoalStatus() = %q, want the loop to keep going until a pause", got)
|
||||
}
|
||||
if rt := c.GoalRuntime(); rt.StopCause != stopCauseNoProgress || rt.TurnsUsed < 2 {
|
||||
t.Fatalf("runtime = %+v, want no-progress pause after multiple turns", rt)
|
||||
if rt := c.GoalRuntime(); rt.StopCause != stopCauseBudgetTurns || rt.TurnsUsed != rt.TurnsLimit {
|
||||
t.Fatalf("runtime = %+v, want outer turn-budget pause", rt)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -183,118 +181,18 @@ func TestEvaluatorCompleteStillGatedByReadiness(t *testing.T) {
|
||||
c.Submit("/goal fix everything")
|
||||
<-done
|
||||
// The evaluator's complete claim is rejected (incomplete todos) and the
|
||||
// goal continues; without host-verifiable progress the no-progress gate
|
||||
// pauses instead of looping forever.
|
||||
// goal continues until the outer continuation backstop.
|
||||
if got := c.GoalStatus(); got != GoalStatusBlocked {
|
||||
t.Fatalf("GoalStatus() = %q, want blocked (no-progress after rejected complete)", got)
|
||||
t.Fatalf("GoalStatus() = %q, want blocked (turn budget after rejected complete)", got)
|
||||
}
|
||||
if rt := c.GoalRuntime(); rt.StopCause != stopCauseNoProgress {
|
||||
t.Fatalf("StopCause = %q, want %q", rt.StopCause, stopCauseNoProgress)
|
||||
if rt := c.GoalRuntime(); rt.StopCause != stopCauseBudgetTurns {
|
||||
t.Fatalf("StopCause = %q, want %q", rt.StopCause, stopCauseBudgetTurns)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnTokenNoProgressPausesAndResumeExtendsBudget covers turn and
|
||||
// no-progress budgets at the FSM level and the resume extension contract.
|
||||
// TestTurnTokenNoProgressPausesAndResumeExtendsBudget covers the outer turn
|
||||
// budget, observational no-progress state, and the resume extension contract.
|
||||
// Token hard limits no longer pause goals.
|
||||
func TestTurnTokenNoProgressPausesAndResumeExtendsBudget(t *testing.T) {
|
||||
newMachine := func() *goalMachine {
|
||||
g := &goalMachine{goal: "fix the parser", status: GoalStatusRunning}
|
||||
g.budgetClass = budgetClassWrite
|
||||
g.turnsLimit = budgetQuota(budgetClassWrite)
|
||||
g.tokensLimit = 0
|
||||
g.noProgressLimit = defaultNoProgressLimit
|
||||
return g
|
||||
}
|
||||
in := func(report *goalTurnReport, before, after string) goalAdvanceInput {
|
||||
return goalAdvanceInput{report: report, progressBefore: before, progressAfter: after}
|
||||
}
|
||||
|
||||
t.Run("turn budget pauses", func(t *testing.T) {
|
||||
g := newMachine()
|
||||
for i := range g.turnsLimit {
|
||||
// Progress changes every turn so only the turn budget can fire.
|
||||
res := g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "keep going"}, "s", "s"+fmt.Sprint(i)))
|
||||
if res.cont && i == g.turnsLimit-1 {
|
||||
t.Fatal("last turn must pause")
|
||||
}
|
||||
}
|
||||
if g.status != GoalStatusBlocked || g.stopCause != stopCauseBudgetTurns {
|
||||
t.Fatalf("machine = (%q, %q), want blocked+budget_turns", g.status, g.stopCause)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("token usage never pauses", func(t *testing.T) {
|
||||
g := newMachine()
|
||||
// Even a huge observational total must not stop the goal.
|
||||
g.tokensUsed = 900_000
|
||||
res := g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "keep going"}, "s", "s2"))
|
||||
if !res.cont {
|
||||
t.Fatal("token usage must not pause the goal")
|
||||
}
|
||||
if g.stopCause != "" {
|
||||
t.Fatalf("stop cause = %q, want empty", g.stopCause)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no-progress pauses", func(t *testing.T) {
|
||||
g := newMachine()
|
||||
for range g.noProgressLimit {
|
||||
g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "still working"}, "sig", "sig"))
|
||||
}
|
||||
if g.status != GoalStatusBlocked || g.stopCause != stopCauseNoProgress {
|
||||
t.Fatalf("machine = (%q, %q), want blocked+no_progress", g.status, g.stopCause)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("host-verifiable progress resets no-progress", func(t *testing.T) {
|
||||
g := newMachine()
|
||||
for range g.noProgressLimit - 1 {
|
||||
g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "still working"}, "sig", "sig"))
|
||||
}
|
||||
res := g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "progress!"}, "sig", "sig2"))
|
||||
if !res.cont {
|
||||
t.Fatalf("progress must reset the stall counter: %+v", g)
|
||||
}
|
||||
if g.noProgressTurns != 0 {
|
||||
t.Fatalf("noProgressTurns = %d, want 0", g.noProgressTurns)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume extends turn budget once", func(t *testing.T) {
|
||||
g := newMachine()
|
||||
for range g.turnsLimit {
|
||||
g.advance(in(&goalTurnReport{status: GoalStatusRunning, reason: "keep going"}, "s", "s"))
|
||||
}
|
||||
beforeLimit := g.turnsLimit
|
||||
_, _, _, resumed, extended := g.resume(nil)
|
||||
if !resumed || !extended {
|
||||
t.Fatalf("resume = (%v, %v), want (true, true)", resumed, extended)
|
||||
}
|
||||
if g.turnsLimit != beforeLimit+budgetQuota(budgetClassWrite) {
|
||||
t.Fatalf("turnsLimit = %d, want %d", g.turnsLimit, beforeLimit+budgetQuota(budgetClassWrite))
|
||||
}
|
||||
if g.tokensLimit != 0 {
|
||||
t.Fatalf("tokensLimit = %d, want 0 (no hard token ceiling)", g.tokensLimit)
|
||||
}
|
||||
if g.budgetExtensions != 1 || g.stopCause != "" || g.noProgressTurns != 0 {
|
||||
t.Fatalf("machine after resume = %+v", g)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("manual pause resume does not extend", func(t *testing.T) {
|
||||
g := newMachine()
|
||||
g.pauseFor(stopCauseManual, "user paused", nil)
|
||||
beforeLimit := g.turnsLimit
|
||||
_, _, _, resumed, extended := g.resume(nil)
|
||||
if !resumed || extended {
|
||||
t.Fatalf("resume = (%v, %v), want (true, false)", resumed, extended)
|
||||
}
|
||||
if g.turnsLimit != beforeLimit {
|
||||
t.Fatalf("turnsLimit changed on manual resume: %d", g.turnsLimit)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGoalTurnRecorderProtocol covers idempotency, upgrades, terminal
|
||||
// conflicts, and stale-epoch rejection.
|
||||
func TestGoalTurnRecorderProtocol(t *testing.T) {
|
||||
@@ -322,6 +220,20 @@ func TestGoalTurnRecorderProtocol(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wire continue maps to the running FSM state", func(t *testing.T) {
|
||||
_, rec := newRec(t)
|
||||
got, err := rec.RecordGoalReport(report("continue", "working"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "update_goal: continue recorded for this turn." {
|
||||
t.Fatalf("tool result = %q", got)
|
||||
}
|
||||
if got := rec.validReport(rec.epoch); got == nil || got.status != GoalStatusRunning {
|
||||
t.Fatalf("validReport = %+v, want internal running status", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("continue upgrades to complete", func(t *testing.T) {
|
||||
_, rec := newRec(t)
|
||||
if _, err := rec.RecordGoalReport(report(GoalStatusRunning, "working")); err != nil {
|
||||
@@ -401,12 +313,12 @@ func TestGoalUsageTeeAttributesScopedBillableCallsAndExcludesTitle(t *testing.T)
|
||||
g.budgetClass = budgetClassWrite
|
||||
g.turnsLimit = budgetQuota(budgetClassWrite)
|
||||
g.tokensLimit = 0
|
||||
g.noProgressLimit = defaultNoProgressLimit
|
||||
g.noProgressLimit = noProgressQuota(g.budgetClass)
|
||||
g.scopeID = newGoalScopeID()
|
||||
rec := g.newTurnRecorder(g.scopeID, g.continuationEpoch)
|
||||
tee.setActiveRecorder(rec)
|
||||
|
||||
usage := func(tokens int) *provider.Usage { return &provider.Usage{TotalTokens: tokens} }
|
||||
usage := func(tokens int) *provider.Usage { return &provider.Usage{TotalTokens: tokens, RequestCount: 1} }
|
||||
tee.Emit(event.Event{Kind: event.Usage, Usage: usage(100), UsageSource: event.UsageSourceExecutor})
|
||||
tee.Emit(event.Event{Kind: event.Usage, Usage: usage(200), UsageSource: event.UsageSourcePlanner})
|
||||
tee.Emit(event.Event{Kind: event.Usage, Usage: usage(300), UsageSource: event.UsageSourceSubagent})
|
||||
@@ -425,6 +337,9 @@ func TestGoalUsageTeeAttributesScopedBillableCallsAndExcludesTitle(t *testing.T)
|
||||
if g.tokensUsed != 3600 {
|
||||
t.Fatalf("live goal tokens = %d, want 3600", g.tokensUsed)
|
||||
}
|
||||
if g.requestsUsed != 8 || rec.requestsUsed != 8 {
|
||||
t.Fatalf("requests = goal:%d recorder:%d, want 8", g.requestsUsed, rec.requestsUsed)
|
||||
}
|
||||
|
||||
// No active goal turn → nothing folds.
|
||||
tee.setActiveRecorder(nil)
|
||||
@@ -537,9 +452,8 @@ func TestGoalLargeTokenUsageDoesNotExhaustBudget(t *testing.T) {
|
||||
t.Fatal("budgetExhausted must ignore tokensUsed")
|
||||
}
|
||||
res := g.advance(goalAdvanceInput{
|
||||
report: &goalTurnReport{status: GoalStatusRunning, reason: "progress"},
|
||||
progressBefore: "a",
|
||||
progressAfter: "b",
|
||||
report: &goalTurnReport{status: GoalStatusRunning, reason: "progress"},
|
||||
progressEvidence: []string{"new-evidence"},
|
||||
})
|
||||
if !res.cont {
|
||||
t.Fatal("goal with large tokensUsed must continue while turns remain")
|
||||
@@ -692,12 +606,12 @@ func TestFooterTextDoesNotDriveGoalState(t *testing.T) {
|
||||
c.Submit("/goal migrate the storage")
|
||||
waitGoalTurnDone(t, events)
|
||||
// The footer alone must never complete the goal: the evaluator's continue
|
||||
// keeps it going until the no-progress gate pauses it.
|
||||
// keeps it going until the outer continuation backstop pauses it.
|
||||
if got := c.GoalStatus(); got == GoalStatusComplete {
|
||||
t.Fatal("a [goal:complete] footer must not complete the goal")
|
||||
}
|
||||
if rt := c.GoalRuntime(); rt.StopCause != stopCauseNoProgress {
|
||||
t.Fatalf("runtime = %+v, want no-progress pause (footer ignored)", rt)
|
||||
if rt := c.GoalRuntime(); rt.StopCause != stopCauseBudgetTurns {
|
||||
t.Fatalf("runtime = %+v, want turn-budget pause (footer ignored)", rt)
|
||||
}
|
||||
c.ClearGoal()
|
||||
for _, m := range c.History() {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package control
|
||||
|
||||
import "fmt"
|
||||
|
||||
// GoalRuntimeView is the host-side runtime summary exposed to frontends.
|
||||
type GoalRuntimeView struct {
|
||||
TurnsUsed int
|
||||
TurnsLimit int
|
||||
TokensUsed int
|
||||
RequestsUsed int
|
||||
TokensLimit int
|
||||
NoProgressTurns int
|
||||
NoProgressLimit int
|
||||
LastReason string
|
||||
StopCause string
|
||||
BudgetExtensions int
|
||||
}
|
||||
|
||||
func (g *goalMachine) runtimeView() GoalRuntimeView {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
last := g.lastEvaluatorReason
|
||||
if last == "" {
|
||||
last = g.lastContinuationReason
|
||||
}
|
||||
return GoalRuntimeView{
|
||||
TurnsUsed: g.turnsUsed, TurnsLimit: g.turnsLimit,
|
||||
TokensUsed: g.tokensUsed, RequestsUsed: g.requestsUsed,
|
||||
TokensLimit: 0, NoProgressTurns: g.noProgressTurns,
|
||||
NoProgressLimit: g.noProgressLimit, LastReason: last,
|
||||
StopCause: g.stopCause, BudgetExtensions: g.budgetExtensions,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *goalMachine) lastContinuationReasonText() string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.lastEvaluatorReason != "" {
|
||||
return g.lastEvaluatorReason
|
||||
}
|
||||
return g.lastContinuationReason
|
||||
}
|
||||
|
||||
func (g *goalMachine) budgetStatusText() string {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return fmt.Sprintf("turns: %d/%d used, tokens: %d, requests: %d, no-progress turns: %d (observational)",
|
||||
g.turnsUsed, g.turnsLimit, g.tokensUsed, g.requestsUsed, g.noProgressTurns)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package control
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
var goalStateKnownFields = map[string]struct{}{
|
||||
"goal": {}, "status": {}, "researchMode": {}, "autoResearchTaskID": {},
|
||||
"scopeID": {}, "deliveryCheckpoint": {}, "turns": {}, "blocks": {},
|
||||
"block": {}, "strict": {}, "todos": {}, "budgetClass": {},
|
||||
"turnsUsed": {}, "turnsLimit": {}, "tokensUsed": {}, "requestsUsed": {},
|
||||
"tokensLimit": {}, "noProgressTurns": {}, "noProgressLimit": {},
|
||||
"lastContinuationReason": {}, "lastEvaluatorReason": {}, "stopCause": {},
|
||||
"budgetExtensions": {}, "progressEvidence": {},
|
||||
}
|
||||
|
||||
func goalStateUnknownFields(raw []byte) map[string]json.RawMessage {
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &fields) != nil {
|
||||
return nil
|
||||
}
|
||||
for key := range goalStateKnownFields {
|
||||
delete(fields, key)
|
||||
}
|
||||
return cloneGoalStateExtra(fields)
|
||||
}
|
||||
|
||||
func marshalGoalState(state goalState, extra map[string]json.RawMessage) ([]byte, error) {
|
||||
known, err := json.Marshal(state)
|
||||
if err != nil || len(extra) == 0 {
|
||||
return known, err
|
||||
}
|
||||
var merged map[string]json.RawMessage
|
||||
if err := json.Unmarshal(known, &merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range extra {
|
||||
if _, current := goalStateKnownFields[key]; current {
|
||||
continue
|
||||
}
|
||||
merged[key] = append(json.RawMessage(nil), value...)
|
||||
}
|
||||
return json.Marshal(merged)
|
||||
}
|
||||
|
||||
func cloneGoalStateExtra(in map[string]json.RawMessage) map[string]json.RawMessage {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]json.RawMessage, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = append(json.RawMessage(nil), value...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// migrateRemovedGoalPause clears pauses produced by gates no longer enforced.
|
||||
func (g *goalMachine) migrateRemovedGoalPause() bool {
|
||||
if g.status != GoalStatusBlocked {
|
||||
return false
|
||||
}
|
||||
if g.stopCause != stopCauseBudgetTokens && g.stopCause != stopCauseNoProgress {
|
||||
return false
|
||||
}
|
||||
g.status = GoalStatusRunning
|
||||
g.stopCause = ""
|
||||
g.block = ""
|
||||
return true
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func (t *goalUsageTee) Emit(e event.Event) {
|
||||
rec := t.active
|
||||
t.mu.Unlock()
|
||||
if rec != nil {
|
||||
rec.addUsage(usageTotalTokens(e.Usage))
|
||||
rec.addUsageWithRequests(usageTotalTokens(e.Usage), e.Usage.RequestCount)
|
||||
}
|
||||
}
|
||||
if t.inner != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
@@ -22,6 +23,11 @@ type turnOrchestrator struct {
|
||||
c *Controller
|
||||
}
|
||||
|
||||
const (
|
||||
goalRunRoundLimit = 16
|
||||
goalRunRoundKey = "goal model rounds"
|
||||
)
|
||||
|
||||
type orchestratedTurn struct {
|
||||
input string
|
||||
raw string
|
||||
@@ -91,7 +97,9 @@ func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, sk
|
||||
// The skill turn's model requests count against the active goal's token
|
||||
// budget, so bind a recorder for the span even though the sub-agent cannot
|
||||
// call update_goal itself.
|
||||
if scopeID, _, ok := o.c.goals.deliveryScope(); ok {
|
||||
if scopeID, goal, ok := o.c.goals.deliveryScope(); ok {
|
||||
ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{ID: scopeID, TaskText: goal})
|
||||
ctx = agent.WithDefaultRunStepLimit(ctx, goalRunRoundLimit, goalRunRoundKey)
|
||||
recorder := o.c.goals.newTurnRecorder(scopeID, o.c.goals.continuationToken())
|
||||
o.c.goalUsageTee.setActiveRecorder(recorder)
|
||||
}
|
||||
@@ -100,8 +108,11 @@ func (o *turnOrchestrator) runSubagentSkillTurnsGoalLoop(ctx context.Context, sk
|
||||
o.c.goalUsageTee.setActiveRecorder(nil)
|
||||
o.c.stopGoal(GoalStatusStopped)
|
||||
}
|
||||
o.c.goalUsageTee.setActiveRecorder(nil)
|
||||
return err
|
||||
if !goalTurnErrorAbsorbable(err) || !o.c.goals.active() {
|
||||
o.c.goalUsageTee.setActiveRecorder(nil)
|
||||
return err
|
||||
}
|
||||
return o.continueGoal(ctx, expectedContinuationEpoch, err)
|
||||
}
|
||||
return o.continueGoal(ctx, expectedContinuationEpoch, nil)
|
||||
}
|
||||
@@ -270,10 +281,8 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
|
||||
// active until the FSM commits (advanceGoalAfterTurn) so evaluator usage
|
||||
// also counts; error paths that skip the FSM clear it explicitly.
|
||||
if goalScopeID, ok := c.goals.goalScopeIDForTurn(continuation); ok {
|
||||
ctx = agent.WithDefaultRunStepLimit(ctx, goalRunRoundLimit, goalRunRoundKey)
|
||||
recorder := c.goals.newTurnRecorder(goalScopeID, c.goals.continuationToken())
|
||||
if c.executor != nil {
|
||||
recorder.setProgressBefore(c.executor.HostProgressSignature())
|
||||
}
|
||||
ctx = tool.WithGoalTurnRecorder(ctx, recorder)
|
||||
c.goalUsageTee.setActiveRecorder(recorder)
|
||||
}
|
||||
@@ -403,8 +412,7 @@ func (o *turnOrchestrator) runGoalLoopWithPreparedTurn(ctx context.Context, turn
|
||||
o.c.stopGoal(GoalStatusStopped)
|
||||
return err
|
||||
}
|
||||
var readinessErr *agent.FinalReadinessError
|
||||
if !errors.As(err, &readinessErr) || !o.c.goals.active() {
|
||||
if !goalTurnErrorAbsorbable(err) || !o.c.goals.active() {
|
||||
// Terminal provider/host error (or a plain non-Goal Delivery
|
||||
// readiness failure): stop auto-continue. With no active Goal the
|
||||
// error surfaces the recovery card; with a Goal it stays running so
|
||||
@@ -435,8 +443,7 @@ func (o *turnOrchestrator) runEditedGoalLoopWithImageRefsRawDisplay(ctx context.
|
||||
o.c.stopGoal(GoalStatusStopped)
|
||||
return err
|
||||
}
|
||||
var readinessErr *agent.FinalReadinessError
|
||||
if !errors.As(err, &readinessErr) || !o.c.goals.active() {
|
||||
if !goalTurnErrorAbsorbable(err) || !o.c.goals.active() {
|
||||
o.c.goalUsageTee.setActiveRecorder(nil)
|
||||
return err
|
||||
}
|
||||
@@ -477,8 +484,7 @@ func (o *turnOrchestrator) continueGoal(ctx context.Context, expectedContinuatio
|
||||
c.stopGoal(GoalStatusStopped)
|
||||
return err
|
||||
}
|
||||
var readinessErr *agent.FinalReadinessError
|
||||
if !errors.As(err, &readinessErr) {
|
||||
if !goalTurnErrorAbsorbable(err) {
|
||||
// Terminal provider/host error: stop auto-continue; the Goal
|
||||
// stays running for the next user turn.
|
||||
c.goalUsageTee.setActiveRecorder(nil)
|
||||
@@ -495,6 +501,38 @@ func (o *turnOrchestrator) continueGoal(ctx context.Context, expectedContinuatio
|
||||
}
|
||||
}
|
||||
|
||||
func goalTurnErrorAbsorbable(err error) bool {
|
||||
var readinessErr *agent.FinalReadinessError
|
||||
if errors.As(err, &readinessErr) {
|
||||
return true
|
||||
}
|
||||
_, _, ok := goalPauseFromRunError(err)
|
||||
return ok
|
||||
}
|
||||
|
||||
func goalPauseFromRunError(err error) (cause, reason string, ok bool) {
|
||||
info, ok := agent.InspectRunPause(err)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
switch {
|
||||
case info.Kind == "max_steps" && info.HostOwned && info.Key == goalRunRoundKey:
|
||||
return stopCauseGoalRunBudget,
|
||||
fmt.Sprintf("Goal run budget exhausted (%d model rounds); completed work is saved", info.Limit), true
|
||||
case info.Kind == "goal_stuck" && info.HostOwned:
|
||||
reason := strings.TrimSpace(info.Reason)
|
||||
if reason == "" {
|
||||
reason = "host-detected structural no-progress loop"
|
||||
}
|
||||
return stopCauseGoalStuck, reason, true
|
||||
case info.Kind == "todo_stall" && info.HostOwned:
|
||||
return stopCauseGoalStuck,
|
||||
fmt.Sprintf("current todo stalled for %d model rounds without host-observed progress", info.Limit), true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
// advanceGoalAfterTurn gathers every input the FSM needs off the goal lock —
|
||||
// the turn's update_goal report, Delivery readiness, budget/usage state, and
|
||||
// the evaluator verdict — then lets the FSM exclusively decide complete,
|
||||
@@ -513,6 +551,7 @@ func (o *turnOrchestrator) advanceGoalAfterTurn(ctx context.Context, expectedCon
|
||||
}
|
||||
|
||||
var readiness agent.ReadinessResult
|
||||
pauseCause, pauseReason, runPaused := goalPauseFromRunError(turnErr)
|
||||
var readinessErr *agent.FinalReadinessError
|
||||
if errors.As(turnErr, &readinessErr) {
|
||||
readiness = agent.ReadinessResult{
|
||||
@@ -521,7 +560,7 @@ func (o *turnOrchestrator) advanceGoalAfterTurn(ctx context.Context, expectedCon
|
||||
Reason: readinessErr.Reason,
|
||||
ProgressKey: readinessErr.Reason,
|
||||
}
|
||||
} else if turnErr != nil {
|
||||
} else if turnErr != nil && !runPaused {
|
||||
// Terminal provider/host error: stop auto-continue without an FSM
|
||||
// transition; the goal stays running for the next user turn.
|
||||
return goalAdvanceResult{cont: false}
|
||||
@@ -549,23 +588,21 @@ func (o *turnOrchestrator) advanceGoalAfterTurn(ctx context.Context, expectedCon
|
||||
}
|
||||
}
|
||||
|
||||
var progressBefore, progressAfter string
|
||||
if recorder != nil {
|
||||
progressBefore = recorder.progressBeforeText()
|
||||
}
|
||||
var progressEvidence []string
|
||||
if c.executor != nil {
|
||||
progressAfter = c.executor.HostProgressSignature()
|
||||
progressEvidence = c.executor.HostProgressSignatures()
|
||||
}
|
||||
|
||||
res := c.goals.advance(goalAdvanceInput{
|
||||
report: report,
|
||||
readiness: readiness,
|
||||
evaluator: evaluator,
|
||||
evaluatorFailed: evaluatorFailed,
|
||||
todos: c.goalTodos(),
|
||||
progressBefore: progressBefore,
|
||||
progressAfter: progressAfter,
|
||||
expectedEpoch: &expectedContinuationEpoch,
|
||||
report: report,
|
||||
readiness: readiness,
|
||||
evaluator: evaluator,
|
||||
evaluatorFailed: evaluatorFailed,
|
||||
todos: c.goalTodos(),
|
||||
progressEvidence: progressEvidence,
|
||||
pauseCause: pauseCause,
|
||||
pauseReason: pauseReason,
|
||||
expectedEpoch: &expectedContinuationEpoch,
|
||||
})
|
||||
c.persistGoalState(res.path, res.data, res.ok)
|
||||
if res.notice != "" {
|
||||
|
||||
@@ -343,7 +343,7 @@ func (r *deliveryScopeErrorRunner) Run(ctx context.Context, _ string) error {
|
||||
return &agent.FinalReadinessError{Attempts: 1, Reason: "missing verification", Missing: []string{"verification"}}
|
||||
}
|
||||
|
||||
func TestGoalReadinessFailureContinuesThenPausesOnNoProgress(t *testing.T) {
|
||||
func TestGoalReadinessFailureContinuesThenPausesOnTurnBudget(t *testing.T) {
|
||||
runner := &deliveryScopeErrorRunner{}
|
||||
executor := agent.New(nil, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
|
||||
c := New(Options{Runner: runner, Executor: executor})
|
||||
@@ -351,16 +351,16 @@ func TestGoalReadinessFailureContinuesThenPausesOnNoProgress(t *testing.T) {
|
||||
|
||||
err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "")
|
||||
if err != nil {
|
||||
t.Fatalf("run err = %v, want the loop to absorb FinalReadinessError and pause on no-progress", err)
|
||||
t.Fatalf("run err = %v, want the loop to absorb FinalReadinessError and pause on the turn budget", err)
|
||||
}
|
||||
// The FSM absorbs the readiness failure and continues with the missing
|
||||
// requirements; with no host-verifiable progress across turns the
|
||||
// no-progress gate pauses the goal instead of looping forever.
|
||||
// requirements; cross-turn no-progress remains observational and the outer
|
||||
// continuation backstop eventually pauses the goal.
|
||||
if got := c.GoalStatus(); got != GoalStatusBlocked {
|
||||
t.Fatalf("GoalStatus = %q, want blocked (no-progress pause)", got)
|
||||
t.Fatalf("GoalStatus = %q, want blocked (turn-budget pause)", got)
|
||||
}
|
||||
if rt := c.GoalRuntime(); rt.StopCause != stopCauseNoProgress {
|
||||
t.Fatalf("stop cause = %q, want %q", rt.StopCause, stopCauseNoProgress)
|
||||
if rt := c.GoalRuntime(); rt.StopCause != stopCauseBudgetTurns {
|
||||
t.Fatalf("stop cause = %q, want %q", rt.StopCause, stopCauseBudgetTurns)
|
||||
}
|
||||
if len(runner.scopes) < 2 || runner.scopes[0].ID == "" || runner.scopes[0].TaskText != "ship the integration" {
|
||||
t.Fatalf("delivery scopes = %+v, want scoped continuation turns", runner.scopes)
|
||||
|
||||
@@ -2,7 +2,6 @@ package evidence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
@@ -526,54 +525,6 @@ func (l *Ledger) HasWriteOrCommandSince(index int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SuccessfulProgressSignaturesSince returns stable identities for successful
|
||||
// host-observed work recorded at or after index. Callers can keep a per-turn set
|
||||
// of these signatures so a new read, command, or mutation renews an execution
|
||||
// lease while exact repeats do not masquerade as progress.
|
||||
func (l *Ledger) SuccessfulProgressSignaturesSince(index int) []string {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
var out []string
|
||||
for i := index; i < len(l.receipts); i++ {
|
||||
if sig, ok := progressReceiptSignature(l.receipts[i]); ok {
|
||||
out = append(out, sig)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func progressReceiptSignature(r Receipt) (string, bool) {
|
||||
if !r.Success {
|
||||
return "", false
|
||||
}
|
||||
kind := ""
|
||||
switch {
|
||||
case r.Mutation || r.Write:
|
||||
kind = "mutation"
|
||||
case r.Command != "":
|
||||
kind = "command"
|
||||
case r.Read && r.OutputBytes > 0:
|
||||
kind = "read"
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
payload := strings.TrimSpace(string(r.Args))
|
||||
var decoded any
|
||||
if json.Unmarshal(r.Args, &decoded) == nil {
|
||||
if canonical, err := json.Marshal(decoded); err == nil {
|
||||
payload = string(canonical)
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(kind + "\x00" + r.ToolName + "\x00" + payload))
|
||||
return fmt.Sprintf("%x", sum), true
|
||||
}
|
||||
|
||||
func (l *Ledger) HasSuccessfulCommand(command string) bool {
|
||||
command = strings.TrimSpace(command)
|
||||
if l == nil || command == "" {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SuccessfulProgressSignaturesSince returns stable successful-work identities.
|
||||
func (l *Ledger) SuccessfulProgressSignaturesSince(index int) []string {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
var out []string
|
||||
for i := index; i < len(l.receipts); i++ {
|
||||
if sig, ok := progressReceiptSignature(l.receipts[i]); ok {
|
||||
out = append(out, sig)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func progressReceiptSignature(r Receipt) (string, bool) {
|
||||
if !r.Success {
|
||||
return "", false
|
||||
}
|
||||
kind := ""
|
||||
switch {
|
||||
case r.Mutation || r.Write:
|
||||
kind = "mutation"
|
||||
case r.Command != "":
|
||||
kind = "command"
|
||||
case r.ToolName == "todo_write" && len(r.Todos) > 0:
|
||||
kind = "todo"
|
||||
case r.ToolName == "complete_step" && r.StepProof:
|
||||
kind = "signoff"
|
||||
case successfulForegroundReviewReceipt(r) || completedStructuredReviewReceipt(r, nil):
|
||||
kind = "review"
|
||||
case r.Read && r.OutputBytes > 0:
|
||||
kind = "read"
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
payload := strings.TrimSpace(string(r.Args))
|
||||
var decoded any
|
||||
if json.Unmarshal(r.Args, &decoded) == nil {
|
||||
if canonical, err := json.Marshal(decoded); err == nil {
|
||||
payload = string(canonical)
|
||||
}
|
||||
}
|
||||
if (kind == "read" || kind == "command") && r.OutputDigest != "" {
|
||||
payload += "\x00output=" + r.OutputDigest
|
||||
}
|
||||
sum := sha256.Sum256([]byte(kind + "\x00" + r.ToolName + "\x00" + payload))
|
||||
return fmt.Sprintf("%x", sum), true
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package evidence
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSuccessfulProgressSignaturesUseObservedReadResult(t *testing.T) {
|
||||
first := ReceiptFromToolCall("grep", json.RawMessage(`{"path":"a.go","pattern":"TODO"}`), true, true)
|
||||
first.ObserveOutput("a.go:10: TODO")
|
||||
repeat := first
|
||||
changed := ReceiptFromToolCall("grep", json.RawMessage(`{"pattern":"TODO","path":"a.go"}`), true, true)
|
||||
changed.ObserveOutput("a.go:10: TODO\na.go:20: TODO")
|
||||
|
||||
firstSig, ok := progressReceiptSignature(first)
|
||||
if !ok {
|
||||
t.Fatal("successful non-empty grep must produce progress evidence")
|
||||
}
|
||||
repeatSig, _ := progressReceiptSignature(repeat)
|
||||
changedSig, _ := progressReceiptSignature(changed)
|
||||
if firstSig != repeatSig {
|
||||
t.Fatalf("same args and result changed identity: %q != %q", firstSig, repeatSig)
|
||||
}
|
||||
if firstSig == changedSig {
|
||||
t.Fatal("same query with changed host-observed output must be new evidence")
|
||||
}
|
||||
if first.OutputBytes == 0 || first.OutputDigest == "" {
|
||||
t.Fatalf("ObserveOutput did not attest output: %+v", first)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package evidence
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Receipt is the host-runtime record of one tool call. It stays in memory for
|
||||
// the current agent turn and is not serialized into prompts or session state.
|
||||
@@ -22,6 +27,10 @@ type Receipt struct {
|
||||
// output. Content-evidence checks require it to be non-zero so a command
|
||||
// that printed nothing (head -n 0, >/dev/null) can never count as reading.
|
||||
OutputBytes int `json:"output_bytes,omitempty"`
|
||||
// OutputDigest is a bounded host-derived identity for the model-visible
|
||||
// output. Goal progress uses it to distinguish a genuinely changed read or
|
||||
// command result from an exact successful repeat without retaining content.
|
||||
OutputDigest string `json:"output_digest,omitempty"`
|
||||
// ExitCode is the status the child process actually returned. Success only
|
||||
// says the tool call itself completed, so a failing test run the tool
|
||||
// reported cleanly stays distinguishable here. Zero differs from unset.
|
||||
@@ -31,6 +40,22 @@ type Receipt struct {
|
||||
Verification string `json:"verification,omitempty"`
|
||||
}
|
||||
|
||||
// ObserveOutput records the trimmed output size and a compact digest without
|
||||
// retaining model-visible content in the evidence ledger.
|
||||
func (r *Receipt) ObserveOutput(output string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
trimmed := strings.TrimSpace(output)
|
||||
r.OutputBytes = len(trimmed)
|
||||
if trimmed == "" {
|
||||
r.OutputDigest = ""
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256([]byte(trimmed))
|
||||
r.OutputDigest = fmt.Sprintf("%x", sum[:16])
|
||||
}
|
||||
|
||||
// Verification classifications mirror tool.ShellVerification*, duplicated so
|
||||
// this package keeps importing nothing from the tool layer.
|
||||
const (
|
||||
|
||||
@@ -320,7 +320,7 @@ type Messages struct {
|
||||
GoalPausedReason string
|
||||
GoalPausedFmt string // %s = stop cause
|
||||
GoalBudgetExtended string
|
||||
GoalRuntimeFmt string // turns used/limit, tokens used, no-progress, extensions
|
||||
GoalRuntimeFmt string // turns used/limit, tokens, requests, observational no-progress, extensions
|
||||
GoalRuntimeLastReason string
|
||||
ModelSwitchUnavailable string
|
||||
ModelSwitchBusy string
|
||||
|
||||
@@ -332,7 +332,7 @@ var English = Messages{
|
||||
GoalPausedReason: "paused by the user",
|
||||
GoalPausedFmt: "goal is paused (%s) — use /goal resume to continue",
|
||||
GoalBudgetExtended: "goal resumed — one additional turn slice added",
|
||||
GoalRuntimeFmt: "runtime: turns %d/%d, tokens %d, no-progress %d/%d, extensions %d",
|
||||
GoalRuntimeFmt: "runtime: turns %d/%d, tokens %d, requests %d, no-progress %d (observational), extensions %d",
|
||||
GoalRuntimeLastReason: "last reason",
|
||||
ModelSwitchUnavailable: "model switching is unavailable in this session",
|
||||
ModelSwitchBusy: "finish or cancel active work and stop background jobs before switching models",
|
||||
|
||||
@@ -333,7 +333,7 @@ var Chinese = Messages{
|
||||
GoalPausedReason: "用户手动暂停",
|
||||
GoalPausedFmt: "目标已暂停(%s)— 使用 /goal resume 继续",
|
||||
GoalBudgetExtended: "目标已恢复 — 追加了一档轮次数",
|
||||
GoalRuntimeFmt: "运行状态:轮次 %d/%d,token %d,无进展 %d/%d,追加 %d",
|
||||
GoalRuntimeFmt: "运行状态:轮次 %d/%d,token %d,请求 %d,无进展 %d(仅观测),追加 %d",
|
||||
GoalRuntimeLastReason: "最近原因",
|
||||
ModelSwitchUnavailable: "本会话不支持切换模型",
|
||||
ModelSwitchBusy: "请先完成或取消当前工作,并停止后台任务后再切换模型",
|
||||
|
||||
@@ -562,7 +562,7 @@ var ChineseTraditional = Messages{
|
||||
GoalPausedReason: "使用者手動暫停",
|
||||
GoalPausedFmt: "目標已暫停(%s)— 使用 /goal resume 繼續",
|
||||
GoalBudgetExtended: "目標已恢復 — 追加了一檔輪次數",
|
||||
GoalRuntimeFmt: "執行狀態:輪次 %d/%d,token %d,無進展 %d/%d,追加 %d",
|
||||
GoalRuntimeFmt: "執行狀態:輪次 %d/%d,token %d,請求 %d,無進展 %d(僅觀測),追加 %d",
|
||||
GoalRuntimeLastReason: "最近原因",
|
||||
ProviderErrAuthRejected: "認證失敗 (HTTP 401):服務端拒絕了你的 API key。可能是 key 錯誤或已過期,也可能是服務端出現瞬時鑑權/額度問題——已退避重試仍失敗。請稍後再試,或檢查 .env 中的金鑰 / 執行 `reasonix setup`。",
|
||||
SelectMoreAboveFmt: " ↑ 上方還有 %d 個",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package openai
|
||||
|
||||
// CloseIdleConnections releases pooled HTTP connections for bounded probes.
|
||||
func (c *client) CloseIdleConnections() {
|
||||
if c != nil && c.http != nil {
|
||||
c.http.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@
|
||||
"essay": 2
|
||||
},
|
||||
"desktop/frontend/src/App.tsx": {
|
||||
"file-size": 4725
|
||||
"file-size": 4733
|
||||
},
|
||||
"desktop/frontend/src/__tests__/app-chrome-tabs.test.ts": {
|
||||
"test-file-size": 19
|
||||
|
||||
Reference in new issue
Block a user