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:
SivanCola authored and GitHub committed 2026-08-10 19:05:53 +08:00
commit 4e36c130a1
52 files changed
+1537 -587

No files matched your search

+15
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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";
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "需審批的工具呼叫會先詢問;詢問不是唯讀模式",
+1 -1
View File
@@ -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
View File
@@ -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. |
+11 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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 {
+32
View File
@@ -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 {
+3 -3
View File
@@ -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,
}
}
+126
View File
@@ -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])
}
}
+146
View File
@@ -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
}
+175
View File
@@ -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")
}
+23 -2
View File
@@ -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
+32 -7
View File
@@ -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
View File
@@ -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
+29
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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
}
+2 -2
View File
@@ -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))
}
+2 -2
View File
@@ -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
View File
@@ -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)
}
}
+11 -1
View File
@@ -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()
}
+6
View File
@@ -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)
}
}
+4 -3
View File
@@ -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()
+72
View File
@@ -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++
}
+34 -120
View File
@@ -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() {
+49
View File
@@ -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)
}
+67
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+63 -26
View File
@@ -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 != "" {
+7 -7
View File
@@ -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)
-49
View File
@@ -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 == "" {
+62
View File
@@ -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
}
+30
View File
@@ -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)
}
}
+26 -1
View File
@@ -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 (
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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: "请先完成或取消当前工作,并停止后台任务后再切换模型",
+1 -1
View File
@@ -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 個",
+8
View File
@@ -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()
}
}
+1 -1
View File
@@ -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