") || strings.Contains(strings.ToLower(got), "autoresearch") {
+ t.Fatalf("unified research Goal prompt = %q", got)
+ }
+ if c.GoalRuntime().TurnsLimit != 40 {
+ t.Fatalf("research budget = %+v", c.GoalRuntime())
}
}
func TestGoalAutoResearchCanBeForcedOrDisabled(t *testing.T) {
c := New(Options{})
c.SetGoalWithResearchMode("fix the typo and add a test", GoalResearchOn)
- if got := c.Compose("start"); !strings.Contains(got, "AutoResearch protocol") {
- t.Fatalf("forced research goal should include AutoResearch protocol:\n%s", got)
+ if got := c.Compose("start"); strings.Contains(strings.ToLower(got), "autoresearch") || c.GoalRuntime().TurnsLimit != 40 {
+ t.Fatalf("forced research Goal should use hidden 40-turn budget: %q %+v", got, c.GoalRuntime())
}
c.SetGoalWithResearchMode("持续排查这个线上卡顿直到根因明确", GoalResearchOff)
- if got := c.Compose("start"); strings.Contains(got, "AutoResearch protocol") {
- t.Fatalf("simple override should suppress AutoResearch protocol:\n%s", got)
+ if got := c.Compose("start"); strings.Contains(strings.ToLower(got), "autoresearch") || c.GoalRuntime().TurnsLimit == 40 {
+ t.Fatalf("simple override should use non-research budget: %q %+v", got, c.GoalRuntime())
}
}
@@ -876,27 +870,27 @@ func TestGoalCommandPreservesResearchModeFlags(t *testing.T) {
if !c.applyGoalCommand("/goal --research fix the typo", "") {
t.Fatal("goal command was not parsed")
}
- if got := c.Compose("start"); !strings.Contains(got, "AutoResearch protocol") {
- t.Fatalf("/goal --research should force AutoResearch through command dispatch:\n%s", got)
+ if got := c.Compose("start"); strings.Contains(strings.ToLower(got), "autoresearch") || c.GoalRuntime().TurnsLimit != 40 {
+ t.Fatalf("/goal --research should select research budget: %q %+v", got, c.GoalRuntime())
}
c = New(Options{})
if !c.applyGoalCommand("/goal --simple 持续排查这个线上卡顿直到根因明确", "") {
t.Fatal("goal command was not parsed")
}
- if got := c.Compose("start"); strings.Contains(got, "AutoResearch protocol") {
- t.Fatalf("/goal --simple should suppress AutoResearch through command dispatch:\n%s", got)
+ if got := c.Compose("start"); strings.Contains(strings.ToLower(got), "autoresearch") || c.GoalRuntime().TurnsLimit == 40 {
+ t.Fatalf("/goal --simple should suppress research budget: %q %+v", got, c.GoalRuntime())
}
}
func TestParseGoalCommandResearchFlags(t *testing.T) {
cmd, ok := ParseGoalCommand("/goal --research fix the typo")
- if !ok || cmd.Action != GoalCommandSet || cmd.Text != "fix the typo" || cmd.ResearchMode != GoalResearchOn {
+ if !ok || cmd.Action != GoalCommandSet || cmd.Text != "fix the typo" || cmd.ResearchMode != GoalResearchOn || !cmd.DeprecatedBudgetFlag {
t.Fatalf("ParseGoalCommand --research = %+v ok=%v", cmd, ok)
}
cmd, ok = ParseGoalCommand("/goal --simple 持续排查直到根因明确")
- if !ok || cmd.Action != GoalCommandSet || cmd.Text != "持续排查直到根因明确" || cmd.ResearchMode != GoalResearchOff {
+ if !ok || cmd.Action != GoalCommandSet || cmd.Text != "持续排查直到根因明确" || cmd.ResearchMode != GoalResearchOff || !cmd.DeprecatedBudgetFlag {
t.Fatalf("ParseGoalCommand --simple = %+v ok=%v", cmd, ok)
}
}
diff --git a/internal/control/port.go b/internal/control/port.go
index 8e6f8c83e8..861caef406 100644
--- a/internal/control/port.go
+++ b/internal/control/port.go
@@ -4,7 +4,6 @@ import (
"context"
"reasonix/internal/agent"
- "reasonix/internal/autoresearch"
"reasonix/internal/billing"
"reasonix/internal/checkpoint"
"reasonix/internal/command"
@@ -107,10 +106,6 @@ type Goals interface {
GoalRuntime() GoalRuntimeView
GoalStrict(strict bool)
ClearGoal()
- AutoResearchSummary() (*autoresearch.Summary, bool)
- AutoResearchList() ([]autoresearch.Summary, bool)
- AutoResearchFindings(limit int) ([]autoresearch.Finding, bool)
- RecordAutoResearchEvidence(criterionID string, input AutoResearchEvidenceInput) error
ResetPlannerSession()
PlanMode() bool
SetPlanMode(v bool)
diff --git a/internal/control/slash.go b/internal/control/slash.go
index 9e414d665e..bf208d44a7 100644
--- a/internal/control/slash.go
+++ b/internal/control/slash.go
@@ -131,8 +131,6 @@ func goalArgItems(prior []string) []SlashItem {
return nil
}
return []SlashItem{
- {Label: "--research", Insert: "--research ", Hint: "force durable AutoResearch state"},
- {Label: "--simple", Insert: "--simple ", Hint: "force lightweight Goal"},
{Label: "status", Insert: "status", Hint: "show active goal and budget runtime"},
{Label: "pause", Insert: "pause", Hint: "pause the running goal (keeps all state)"},
{Label: "resume", Insert: "resume", Hint: "resume a paused goal (adds one turn slice)"},
diff --git a/internal/control/slash_test.go b/internal/control/slash_test.go
index ec55e28883..859a8f6e92 100644
--- a/internal/control/slash_test.go
+++ b/internal/control/slash_test.go
@@ -145,8 +145,8 @@ func TestSlashArgItems(t *testing.T) {
}
// /goal
items, _ = SlashArgItems("/goal ", data)
- if !has(items, "--research") || !has(items, "--simple") || !has(items, "status") || !has(items, "clear") {
- t.Errorf("/goal should offer research overrides and management commands; got %v", labelsOf(items))
+ if has(items, "--research") || has(items, "--simple") || !has(items, "status") || !has(items, "clear") {
+ t.Errorf("/goal should hide legacy budget flags and offer management commands; got %v", labelsOf(items))
}
if items, _ := SlashArgItems("/goal --research ", data); len(items) != 0 {
t.Errorf("/goal after a research flag should accept free-form objectives; got %v", labelsOf(items))
diff --git a/internal/control/turn_orchestrator.go b/internal/control/turn_orchestrator.go
index 91cb860b6f..83ce2de242 100644
--- a/internal/control/turn_orchestrator.go
+++ b/internal/control/turn_orchestrator.go
@@ -5,11 +5,9 @@ import (
"encoding/json"
"errors"
"fmt"
- "strings"
"time"
"reasonix/internal/agent"
- "reasonix/internal/autoresearch"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/jobs"
@@ -208,7 +206,6 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
continuation.goal,
GoalStatusRunning,
continuation.researchMode,
- continuation.autoResearchTaskID,
)
} else {
input = c.compose(turn.input, turn.raw, !turn.synthetic)
@@ -259,14 +256,6 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }()
}
c.markInFlightTurn(startMessages, !turn.synthetic && !IsSyntheticUserMessage(turn.raw))
- var autoResearchTaskID string
- if continuation != nil {
- autoResearchTaskID = continuation.autoResearchTaskID
- } else {
- autoResearchTaskID = c.goals.currentAutoResearchTaskID()
- }
- autoResearchAcceptedBefore := c.autoResearch.acceptedEvidenceIDs(autoResearchTaskID)
- c.autoResearch.heartbeat(autoResearchTaskID, autoresearch.HeartbeatStartingTurn, "")
if continuation != nil {
ctx = agent.WithDeliveryExecutionScope(ctx, agent.DeliveryExecutionScope{
ID: continuation.scopeID,
@@ -302,13 +291,8 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
err = c.runner.Run(ctx, modelInput)
c.persistGoalDeliveryCheckpoint()
if err == nil {
- assistantText := lastAssistantText(c.History())
- c.autoResearch.recordEvidenceFromAssistant(autoResearchTaskID, assistantText)
- c.autoResearch.recordTurnProgress(autoResearchTaskID, autoResearchAcceptedBefore, assistantText)
- c.autoResearch.heartbeat(autoResearchTaskID, autoresearch.HeartbeatTurnDone, "")
c.clearInFlightTurn()
} else {
- c.autoResearch.heartbeat(autoResearchTaskID, autoresearch.HeartbeatWarning, err.Error())
// When the user explicitly cancels, keep the real prompt and any fully
// paired tool work. Partial reasoning/output remains durable for display
// but is marked local-only, and a bounded recovery summary is folded into
@@ -515,17 +499,6 @@ func (o *turnOrchestrator) advanceGoalAfterTurn(ctx context.Context, expectedCon
} else if c.executor != nil {
readiness = c.executor.ReadinessResult()
}
- if arReadiness := c.autoResearchReadinessFailure(); arReadiness != "" {
- readiness.Ready = false
- readiness.Missing = append(readiness.Missing, "autoresearch")
- if readiness.Reason != "" {
- readiness.Reason += "\n" + arReadiness
- } else {
- readiness.Reason = arReadiness
- }
- }
- autoResearchTaskID := c.goals.currentAutoResearchTaskID()
-
// The validated update_goal report for this turn, if any.
var report *goalTurnReport
if recorder != nil {
@@ -567,7 +540,6 @@ func (o *turnOrchestrator) advanceGoalAfterTurn(ctx context.Context, expectedCon
})
c.persistGoalState(res.path, res.data, res.ok)
if res.notice != "" {
- c.finalizeAutoResearchTask(autoResearchTaskID, res.notice)
c.notice(res.notice)
}
if res.notice == goalCompleteNotice && c.executor != nil {
@@ -576,32 +548,6 @@ func (o *turnOrchestrator) advanceGoalAfterTurn(ctx context.Context, expectedCon
return res
}
-func (c *Controller) finalizeAutoResearchTask(taskID, notice string) {
- if !c.autoResearch.enabled() || strings.TrimSpace(taskID) == "" {
- return
- }
- switch {
- case notice == goalCompleteNotice:
- status := autoresearch.StatusComplete
- if err := c.autoResearch.updateProgress(taskID, autoresearch.ProgressPatch{Status: &status}); err != nil {
- c.noticeDetail("AutoResearch status update failed.", "autoresearch task completion update failed: "+err.Error())
- return
- }
- c.notice("autoresearch task completed: " + taskID)
- case strings.HasPrefix(notice, "goal blocked: ") || notice == "goal continuation limit reached":
- status := autoresearch.StatusBlocked
- reason := strings.TrimPrefix(notice, "goal blocked: ")
- if reason == "" {
- reason = notice
- }
- if err := c.autoResearch.updateProgress(taskID, autoresearch.ProgressPatch{Status: &status, BlockedReason: &reason}); err != nil {
- c.noticeDetail("AutoResearch status update failed.", "autoresearch task blocked update failed: "+err.Error())
- return
- }
- c.noticeDetail("AutoResearch task marked blocked.", "autoresearch task blocked: "+taskID+"\nreason: "+reason)
- }
-}
-
// completeRemainingGoalTodos force-completes any remaining incomplete canonical
// todos when the goal FSM transitions to completed and emits a synthetic
// todo_write event so the frontend panel reflects the final state. Handles the
diff --git a/internal/goaleval/evaluator.go b/internal/goaleval/evaluator.go
index a962b2f142..d4c131d1d3 100644
--- a/internal/goaleval/evaluator.go
+++ b/internal/goaleval/evaluator.go
@@ -60,13 +60,12 @@ const (
// MaxEvidenceBytes caps the serialized evidence JSON.
MaxEvidenceBytes = 6 * 1024
// Field budgets keep the total request inside boundedllm.DefaultMaxTotalBytes.
- MaxGoalBytes = 600
- MaxAssistantFinal = 1200
- MaxTodoSummary = 600
- MaxAutoResearchBytes = 600
- MaxTurnStatusBytes = 300
- MaxLastReasonBytes = 200
- MaxReasonBytes = 500
+ MaxGoalBytes = 600
+ MaxAssistantFinal = 1200
+ MaxTodoSummary = 600
+ MaxTurnStatusBytes = 300
+ MaxLastReasonBytes = 200
+ MaxReasonBytes = 500
)
// Outcome is the evaluator's structured verdict disposition.
@@ -95,8 +94,6 @@ type GoalEvidence struct {
AssistantFinal string
// TodoSummary is a host-built todo/readiness summary.
TodoSummary string
- // AutoResearchSummary is the AutoResearch success-criteria summary.
- AutoResearchSummary string
// TurnStatus describes turn/budget state.
TurnStatus string
// LastContinuationReason is the previous continuation's recorded reason.
@@ -184,13 +181,12 @@ func (s *Session) Evaluate(ctx context.Context, evidence GoalEvidence) (Verdict,
}
type evidencePayload struct {
- Notice string `json:"notice"`
- GoalContract string `json:"goal_contract,omitempty"`
- AssistantFinal string `json:"assistant_final,omitempty"`
- TodoSummary string `json:"todo_summary,omitempty"`
- AutoResearchSummary string `json:"autoresearch_summary,omitempty"`
- TurnStatus string `json:"turn_status,omitempty"`
- LastReason string `json:"last_reason,omitempty"`
+ Notice string `json:"notice"`
+ GoalContract string `json:"goal_contract,omitempty"`
+ AssistantFinal string `json:"assistant_final,omitempty"`
+ TodoSummary string `json:"todo_summary,omitempty"`
+ TurnStatus string `json:"turn_status,omitempty"`
+ LastReason string `json:"last_reason,omitempty"`
}
// buildEvidence budgets every field before marshaling; the serialized payload
@@ -208,9 +204,6 @@ func buildEvidence(evidence GoalEvidence) (string, error) {
if s := clip(strings.TrimSpace(evidence.TodoSummary), MaxTodoSummary); s != "" {
payload.TodoSummary = s
}
- if s := clip(strings.TrimSpace(evidence.AutoResearchSummary), MaxAutoResearchBytes); s != "" {
- payload.AutoResearchSummary = s
- }
if s := clip(strings.TrimSpace(evidence.TurnStatus), MaxTurnStatusBytes); s != "" {
payload.TurnStatus = s
}
diff --git a/internal/taskintent/boundary_test.go b/internal/taskintent/boundary_test.go
index e573fd693b..a7cdad3642 100644
--- a/internal/taskintent/boundary_test.go
+++ b/internal/taskintent/boundary_test.go
@@ -19,14 +19,17 @@ var allowedExports = map[string]bool{
"ObservableRead": true, "Mutation": true, "PersistentAction": true,
"Classify": true, "NeedsEvidence": true, "NeedsMutation": true,
"NeedsPersistentAction": true, "GoalNeedsWriteBudget": true,
+ "BudgetClassSimple": true, "BudgetClassWrite": true, "BudgetClassResearch": true,
+ "BudgetTurns": true, "ClassifyGoalBudget": true,
}
// lineBudgets caps the heuristic files: vocabulary growth must displace
// something or justify a deliberate budget bump in review.
var lineBudgets = map[string]int{
- "intent.go": 620,
- "heuristic.go": 180,
- "goal_budget.go": 140,
+ "intent.go": 620,
+ "heuristic.go": 180,
+ "goal_budget.go": 140,
+ "goal_research_budget.go": 120,
}
func TestExportSurfaceIsFrozen(t *testing.T) {
diff --git a/internal/taskintent/doc.go b/internal/taskintent/doc.go
index 252cbb454a..360a605496 100644
--- a/internal/taskintent/doc.go
+++ b/internal/taskintent/doc.go
@@ -1,9 +1,9 @@
-// Package taskintent answers exactly one question from task text: is this
-// obviously chat, a read, a mutation, or a persistent action. That is its
-// whole charter. It must not grow into complexity, risk, planner depth,
-// verification depth, completion, budget, or tool-surface decisions —
-// those belong to runtime evidence (see internal/taskcontract), where a
-// receipt outranks any keyword.
+// Package taskintent answers classification questions from task text: is this
+// obviously chat, a read, a mutation, or a persistent action, and which Goal
+// turn-budget class (simple/write/research) the objective should start on.
+// It must not grow into complexity, risk, planner depth, verification depth,
+// completion, or tool-surface decisions — those belong to runtime evidence
+// (see internal/taskcontract), where a receipt outranks any keyword.
//
// The vocabulary is a liability, not an asset: every added keyword, negation
// rule, or language case moves this package toward an unowned NLP parser.
diff --git a/internal/taskintent/goal_budget_test.go b/internal/taskintent/goal_budget_test.go
index f779e6323d..9211a1b8f1 100644
--- a/internal/taskintent/goal_budget_test.go
+++ b/internal/taskintent/goal_budget_test.go
@@ -75,6 +75,21 @@ func TestGoalBareFaultDoesNotChangeDeliveryClassification(t *testing.T) {
}
}
+func TestClassifyGoalBudgetMatrix(t *testing.T) {
+ if got := ClassifyGoalBudget("hello"); got != BudgetClassSimple {
+ t.Fatalf("simple = %q", got)
+ }
+ if got := ClassifyGoalBudget("fix the crash in a.go"); got != BudgetClassWrite {
+ t.Fatalf("write = %q", got)
+ }
+ if got := ClassifyGoalBudget("持续排查这个线上卡顿直到根因明确,并验证修复"); got != BudgetClassResearch {
+ t.Fatalf("research = %q", got)
+ }
+ if BudgetTurns(BudgetClassSimple) != 10 || BudgetTurns(BudgetClassWrite) != 20 || BudgetTurns(BudgetClassResearch) != 40 {
+ t.Fatalf("quotas simple=%d write=%d research=%d", BudgetTurns(BudgetClassSimple), BudgetTurns(BudgetClassWrite), BudgetTurns(BudgetClassResearch))
+ }
+}
+
func TestTaskFaultSignalsSharedWithGoalClassification(t *testing.T) {
// Shared fault list must keep task recognition and Goal classification
// aligned for bare problem statements.
diff --git a/internal/taskintent/goal_research_budget.go b/internal/taskintent/goal_research_budget.go
new file mode 100644
index 0000000000..5caaee37ce
--- /dev/null
+++ b/internal/taskintent/goal_research_budget.go
@@ -0,0 +1,89 @@
+package taskintent
+
+import "strings"
+
+// Goal turn-budget classes. Quotas are fixed; classes never gate permissions.
+const (
+ BudgetClassSimple = "simple"
+ BudgetClassWrite = "write"
+ BudgetClassResearch = "research"
+)
+
+// BudgetTurns returns the default turn quota for a Goal budget class.
+func BudgetTurns(class string) int {
+ switch class {
+ case BudgetClassResearch:
+ return 40
+ case BudgetClassWrite:
+ return 20
+ default:
+ return 10
+ }
+}
+
+// ClassifyGoalBudget selects simple/write/research from goal text alone.
+// Legacy CLI flags and sidecars apply on/off overrides in the control package.
+func ClassifyGoalBudget(goal string) string {
+ if needsResearchBudget(goal) {
+ return BudgetClassResearch
+ }
+ if GoalNeedsWriteBudget(goal) {
+ return BudgetClassWrite
+ }
+ return BudgetClassSimple
+}
+
+func needsResearchBudget(goal string) bool {
+ trimmed := strings.TrimSpace(goal)
+ if trimmed == "" {
+ return false
+ }
+ lower := strings.ToLower(trimmed)
+ if strings.Contains(lower, ".reasonix/autoresearch/") {
+ return true
+ }
+ for _, kw := range researchBudgetStrongKeywords {
+ if strings.Contains(lower, kw) {
+ return true
+ }
+ }
+ return researchBudgetPhaseCount(lower) >= 4
+}
+
+func researchBudgetPhaseCount(lower string) int {
+ categories := 0
+ for _, group := range researchBudgetPhaseKeywords {
+ if containsAnyGoalKeyword(lower, group) {
+ categories++
+ }
+ }
+ return categories
+}
+
+func containsAnyGoalKeyword(s string, needles []string) bool {
+ for _, needle := range needles {
+ if strings.Contains(s, needle) {
+ return true
+ }
+ }
+ return false
+}
+
+var researchBudgetStrongKeywords = []string{
+ "持续", "长期", "彻底", "直到根因", "根因明确", "多轮",
+ "不要原地打转", "别原地打转", "完整方案", "完整做成方案",
+ "跑实验", "反复验证", "长期优化", "系统性研究", "持续研究",
+ "持续排查", "持续推进", "长期跑",
+ "long-horizon", "long horizon", "long-running", "keep researching",
+ "keep working", "root cause", "until the root cause", "do not spin",
+ "don't spin", "thoroughly", "systematically",
+}
+
+var researchBudgetPhaseKeywords = [][]string{
+ {"研究", "调研", "排查", "分析", "定位", "诊断", "research", "investigate", "diagnose", "analyze", "analysis"},
+ {"实现", "修复", "改造", "开发", "重构", "implement", "build", "fix", "refactor"},
+ {"验证", "测试", "复现", "联调", "benchmark", "verify", "validate", "test", "reproduce"},
+ {"优化", "完善", "提升", "收敛", "optimize", "improve", "tune", "polish"},
+ {"文档", "方案", "说明", "总结", "document", "docs", "writeup", "plan"},
+ {"发布", "上线", "提交", "pull request", "publish", "ship", "deploy"},
+}
diff --git a/scripts/check-cache-impact.sh b/scripts/check-cache-impact.sh
index b3866f3f1a..99408c972b 100755
--- a/scripts/check-cache-impact.sh
+++ b/scripts/check-cache-impact.sh
@@ -64,6 +64,7 @@ for file in "${changed_files[@]:-}"; do
internal/agent/ask.go|\
internal/agent/cache*|\
internal/agent/compact*|\
+ internal/agent/goal_display.go|\
internal/agent/parallel_tasks.go|\
internal/agent/prune*|\
internal/agent/subagent_registry*|\
@@ -72,7 +73,11 @@ for file in "${changed_files[@]:-}"; do
internal/command/slashtool.go|\
internal/config/config.go|\
internal/config/system_prompt*|\
+ internal/control/goal.go|\
+ internal/control/input.go|\
+ internal/control/turn_orchestrator.go|\
internal/environment/*|\
+ internal/goaleval/*|\
internal/history/tool.go|\
internal/installsource/*|\
internal/lsp/tool.go|\
@@ -81,6 +86,7 @@ for file in "${changed_files[@]:-}"; do
internal/plugin/*|\
internal/provider/*|\
internal/skill/*|\
+ internal/taskintent/*|\
internal/tool/*|\
scripts/cache-guard.sh|\
scripts/check-cache-impact.sh)
diff --git a/site/src/pages/docs.astro b/site/src/pages/docs.astro
index ec2ea0d9d8..7d6bc636b9 100644
--- a/site/src/pages/docs.astro
+++ b/site/src/pages/docs.astro
@@ -301,7 +301,7 @@ reasonix upgrade
Mouse capture is on by default so Reasonix can handle transcript selection, wheel scroll, and the scrollbar. Turn it off with /mouse, or start with REASONIX_DISABLE_MOUSE=1, when you prefer the terminal's own selection behavior.默认会开启鼠标接管,用于对话选中、滚轮滚动和滚动条。需要终端自己的选中行为时,用 /mouse 关闭;也可以用 REASONIX_DISABLE_MOUSE=1 默认关闭。
In a local session, releasing an in-app text selection copies through the native system clipboard and shows success only after the write completes. SSH falls back to a clearly labelled OSC 52 request. Text paste remains your terminal's bracketed-paste shortcut, such as Cmd+V on macOS. Image paste is separate: use Ctrl+V on macOS/Linux, Alt+V on Windows, or /paste-image; the footer shows Pasting image… while the attachment is prepared.本地会话中,应用内文本选区会写入系统剪贴板,只有写入完成后才提示成功;SSH 会回退到明确标记的 OSC 52 请求。文本继续使用终端原生 bracketed-paste 快捷键,例如 macOS 的 Cmd+V。图片粘贴使用独立入口:macOS/Linux 按 Ctrl+V,Windows 按 Alt+V,或运行 /paste-image;附件准备期间底栏显示“正在粘贴图片…”。
/branch [name] forks the current conversation tip, /switch <id|name> loads another branch, and /clear confirms before discarding unsaved context. Custom commands are Markdown files under .reasonix/commands/ or ~/.reasonix/commands/./branch [name] 从当前会话尖端分叉,/switch <id|name> 加载另一条分支,/clear 会确认后丢弃未保存上下文。自定义命令是 .reasonix/commands/ 或 ~/.reasonix/commands/ 下的 Markdown 文件。
- /goal is for long-running objectives. Ordinary chat never changes mode automatically. Goals run under a per-class budget (simple 10 turns / 200k tokens, write 20 turns / 400k tokens, AutoResearch 40 turns / 800k tokens; 4 turns without host-verifiable progress pause) — /goal status shows the runtime, /goal pause suspends, /goal resume continues (budget pauses add one more slice). Each goal turn ends with a structured update_goal report (continue/complete/blocked) that the host validates against Delivery readiness; without a report, an independent bounded evaluator judges the turn once and any failure pauses safely. Clearly long-horizon work can use the AutoResearch strategy, which keeps state under .reasonix/autoresearch/..., tracks evidence, and forces a new direction when progress stalls. Use /goal --research <objective> to force it or /goal --simple <objective> to keep the lightweight path. AutoResearch is a Goal strategy, not a separate app-start daemon or standalone built-in skill./goal 用于长目标。普通聊天不会自动切换模式。Goal 按类别运行在预算内(简单 10 轮 / 20 万 token,写入型 20 轮 / 40 万 token,AutoResearch 40 轮 / 80 万 token;连续 4 轮无宿主可验证进展会暂停)——/goal status 显示运行摘要,/goal pause 暂停,/goal resume 继续(预算型暂停追加一档额度)。每个目标 turn 结束时通过结构化的 update_goal 报告(continue/complete/blocked),宿主会用 Delivery readiness 校验;没有报告时由独立有界 evaluator 判定一次,任何故障都会安全暂停。明显长周期的任务可以启用 AutoResearch 策略,在 .reasonix/autoresearch/... 下保存状态、记录证据,并在进展停滞时强制换方向。用 /goal --research <目标> 强制启用,或用 /goal --simple <目标> 保持轻量路径。AutoResearch 是 Goal 的策略,不是 App 启动即运行的 daemon,也不是独立内置 skill。
+ /goal is for long-running objectives. Ordinary chat never changes mode automatically. Goal selects a simple (10), write (20), or research (40) turn budget and pauses after four turns without host-verifiable progress. /goal status shows the runtime, /goal pause suspends, and /goal resume continues. Every class uses the same Goal state machine, structured update_goal reports, host receipts, Delivery readiness, and bounded evaluator. Legacy research archives are read-only and new Goals never create them./goal 用于长目标。普通聊天不会自动切换模式。Goal 自动选择简单(10)、写入(20)或研究(40)轮预算,连续 4 轮无宿主可验证进展会暂停。/goal status 显示运行摘要,/goal pause 暂停,/goal resume 继续。所有预算类别共用同一个 Goal 状态机、结构化 update_goal、宿主 receipt、Delivery readiness 与有界 evaluator。旧研究归档保持只读,新 Goal 不再创建这些目录。
Use @path to inject files or directories, and @server:uri for MCP resources. Plan Mode is an explicit user choice: select it in the desktop collaboration control or cycle to it with Shift+Tab in the CLI. reasonix config reasoning-language auto|zh|en updates the user default from scripts; --local remains available for settings that support project-local overrides.用 @path 注入文件或目录,用 @server:uri 引入 MCP resource。计划模式始终由用户显式选择:桌面端在协作方式中选择,CLI 用 Shift+Tab 切换。脚本中可用 reasonix config reasoning-language auto|zh|en 更新用户级默认值;--local 仍可用于支持项目级覆盖的设置。
Built-in documentation search内置文档检索
From 03958301e480f7244face6f54896cbdfdae2c7f1 Mon Sep 17 00:00:00 2001
From: SivanCola <32437197+SivanCola@users.noreply.github.com>
Date: Sun, 9 Aug 2026 00:58:33 +0800
Subject: [PATCH 02/12] fix(agent): hide context-unavailable tools
Problem: models could repeatedly call Goal, planning-only, or background-job tools outside the workflow phase that owns them, leaving no visible answer or operating inherited state.\n\nRoot cause: provider schemas were static and the run-loop repair path recognized only update_goal.\n\nFix: add contextual provider visibility, phase-safe Planner filtering, Goal and Jobs context shadowing, and one bounded generic recovery nudge with focused regression coverage.\n\nVerification: go test ./...; go test -race ./internal/agent ./internal/jobs ./internal/tool ./internal/tool/builtin ./internal/control; go vet ./...; scripts/cache-guard.sh.
---
internal/agent/agent.go | 12 ++
internal/agent/coordinator.go | 5 +-
internal/agent/coordinator_test.go | 79 ++++++++-
internal/agent/delivery_hardening_test.go | 185 ++++++++++++++++++++-
internal/agent/planmode_test.go | 111 ++++++++++++-
internal/agent/run_loop.go | 41 +++--
internal/agent/sampling_request.go | 3 +-
internal/agent/task.go | 11 ++
internal/boot/boot_test.go | 23 ++-
internal/jobs/jobs.go | 8 +
internal/jobs/jobs_test.go | 15 ++
internal/tool/builtin/bgjobs.go | 15 ++
internal/tool/builtin/bgjobs_test.go | 26 +++
internal/tool/builtin/completestep.go | 8 +
internal/tool/builtin/completestep_test.go | 14 ++
internal/tool/builtin/updategoal.go | 10 +-
internal/tool/builtin/updategoal_test.go | 14 ++
internal/tool/contract_lock_test.go | 58 +++++++
internal/tool/contract_test.go | 12 ++
internal/tool/goal.go | 12 ++
internal/tool/goal_test.go | 30 ++++
internal/tool/tool.go | 45 +++--
22 files changed, 689 insertions(+), 48 deletions(-)
create mode 100644 internal/tool/goal_test.go
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 222f35ae14..a9e13f053b 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -139,6 +139,18 @@ func PlanModeFromContext(ctx context.Context) bool {
return ok && cc.planMode
}
+func (a *Agent) withAgentContext(ctx context.Context) context.Context {
+ if a == nil {
+ return ctx
+ }
+ if a.jobs != nil {
+ ctx = jobs.WithManager(ctx, a.jobs)
+ } else {
+ ctx = jobs.WithoutManager(ctx)
+ }
+ return planmode.WithActive(ctx, a.planMode.Load())
+}
+
// WithParentSession stamps the active parent session ID onto a turn context so
// persisted sub-agents can record and enforce their owning conversation.
func WithParentSession(ctx context.Context, parentSession string) context.Context {
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index c476d1b3d7..d4582aa83f 100644
--- a/internal/agent/coordinator.go
+++ b/internal/agent/coordinator.go
@@ -361,7 +361,10 @@ func (c *Coordinator) Run(ctx context.Context, input string) error {
return c.executor.Run(ctx, input)
}
c.sink.Emit(event.Event{Kind: event.Phase, Text: c.planner.Name() + " · planning", Detail: routeDetail, Source: event.UsageSourcePlanner})
- plannerCtx := ctx
+ // The planner researches and proposes work but does not own the root Goal
+ // turn's disposition. Hide the recorder only for planning; the executor
+ // still receives the original context and can report after doing the work.
+ plannerCtx := tool.WithoutGoalTurnRecorder(ctx)
if decision.MaxResearchRounds > 0 {
plannerCtx = withRunStepLimit(plannerCtx, decision.MaxResearchRounds, "planner research rounds")
}
diff --git a/internal/agent/coordinator_test.go b/internal/agent/coordinator_test.go
index 5ca880ab60..3272012780 100644
--- a/internal/agent/coordinator_test.go
+++ b/internal/agent/coordinator_test.go
@@ -85,6 +85,67 @@ func TestCoordinatorHandsPlanToExecutor(t *testing.T) {
}
}
+type coordinatorGoalRecorder struct {
+ reports []tool.GoalReport
+}
+
+func (r *coordinatorGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
+ r.reports = append(r.reports, report)
+ return "recorded " + report.Status, nil
+}
+
+func TestCoordinatorPlannerCannotReportExecutorGoalDisposition(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
+ {toolCallChunk("planner-goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "1. inspect the implementation\n2. apply and verify the fix"}, {Type: provider.ChunkDone}},
+ }}
+ exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
+ {Type: provider.ChunkText, Text: "Implemented and verified."},
+ {Type: provider.ChunkDone},
+ }}
+ plannerSess := NewSession("planner-sys")
+ executor := New(exec, reg, NewSession("exec-sys"), Options{}, event.Discard)
+ customPlannerReg := tool.NewRegistry()
+ customPlannerReg.Add(goalTool)
+ coord := NewCoordinator(planner, plannerSess, nil, customPlannerReg, Options{}, executor, 0, event.Discard, nil)
+ recorder := &coordinatorGoalRecorder{}
+ ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
+
+ if err := coord.Run(ctx, "fix the goal bug"); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if len(planner.requests) != 2 {
+ t.Fatalf("planner requests = %d, want hallucinated call plus repair", len(planner.requests))
+ }
+ for i, req := range planner.requests {
+ for _, schema := range req.Tools {
+ if schema.Name == "update_goal" {
+ t.Fatalf("planner request %d exposed update_goal", i+1)
+ }
+ }
+ }
+ if got := lastToolResult(plannerSess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
+ t.Fatalf("planner update_goal result = %q", got)
+ }
+ if len(exec.requests) == 0 {
+ t.Fatal("executor made no requests")
+ }
+ for i, req := range exec.requests {
+ if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
+ t.Fatalf("executor request %d lost update_goal after planner isolation: %v", i+1, toolSchemaNames(req.Tools))
+ }
+ }
+ if len(recorder.reports) != 0 {
+ t.Fatalf("planner wrote reports into executor Goal recorder: %+v", recorder.reports)
+ }
+}
+
type coordinatorApprovalGate struct {
calls int
allow bool
@@ -714,6 +775,19 @@ func (t coordinatorTestTool) Execute(context.Context, json.RawMessage) (string,
}
func (t coordinatorTestTool) ReadOnly() bool { return t.readOnly }
+type plannerPhaseOnlyTool struct{}
+
+func (plannerPhaseOnlyTool) Name() string { return "planner_phase_only" }
+func (plannerPhaseOnlyTool) Description() string { return "planner phase-only test tool" }
+func (plannerPhaseOnlyTool) Schema() json.RawMessage {
+ return json.RawMessage(`{"type":"object"}`)
+}
+func (plannerPhaseOnlyTool) Execute(context.Context, json.RawMessage) (string, error) {
+ return "phase-only", nil
+}
+func (plannerPhaseOnlyTool) ReadOnly() bool { return true }
+func (plannerPhaseOnlyTool) PlanModeSafe() bool { return false }
+
func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
{
@@ -734,6 +808,9 @@ func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "Rule: keep changes narrow."})
parentReg.Add(coordinatorTestTool{name: "write_file", readOnly: false})
parentReg.Add(coordinatorTestTool{name: "todo_write", readOnly: true})
+ parentReg.Add(mustBuiltinTool(t, "complete_step"))
+ parentReg.Add(mustBuiltinTool(t, "update_goal"))
+ parentReg.Add(plannerPhaseOnlyTool{})
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession(PlannerPromptWithContext("Rule: keep changes narrow."))
@@ -750,7 +827,7 @@ func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
if !contains(tools, "read_file") {
t.Fatalf("planner tools = %v, want read_file", tools)
}
- for _, forbidden := range []string{"write_file", "todo_write"} {
+ for _, forbidden := range []string{"write_file", "todo_write", "complete_step", "update_goal", "planner_phase_only"} {
if contains(tools, forbidden) {
t.Fatalf("planner tools = %v, must not include %s", tools, forbidden)
}
diff --git a/internal/agent/delivery_hardening_test.go b/internal/agent/delivery_hardening_test.go
index 3dc243e66b..3a1ae2607b 100644
--- a/internal/agent/delivery_hardening_test.go
+++ b/internal/agent/delivery_hardening_test.go
@@ -12,6 +12,7 @@ import (
"reasonix/internal/capability"
"reasonix/internal/event"
"reasonix/internal/evidence"
+ "reasonix/internal/jobs"
"reasonix/internal/provider"
"reasonix/internal/taskintent"
"reasonix/internal/tool"
@@ -198,7 +199,159 @@ func TestDeliveryDurableMemoryRequiresRememberWithoutCodeCeremony(t *testing.T)
}
}
-func TestNonGoalUpdateGoalWithVisibleTextDoesNotSpendRepairRound(t *testing.T) {
+func TestNonGoalRequestDoesNotExposeUpdateGoal(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
+ {{Type: provider.ChunkText, Text: "Here is the answer."}, {Type: provider.ChunkDone}},
+ }}
+ a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
+ if err := a.Run(context.Background(), "answer normally"); err != nil {
+ t.Fatalf("non-Goal answer: %v", err)
+ }
+ if len(prov.requests) != 1 {
+ t.Fatalf("provider requests = %d, want 1", len(prov.requests))
+ }
+ for _, schema := range prov.requests[0].Tools {
+ if schema.Name == "update_goal" {
+ t.Fatal("non-Goal provider request exposed update_goal")
+ }
+ }
+ if got := lastAssistantContent(a.Session()); got != "Here is the answer." {
+ t.Fatalf("last assistant text = %q", got)
+ }
+}
+
+func TestAgentWithoutJobsDoesNotExposeBackgroundTools(t *testing.T) {
+ reg := tool.NewRegistry()
+ for _, name := range []string{"wait", "bash_output", "kill_shell"} {
+ jobTool, ok := tool.LookupBuiltin(name)
+ if !ok {
+ t.Fatalf("%s builtin not registered", name)
+ }
+ reg.Add(jobTool)
+ }
+ manager := jobs.NewManager(event.Discard)
+ defer manager.Close()
+ ctx := jobs.WithManager(context.Background(), manager)
+ prov := &scriptedProvider{name: "no-jobs", turns: [][]provider.Chunk{
+ {{Type: provider.ChunkText, Text: "No background work."}, {Type: provider.ChunkDone}},
+ }}
+ a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
+ if err := a.Run(ctx, "answer normally"); err != nil {
+ t.Fatalf("no-Jobs answer: %v", err)
+ }
+ if len(prov.requests) != 1 {
+ t.Fatalf("provider requests = %d, want 1", len(prov.requests))
+ }
+ if len(prov.requests[0].Tools) != 0 {
+ t.Fatalf("no-Jobs provider tools = %v, want background tools hidden", prov.requests[0].Tools)
+ }
+
+ withJobsProv := &scriptedProvider{name: "with-jobs", turns: [][]provider.Chunk{
+ {{Type: provider.ChunkText, Text: "Background tools available."}, {Type: provider.ChunkDone}},
+ }}
+ withJobs := New(withJobsProv, reg, NewSession("sys"), Options{Jobs: manager}, event.Discard)
+ if err := withJobs.Run(context.Background(), "answer normally"); err != nil {
+ t.Fatalf("with-Jobs answer: %v", err)
+ }
+ visible := make(map[string]bool)
+ for _, schema := range withJobsProv.requests[0].Tools {
+ visible[schema.Name] = true
+ }
+ for _, name := range []string{"wait", "bash_output", "kill_shell"} {
+ if !visible[name] {
+ t.Fatalf("with-Jobs provider tools = %v, missing %s", visible, name)
+ }
+ }
+}
+
+type requestGoalRecorder struct{}
+
+func (requestGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
+ return "recorded " + report.Status, nil
+}
+
+type childIsolationGoalRecorder struct {
+ reports []tool.GoalReport
+}
+
+func (r *childIsolationGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
+ r.reports = append(r.reports, report)
+ return "recorded " + report.Status, nil
+}
+
+func TestGoalRequestExposesUpdateGoal(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
+ {{Type: provider.ChunkText, Text: "Goal work continues."}, {Type: provider.ChunkDone}},
+ }}
+ a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
+ ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ if err := a.Run(ctx, "continue goal"); err != nil {
+ t.Fatalf("Goal answer: %v", err)
+ }
+ if len(prov.requests) != 1 {
+ t.Fatalf("provider requests = %d, want 1", len(prov.requests))
+ }
+ for _, schema := range prov.requests[0].Tools {
+ if schema.Name == "update_goal" {
+ return
+ }
+ }
+ t.Fatal("Goal provider request did not expose update_goal")
+}
+
+func TestSubAgentDoesNotInheritParentGoalRecorder(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ prov := &scriptedProvider{name: "goal-child", turns: [][]provider.Chunk{
+ {toolCallChunk("goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "Child result."}, {Type: provider.ChunkDone}},
+ }}
+ recorder := &childIsolationGoalRecorder{}
+ ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
+ sess := NewSession("child system")
+
+ answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the task", Options{}, event.Discard)
+ if err != nil {
+ t.Fatalf("Goal child: %v", err)
+ }
+ if answer != "Child result." {
+ t.Fatalf("Goal child answer = %q", answer)
+ }
+ if len(prov.requests) != 2 {
+ t.Fatalf("provider requests = %d, want hallucinated call plus repair", len(prov.requests))
+ }
+ for i, req := range prov.requests {
+ for _, schema := range req.Tools {
+ if schema.Name == "update_goal" {
+ t.Fatalf("child provider request %d exposed update_goal", i+1)
+ }
+ }
+ }
+ if len(recorder.reports) != 0 {
+ t.Fatalf("child wrote reports into parent Goal recorder: %+v", recorder.reports)
+ }
+ if got := lastToolResult(sess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
+ t.Fatalf("child update_goal result = %q", got)
+ }
+}
+
+func TestNonGoalHallucinatedUpdateGoalWithVisibleTextDoesNotSpendRepairRound(t *testing.T) {
goalTool, ok := tool.LookupBuiltin("update_goal")
if !ok {
t.Fatal("update_goal builtin not registered")
@@ -211,7 +364,7 @@ func TestNonGoalUpdateGoalWithVisibleTextDoesNotSpendRepairRound(t *testing.T) {
}}
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
if err := a.Run(context.Background(), "answer normally"); err != nil {
- t.Fatalf("non-Goal update_goal with text: %v", err)
+ t.Fatalf("non-Goal hallucinated update_goal with text: %v", err)
}
if prov.call != 1 {
t.Fatalf("provider calls = %d, want no repair round", prov.call)
@@ -224,6 +377,32 @@ func TestNonGoalUpdateGoalWithVisibleTextDoesNotSpendRepairRound(t *testing.T) {
}
}
+func TestNonGoalToolOnlyUpdateGoalNudgesVisibleAnswer(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
+ {toolCallChunk("goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "Here is the recovered answer."}, {Type: provider.ChunkDone}},
+ }}
+ a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
+ if err := a.Run(context.Background(), "answer normally"); err != nil {
+ t.Fatalf("non-Goal update_goal repair: %v", err)
+ }
+ if len(prov.requests) != 2 {
+ t.Fatalf("provider requests = %d, want repair round", len(prov.requests))
+ }
+ if got := lastUser(prov.requests[1]); !strings.Contains(got, "visible answer text") {
+ t.Fatalf("repair instruction = %q, want visible-answer nudge", got)
+ }
+ if got := lastAssistantContent(a.Session()); got != "Here is the recovered answer." {
+ t.Fatalf("last assistant text = %q", got)
+ }
+}
+
func TestNonGoalToolOnlyUpdateGoalGetsAtMostOneRepairRound(t *testing.T) {
goalTool, ok := tool.LookupBuiltin("update_goal")
if !ok {
@@ -238,7 +417,7 @@ func TestNonGoalToolOnlyUpdateGoalGetsAtMostOneRepairRound(t *testing.T) {
}}
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
err := a.Run(context.Background(), "answer normally")
- if err == nil || !strings.Contains(err.Error(), "repeatedly called update_goal outside Goal mode") {
+ if err == nil || !strings.Contains(err.Error(), "repeatedly called context-unavailable tools") {
t.Fatalf("repeated tool-only misuse error = %v", err)
}
if prov.call != 2 {
diff --git a/internal/agent/planmode_test.go b/internal/agent/planmode_test.go
index ed1a53e168..b135c03cb7 100644
--- a/internal/agent/planmode_test.go
+++ b/internal/agent/planmode_test.go
@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
+ "slices"
"strings"
"testing"
@@ -267,11 +268,10 @@ func TestPlanModeCanReplacePriorExecutionTodoState(t *testing.T) {
}
}
-// TestPlanModeDoesNotMutateSystemOrTools is the cache-stability test. Toggling
-// plan mode between two stream calls must not change the system prompt or the
-// tool list seen by the provider — those are the cache-key prefix, and any
-// change there forces an expensive cache miss.
-func TestPlanModeDoesNotMutateSystemOrTools(t *testing.T) {
+// TestPlanModePreservesSystemAndOrdinaryTools is the cache-stability test for
+// non-contextual tools. Phase-only tools are the intentional exception and are
+// covered by TestPlanModeRequestHidesCompleteStepUntilExecution.
+func TestPlanModePreservesSystemAndOrdinaryTools(t *testing.T) {
prov := &mockProvider{name: "p", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "ok"},
{Type: provider.ChunkDone},
@@ -303,6 +303,107 @@ func TestPlanModeDoesNotMutateSystemOrTools(t *testing.T) {
}
}
+func TestPlanModeRequestHidesCompleteStepUntilExecution(t *testing.T) {
+ prov := &mockProvider{name: "p", chunks: []provider.Chunk{
+ {Type: provider.ChunkText, Text: "ok"},
+ {Type: provider.ChunkDone},
+ }}
+ reg := tool.NewRegistry()
+ reg.Add(fakeTool{name: "read_file", readOnly: true})
+ reg.Add(mustBuiltinTool(t, "complete_step"))
+ a := New(prov, reg, NewSession("STABLE-SYS"), Options{}, event.Discard)
+
+ if err := a.Run(context.Background(), "execution"); err != nil {
+ t.Fatalf("execution Run: %v", err)
+ }
+ if !slices.Contains(toolSchemaNames(prov.lastReq.Tools), "complete_step") {
+ t.Fatalf("execution request missing complete_step: %v", toolSchemaNames(prov.lastReq.Tools))
+ }
+
+ prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "plan"}, {Type: provider.ChunkDone}}
+ a.SetPlanMode(true)
+ if err := a.Run(context.Background(), "plan first"); err != nil {
+ t.Fatalf("Plan Run: %v", err)
+ }
+ planTools := toolSchemaNames(prov.lastReq.Tools)
+ if slices.Contains(planTools, "complete_step") {
+ t.Fatalf("Plan request exposed complete_step: %v", planTools)
+ }
+ if !slices.Contains(planTools, "read_file") {
+ t.Fatalf("Plan request lost ordinary tool: %v", planTools)
+ }
+ stablePlanTools := serializeToolSchemas(t, prov.lastReq.Tools)
+ prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "plan again"}, {Type: provider.ChunkDone}}
+ if err := a.Run(context.Background(), "refine plan"); err != nil {
+ t.Fatalf("second Plan Run: %v", err)
+ }
+ if got := serializeToolSchemas(t, prov.lastReq.Tools); got != stablePlanTools {
+ t.Fatalf("Plan tool schemas changed within the same mode:\nfirst=%s\nsecond=%s", stablePlanTools, got)
+ }
+
+ prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "execute"}, {Type: provider.ChunkDone}}
+ a.SetPlanMode(false)
+ if err := a.Run(context.Background(), "execute approved plan"); err != nil {
+ t.Fatalf("post-approval Run: %v", err)
+ }
+ if !slices.Contains(toolSchemaNames(prov.lastReq.Tools), "complete_step") {
+ t.Fatalf("post-approval request missing complete_step: %v", toolSchemaNames(prov.lastReq.Tools))
+ }
+}
+
+func TestPlanModeHallucinatedCompleteStepPreservesVisibleAnswer(t *testing.T) {
+ reg := tool.NewRegistry()
+ reg.Add(mustBuiltinTool(t, "complete_step"))
+ prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
+ {
+ {Type: provider.ChunkText, Text: "Here is the plan."},
+ toolCallChunk("step", "complete_step", `{}`),
+ {Type: provider.ChunkDone},
+ },
+ {{Type: provider.ChunkText, Text: "unexpected repair"}, {Type: provider.ChunkDone}},
+ }}
+ a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
+ a.SetPlanMode(true)
+ if err := a.Run(context.Background(), "plan the change"); err != nil {
+ t.Fatalf("Plan Run: %v", err)
+ }
+ if prov.call != 1 {
+ t.Fatalf("provider calls = %d, want no repair round", prov.call)
+ }
+ if got := lastAssistantContent(a.Session()); got != "Here is the plan." {
+ t.Fatalf("last assistant text = %q", got)
+ }
+ if got := lastToolResult(a.Session(), "complete_step"); !strings.Contains(got, "only available after plan approval") {
+ t.Fatalf("complete_step result = %q", got)
+ }
+}
+
+func TestPlanModeToolOnlyCompleteStepNudgesVisibleAnswer(t *testing.T) {
+ reg := tool.NewRegistry()
+ reg.Add(mustBuiltinTool(t, "complete_step"))
+ prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
+ {toolCallChunk("step", "complete_step", `{}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "Here is the recovered plan."}, {Type: provider.ChunkDone}},
+ }}
+ a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
+ a.SetPlanMode(true)
+ if err := a.Run(context.Background(), "plan the change"); err != nil {
+ t.Fatalf("Plan repair: %v", err)
+ }
+ if len(prov.requests) != 2 {
+ t.Fatalf("provider requests = %d, want repair round", len(prov.requests))
+ }
+ if got := lastUser(prov.requests[1]); !strings.Contains(got, "complete_step") || !strings.Contains(got, "visible answer text") {
+ t.Fatalf("repair instruction = %q", got)
+ }
+ if slices.Contains(toolSchemaNames(prov.requests[1].Tools), "complete_step") {
+ t.Fatalf("repair request re-exposed complete_step: %v", toolSchemaNames(prov.requests[1].Tools))
+ }
+ if got := lastAssistantContent(a.Session()); got != "Here is the recovered plan." {
+ t.Fatalf("last assistant text = %q", got)
+ }
+}
+
func serializeToolSchemas(t *testing.T, schemas []provider.ToolSchema) string {
t.Helper()
b, err := json.Marshal(schemas)
diff --git a/internal/agent/run_loop.go b/internal/agent/run_loop.go
index a7cde3b8c8..5b143f57a8 100644
--- a/internal/agent/run_loop.go
+++ b/internal/agent/run_loop.go
@@ -27,7 +27,7 @@ type runLoopState struct {
emptyFinalBlocks int
handoffNudges int
usedAnyTool bool
- goalToolRepairs int
+ contextToolRepairs int
graceRound bool
recoveryGraceRound bool
@@ -327,6 +327,7 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
// runToolLoop owns the main tool-round budget and dispatches each streamed
// 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++ {
// Consume a queued steer and persist it to the session so it
// survives tab switches and history replay. The model sees it as
@@ -336,7 +337,7 @@ func (a *Agent) runToolLoop(ctx context.Context, state *runLoopState) error {
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(midTurnSteerMessage(text))})
a.sink.Emit(event.Event{Kind: event.Steer, Text: text})
}
- schemas := a.tools.Schemas()
+ schemas := a.tools.SchemasForContext(ctx)
prefixShape := a.capturePrefixShape(schemas)
prevPrefixShape := a.lastPrefixShape
if !a.haveLastPrefixShape {
@@ -955,7 +956,7 @@ func (a *Agent) handleFinalResponse(ctx context.Context, state *runLoopState, te
func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step int, text, reasoning string, calls []provider.ToolCall, usage *provider.Usage) (cont bool, err error) {
state.emptyFinalBlocks = 0
state.usedAnyTool = true
- outOfContextGoalOnly := toolCallsAreOutOfContextGoalReports(ctx, calls)
+ unavailableContextTools, contextualOnly := a.unavailableContextualToolCalls(ctx, calls)
// Grace round guard: if we already gave the model one extra response
// and it still wants to call tools, stop here.
@@ -1012,17 +1013,19 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
a.recordInterruptedDisplay("", "", nil, true, state.workDurationMs())
return false, ctx.Err()
}
- if outOfContextGoalOnly {
+ if contextualOnly {
if hasVisibleFinalAnswer(text) {
// Keep the assistant tool call and host error paired in the transcript,
- // but accept the co-streamed answer instead of spending another model
- // request repairing harmless Goal bookkeeping outside Goal mode.
+ // but accept the co-streamed answer instead of spending another request
+ // repairing a phase-only bookkeeping call.
return a.handleFinalResponse(ctx, state, text, reasoning, usage)
}
- state.goalToolRepairs++
- if state.goalToolRepairs > 1 {
- return false, fmt.Errorf("model repeatedly called update_goal outside Goal mode without a visible answer")
+ state.contextToolRepairs++
+ if state.contextToolRepairs > 1 {
+ return false, fmt.Errorf("model repeatedly called context-unavailable tools without a visible answer: %s", strings.Join(unavailableContextTools, ", "))
}
+ 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()
@@ -1095,17 +1098,21 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
return true, nil
}
-func toolCallsAreOutOfContextGoalReports(ctx context.Context, calls []provider.ToolCall) bool {
+func (a *Agent) unavailableContextualToolCalls(ctx context.Context, calls []provider.ToolCall) ([]string, bool) {
if len(calls) == 0 {
- return false
- }
- if _, ok := tool.GoalTurnRecorderFromContext(ctx); ok {
- return false
+ return nil, false
}
+ names := make([]string, 0, len(calls))
for _, call := range calls {
- if call.Name != "update_goal" {
- return false
+ t, ok := a.tools.Get(call.Name)
+ if !ok {
+ return nil, false
+ }
+ contextual, ok := t.(tool.ContextualTool)
+ if !ok || contextual.ProviderVisible(ctx) {
+ return nil, false
}
+ names = append(names, call.Name)
}
- return true
+ return names, true
}
diff --git a/internal/agent/sampling_request.go b/internal/agent/sampling_request.go
index 409eec82f6..fbcb0d658e 100644
--- a/internal/agent/sampling_request.go
+++ b/internal/agent/sampling_request.go
@@ -16,6 +16,7 @@ type samplingRequest struct {
// prepareSamplingRequest freezes one model-round request (preflight + interceptors).
func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, error) {
+ ctx = a.withAgentContext(ctx)
// CreatedAt is durable UI metadata, not model input. Strip it from the
// transport copy so wall-clock differences never invalidate the provider's
// prompt-cache prefix (and custom providers cannot accidentally send it).
@@ -35,7 +36,7 @@ func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, er
}
req := provider.Request{
Messages: requestMessages,
- Tools: a.tools.Schemas(),
+ Tools: a.tools.SchemasForContext(ctx),
MaxTokens: a.maxOutputTokens,
Temperature: provider.OptionalTemperature(a.temperature),
ResponseFormat: responseFormatFromRequest(ctx),
diff --git a/internal/agent/task.go b/internal/agent/task.go
index 41a5c27300..5a6068971c 100644
--- a/internal/agent/task.go
+++ b/internal/agent/task.go
@@ -1507,6 +1507,7 @@ var plannerNonResearchTools = []string{
"complete_step",
"slash_command",
"todo_write",
+ "update_goal",
"wait",
}
@@ -1528,6 +1529,12 @@ func PlannerToolRegistry(parent *tool.Registry) *tool.Registry {
continue
}
if tl, ok := base.Get(name); ok {
+ if classifier, ok := tl.(tool.PlanModeClassifier); ok && !classifier.PlanModeSafe() {
+ // The two-model planner is a planning-phase agent even when
+ // the controller's explicit Plan mode flag is off. Do not let
+ // read-only execution sign-offs leak into its provider schema.
+ continue
+ }
sub.Add(tl)
}
}
@@ -1872,6 +1879,10 @@ func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *to
if sess == nil {
return "", fmt.Errorf("sub-agent session is nil")
}
+ // A child may run inside a parent Goal turn, but only the root working
+ // model owns that turn's disposition. Keep cancellation and other parent
+ // context while preventing the child from seeing or writing its recorder.
+ ctx = tool.WithoutGoalTurnRecorder(ctx)
// Isolate temporary files for this run before any tool execution.
ctx, releaseTemp := withSubagentSessionTemp(ctx)
defer releaseTemp()
diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go
index b9f7a90db8..ce41a24d9d 100644
--- a/internal/boot/boot_test.go
+++ b/internal/boot/boot_test.go
@@ -2049,7 +2049,7 @@ model = "x"
}
}
-func TestBootToolContractMatchesProviderVisibleSurface(t *testing.T) {
+func TestBootToolContractCoversProviderVisibleSurface(t *testing.T) {
for _, tc := range []struct {
name string
tokenMode string
@@ -2081,11 +2081,21 @@ model = "x"
if got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantNames) {
t.Fatalf("%s provider-visible tool surface changed\ngot %v\nwant %v", tc.name, got, wantNames)
}
- if len(entries) != len(req.Tools) {
- t.Fatalf("contract entries = %d, provider tools = %d\ncontract=%v\nprovider=%v", len(entries), len(req.Tools), contractEntryNames(entries), toolSchemaNames(req.Tools))
+ entryByName := make(map[string]tool.ContractEntry, len(entries))
+ for _, entry := range entries {
+ entryByName[entry.Name] = entry
}
- for i, e := range entries {
- s := req.Tools[i]
+ if _, ok := entryByName["update_goal"]; !ok {
+ t.Fatalf("static contract must retain contextual update_goal: %v", contractEntryNames(entries))
+ }
+ if len(entries) != len(req.Tools)+1 {
+ t.Fatalf("contract entries = %d, provider tools = %d; want only contextual update_goal hidden\ncontract=%v\nprovider=%v", len(entries), len(req.Tools), contractEntryNames(entries), toolSchemaNames(req.Tools))
+ }
+ for i, s := range req.Tools {
+ e, ok := entryByName[s.Name]
+ if !ok {
+ t.Fatalf("provider tool %q missing from static contract", s.Name)
+ }
if e.Name != s.Name {
t.Fatalf("tool[%d] name = %q, want %q\ncontract=%v\nprovider=%v", i, e.Name, s.Name, contractEntryNames(entries), toolSchemaNames(req.Tools))
}
@@ -2222,7 +2232,6 @@ func defaultFullBootToolNames() []string {
"slash_command",
"task",
"todo_write",
- "update_goal",
"wait",
"web_fetch",
"write_file",
@@ -2238,7 +2247,6 @@ func economyBootToolNames() []string {
"edit_file",
"kill_shell",
"read_file",
- "update_goal",
"wait",
"write_file",
}
@@ -2290,7 +2298,6 @@ command = "reasonix-missing-mockmcp"
"edit_file",
"kill_shell",
"read_file",
- "update_goal",
"wait",
"write_file",
}
diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go
index bfe75fbfd1..2b17635c05 100644
--- a/internal/jobs/jobs.go
+++ b/internal/jobs/jobs.go
@@ -1911,6 +1911,7 @@ func jobKey(parentSession, id string) string {
type ctxKey struct{}
type sessionCtxKey struct{}
type jobCtxKey struct{}
+type noManager struct{}
// WithManager stamps ctx with the job manager so tools can reach it via
// FromContext. The agent sets this on every tool call's context.
@@ -1918,6 +1919,13 @@ func WithManager(ctx context.Context, m *Manager) context.Context {
return context.WithValue(ctx, ctxKey{}, m)
}
+// WithoutManager shadows an ancestor manager while preserving the rest of the
+// context chain. Agents without Jobs must not accidentally operate a parent's
+// background jobs through inherited call context.
+func WithoutManager(ctx context.Context) context.Context {
+ return context.WithValue(ctx, ctxKey{}, noManager{})
+}
+
// FromContext returns the job manager set by the agent, if any. ok is false for a
// plain context (headless tests, calls outside the run loop).
func FromContext(ctx context.Context) (*Manager, bool) {
diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go
index fc1d8c15ba..292f537aec 100644
--- a/internal/jobs/jobs_test.go
+++ b/internal/jobs/jobs_test.go
@@ -41,6 +41,8 @@ type blockingFinishedSink struct {
once sync.Once
}
+type preservedContextKey struct{}
+
func (s *blockingFinishedSink) Emit(ev event.Event) {
if strings.Contains(ev.Text, "background bash finished") {
s.once.Do(func() { close(s.entered) })
@@ -79,6 +81,19 @@ func TestStartForSessionStampsJobContext(t *testing.T) {
}
}
+func TestWithoutManagerShadowsOnlyManager(t *testing.T) {
+ manager := NewManager(event.Discard)
+ defer manager.Close()
+ parent := context.WithValue(WithManager(context.Background(), manager), preservedContextKey{}, "preserved")
+ child := WithoutManager(parent)
+ if _, ok := FromContext(child); ok {
+ t.Fatal("child context inherited a disabled parent job manager")
+ }
+ if got := child.Value(preservedContextKey{}); got != "preserved" {
+ t.Fatalf("unrelated context value = %v, want preserved", got)
+ }
+}
+
func TestJobStartObserverSeesLifetimeUntilTerminal(t *testing.T) {
observed := make(chan (<-chan struct{}), 1)
release := make(chan struct{})
diff --git a/internal/tool/builtin/bgjobs.go b/internal/tool/builtin/bgjobs.go
index 226bd75e2c..1f1d9edda3 100644
--- a/internal/tool/builtin/bgjobs.go
+++ b/internal/tool/builtin/bgjobs.go
@@ -42,6 +42,11 @@ func (bashOutput) Schema() json.RawMessage {
func (bashOutput) ReadOnly() bool { return true }
+func (bashOutput) ProviderVisible(ctx context.Context) bool {
+ _, ok := jobs.FromContext(ctx)
+ return ok
+}
+
func (bashOutput) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
JobID string `json:"job_id"`
@@ -109,6 +114,11 @@ func (killShell) Schema() json.RawMessage {
func (killShell) ReadOnly() bool { return false }
+func (killShell) ProviderVisible(ctx context.Context) bool {
+ _, ok := jobs.FromContext(ctx)
+ return ok
+}
+
func (killShell) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
JobID string `json:"job_id"`
@@ -145,6 +155,11 @@ func (waitJob) Schema() json.RawMessage {
func (waitJob) ReadOnly() bool { return true }
+func (waitJob) ProviderVisible(ctx context.Context) bool {
+ _, ok := jobs.FromContext(ctx)
+ return ok
+}
+
func (waitJob) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
JobIDs []string `json:"job_ids"`
diff --git a/internal/tool/builtin/bgjobs_test.go b/internal/tool/builtin/bgjobs_test.go
index 48f3031620..bdeff1c629 100644
--- a/internal/tool/builtin/bgjobs_test.go
+++ b/internal/tool/builtin/bgjobs_test.go
@@ -12,6 +12,32 @@ import (
"reasonix/internal/planmode"
)
+func TestBackgroundJobToolsVisibleOnlyWithManager(t *testing.T) {
+ plain := context.Background()
+ for name, visible := range map[string]func(context.Context) bool{
+ "bash_output": bashOutput{}.ProviderVisible,
+ "kill_shell": killShell{}.ProviderVisible,
+ "wait": waitJob{}.ProviderVisible,
+ } {
+ if visible(plain) {
+ t.Fatalf("%s visible without a job manager", name)
+ }
+ }
+
+ manager := jobs.NewManager(event.Discard)
+ defer manager.Close()
+ ctx := jobs.WithManager(plain, manager)
+ for name, visible := range map[string]func(context.Context) bool{
+ "bash_output": bashOutput{}.ProviderVisible,
+ "kill_shell": killShell{}.ProviderVisible,
+ "wait": waitJob{}.ProviderVisible,
+ } {
+ if !visible(ctx) {
+ t.Fatalf("%s hidden despite an active job manager", name)
+ }
+ }
+}
+
// End-to-end through the actual tools: a background bash job runs under a manager
// injected on the context, the wait tool collects its output, and bash_output
// reads it — the same path the agent drives.
diff --git a/internal/tool/builtin/completestep.go b/internal/tool/builtin/completestep.go
index c9704e0867..a4b2355aa2 100644
--- a/internal/tool/builtin/completestep.go
+++ b/internal/tool/builtin/completestep.go
@@ -9,6 +9,7 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/instruction"
+ "reasonix/internal/planmode"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
@@ -80,6 +81,13 @@ func (completeStep) Schema() json.RawMessage {
// effect), so it never needs approval and stays available alongside todo_write.
func (completeStep) ReadOnly() bool { return true }
+// ProviderVisible hides execution-only sign-off from planning requests. The
+// execution gate remains authoritative for stale transcripts and hallucinated
+// calls that still reach the host.
+func (completeStep) ProviderVisible(ctx context.Context) bool {
+ return !planmode.Active(ctx)
+}
+
// PlanModeSafe reports false: although complete_step is read-only, it signs off a
// completed execution step, which is meaningful only after plan approval — not
// during planning. This explicit phase opt-out is the Plan gate's enforced
diff --git a/internal/tool/builtin/completestep_test.go b/internal/tool/builtin/completestep_test.go
index 1b2861e30c..d81497d573 100644
--- a/internal/tool/builtin/completestep_test.go
+++ b/internal/tool/builtin/completestep_test.go
@@ -8,7 +8,9 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/instruction"
+ "reasonix/internal/planmode"
"reasonix/internal/provider"
+ "reasonix/internal/tool"
)
func TestTodoInventoryListsTurnTodos(t *testing.T) {
@@ -488,6 +490,18 @@ func TestCompleteStepReadOnlyForPermissionLayer(t *testing.T) {
}
}
+func TestCompleteStepSchemaOnlyVisibleAfterPlanApproval(t *testing.T) {
+ reg := tool.NewRegistry()
+ reg.Add(completeStep{})
+ if got := reg.SchemasForContext(planmode.WithActive(context.Background(), true)); len(got) != 0 {
+ t.Fatalf("Plan-mode schemas = %+v, want complete_step hidden", got)
+ }
+ got := reg.SchemasForContext(planmode.WithActive(context.Background(), false))
+ if len(got) != 1 || got[0].Name != "complete_step" {
+ t.Fatalf("execution schemas = %+v, want complete_step", got)
+ }
+}
+
// Replays of real complete_step rejections captured from local sessions (2026-06-02) and issue #2917.
func TestCompleteStepMatchesParaphrasedCommands(t *testing.T) {
cases := []struct {
diff --git a/internal/tool/builtin/updategoal.go b/internal/tool/builtin/updategoal.go
index d38635bfb8..16a62a78ce 100644
--- a/internal/tool/builtin/updategoal.go
+++ b/internal/tool/builtin/updategoal.go
@@ -43,8 +43,14 @@ func (updateGoal) Schema() json.RawMessage {
// tool permissions or bypass sandbox policy.
func (updateGoal) ReadOnly() bool { return true }
-// PlanModeSafe reports true: the tool is read-only host bookkeeping, and
-// outside an active goal turn its Execute fails closed anyway.
+func (updateGoal) ProviderVisible(ctx context.Context) bool {
+ _, ok := tool.GoalTurnRecorderFromContext(ctx)
+ return ok
+}
+
+// PlanModeSafe reports true: the tool is read-only host bookkeeping. It is
+// provider-visible only during an active goal turn, and Execute also fails
+// closed if a stale or hallucinated call reaches an ordinary turn.
func (updateGoal) PlanModeSafe() bool { return true }
func (updateGoal) Execute(ctx context.Context, args json.RawMessage) (string, error) {
diff --git a/internal/tool/builtin/updategoal_test.go b/internal/tool/builtin/updategoal_test.go
index ee514f6ebe..428c9b416d 100644
--- a/internal/tool/builtin/updategoal_test.go
+++ b/internal/tool/builtin/updategoal_test.go
@@ -75,6 +75,20 @@ func TestUpdateGoalFailsClosedOutsideActiveGoalTurn(t *testing.T) {
}
}
+func TestUpdateGoalSchemaOnlyVisibleDuringActiveGoalTurn(t *testing.T) {
+ reg := tool.NewRegistry()
+ reg.Add(updateGoal{})
+ if got := reg.SchemasForContext(context.Background()); len(got) != 0 {
+ t.Fatalf("ordinary turn schemas = %+v, want update_goal hidden", got)
+ }
+
+ _, _, ctx := goalTool(t)
+ got := reg.SchemasForContext(ctx)
+ if len(got) != 1 || got[0].Name != "update_goal" {
+ t.Fatalf("goal turn schemas = %+v, want update_goal", got)
+ }
+}
+
func TestUpdateGoalRecordsReport(t *testing.T) {
toolFn, rec, ctx := goalTool(t)
_, err := toolFn.Execute(ctx, json.RawMessage(`{"status":"continue","reason":"fixing the parser","next_action":"run tests"}`))
diff --git a/internal/tool/contract_lock_test.go b/internal/tool/contract_lock_test.go
index de78eea88f..99ed961905 100644
--- a/internal/tool/contract_lock_test.go
+++ b/internal/tool/contract_lock_test.go
@@ -5,6 +5,8 @@ import (
"encoding/json"
"testing"
"time"
+
+ "reasonix/internal/provider"
)
// blockingReadOnlyTool lets a test park ContractEntries inside the per-tool
@@ -15,6 +17,27 @@ type blockingReadOnlyTool struct {
release <-chan struct{}
}
+type blockingContextualTool struct {
+ name string
+ entered chan<- struct{}
+ release <-chan struct{}
+}
+
+func (t *blockingContextualTool) Name() string { return t.name }
+func (t *blockingContextualTool) Description() string { return "blocking contextual test tool" }
+func (t *blockingContextualTool) Schema() json.RawMessage {
+ return json.RawMessage(`{"type":"object","properties":{}}`)
+}
+func (t *blockingContextualTool) Execute(context.Context, json.RawMessage) (string, error) {
+ return "ok", nil
+}
+func (t *blockingContextualTool) ReadOnly() bool { return true }
+func (t *blockingContextualTool) ProviderVisible(context.Context) bool {
+ close(t.entered)
+ <-t.release
+ return true
+}
+
func (t *blockingReadOnlyTool) Name() string { return t.name }
func (t *blockingReadOnlyTool) Description() string { return "blocking test tool" }
func (t *blockingReadOnlyTool) Schema() json.RawMessage {
@@ -72,3 +95,38 @@ func TestContractEntriesDoesNotHoldRegistryLockAcrossToolCallbacks(t *testing.T)
t.Fatalf("ContractEntries returned %+v, want one read-only blocking_tool", entries)
}
}
+
+func TestSchemasForContextDoesNotHoldRegistryLockAcrossAvailability(t *testing.T) {
+ reg := NewRegistry()
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ reg.Add(&blockingContextualTool{name: "contextual", entered: entered, release: release})
+
+ schemasCh := make(chan []provider.ToolSchema, 1)
+ go func() {
+ schemasCh <- reg.SchemasForContext(context.Background())
+ }()
+
+ select {
+ case <-entered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("SchemasForContext never reached the availability callback")
+ }
+
+ addDone := make(chan struct{})
+ go func() {
+ reg.Add(stubTool{name: "writer_tool"})
+ close(addDone)
+ }()
+ select {
+ case <-addDone:
+ case <-time.After(5 * time.Second):
+ t.Fatal("registry writer blocked while SchemasForContext checked availability")
+ }
+
+ close(release)
+ schemas := <-schemasCh
+ if len(schemas) != 1 || schemas[0].Name != "contextual" {
+ t.Fatalf("SchemasForContext returned %+v, want contextual snapshot", schemas)
+ }
+}
diff --git a/internal/tool/contract_test.go b/internal/tool/contract_test.go
index f1ca0ae8fa..61b7ab2249 100644
--- a/internal/tool/contract_test.go
+++ b/internal/tool/contract_test.go
@@ -85,3 +85,15 @@ func TestEveryBuiltinDeclaresSnipStance(t *testing.T) {
}
}
}
+
+func TestPlanModeUnsafeBuiltinsDeclareContextualVisibility(t *testing.T) {
+ for _, builtin := range tool.Builtins() {
+ classifier, ok := builtin.(tool.PlanModeClassifier)
+ if !ok || classifier.PlanModeSafe() {
+ continue
+ }
+ if _, ok := builtin.(tool.ContextualTool); !ok {
+ t.Errorf("Plan-mode-unsafe builtin %q must hide itself from provider schemas while unavailable", builtin.Name())
+ }
+ }
+}
diff --git a/internal/tool/goal.go b/internal/tool/goal.go
index f931d05163..1ad0ea538a 100644
--- a/internal/tool/goal.go
+++ b/internal/tool/goal.go
@@ -27,6 +27,11 @@ type GoalTurnRecorder interface {
type goalTurnRecorderKey struct{}
+// noGoalTurnRecorder shadows an ancestor recorder while preserving the rest
+// of the context chain. Child agents must not report disposition for the
+// parent's goal turn.
+type noGoalTurnRecorder struct{}
+
// WithGoalTurnRecorder stamps ctx with the per-turn goal recorder so the
// update_goal tool can reach it from inside the run loop.
func WithGoalTurnRecorder(ctx context.Context, r GoalTurnRecorder) context.Context {
@@ -36,6 +41,13 @@ func WithGoalTurnRecorder(ctx context.Context, r GoalTurnRecorder) context.Conte
return context.WithValue(ctx, goalTurnRecorderKey{}, r)
}
+// WithoutGoalTurnRecorder returns a child context that cannot access a goal
+// recorder inherited from its parent. Other values and cancellation continue
+// to flow through the context normally.
+func WithoutGoalTurnRecorder(ctx context.Context) context.Context {
+ return context.WithValue(ctx, goalTurnRecorderKey{}, noGoalTurnRecorder{})
+}
+
// GoalTurnRecorderFromContext returns the active goal turn's recorder, if any.
func GoalTurnRecorderFromContext(ctx context.Context) (GoalTurnRecorder, bool) {
if ctx == nil {
diff --git a/internal/tool/goal_test.go b/internal/tool/goal_test.go
new file mode 100644
index 0000000000..e0bdbaf9de
--- /dev/null
+++ b/internal/tool/goal_test.go
@@ -0,0 +1,30 @@
+package tool
+
+import (
+ "context"
+ "testing"
+)
+
+type goalTestRecorder struct{}
+
+func (goalTestRecorder) RecordGoalReport(GoalReport) (string, error) { return "recorded", nil }
+
+type preservedGoalContextKey struct{}
+
+func TestWithoutGoalTurnRecorderShadowsOnlyRecorder(t *testing.T) {
+ parent, cancel := context.WithCancel(context.Background())
+ parent = context.WithValue(parent, preservedGoalContextKey{}, "preserved")
+ parent = WithGoalTurnRecorder(parent, goalTestRecorder{})
+
+ child := WithoutGoalTurnRecorder(parent)
+ if _, ok := GoalTurnRecorderFromContext(child); ok {
+ t.Fatal("child context inherited the parent goal recorder")
+ }
+ if got := child.Value(preservedGoalContextKey{}); got != "preserved" {
+ t.Fatalf("unrelated context value = %v, want preserved", got)
+ }
+ cancel()
+ if child.Err() != context.Canceled {
+ t.Fatalf("child cancellation = %v, want context.Canceled", child.Err())
+ }
+}
diff --git a/internal/tool/tool.go b/internal/tool/tool.go
index 90512f95d5..09610a3565 100644
--- a/internal/tool/tool.go
+++ b/internal/tool/tool.go
@@ -33,6 +33,13 @@ type Tool interface {
ReadOnly() bool
}
+// ContextualTool can hide a registered tool from provider requests when the
+// current turn cannot execute it. Execute must still validate the context so
+// stale transcripts and provider-hallucinated calls fail closed.
+type ContextualTool interface {
+ ProviderVisible(context.Context) bool
+}
+
// Previewer is an optional capability a writer Tool may implement: given the
// same raw JSON args Execute would receive, compute the file change the call
// *would* make — without touching disk. ctx must be Execute's, so the preview
@@ -519,23 +526,41 @@ func (r *Registry) Names() []string {
// Schemas exports tool definitions in stable name order for the provider.
func (r *Registry) Schemas() []provider.ToolSchema {
- r.mu.RLock()
- defer r.mu.RUnlock()
+ return r.schemasForContext(nil, false)
+}
- names := make([]string, len(r.order))
- copy(names, r.order)
- sort.Strings(names)
+// SchemasForContext exports only tools available during ctx. Tools without a
+// contextual availability contract remain visible as before.
+func (r *Registry) SchemasForContext(ctx context.Context) []provider.ToolSchema {
+ return r.schemasForContext(ctx, true)
+}
+
+func (r *Registry) schemasForContext(ctx context.Context, filterContextual bool) []provider.ToolSchema {
+ r.mu.RLock()
+ type schemaEntry struct {
+ name string
+ tool Tool
+ canonical json.RawMessage
+ }
+ entries := make([]schemaEntry, 0, len(r.order))
+ for _, name := range r.order {
+ if t := r.tools[name]; t != nil {
+ entries = append(entries, schemaEntry{name: name, tool: t, canonical: r.canon[name]})
+ }
+ }
+ r.mu.RUnlock()
+ sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name })
- out := make([]provider.ToolSchema, 0, len(names))
- for _, name := range names {
- t := r.tools[name]
- if t == nil {
+ out := make([]provider.ToolSchema, 0, len(entries))
+ for _, entry := range entries {
+ t := entry.tool
+ if contextual, ok := t.(ContextualTool); filterContextual && ok && !contextual.ProviderVisible(ctx) {
continue
}
out = append(out, provider.ToolSchema{
Name: t.Name(),
Description: t.Description(),
- Parameters: r.canon[name],
+ Parameters: entry.canonical,
})
}
return out
From eabcc35160b1a9385a96ae22970d278526690d7e Mon Sep 17 00:00:00 2001
From: SivanCola <32437197+SivanCola@users.noreply.github.com>
Date: Sun, 9 Aug 2026 01:07:26 +0800
Subject: [PATCH 03/12] chore(lint): account for contextual tool coverage
Problem: the contextual-tool fix intentionally grows several already-baselined owner and test files, so repo standards rejects the PR.\n\nRoot cause: repolint budgets remained at the pre-fix line and function counts.\n\nFix: raise only the nine affected file/function budgets and add the new complete_step test-file allowance, without rewriting unrelated baseline entries.\n\nVerification: go run ./tools/repolint; git diff --check.
---
tools/repolint/baseline.json | 23 ++++++++++++++---------
1 file changed, 14 insertions(+), 9 deletions(-)
diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json
index a0508def09..47d9268ca1 100644
--- a/tools/repolint/baseline.json
+++ b/tools/repolint/baseline.json
@@ -439,14 +439,16 @@
},
"internal/agent/coordinator.go": {
"essay": 17,
- "file-size": 264
+ "file-size": 267,
+ "function-size": 3
},
"internal/agent/coordinator_test.go": {
"essay": 3,
- "test-file-size": 1239
+ "test-file-size": 1316
},
"internal/agent/delivery_hardening_test.go": {
- "essay": 5
+ "essay": 5,
+ "test-file-size": 152
},
"internal/agent/delivery_scope_test.go": {
"essay": 1
@@ -545,8 +547,8 @@
"internal/agent/run_loop.go": {
"complexity": 5,
"essay": 45,
- "file-size": 311,
- "function-size": 46
+ "file-size": 318,
+ "function-size": 48
},
"internal/agent/save.go": {
"complexity": 44,
@@ -609,7 +611,7 @@
"internal/agent/task.go": {
"complexity": 25,
"essay": 56,
- "file-size": 1377,
+ "file-size": 1388,
"function-size": 114
},
"internal/agent/task_test.go": {
@@ -647,7 +649,7 @@
},
"internal/boot/boot_test.go": {
"essay": 10,
- "test-file-size": 4331
+ "test-file-size": 4338
},
"internal/boot/extension_dispatch_test.go": {
"essay": 3,
@@ -1382,7 +1384,7 @@
},
"internal/jobs/jobs.go": {
"essay": 42,
- "file-size": 1264,
+ "file-size": 1272,
"function-size": 12
},
"internal/jobs/jobs_extra_test.go": {
@@ -1392,7 +1394,7 @@
"essay": 4
},
"internal/jobs/jobs_test.go": {
- "test-file-size": 12
+ "test-file-size": 27
},
"internal/memory/doc.go": {
"essay": 1
@@ -1798,6 +1800,9 @@
"internal/tool/builtin/completestep.go": {
"essay": 3
},
+ "internal/tool/builtin/completestep_test.go": {
+ "test-file-size": 8
+ },
"internal/tool/builtin/confine.go": {
"essay": 16
},
From c459e8a88d60ac029ab60be07cf8057341aebebe Mon Sep 17 00:00:00 2001
From: SivanCola <32437197+SivanCola@users.noreply.github.com>
Date: Sun, 9 Aug 2026 01:30:13 +0800
Subject: [PATCH 04/12] chore(lint): baseline latest governor growth
Problem: the latest main-v2 governor merge adds three baselined function and file lines after the Goal PR sync.\n\nRoot cause: the governor commit did not update repository standards budgets before becoming the PR base.\n\nFix: record only the exact e2ebench and agent.go growth reported by repolint.\n\nVerification: go run ./tools/repolint; git diff --check.
---
tools/repolint/baseline.json | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json
index 6422cb6010..dbe140cfb7 100644
--- a/tools/repolint/baseline.json
+++ b/tools/repolint/baseline.json
@@ -4,8 +4,8 @@
"commented-code": 0,
"complexity": 2047,
"essay": 4006,
- "file-size": 107830,
- "function-size": 9091,
+ "file-size": 107833,
+ "function-size": 9094,
"layering": 1,
"marker": 0,
"narrative": 61,
@@ -13,7 +13,8 @@
},
"files": {
"cmd/e2ebench/main.go": {
- "essay": 1
+ "essay": 1,
+ "function-size": 3
},
"cmd/e2ebench/mutation.go": {
"essay": 1
@@ -405,7 +406,7 @@
"internal/agent/agent.go": {
"complexity": 61,
"essay": 109,
- "file-size": 2727,
+ "file-size": 2730,
"function-size": 124
},
"internal/agent/ask.go": {
From a8a2a83643be6decad9b43623fd68b86dd1daf95 Mon Sep 17 00:00:00 2001
From: SivanCola <32437197+SivanCola@users.noreply.github.com>
Date: Sun, 9 Aug 2026 03:43:08 +0800
Subject: [PATCH 05/12] fix(goal): harden legacy migration and contextual tools
Problem:
Workflow-only tools could remain model-visible outside their executable context, mixed batches did not repair every unavailable call, and legacy AutoResearch recovery could lose retry state or reactivate after downgrade.
Root cause:
Tool schemas and child metadata were assembled from static registries, parent runtime services leaked through inherited contexts, and legacy task IDs were cleared before migration persistence was known to succeed.
Fix:
Filter schemas through ContextualTool, bound mixed-batch repair, isolate child Goal/Jobs/memory state, compute contextual metadata, and make legacy sidecar migration retryable, fail-closed, and downgrade-safe with budgetClass as the authority.
Verification:
go test ./... -count=1
go test -race ./internal/control ./internal/agent ./internal/jobs ./internal/tool ./internal/tool/builtin ./internal/memory ./internal/autoresearch -count=1
go vet ./...
golangci-lint run --timeout=5m
cd desktop && go test ./... -count=1
pnpm typecheck; pnpm test:all; pnpm build
scripts/cache-guard.sh
go run ./tools/repolint
git diff --check
---
CHANGELOG.md | 9 +
desktop/goal_delivery_yolo_test.go | 4 +-
internal/agent/coordinator_test.go | 79 +--
internal/agent/delivery_hardening_test.go | 179 -----
internal/agent/extensions.go | 3 +-
internal/agent/extensions_test.go | 35 +
internal/agent/goal_schema_isolation_test.go | 350 ++++++++++
internal/agent/run_loop.go | 36 +-
.../agent/subagent_context_isolation_test.go | 74 +++
internal/agent/subagent_store.go | 16 +-
internal/agent/task.go | 20 +-
internal/autoresearch/fixture_test.go | 25 +-
internal/autoresearch/store.go | 333 ++++++++--
internal/autoresearch/store_test.go | 148 +++++
internal/autoresearch/task.go | 13 -
internal/cli/chat_tui.go | 8 +-
internal/cli/chat_tui_goal.go | 9 +
internal/cli/chat_tui_goal_test.go | 36 +
internal/control/autoresearch_manager.go | 140 +++-
internal/control/controller.go | 81 ++-
internal/control/controller_test.go | 6 +-
internal/control/goal.go | 245 +++----
internal/control/goal_command.go | 20 +
internal/control/goal_durable.go | 63 ++
internal/control/goal_durable_test.go | 38 ++
internal/control/goal_legacy.go | 181 +++++
internal/control/goal_legacy_restore_test.go | 619 ++++++++++++++++++
internal/control/goal_runtime_test.go | 12 +-
internal/control/goal_test.go | 65 +-
internal/control/input.go | 11 +-
internal/control/planner_gate_test.go | 6 +-
internal/control/port.go | 2 +
internal/control/turn_orchestrator.go | 1 -
internal/memory/queue.go | 8 +
internal/memory/queue_test.go | 28 +
internal/tool/tool.go | 2 +-
tools/repolint/baseline.json | 39 +-
37 files changed, 2329 insertions(+), 615 deletions(-)
create mode 100644 internal/agent/goal_schema_isolation_test.go
create mode 100644 internal/agent/subagent_context_isolation_test.go
create mode 100644 internal/cli/chat_tui_goal.go
create mode 100644 internal/cli/chat_tui_goal_test.go
create mode 100644 internal/control/goal_command.go
create mode 100644 internal/control/goal_durable.go
create mode 100644 internal/control/goal_durable_test.go
create mode 100644 internal/control/goal_legacy.go
create mode 100644 internal/control/goal_legacy_restore_test.go
create mode 100644 internal/memory/queue_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 56185239a7..75e6b2089d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,15 @@ branch.
### Fixed
+- Goal is now the sole long-task runtime. Historical AutoResearch sidecars
+ migrate transactionally into research-budget Goals, retain their archive id
+ for retry when recovery fails, and write an explicit legacy-reader fence so
+ downgrading cannot reactivate the removed AutoResearch runtime.
+- Workflow-only tools are exposed to models only while their required Goal,
+ Plan, or background-job context is active. Mixed valid/unavailable tool
+ batches receive one bounded repair, while sub-agents no longer inherit parent
+ Goal reports, background jobs, or immediate memory-queue injection.
+
- **Issue #7575:** Linux Bash under bubblewrap no longer mounts a fresh empty
`--tmpfs /tmp` on every call. Consecutive commands in the same logical session
now share a private temporary directory (bound at `/tmp` on Linux, exported via
diff --git a/desktop/goal_delivery_yolo_test.go b/desktop/goal_delivery_yolo_test.go
index 2ce256d974..3161496807 100644
--- a/desktop/goal_delivery_yolo_test.go
+++ b/desktop/goal_delivery_yolo_test.go
@@ -54,8 +54,8 @@ func newGoalDeliveryYoloTestApp(t *testing.T, goalStatus string) (*App, *Workspa
state := map[string]any{
"goal": "ship the combined mode",
"status": goalStatus,
- "researchMode": control.GoalResearchOn,
- "autoResearchTaskID": "research-task-1",
+ "budgetClass": "research",
+ "turnsLimit": 40,
"scopeID": checkpoint.ScopeID,
"deliveryCheckpoint": checkpoint,
}
diff --git a/internal/agent/coordinator_test.go b/internal/agent/coordinator_test.go
index 3272012780..5ca880ab60 100644
--- a/internal/agent/coordinator_test.go
+++ b/internal/agent/coordinator_test.go
@@ -85,67 +85,6 @@ func TestCoordinatorHandsPlanToExecutor(t *testing.T) {
}
}
-type coordinatorGoalRecorder struct {
- reports []tool.GoalReport
-}
-
-func (r *coordinatorGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
- r.reports = append(r.reports, report)
- return "recorded " + report.Status, nil
-}
-
-func TestCoordinatorPlannerCannotReportExecutorGoalDisposition(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
- {toolCallChunk("planner-goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
- {{Type: provider.ChunkText, Text: "1. inspect the implementation\n2. apply and verify the fix"}, {Type: provider.ChunkDone}},
- }}
- exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
- {Type: provider.ChunkText, Text: "Implemented and verified."},
- {Type: provider.ChunkDone},
- }}
- plannerSess := NewSession("planner-sys")
- executor := New(exec, reg, NewSession("exec-sys"), Options{}, event.Discard)
- customPlannerReg := tool.NewRegistry()
- customPlannerReg.Add(goalTool)
- coord := NewCoordinator(planner, plannerSess, nil, customPlannerReg, Options{}, executor, 0, event.Discard, nil)
- recorder := &coordinatorGoalRecorder{}
- ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
-
- if err := coord.Run(ctx, "fix the goal bug"); err != nil {
- t.Fatalf("Run: %v", err)
- }
- if len(planner.requests) != 2 {
- t.Fatalf("planner requests = %d, want hallucinated call plus repair", len(planner.requests))
- }
- for i, req := range planner.requests {
- for _, schema := range req.Tools {
- if schema.Name == "update_goal" {
- t.Fatalf("planner request %d exposed update_goal", i+1)
- }
- }
- }
- if got := lastToolResult(plannerSess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
- t.Fatalf("planner update_goal result = %q", got)
- }
- if len(exec.requests) == 0 {
- t.Fatal("executor made no requests")
- }
- for i, req := range exec.requests {
- if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
- t.Fatalf("executor request %d lost update_goal after planner isolation: %v", i+1, toolSchemaNames(req.Tools))
- }
- }
- if len(recorder.reports) != 0 {
- t.Fatalf("planner wrote reports into executor Goal recorder: %+v", recorder.reports)
- }
-}
-
type coordinatorApprovalGate struct {
calls int
allow bool
@@ -775,19 +714,6 @@ func (t coordinatorTestTool) Execute(context.Context, json.RawMessage) (string,
}
func (t coordinatorTestTool) ReadOnly() bool { return t.readOnly }
-type plannerPhaseOnlyTool struct{}
-
-func (plannerPhaseOnlyTool) Name() string { return "planner_phase_only" }
-func (plannerPhaseOnlyTool) Description() string { return "planner phase-only test tool" }
-func (plannerPhaseOnlyTool) Schema() json.RawMessage {
- return json.RawMessage(`{"type":"object"}`)
-}
-func (plannerPhaseOnlyTool) Execute(context.Context, json.RawMessage) (string, error) {
- return "phase-only", nil
-}
-func (plannerPhaseOnlyTool) ReadOnly() bool { return true }
-func (plannerPhaseOnlyTool) PlanModeSafe() bool { return false }
-
func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
{
@@ -808,9 +734,6 @@ func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "Rule: keep changes narrow."})
parentReg.Add(coordinatorTestTool{name: "write_file", readOnly: false})
parentReg.Add(coordinatorTestTool{name: "todo_write", readOnly: true})
- parentReg.Add(mustBuiltinTool(t, "complete_step"))
- parentReg.Add(mustBuiltinTool(t, "update_goal"))
- parentReg.Add(plannerPhaseOnlyTool{})
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession(PlannerPromptWithContext("Rule: keep changes narrow."))
@@ -827,7 +750,7 @@ func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
if !contains(tools, "read_file") {
t.Fatalf("planner tools = %v, want read_file", tools)
}
- for _, forbidden := range []string{"write_file", "todo_write", "complete_step", "update_goal", "planner_phase_only"} {
+ for _, forbidden := range []string{"write_file", "todo_write"} {
if contains(tools, forbidden) {
t.Fatalf("planner tools = %v, must not include %s", tools, forbidden)
}
diff --git a/internal/agent/delivery_hardening_test.go b/internal/agent/delivery_hardening_test.go
index 3a1ae2607b..707bc5c6b9 100644
--- a/internal/agent/delivery_hardening_test.go
+++ b/internal/agent/delivery_hardening_test.go
@@ -12,7 +12,6 @@ import (
"reasonix/internal/capability"
"reasonix/internal/event"
"reasonix/internal/evidence"
- "reasonix/internal/jobs"
"reasonix/internal/provider"
"reasonix/internal/taskintent"
"reasonix/internal/tool"
@@ -199,158 +198,6 @@ func TestDeliveryDurableMemoryRequiresRememberWithoutCodeCeremony(t *testing.T)
}
}
-func TestNonGoalRequestDoesNotExposeUpdateGoal(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
- {{Type: provider.ChunkText, Text: "Here is the answer."}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- if err := a.Run(context.Background(), "answer normally"); err != nil {
- t.Fatalf("non-Goal answer: %v", err)
- }
- if len(prov.requests) != 1 {
- t.Fatalf("provider requests = %d, want 1", len(prov.requests))
- }
- for _, schema := range prov.requests[0].Tools {
- if schema.Name == "update_goal" {
- t.Fatal("non-Goal provider request exposed update_goal")
- }
- }
- if got := lastAssistantContent(a.Session()); got != "Here is the answer." {
- t.Fatalf("last assistant text = %q", got)
- }
-}
-
-func TestAgentWithoutJobsDoesNotExposeBackgroundTools(t *testing.T) {
- reg := tool.NewRegistry()
- for _, name := range []string{"wait", "bash_output", "kill_shell"} {
- jobTool, ok := tool.LookupBuiltin(name)
- if !ok {
- t.Fatalf("%s builtin not registered", name)
- }
- reg.Add(jobTool)
- }
- manager := jobs.NewManager(event.Discard)
- defer manager.Close()
- ctx := jobs.WithManager(context.Background(), manager)
- prov := &scriptedProvider{name: "no-jobs", turns: [][]provider.Chunk{
- {{Type: provider.ChunkText, Text: "No background work."}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- if err := a.Run(ctx, "answer normally"); err != nil {
- t.Fatalf("no-Jobs answer: %v", err)
- }
- if len(prov.requests) != 1 {
- t.Fatalf("provider requests = %d, want 1", len(prov.requests))
- }
- if len(prov.requests[0].Tools) != 0 {
- t.Fatalf("no-Jobs provider tools = %v, want background tools hidden", prov.requests[0].Tools)
- }
-
- withJobsProv := &scriptedProvider{name: "with-jobs", turns: [][]provider.Chunk{
- {{Type: provider.ChunkText, Text: "Background tools available."}, {Type: provider.ChunkDone}},
- }}
- withJobs := New(withJobsProv, reg, NewSession("sys"), Options{Jobs: manager}, event.Discard)
- if err := withJobs.Run(context.Background(), "answer normally"); err != nil {
- t.Fatalf("with-Jobs answer: %v", err)
- }
- visible := make(map[string]bool)
- for _, schema := range withJobsProv.requests[0].Tools {
- visible[schema.Name] = true
- }
- for _, name := range []string{"wait", "bash_output", "kill_shell"} {
- if !visible[name] {
- t.Fatalf("with-Jobs provider tools = %v, missing %s", visible, name)
- }
- }
-}
-
-type requestGoalRecorder struct{}
-
-func (requestGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
- return "recorded " + report.Status, nil
-}
-
-type childIsolationGoalRecorder struct {
- reports []tool.GoalReport
-}
-
-func (r *childIsolationGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
- r.reports = append(r.reports, report)
- return "recorded " + report.Status, nil
-}
-
-func TestGoalRequestExposesUpdateGoal(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
- {{Type: provider.ChunkText, Text: "Goal work continues."}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
- if err := a.Run(ctx, "continue goal"); err != nil {
- t.Fatalf("Goal answer: %v", err)
- }
- if len(prov.requests) != 1 {
- t.Fatalf("provider requests = %d, want 1", len(prov.requests))
- }
- for _, schema := range prov.requests[0].Tools {
- if schema.Name == "update_goal" {
- return
- }
- }
- t.Fatal("Goal provider request did not expose update_goal")
-}
-
-func TestSubAgentDoesNotInheritParentGoalRecorder(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- prov := &scriptedProvider{name: "goal-child", turns: [][]provider.Chunk{
- {toolCallChunk("goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
- {{Type: provider.ChunkText, Text: "Child result."}, {Type: provider.ChunkDone}},
- }}
- recorder := &childIsolationGoalRecorder{}
- ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
- sess := NewSession("child system")
-
- answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the task", Options{}, event.Discard)
- if err != nil {
- t.Fatalf("Goal child: %v", err)
- }
- if answer != "Child result." {
- t.Fatalf("Goal child answer = %q", answer)
- }
- if len(prov.requests) != 2 {
- t.Fatalf("provider requests = %d, want hallucinated call plus repair", len(prov.requests))
- }
- for i, req := range prov.requests {
- for _, schema := range req.Tools {
- if schema.Name == "update_goal" {
- t.Fatalf("child provider request %d exposed update_goal", i+1)
- }
- }
- }
- if len(recorder.reports) != 0 {
- t.Fatalf("child wrote reports into parent Goal recorder: %+v", recorder.reports)
- }
- if got := lastToolResult(sess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
- t.Fatalf("child update_goal result = %q", got)
- }
-}
-
func TestNonGoalHallucinatedUpdateGoalWithVisibleTextDoesNotSpendRepairRound(t *testing.T) {
goalTool, ok := tool.LookupBuiltin("update_goal")
if !ok {
@@ -377,32 +224,6 @@ func TestNonGoalHallucinatedUpdateGoalWithVisibleTextDoesNotSpendRepairRound(t *
}
}
-func TestNonGoalToolOnlyUpdateGoalNudgesVisibleAnswer(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
- {toolCallChunk("goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
- {{Type: provider.ChunkText, Text: "Here is the recovered answer."}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- if err := a.Run(context.Background(), "answer normally"); err != nil {
- t.Fatalf("non-Goal update_goal repair: %v", err)
- }
- if len(prov.requests) != 2 {
- t.Fatalf("provider requests = %d, want repair round", len(prov.requests))
- }
- if got := lastUser(prov.requests[1]); !strings.Contains(got, "visible answer text") {
- t.Fatalf("repair instruction = %q, want visible-answer nudge", got)
- }
- if got := lastAssistantContent(a.Session()); got != "Here is the recovered answer." {
- t.Fatalf("last assistant text = %q", got)
- }
-}
-
func TestNonGoalToolOnlyUpdateGoalGetsAtMostOneRepairRound(t *testing.T) {
goalTool, ok := tool.LookupBuiltin("update_goal")
if !ok {
diff --git a/internal/agent/extensions.go b/internal/agent/extensions.go
index 20a8131f62..16eae72788 100644
--- a/internal/agent/extensions.go
+++ b/internal/agent/extensions.go
@@ -111,9 +111,10 @@ func (a *Agent) interceptAgentStart(ctx context.Context) error {
if d == nil {
return nil
}
+ providerCtx := a.withAgentContext(ctx)
payload := dispatch.AgentStartPayload{
Model: a.prov.Name(),
- ToolCount: len(a.tools.Schemas()),
+ ToolCount: len(a.tools.SchemasForContext(providerCtx)),
SessionID: ParentSession(ctx),
}
result, err := d.Intercept(ctx, extension.PointAgentBeforeStart, &payload)
diff --git a/internal/agent/extensions_test.go b/internal/agent/extensions_test.go
index 43cc36b4f3..8f935cc51c 100644
--- a/internal/agent/extensions_test.go
+++ b/internal/agent/extensions_test.go
@@ -272,6 +272,41 @@ func TestAgentBeforeStartReplace(t *testing.T) {
}
}
+func TestAgentBeforeStartToolCountUsesContextualSchemas(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+
+ run := func(ctx context.Context) dispatch.AgentStartPayload {
+ t.Helper()
+ client := &fakeDispatchClient{}
+ d := newExtDispatcher(client, true, nil, extension.PointAgentBeforeStart)
+ mp := &mockProvider{name: "p", chunks: []provider.Chunk{
+ {Type: provider.ChunkText, Text: "hi"}, {Type: provider.ChunkDone},
+ }}
+ a := New(mp, reg, NewSession("sys"), Options{Extensions: d}, event.Discard)
+ if err := a.Run(ctx, "hello"); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ var payload dispatch.AgentStartPayload
+ if !client.interceptPayloadFor(protocol.EventAgentBeforeStart, &payload) {
+ t.Fatal("agent.before_start did not fire")
+ }
+ return payload
+ }
+
+ if got := run(context.Background()).ToolCount; got != 0 {
+ t.Fatalf("ordinary ToolCount = %d, want update_goal hidden", got)
+ }
+ ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ if got := run(ctx).ToolCount; got != 1 {
+ t.Fatalf("Goal ToolCount = %d, want update_goal visible", got)
+ }
+}
+
func TestAgentBeforeStartFailurePolicy(t *testing.T) {
boom := errors.New("sidecar timeout")
t.Run("required fails the run", func(t *testing.T) {
diff --git a/internal/agent/goal_schema_isolation_test.go b/internal/agent/goal_schema_isolation_test.go
new file mode 100644
index 0000000000..f4b9cd2127
--- /dev/null
+++ b/internal/agent/goal_schema_isolation_test.go
@@ -0,0 +1,350 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "slices"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "reasonix/internal/event"
+ "reasonix/internal/provider"
+ "reasonix/internal/tool"
+)
+
+type requestGoalRecorder struct{}
+
+func (requestGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
+ return "recorded " + report.Status, nil
+}
+
+type childIsolationGoalRecorder struct {
+ reports []tool.GoalReport
+}
+
+func (r *childIsolationGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
+ r.reports = append(r.reports, report)
+ return "recorded " + report.Status, nil
+}
+
+type plannerPhaseOnlyTool struct{}
+
+func (plannerPhaseOnlyTool) Name() string { return "planner_phase_only" }
+func (plannerPhaseOnlyTool) Description() string { return "planner phase-only test tool" }
+func (plannerPhaseOnlyTool) Schema() json.RawMessage {
+ return json.RawMessage(`{"type":"object"}`)
+}
+func (plannerPhaseOnlyTool) Execute(context.Context, json.RawMessage) (string, error) {
+ return "phase-only", nil
+}
+func (plannerPhaseOnlyTool) ReadOnly() bool { return true }
+func (plannerPhaseOnlyTool) PlanModeSafe() bool { return false }
+
+func TestPlannerToolRegistryExcludesNonContextualPlanUnsafeTools(t *testing.T) {
+ parent := tool.NewRegistry()
+ parent.Add(plannerPhaseOnlyTool{})
+ if _, ok := PlannerToolRegistry(parent).Get("planner_phase_only"); ok {
+ t.Fatal("two-model Planner exposed a PlanModeSafe=false custom tool")
+ }
+}
+
+func TestGoalContextChangesOnlyUpdateGoalVisibility(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ ordinary := &scriptedProvider{name: "ordinary", turns: [][]provider.Chunk{
+ {{Type: provider.ChunkText, Text: "ordinary"}, {Type: provider.ChunkDone}},
+ }}
+ ordinaryAgent := New(ordinary, reg, NewSession("sys"), Options{}, event.Discard)
+ if err := ordinaryAgent.Run(context.Background(), "answer normally"); err != nil {
+ t.Fatalf("ordinary Run: %v", err)
+ }
+ goal := &scriptedProvider{name: "goal", turns: [][]provider.Chunk{
+ {{Type: provider.ChunkText, Text: "goal"}, {Type: provider.ChunkDone}},
+ }}
+ goalAgent := New(goal, reg, NewSession("sys"), Options{}, event.Discard)
+ ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ if err := goalAgent.Run(ctx, "continue goal"); err != nil {
+ t.Fatalf("Goal Run: %v", err)
+ }
+ ordinarySchemas, err := json.Marshal(ordinary.requests[0].Tools)
+ if err != nil {
+ t.Fatal(err)
+ }
+ goalSchemas, err := json.Marshal(goal.requests[0].Tools)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(ordinarySchemas) == string(goalSchemas) {
+ t.Fatalf("Goal context did not expose update_goal:\nordinary=%s\ngoal=%s", ordinarySchemas, goalSchemas)
+ }
+ if slices.Contains(toolSchemaNames(ordinary.requests[0].Tools), "update_goal") {
+ t.Fatalf("ordinary request exposed update_goal: %s", ordinarySchemas)
+ }
+ if !slices.Contains(toolSchemaNames(goal.requests[0].Tools), "update_goal") {
+ t.Fatalf("Goal request hid update_goal: %s", goalSchemas)
+ }
+}
+
+func TestContextualToolSchemasStayStableWithinEachGoalPhase(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ reg.Add(fakeTool{name: "read_file", readOnly: true})
+
+ marshal := func(ctx context.Context) string {
+ t.Helper()
+ raw, err := json.Marshal(reg.SchemasForContext(ctx))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(raw)
+ }
+ ordinaryCtx := context.Background()
+ goalCtx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ ordinary := marshal(ordinaryCtx)
+ goal := marshal(goalCtx)
+ if ordinary != marshal(ordinaryCtx) {
+ t.Fatal("ordinary-phase schema bytes changed between identical requests")
+ }
+ if goal != marshal(goalCtx) {
+ t.Fatal("Goal-phase schema bytes changed between identical requests")
+ }
+ if ordinary == goal {
+ t.Fatal("Goal phase transition did not produce the expected one-time schema difference")
+ }
+}
+
+func TestGoalRequestExposesUpdateGoal(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
+ {{Type: provider.ChunkText, Text: "Goal work continues."}, {Type: provider.ChunkDone}},
+ }}
+ a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
+ ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ if err := a.Run(ctx, "continue goal"); err != nil {
+ t.Fatalf("Goal answer: %v", err)
+ }
+ if len(prov.requests) != 1 {
+ t.Fatalf("provider requests = %d, want 1", len(prov.requests))
+ }
+ if !slices.Contains(toolSchemaNames(prov.requests[0].Tools), "update_goal") {
+ t.Fatal("Goal provider request did not expose update_goal")
+ }
+}
+
+func TestMixedContextUnavailableBatchExecutesValidToolsAndRepairsOnce(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ var validCalls int32
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &validCalls})
+ prov := &scriptedProvider{name: "mixed", turns: [][]provider.Chunk{
+ {
+ toolCallChunk("goal", "update_goal", `{"status":"complete"}`),
+ toolCallChunk("read", "read_file", `{}`),
+ {Type: provider.ChunkDone},
+ },
+ {{Type: provider.ChunkText, Text: "Visible answer after collecting the valid result."}, {Type: provider.ChunkDone}},
+ }}
+ sess := NewSession("sys")
+ a := New(prov, reg, sess, Options{}, event.Discard)
+
+ if err := a.Run(context.Background(), "inspect and answer"); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if got := atomic.LoadInt32(&validCalls); got != 1 {
+ t.Fatalf("valid tool calls = %d, want 1", got)
+ }
+ if len(prov.requests) != 2 {
+ t.Fatalf("provider requests = %d, want one repair", len(prov.requests))
+ }
+ if got := lastUser(prov.requests[1]); !strings.Contains(got, "update_goal") || !strings.Contains(got, "visible answer text") {
+ t.Fatalf("repair instruction = %q", got)
+ }
+ if slices.Contains(toolSchemaNames(prov.requests[1].Tools), "update_goal") || !slices.Contains(toolSchemaNames(prov.requests[1].Tools), "read_file") {
+ t.Fatalf("repair schemas = %v", toolSchemaNames(prov.requests[1].Tools))
+ }
+ if got := toolResultByID(sess, "goal"); !strings.Contains(got, "only available while an active goal turn") {
+ t.Fatalf("unavailable result = %q", got)
+ }
+ if got := toolResultByID(sess, "read"); got != "read_file done" {
+ t.Fatalf("valid result = %q", got)
+ }
+}
+
+func TestRepeatedMixedContextUnavailableBatchStopsBeforeReexecution(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ var validCalls int32
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &validCalls})
+ firstMixed := []provider.Chunk{
+ toolCallChunk("goal", "update_goal", `{"status":"complete"}`),
+ toolCallChunk("read", "read_file", `{}`),
+ {Type: provider.ChunkDone},
+ }
+ secondMixed := []provider.Chunk{
+ toolCallChunk("goal-2", "update_goal", `{"status":"complete"}`),
+ toolCallChunk("read-2", "read_file", `{}`),
+ {Type: provider.ChunkDone},
+ }
+ prov := &scriptedProvider{name: "repeated-mixed", turns: [][]provider.Chunk{firstMixed, secondMixed}}
+ sess := NewSession("sys")
+ a := New(prov, reg, sess, Options{MaxSteps: 1}, event.Discard)
+
+ err := a.Run(context.Background(), "inspect and answer")
+ if err == nil || !strings.Contains(err.Error(), "repeatedly called context-unavailable tools") {
+ t.Fatalf("Run error = %v, want repeated contextual misuse", err)
+ }
+ if got := atomic.LoadInt32(&validCalls); got != 1 {
+ t.Fatalf("valid tool calls = %d, want second mixed batch blocked before execution", got)
+ }
+ if got := toolResultByID(sess, "read-2"); !strings.Contains(got, "called again after the repair instruction") {
+ t.Fatalf("second batch pairing result = %q", got)
+ }
+}
+
+func TestSubAgentDoesNotInheritParentGoalRecorder(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ prov := &scriptedProvider{name: "goal-child", turns: [][]provider.Chunk{
+ {toolCallChunk("goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "Child result."}, {Type: provider.ChunkDone}},
+ }}
+ recorder := &childIsolationGoalRecorder{}
+ ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
+ sess := NewSession("child system")
+
+ answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the task", Options{}, event.Discard)
+ if err != nil {
+ t.Fatalf("Goal child: %v", err)
+ }
+ if answer != "Child result." {
+ t.Fatalf("Goal child answer = %q", answer)
+ }
+ if len(prov.requests) != 2 {
+ t.Fatalf("provider requests = %d, want hallucinated call plus repair", len(prov.requests))
+ }
+ for i, req := range prov.requests {
+ if slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
+ t.Fatalf("child provider request %d exposed update_goal: %v", i+1, toolSchemaNames(req.Tools))
+ }
+ }
+ if len(recorder.reports) != 0 {
+ t.Fatalf("child wrote reports into parent Goal recorder: %+v", recorder.reports)
+ }
+ if got := lastToolResult(sess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
+ t.Fatalf("child update_goal result = %q", got)
+ }
+}
+
+type coordinatorGoalRecorder struct {
+ reports []tool.GoalReport
+}
+
+func (r *coordinatorGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
+ r.reports = append(r.reports, report)
+ return "recorded " + report.Status, nil
+}
+
+func TestCoordinatorPlannerCannotReportExecutorGoalDisposition(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
+ {toolCallChunk("planner-goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "1. inspect the implementation\n2. apply and verify the fix"}, {Type: provider.ChunkDone}},
+ }}
+ exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
+ {Type: provider.ChunkText, Text: "Implemented and verified."},
+ {Type: provider.ChunkDone},
+ }}
+ plannerSess := NewSession("planner-sys")
+ executor := New(exec, reg, NewSession("exec-sys"), Options{}, event.Discard)
+ customPlannerReg := tool.NewRegistry()
+ customPlannerReg.Add(goalTool)
+ coord := NewCoordinator(planner, plannerSess, nil, customPlannerReg, Options{}, executor, 0, event.Discard, nil)
+ recorder := &coordinatorGoalRecorder{}
+ ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
+
+ if err := coord.Run(ctx, "fix the goal bug"); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if len(planner.requests) != 2 {
+ t.Fatalf("planner requests = %d, want hallucinated call plus repair", len(planner.requests))
+ }
+ for i, req := range planner.requests {
+ if slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
+ t.Fatalf("planner request %d exposed update_goal: %v", i+1, toolSchemaNames(req.Tools))
+ }
+ }
+ if got := lastToolResult(plannerSess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
+ t.Fatalf("planner update_goal result = %q", got)
+ }
+ if len(exec.requests) == 0 {
+ t.Fatal("executor made no requests")
+ }
+ for i, req := range exec.requests {
+ if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
+ t.Fatalf("executor request %d lost update_goal after planner isolation: %v", i+1, toolSchemaNames(req.Tools))
+ }
+ }
+ if len(recorder.reports) != 0 {
+ t.Fatalf("planner wrote reports into executor Goal recorder: %+v", recorder.reports)
+ }
+}
+
+func TestSubagentIdentityUsesEffectiveChildToolSchemas(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ reg.Add(fakeTool{name: "read_file", readOnly: true})
+ store := NewSubagentStore(t.TempDir())
+ task := &TaskTool{transcripts: store, sysPrompt: "child system", workspaceRoot: t.TempDir()}
+ ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ run, err := task.prepareTranscriptRunWithPrompt(ctx, reg, "model", "medium", "parent-session", "call-1", "", "", "child system", "task", "inspect")
+ if err != nil {
+ t.Fatalf("prepareTranscriptRunWithPrompt: %v", err)
+ }
+ defer run.Release()
+ if slices.Contains(run.Meta.ToolScope, "update_goal") || !slices.Contains(run.Meta.ToolScope, "read_file") {
+ t.Fatalf("subagent tool scope = %v, want only child-visible tools", run.Meta.ToolScope)
+ }
+ _, wantHash := toolIdentity(reg, reg.SchemasForContext(subagentProviderContext(ctx)))
+ if run.Meta.ToolSchemaHash != wantHash {
+ t.Fatalf("subagent schema hash = %q, want %q", run.Meta.ToolSchemaHash, wantHash)
+ }
+ _, staticHash := toolIdentity(reg, reg.Schemas())
+ if run.Meta.ToolSchemaHash == staticHash {
+ t.Fatal("subagent identity used static schemas and included parent-only update_goal")
+ }
+}
diff --git a/internal/agent/run_loop.go b/internal/agent/run_loop.go
index 5b143f57a8..5c92617e81 100644
--- a/internal/agent/run_loop.go
+++ b/internal/agent/run_loop.go
@@ -958,6 +958,18 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
state.usedAnyTool = true
unavailableContextTools, contextualOnly := a.unavailableContextualToolCalls(ctx, calls)
+ if len(unavailableContextTools) > 0 && state.contextToolRepairs > 0 {
+ msg := fmt.Sprintf("blocked: context-unavailable tools were called again after the repair instruction: %s", strings.Join(unavailableContextTools, ", "))
+ for _, call := range calls {
+ a.session.Add(provider.Message{
+ Role: provider.RoleTool,
+ Content: msg,
+ ToolCallID: call.ID,
+ Name: call.Name,
+ })
+ }
+ 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 {
@@ -987,7 +999,6 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
StopReason: reason,
}
}
-
receiptMark := 0
if a.evidence != nil {
receiptMark = a.evidence.Len()
@@ -1013,17 +1024,15 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
a.recordInterruptedDisplay("", "", nil, true, state.workDurationMs())
return false, ctx.Err()
}
- if contextualOnly {
+ if len(unavailableContextTools) > 0 {
if hasVisibleFinalAnswer(text) {
- // Keep the assistant tool call and host error paired in the transcript,
- // but accept the co-streamed answer instead of spending another request
- // repairing a phase-only bookkeeping call.
- return a.handleFinalResponse(ctx, state, text, reasoning, usage)
+ if contextualOnly {
+ // Keep the assistant tool call and host error paired in the transcript,
+ // but accept the co-streamed answer when every call was unavailable.
+ return a.handleFinalResponse(ctx, state, text, reasoning, usage)
+ }
}
state.contextToolRepairs++
- if state.contextToolRepairs > 1 {
- return false, fmt.Errorf("model repeatedly called context-unavailable tools without a visible answer: %s", strings.Join(unavailableContextTools, ", "))
- }
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)})
}
@@ -1106,13 +1115,12 @@ func (a *Agent) unavailableContextualToolCalls(ctx context.Context, calls []prov
for _, call := range calls {
t, ok := a.tools.Get(call.Name)
if !ok {
- return nil, false
+ continue
}
contextual, ok := t.(tool.ContextualTool)
- if !ok || contextual.ProviderVisible(ctx) {
- return nil, false
+ if ok && !contextual.ProviderVisible(ctx) {
+ names = append(names, call.Name)
}
- names = append(names, call.Name)
}
- return names, true
+ return names, len(names) == len(calls)
}
diff --git a/internal/agent/subagent_context_isolation_test.go b/internal/agent/subagent_context_isolation_test.go
new file mode 100644
index 0000000000..624e3811cf
--- /dev/null
+++ b/internal/agent/subagent_context_isolation_test.go
@@ -0,0 +1,74 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "slices"
+ "testing"
+
+ "reasonix/internal/event"
+ "reasonix/internal/jobs"
+ "reasonix/internal/memory"
+ "reasonix/internal/provider"
+ "reasonix/internal/tool"
+)
+
+type recordingMemoryQueue struct {
+ notes []string
+}
+
+func (q *recordingMemoryQueue) QueueMemory(note string) {
+ q.notes = append(q.notes, note)
+}
+
+type memoryQueueProbeTool struct{}
+
+func (memoryQueueProbeTool) Name() string { return "memory_queue_probe" }
+func (memoryQueueProbeTool) Description() string { return "probe child memory context" }
+func (memoryQueueProbeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
+func (memoryQueueProbeTool) ReadOnly() bool { return true }
+func (memoryQueueProbeTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
+ if q, ok := memory.QueueFromContext(ctx); ok {
+ q.QueueMemory("child injected into parent")
+ return "queue present", nil
+ }
+ return "queue absent", nil
+}
+
+func TestSubAgentMasksParentJobsAndMemoryContexts(t *testing.T) {
+ reg := tool.NewRegistry()
+ reg.Add(memoryQueueProbeTool{})
+ waitTool, ok := tool.LookupBuiltin("wait")
+ if !ok {
+ t.Fatal("wait builtin not registered")
+ }
+ reg.Add(waitTool)
+ prov := &scriptedProvider{name: "child-context", turns: [][]provider.Chunk{
+ {toolCallChunk("probe", "memory_queue_probe", `{}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "Child result."}, {Type: provider.ChunkDone}},
+ }}
+ parentQueue := &recordingMemoryQueue{}
+ manager := jobs.NewManager(event.Discard)
+ defer manager.Close()
+ ctx := memory.WithQueue(jobs.WithManager(context.Background(), manager), parentQueue)
+ sess := NewSession("child system")
+
+ answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the task", Options{}, event.Discard)
+ if err != nil {
+ t.Fatalf("RunSubAgentWithSession: %v", err)
+ }
+ if answer != "Child result." {
+ t.Fatalf("answer = %q", answer)
+ }
+ if len(parentQueue.notes) != 0 {
+ t.Fatalf("child injected memory notes into parent queue: %v", parentQueue.notes)
+ }
+ if got := toolResultByID(sess, "probe"); got != "queue absent" {
+ t.Fatalf("memory queue probe result = %q", got)
+ }
+ for i, req := range prov.requests {
+ if slices.Contains(toolSchemaNames(req.Tools), "wait") {
+ t.Fatalf("child request %d inherited parent Jobs manager: %v", i+1, toolSchemaNames(req.Tools))
+ }
+ }
+}
diff --git a/internal/agent/subagent_store.go b/internal/agent/subagent_store.go
index 4e5e697226..4fa19a2fba 100644
--- a/internal/agent/subagent_store.go
+++ b/internal/agent/subagent_store.go
@@ -16,6 +16,7 @@ import (
"reasonix/internal/fileutil"
fileencoding "reasonix/internal/fileutil/encoding"
+ "reasonix/internal/provider"
"reasonix/internal/store"
"reasonix/internal/tool"
)
@@ -77,6 +78,7 @@ type SubagentSpec struct {
ParentToolCallID string
SystemPrompt string
Registry *tool.Registry
+ ToolSchemas []provider.ToolSchema
Model string
Effort string
}
@@ -742,7 +744,7 @@ func (s *SubagentStore) LoadMeta(ref string) (SubagentMeta, error) {
}
func metaFromSpec(ref string, status SubagentStatus, created, updated time.Time, spec SubagentSpec) SubagentMeta {
- scope, schemaHash := toolIdentity(spec.Registry)
+ scope, schemaHash := toolIdentity(spec.Registry, spec.ToolSchemas)
return SubagentMeta{
Ref: ref,
CreatedAt: created,
@@ -942,13 +944,19 @@ func validSubagentRef(ref string) bool {
return true
}
-func toolIdentity(reg *tool.Registry) ([]string, string) {
+func toolIdentity(reg *tool.Registry, schemas []provider.ToolSchema) ([]string, string) {
if reg == nil {
return nil, bytesHash(nil)
}
- names := reg.Names()
+ if schemas == nil {
+ schemas = reg.Schemas()
+ }
+ names := make([]string, 0, len(schemas))
+ for _, schema := range schemas {
+ names = append(names, schema.Name)
+ }
sort.Strings(names)
- schemas := normalizeToolSchemas(reg.Schemas())
+ schemas = normalizeToolSchemas(schemas)
data, _ := json.Marshal(schemas)
return names, bytesHash(data)
}
diff --git a/internal/agent/task.go b/internal/agent/task.go
index 5a6068971c..35ee63c746 100644
--- a/internal/agent/task.go
+++ b/internal/agent/task.go
@@ -19,6 +19,7 @@ import (
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/jobs"
+ "reasonix/internal/memory"
"reasonix/internal/permission"
"reasonix/internal/planmode"
"reasonix/internal/provider"
@@ -873,7 +874,7 @@ func (t *TaskTool) RunProfileSpec(ctx context.Context, spec ProfileExecSpec) (re
modelRef, effortRef := spec.Model, spec.Effort
usageModelRef := t.usageModelRef(modelRef, effortRef)
parentID, _, _, _ := CallContext(ctx)
- run, err := t.prepareTranscriptRunWithPrompt(subReg, modelRef, effortRef, ParentSession(ctx), parentID, spec.ContinueFrom, spec.ForkFrom, spec.SystemPrompt, spec.Kind, spec.Name)
+ run, err := t.prepareTranscriptRunWithPrompt(ctx, subReg, modelRef, effortRef, ParentSession(ctx), parentID, spec.ContinueFrom, spec.ForkFrom, spec.SystemPrompt, spec.Kind, spec.Name)
if err != nil {
return "", err
}
@@ -1055,7 +1056,7 @@ func (t *TaskTool) bashCanEnforceWriteRoots() bool {
return false
}
-func (t *TaskTool) prepareTranscriptRunWithPrompt(subReg *tool.Registry, modelRef, effortRef, parentSession, parentID, continueFrom, legacyForkFrom, systemPrompt, kind, name string) (*SubagentRun, error) {
+func (t *TaskTool) prepareTranscriptRunWithPrompt(ctx context.Context, subReg *tool.Registry, modelRef, effortRef, parentSession, parentID, continueFrom, legacyForkFrom, systemPrompt, kind, name string) (*SubagentRun, error) {
continueFrom = strings.TrimSpace(continueFrom)
legacyForkFrom = strings.TrimSpace(legacyForkFrom)
parentSession = strings.TrimSpace(parentSession)
@@ -1089,6 +1090,7 @@ func (t *TaskTool) prepareTranscriptRunWithPrompt(subReg *tool.Registry, modelRe
ParentToolCallID: parentID,
SystemPrompt: systemPrompt,
Registry: subReg,
+ ToolSchemas: subReg.SchemasForContext(subagentProviderContext(ctx)),
Model: identityModel,
Effort: identityEffort,
}
@@ -1530,9 +1532,6 @@ func PlannerToolRegistry(parent *tool.Registry) *tool.Registry {
}
if tl, ok := base.Get(name); ok {
if classifier, ok := tl.(tool.PlanModeClassifier); ok && !classifier.PlanModeSafe() {
- // The two-model planner is a planning-phase agent even when
- // the controller's explicit Plan mode flag is off. Do not let
- // read-only execution sign-offs leak into its provider schema.
continue
}
sub.Add(tl)
@@ -1879,11 +1878,8 @@ func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *to
if sess == nil {
return "", fmt.Errorf("sub-agent session is nil")
}
- // A child may run inside a parent Goal turn, but only the root working
- // model owns that turn's disposition. Keep cancellation and other parent
- // context while preventing the child from seeing or writing its recorder.
- ctx = tool.WithoutGoalTurnRecorder(ctx)
// Isolate temporary files for this run before any tool execution.
+ ctx = subagentProviderContext(ctx)
ctx, releaseTemp := withSubagentSessionTemp(ctx)
defer releaseTemp()
if opts.SubagentDepth > 0 {
@@ -1947,6 +1943,12 @@ func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *to
return "", fmt.Errorf("sub-agent finished without producing a final answer")
}
+func subagentProviderContext(ctx context.Context) context.Context {
+ ctx = tool.WithoutGoalTurnRecorder(ctx)
+ ctx = jobs.WithoutManager(ctx)
+ return memory.WithoutQueue(ctx)
+}
+
// readOnlyAgentConstruction is the single pairing every strictly read-only
// loop shares: the permanent ReadOnlyExecution flag plus the final registry
// filter. Batch children (RunReadOnlySubAgentWithSession) and legacy call sites
diff --git a/internal/autoresearch/fixture_test.go b/internal/autoresearch/fixture_test.go
index 9c65e1134e..e95b941182 100644
--- a/internal/autoresearch/fixture_test.go
+++ b/internal/autoresearch/fixture_test.go
@@ -93,11 +93,6 @@ func writeDirections(t *testing.T, taskRoot string, directions []DirectionTried)
writeJSON(t, filepath.Join(taskRoot, "state", "directions_tried.json"), directions)
}
-func writeTaskSpec(t *testing.T, taskRoot string, spec TaskSpec) {
- t.Helper()
- writeJSON(t, filepath.Join(taskRoot, "state", "task_spec.json"), spec)
-}
-
func appendHeartbeatLine(t *testing.T, taskRoot string, h Heartbeat) {
t.Helper()
data, err := json.Marshal(h)
@@ -141,3 +136,23 @@ func hashTree(t *testing.T, root string) map[string]string {
}
return out
}
+
+func modTimes(t *testing.T, root string) map[string]time.Time {
+ t.Helper()
+ out := map[string]time.Time{}
+ err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ return err
+ }
+ out[rel] = info.ModTime()
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("stat tree: %v", err)
+ }
+ return out
+}
diff --git a/internal/autoresearch/store.go b/internal/autoresearch/store.go
index 5b76d32be0..d1c3505b70 100644
--- a/internal/autoresearch/store.go
+++ b/internal/autoresearch/store.go
@@ -8,17 +8,20 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
+ "unicode"
fileencoding "reasonix/internal/fileutil/encoding"
)
var safeTaskID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
-var explicitTaskPath = regexp.MustCompile(`\.reasonix/autoresearch/([A-Za-z0-9][A-Za-z0-9._-]*)/?`)
+
+const explicitTaskPathPrefix = ".reasonix/autoresearch/"
// Store is a fail-closed reader over a workspace's legacy AutoResearch root.
type Store struct {
@@ -42,13 +45,26 @@ func (s *Store) Root() string {
}
func (s *Store) ListSummaries() ([]Summary, error) {
- entries, err := os.ReadDir(s.root)
+ storeRoot, err := s.openArchiveRoot()
if err != nil {
if os.IsNotExist(err) {
return []Summary{}, nil
}
return nil, fmt.Errorf("autoresearch: list tasks: %w", err)
}
+ defer storeRoot.Close()
+ dir, err := storeRoot.Open(".")
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: open task list: %w", err)
+ }
+ entries, err := dir.ReadDir(-1)
+ closeErr := dir.Close()
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: read task list: %w", err)
+ }
+ if closeErr != nil {
+ return nil, fmt.Errorf("autoresearch: close task list: %w", closeErr)
+ }
ids := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
@@ -78,22 +94,9 @@ func (s *Store) LoadTask(taskID string) (*Task, error) {
return nil, err
}
defer storeRoot.Close()
- info, err := storeRoot.Lstat(taskRel)
- if err != nil {
- if os.IsNotExist(err) {
- return nil, fmt.Errorf("autoresearch: task %s not found", taskID)
- }
- return nil, fmt.Errorf("autoresearch: stat task %s: %w", taskID, err)
- }
- if info.Mode()&os.ModeSymlink != 0 {
- return nil, fmt.Errorf("autoresearch: task %s is a symlink", taskID)
- }
- if !info.IsDir() {
- return nil, fmt.Errorf("autoresearch: task %s is not a directory", taskID)
- }
- var spec TaskSpec
- if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil {
- return nil, err
+ spec, report := validateTaskRoot(storeRoot, taskRel, taskID)
+ if !report.Valid {
+ return nil, fmt.Errorf("autoresearch: task %s is invalid: %v", taskID, report.Errors)
}
return &Task{ID: taskID, Root: s.taskRoot(taskID), Spec: spec}, nil
}
@@ -102,30 +105,39 @@ func (s *Store) LoadTask(taskID string) (*Task, error) {
// `.reasonix/autoresearch//` path. ok is true when a path was found;
// err is non-nil when that path is missing, corrupt, a symlink, or invalid.
func (s *Store) ResumeFromGoalText(goal string) (*Task, bool, error) {
- match := explicitTaskPath.FindStringSubmatch(goal)
- if len(match) < 2 {
- return nil, false, nil
+ taskID, found, err := ExplicitTaskID(goal)
+ if !found || err != nil {
+ return nil, found, err
}
- task, err := s.LoadTask(match[1])
+ task, err := s.LoadTask(taskID)
if err != nil {
return nil, true, err
}
- if report, err := s.ValidateTask(task.ID); err != nil {
- return nil, true, err
- } else if !report.Valid {
- return nil, true, fmt.Errorf("autoresearch: task %s is invalid: %v", task.ID, report.Errors)
- }
return task, true, nil
}
-// ExplicitTaskID extracts a legacy archive id from free-form goal text without
-// loading the archive.
-func ExplicitTaskID(goal string) (string, bool) {
- match := explicitTaskPath.FindStringSubmatch(goal)
- if len(match) < 2 {
- return "", false
+// ExplicitTaskID extracts one complete legacy archive path token from goal
+// text. Once the prefix is present, malformed IDs and additional path
+// components are errors rather than ordinary goal text.
+func ExplicitTaskID(goal string) (string, bool, error) {
+ _, tail, found := strings.Cut(goal, explicitTaskPathPrefix)
+ if !found {
+ return "", false, nil
}
- return match[1], true
+ if end := strings.IndexFunc(tail, unicode.IsSpace); end >= 0 {
+ tail = tail[:end]
+ }
+ taskID := strings.TrimSuffix(tail, "/")
+ if taskID == "" {
+ return "", true, errors.New("autoresearch: explicit task path is missing a task id")
+ }
+ if strings.ContainsAny(taskID, `/\`) {
+ return "", true, fmt.Errorf("autoresearch: explicit task path has extra components: %q", tail)
+ }
+ if err := validateTaskID(taskID); err != nil {
+ return "", true, err
+ }
+ return taskID, true, nil
}
func (s *Store) Findings(taskID string, limit int) ([]Finding, error) {
@@ -214,22 +226,29 @@ func (s *Store) ValidateTask(taskID string) (*ValidationReport, error) {
return nil, err
}
defer storeRoot.Close()
+ _, report := validateTaskRoot(storeRoot, taskRel, taskID)
+ return report, nil
+}
+
+// validateTaskRoot reads and validates a task through one already-open root.
+// The task directory cannot be swapped between validation and goal extraction.
+func validateTaskRoot(storeRoot *os.Root, taskRel, taskID string) (TaskSpec, *ValidationReport) {
report := &ValidationReport{Valid: true}
info, err := storeRoot.Lstat(taskRel)
if err != nil {
report.add("task", "", err.Error())
report.Valid = false
- return report, nil
+ return TaskSpec{}, report
}
if info.Mode()&os.ModeSymlink != 0 {
report.add("task", "", "task directory must not be a symlink")
report.Valid = false
- return report, nil
+ return TaskSpec{}, report
}
if !info.IsDir() {
report.add("task", "", "task path is not a directory")
report.Valid = false
- return report, nil
+ return TaskSpec{}, report
}
var spec TaskSpec
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil {
@@ -243,18 +262,63 @@ func (s *Store) ValidateTask(taskID string) (*ValidationReport, error) {
} else {
validateProgress(report, progress)
}
- for _, rel := range []string{
- "state/directions_tried.json",
- "state/findings.jsonl",
- "state/iteration_log.jsonl",
- "logs/heartbeat.jsonl",
- } {
- if _, err := storeRoot.Stat(filepath.Join(taskRel, rel)); err != nil {
+ validateDirections := func() error {
+ path := filepath.Join(taskRel, "state", "directions_tried.json")
+ data, err := readArchiveFile(storeRoot, path)
+ if err != nil {
+ return err
+ }
+ data = fileencoding.DecodeToUTF8(data)
+ if strings.TrimSpace(string(data)) == "" {
+ return nil
+ }
+ var directions []DirectionTried
+ if err := json.Unmarshal(data, &directions); err != nil {
+ return fmt.Errorf("parse %s: %w", path, err)
+ }
+ return nil
+ }
+ if err := validateDirections(); err != nil {
+ report.add("directions_tried.json", "", err.Error())
+ }
+ validateJSONL := func(rel string, each func([]byte) error) {
+ path := filepath.Join(taskRel, rel)
+ if err := readJSONL(storeRoot, path, each); err != nil {
report.add(filepath.Base(rel), "", err.Error())
}
}
+ validateJSONL("state/findings.jsonl", func(data []byte) error {
+ var finding Finding
+ if err := json.Unmarshal(fileencoding.DecodeToUTF8(data), &finding); err != nil {
+ return err
+ }
+ return validateFinding(finding)
+ })
+ validateJSONL("state/iteration_log.jsonl", func(data []byte) error {
+ var entry json.RawMessage
+ if err := json.Unmarshal(fileencoding.DecodeToUTF8(data), &entry); err != nil {
+ return err
+ }
+ return nil
+ })
+ validateJSONL("logs/heartbeat.jsonl", func(data []byte) error {
+ var heartbeat Heartbeat
+ if err := json.Unmarshal(fileencoding.DecodeToUTF8(data), &heartbeat); err != nil {
+ return err
+ }
+ if strings.TrimSpace(heartbeat.Status) == "" {
+ return errors.New("heartbeat status is required")
+ }
+ if heartbeat.Iteration < 0 {
+ return errors.New("heartbeat iteration must not be negative")
+ }
+ if heartbeat.CreatedAt.IsZero() {
+ return errors.New("heartbeat created_at is required")
+ }
+ return nil
+ })
report.Valid = len(report.Errors) == 0
- return report, nil
+ return spec, report
}
func (s *Store) taskRoot(taskID string) string {
@@ -278,14 +342,109 @@ func (s *Store) openTaskRoot(taskID string) (*os.Root, string, error) {
if err != nil {
return nil, "", err
}
- storeRoot, err := os.OpenRoot(s.root)
+ storeRoot, err := s.openArchiveRoot()
if err != nil {
if os.IsNotExist(err) {
return nil, "", fmt.Errorf("autoresearch: task %s not found", taskID)
}
return nil, "", fmt.Errorf("autoresearch: open root dir: %w", err)
}
- return storeRoot, taskRel, nil
+ info, err := storeRoot.Lstat(taskRel)
+ if err != nil {
+ storeRoot.Close()
+ if os.IsNotExist(err) {
+ return nil, "", fmt.Errorf("autoresearch: task %s not found", taskID)
+ }
+ return nil, "", fmt.Errorf("autoresearch: stat task %s: %w", taskID, err)
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ storeRoot.Close()
+ return nil, "", fmt.Errorf("autoresearch: task %s is a symlink", taskID)
+ }
+ if !info.IsDir() {
+ storeRoot.Close()
+ return nil, "", fmt.Errorf("autoresearch: task %s is not a directory", taskID)
+ }
+ taskRoot, err := storeRoot.OpenRoot(taskRel)
+ if err != nil {
+ storeRoot.Close()
+ return nil, "", fmt.Errorf("autoresearch: open task %s: %w", taskID, err)
+ }
+ opened, err := taskRoot.Stat(".")
+ if err != nil || !os.SameFile(info, opened) {
+ taskRoot.Close()
+ storeRoot.Close()
+ if err != nil {
+ return nil, "", fmt.Errorf("autoresearch: verify task %s: %w", taskID, err)
+ }
+ return nil, "", fmt.Errorf("autoresearch: task %s changed while opening", taskID)
+ }
+ current, err := storeRoot.Lstat(taskRel)
+ if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(info, current) {
+ taskRoot.Close()
+ storeRoot.Close()
+ if err != nil {
+ return nil, "", fmt.Errorf("autoresearch: recheck task %s: %w", taskID, err)
+ }
+ return nil, "", fmt.Errorf("autoresearch: task %s changed while opening", taskID)
+ }
+ if err := storeRoot.Close(); err != nil {
+ taskRoot.Close()
+ return nil, "", fmt.Errorf("autoresearch: close archive root: %w", err)
+ }
+ return taskRoot, ".", nil
+}
+
+// openArchiveRoot anchors every archive read to the resolved workspace root.
+// os.Root prevents a concurrent symlink swap from escaping the workspace; the
+// explicit Lstat/SameFile checks additionally reject symlinked archive roots.
+func (s *Store) openArchiveRoot() (*os.Root, error) {
+ workspace, err := os.OpenRoot(s.workspaceRoot)
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: open workspace root: %w", err)
+ }
+ defer workspace.Close()
+
+ archiveRel := filepath.Join(".reasonix", "autoresearch")
+ rels := []string{".reasonix", archiveRel}
+ infos := make([]os.FileInfo, len(rels))
+ for i, rel := range rels {
+ info, err := workspace.Lstat(rel)
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: stat archive path %s: %w", rel, err)
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ return nil, fmt.Errorf("autoresearch: archive path %s must not be a symlink", rel)
+ }
+ if !info.IsDir() {
+ return nil, fmt.Errorf("autoresearch: archive path %s is not a directory", rel)
+ }
+ infos[i] = info
+ }
+
+ archive, err := workspace.OpenRoot(archiveRel)
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: open archive root: %w", err)
+ }
+ opened, err := archive.Stat(".")
+ if err != nil || !os.SameFile(infos[len(infos)-1], opened) {
+ archive.Close()
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: verify archive root: %w", err)
+ }
+ return nil, errors.New("autoresearch: archive root changed while opening")
+ }
+ for i, rel := range rels {
+ current, err := workspace.Lstat(rel)
+ if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(infos[i], current) {
+ archive.Close()
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: recheck archive path %s: %w", rel, err)
+ }
+ return nil, fmt.Errorf("autoresearch: archive path %s changed while opening", rel)
+ }
+ }
+ return archive, nil
}
func validateTaskID(id string) error {
@@ -317,9 +476,9 @@ func validateFinding(f Finding) error {
}
func readJSONFile(root *os.Root, path string, out any) error {
- data, err := root.ReadFile(path)
+ data, err := readArchiveFile(root, path)
if err != nil {
- return fmt.Errorf("read %s: %w", path, err)
+ return err
}
data = fileencoding.DecodeToUTF8(data)
if err := json.Unmarshal(data, out); err != nil {
@@ -329,7 +488,7 @@ func readJSONFile(root *os.Root, path string, out any) error {
}
func readJSONL(root *os.Root, path string, each func([]byte) error) error {
- f, err := root.Open(path)
+ f, err := openArchiveFile(root, path)
if err != nil {
return fmt.Errorf("autoresearch: open %s: %w", path, err)
}
@@ -369,7 +528,7 @@ func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) {
}
return lines, nil
}
- f, err := root.Open(path)
+ f, err := openArchiveFile(root, path)
if err != nil {
return nil, fmt.Errorf("autoresearch: open %s: %w", path, err)
}
@@ -413,6 +572,78 @@ func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) {
return lines, nil
}
+func readArchiveFile(root *os.Root, path string) ([]byte, error) {
+ f, err := openArchiveFile(root, path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ data, err := io.ReadAll(f)
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: read %s: %w", path, err)
+ }
+ return data, nil
+}
+
+// openArchiveFile rejects symlinks and non-regular files at every path
+// component, then binds parsing to the verified file descriptor. The second
+// identity check closes the Lstat/open replacement window without holding a
+// process-global directory or changing the archive.
+func openArchiveFile(root *os.Root, path string) (*os.File, error) {
+ path = filepath.Clean(path)
+ if !filepath.IsLocal(path) || path == "." {
+ return nil, fmt.Errorf("autoresearch: unsafe archive file path %q", path)
+ }
+ parts := strings.Split(path, string(filepath.Separator))
+ infos := make([]os.FileInfo, len(parts))
+ current := ""
+ for i, part := range parts {
+ current = filepath.Join(current, part)
+ info, err := root.Lstat(current)
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: stat %s: %w", current, err)
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ return nil, fmt.Errorf("autoresearch: archive path %s must not be a symlink", current)
+ }
+ if i < len(parts)-1 {
+ if !info.IsDir() {
+ return nil, fmt.Errorf("autoresearch: archive path %s is not a directory", current)
+ }
+ } else if !info.Mode().IsRegular() {
+ return nil, fmt.Errorf("autoresearch: archive path %s is not a regular file", current)
+ }
+ infos[i] = info
+ }
+
+ f, err := root.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: open %s: %w", path, err)
+ }
+ opened, err := f.Stat()
+ if err != nil || !opened.Mode().IsRegular() || !os.SameFile(infos[len(infos)-1], opened) {
+ f.Close()
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: verify %s: %w", path, err)
+ }
+ return nil, fmt.Errorf("autoresearch: archive path %s changed while opening", path)
+ }
+
+ current = ""
+ for i, part := range parts {
+ current = filepath.Join(current, part)
+ info, err := root.Lstat(current)
+ if err != nil || info.Mode()&os.ModeSymlink != 0 || !os.SameFile(infos[i], info) {
+ f.Close()
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: recheck %s: %w", current, err)
+ }
+ return nil, fmt.Errorf("autoresearch: archive path %s changed while opening", current)
+ }
+ }
+ return f, nil
+}
+
func countCompleteTailLines(buf []byte, atStart bool) int {
segments := strings.Split(string(buf), "\n")
if !atStart && len(segments) > 0 {
diff --git a/internal/autoresearch/store_test.go b/internal/autoresearch/store_test.go
index 7737558fd8..c8cde8eaa3 100644
--- a/internal/autoresearch/store_test.go
+++ b/internal/autoresearch/store_test.go
@@ -61,6 +61,101 @@ func TestLoadTaskRejectsSymlinkAndUnsafeIDs(t *testing.T) {
}
}
+func TestLoadTaskRejectsSymlinkedArchiveRoot(t *testing.T) {
+ root := t.TempDir()
+ if resolved, err := filepath.EvalSymlinks(root); err == nil {
+ root = resolved
+ }
+ outside := t.TempDir()
+ if resolved, err := filepath.EvalSymlinks(outside); err == nil {
+ outside = resolved
+ }
+ const taskID = "outside-task"
+ writeArchiveFixture(t, outside, taskID, "outside workspace goal", nil)
+ if err := os.MkdirAll(filepath.Join(root, ".reasonix"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ outsideRoot := filepath.Join(outside, ".reasonix", "autoresearch")
+ if err := os.Symlink(outsideRoot, filepath.Join(root, ".reasonix", "autoresearch")); err != nil {
+ t.Fatal(err)
+ }
+
+ store := NewStore(root)
+ if _, err := store.LoadTask(taskID); err == nil {
+ t.Fatal("LoadTask accepted a symlinked archive root outside the workspace")
+ }
+ if _, err := store.ListSummaries(); err == nil {
+ t.Fatal("ListSummaries accepted a symlinked archive root outside the workspace")
+ }
+}
+
+func TestArchiveReaderRejectsSymlinkedTaskContent(t *testing.T) {
+ t.Run("state directory", func(t *testing.T) {
+ root := t.TempDir()
+ writeArchiveFixture(t, root, "source-task", "source goal", nil)
+ victimRoot := writeArchiveFixture(t, root, "victim-task", "victim goal", nil)
+ if err := os.RemoveAll(filepath.Join(victimRoot, "state")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join("..", "source-task", "state"), filepath.Join(victimRoot, "state")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := NewStore(root).LoadTask("victim-task"); err == nil {
+ t.Fatal("LoadTask followed a state-directory symlink into another task")
+ }
+ })
+
+ t.Run("task spec file", func(t *testing.T) {
+ root := t.TempDir()
+ taskRoot := writeArchiveFixture(t, root, "file-link-task", "linked goal", nil)
+ specPath := filepath.Join(taskRoot, "state", "task_spec.json")
+ if err := os.Rename(specPath, filepath.Join(taskRoot, "state", "task_spec.real.json")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("task_spec.real.json", specPath); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := NewStore(root).LoadTask("file-link-task"); err == nil {
+ t.Fatal("LoadTask followed a task_spec symlink")
+ }
+ })
+
+ t.Run("validation file", func(t *testing.T) {
+ root := t.TempDir()
+ taskRoot := writeArchiveFixture(t, root, "progress-link-task", "linked progress", nil)
+ progressPath := filepath.Join(taskRoot, "state", "progress.json")
+ if err := os.Rename(progressPath, filepath.Join(taskRoot, "state", "progress.real.json")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("progress.real.json", progressPath); err != nil {
+ t.Fatal(err)
+ }
+ report, err := NewStore(root).ValidateTask("progress-link-task")
+ if err != nil {
+ t.Fatalf("ValidateTask: %v", err)
+ }
+ if report.Valid {
+ t.Fatal("ValidateTask accepted a symlinked progress file")
+ }
+ })
+}
+
+func TestLoadTaskRejectsUnreadableArchiveFile(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("root can bypass archive file permissions")
+ }
+ root := t.TempDir()
+ taskRoot := writeArchiveFixture(t, root, "permission-task", "permission goal", nil)
+ specPath := filepath.Join(taskRoot, "state", "task_spec.json")
+ if err := os.Chmod(specPath, 0); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.Chmod(specPath, 0o644) })
+ if _, err := NewStore(root).LoadTask("permission-task"); err == nil {
+ t.Fatal("LoadTask accepted an unreadable task_spec.json")
+ }
+}
+
func TestFindingsPreserveVerificationAndUnknownKinds(t *testing.T) {
root := t.TempDir()
taskID := "findings-kinds"
@@ -158,6 +253,17 @@ func TestResumeFromGoalTextLoadsExplicitTaskPath(t *testing.T) {
if _, ok, err := store.ResumeFromGoalText("resume .reasonix/autoresearch/missing-task/"); !ok || err == nil {
t.Fatalf("missing task should fail closed: ok=%v err=%v", ok, err)
}
+ for _, input := range []string{
+ "resume .reasonix/autoresearch/../escape",
+ "resume .reasonix/autoresearch/" + taskID + "/../../escape",
+ "resume .reasonix/autoresearch/" + taskID + "/extra",
+ "resume .reasonix/autoresearch/" + taskID + `\extra`,
+ "resume .reasonix/autoresearch/",
+ } {
+ if _, ok, err := store.ResumeFromGoalText(input); !ok || err == nil {
+ t.Errorf("unsafe explicit path %q did not fail closed: ok=%v err=%v", input, ok, err)
+ }
+ }
}
func TestListSummariesAndSummaryAreReadOnly(t *testing.T) {
@@ -180,6 +286,7 @@ func TestListSummariesAndSummaryAreReadOnly(t *testing.T) {
CreatedAt: time.Date(2026, 6, 30, 11, 0, 0, 0, time.UTC),
})
before := hashTree(t, filepath.Join(root, ".reasonix", "autoresearch"))
+ beforeModTimes := modTimes(t, filepath.Join(root, ".reasonix", "autoresearch"))
store := NewStore(root)
list, err := store.ListSummaries()
if err != nil {
@@ -204,6 +311,12 @@ func TestListSummariesAndSummaryAreReadOnly(t *testing.T) {
t.Fatalf("archive mutated at %s", path)
}
}
+ afterModTimes := modTimes(t, filepath.Join(root, ".reasonix", "autoresearch"))
+ for path, modTime := range beforeModTimes {
+ if !afterModTimes[path].Equal(modTime) {
+ t.Fatalf("archive modification time changed at %s", path)
+ }
+ }
}
func TestValidateTaskRejectsCorruptJSON(t *testing.T) {
@@ -223,6 +336,41 @@ func TestValidateTaskRejectsCorruptJSON(t *testing.T) {
}
}
+func TestValidateTaskRejectsCorruptArchiveLogsButAcceptsUnknownFindingKinds(t *testing.T) {
+ t.Run("corrupt finding JSON", func(t *testing.T) {
+ root := t.TempDir()
+ taskID := "corrupt-finding-json"
+ taskRoot := writeArchiveFixture(t, root, taskID, "Validate finding JSON", nil)
+ if err := os.WriteFile(filepath.Join(taskRoot, "state", "findings.jsonl"), []byte("{not-json\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ report, err := NewStore(root).ValidateTask(taskID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.Valid {
+ t.Fatal("corrupt finding JSON reported valid")
+ }
+ })
+
+ t.Run("unknown finding kind", func(t *testing.T) {
+ root := t.TempDir()
+ taskID := "unknown-finding-kind"
+ taskRoot := writeArchiveFixture(t, root, taskID, "Accept future finding kind", nil)
+ appendFindingLine(t, taskRoot, Finding{
+ ID: "future", Kind: "future-kind", Summary: "preserve me", Accepted: true,
+ CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC),
+ })
+ report, err := NewStore(root).ValidateTask(taskID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !report.Valid {
+ t.Fatalf("unknown finding kind rejected: %+v", report.Errors)
+ }
+ })
+}
+
func TestHeartbeatsTailRead(t *testing.T) {
root := t.TempDir()
taskID := "heartbeats"
diff --git a/internal/autoresearch/task.go b/internal/autoresearch/task.go
index c63a8a2fa0..a3b1714100 100644
--- a/internal/autoresearch/task.go
+++ b/internal/autoresearch/task.go
@@ -49,19 +49,6 @@ type Progress struct {
UpdatedAt time.Time `json:"updated_at"`
}
-// Historical finding kinds are free-form strings. The constants below are
-// retained only as documentation of values that older writers produced; the
-// reader accepts any non-empty kind without enumeration.
-const (
- FindingKindCommand = "command"
- FindingKindFile = "file"
- FindingKindTest = "test"
- FindingKindBenchmark = "benchmark"
- FindingKindManual = "manual"
- FindingKindReview = "review"
- FindingKindVerification = "verification"
-)
-
const (
FindingSourceCommand = "command"
FindingSourceFile = "file"
diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go
index 4d37314bf3..c124c1d883 100644
--- a/internal/cli/chat_tui.go
+++ b/internal/cli/chat_tui.go
@@ -4834,13 +4834,17 @@ func (m *chatTUI) runGoalSubcommand(input string) tea.Cmd {
m.notice(i18n.M.GoalEmpty)
return nil
}
- switch cmd.Action {
+ switch m.noticeDeprecatedGoalBudget(cmd); cmd.Action {
case control.GoalCommandSet:
m.planMode = false
m.ctrl.SetPlanMode(false)
m.ctrl.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode)
m.ctrl.GoalStrict(cmd.Strict)
- m.notice(fmt.Sprintf(i18n.M.GoalSetFmt, control.ShortGoalForNotice(cmd.Text)))
+ if m.ctrl.GoalStatus() != control.GoalStatusRunning {
+ m.echoLocalCommand(input)
+ return nil
+ }
+ m.notice(fmt.Sprintf(i18n.M.GoalSetFmt, control.ShortGoalForNotice(m.ctrl.Goal())))
return m.startTurn("Start pursuing the active goal now.", input, input)
case control.GoalCommandClear:
m.echoLocalCommand(input)
diff --git a/internal/cli/chat_tui_goal.go b/internal/cli/chat_tui_goal.go
new file mode 100644
index 0000000000..4c59083074
--- /dev/null
+++ b/internal/cli/chat_tui_goal.go
@@ -0,0 +1,9 @@
+package cli
+
+import "reasonix/internal/control"
+
+func (m *chatTUI) noticeDeprecatedGoalBudget(cmd control.GoalCommand) {
+ if cmd.DeprecatedBudgetFlag {
+ m.notice(control.GoalBudgetFlagDeprecatedNotice)
+ }
+}
diff --git a/internal/cli/chat_tui_goal_test.go b/internal/cli/chat_tui_goal_test.go
new file mode 100644
index 0000000000..692f2e6a91
--- /dev/null
+++ b/internal/cli/chat_tui_goal_test.go
@@ -0,0 +1,36 @@
+package cli
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+
+ "reasonix/internal/control"
+)
+
+func TestGoalLegacyBudgetFlagNoticesExactlyOnce(t *testing.T) {
+ m := newTestChatTUI()
+ m.ctrl = control.New(control.Options{})
+ t.Cleanup(m.ctrl.Close)
+
+ m.runGoalSubcommand("/goal --research investigate the failure")
+
+ joined := ansi.Strip(strings.Join(*m.pendingCommit, "\n"))
+ if got := strings.Count(joined, control.GoalBudgetFlagDeprecatedNotice); got != 1 {
+ t.Fatalf("deprecated budget notices = %d, want 1:\n%s", got, joined)
+ }
+}
+
+func TestMissingLegacyGoalCommandDoesNotStartTUITurn(t *testing.T) {
+ m := newTestChatTUI()
+ m.ctrl = control.New(control.Options{WorkspaceRoot: t.TempDir()})
+ t.Cleanup(m.ctrl.Close)
+
+ if cmd := m.runGoalSubcommand("/goal resume .reasonix/autoresearch/missing-task/"); cmd != nil {
+ t.Fatal("missing legacy archive returned a provider turn command")
+ }
+ if got := m.ctrl.GoalStatus(); got != control.GoalStatusBlocked {
+ t.Fatalf("GoalStatus() = %q, want blocked", got)
+ }
+}
diff --git a/internal/control/autoresearch_manager.go b/internal/control/autoresearch_manager.go
index ab1f33a70c..93ed5fa8c2 100644
--- a/internal/control/autoresearch_manager.go
+++ b/internal/control/autoresearch_manager.go
@@ -9,6 +9,7 @@ import (
"strings"
"reasonix/internal/autoresearch"
+ "reasonix/internal/evidence"
)
type legacyResearchSetup struct {
@@ -28,35 +29,29 @@ type legacyResearchArchive struct {
// prepare reads an explicitly referenced legacy task. It has no create path
// and never mutates the archive, even when validation fails.
func (m legacyResearchArchive) prepare(goal string) legacyResearchSetup {
- if m.store == nil {
- if _, ok := autoresearch.ExplicitTaskID(goal); ok {
- return legacyResearchSetup{
- explicit: true,
- blockReason: "legacy research archive is unavailable for this workspace",
- }
- }
- return legacyResearchSetup{}
- }
- task, ok, err := m.store.ResumeFromGoalText(goal)
- if !ok {
+ taskID, found, parseErr := autoresearch.ExplicitTaskID(goal)
+ if !found {
return legacyResearchSetup{}
}
- if err != nil {
- slog.Warn("controller: resume legacy autoresearch task", "err", err)
- return legacyResearchSetup{explicit: true, blockReason: err.Error()}
+ if parseErr != nil {
+ return legacyResearchSetup{explicit: true, blockReason: parseErr.Error()}
}
- original := strings.TrimSpace(task.Spec.Goal)
- if original == "" {
+ if m.store == nil {
return legacyResearchSetup{
explicit: true,
- taskID: task.ID,
- blockReason: "legacy research archive is missing goal text",
+ taskID: taskID,
+ blockReason: "legacy research archive is unavailable for this workspace",
}
}
+ original, err := m.loadGoalText(taskID)
+ if err != nil {
+ slog.Warn("controller: resume legacy autoresearch task", "err", err)
+ return legacyResearchSetup{explicit: true, taskID: taskID, blockReason: err.Error()}
+ }
return legacyResearchSetup{
goal: original,
- taskID: task.ID,
- notice: "legacy research archive loaded: " + task.ID,
+ taskID: taskID,
+ notice: "legacy research archive loaded: " + taskID,
explicit: true,
}
}
@@ -95,3 +90,108 @@ func (e errString) Error() string { return string(e) }
func (c *Controller) prepareLegacyResearchTask(goal string) legacyResearchSetup {
return c.legacyResearchArchive.prepare(goal)
}
+
+func (c *Controller) restorePendingLegacyGoal(legacy legacyGoalRestore) bool {
+ if legacy.taskID == "" || strings.TrimSpace(c.goals.goalText()) != "" {
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ return false
+ }
+ c.replaceLegacyRestore(legacy)
+ restoreTodos := c.goalTodos()
+ if len(legacy.todos) > 0 {
+ restoreTodos = append([]evidence.TodoItem(nil), legacy.todos...)
+ if c.executor != nil {
+ c.executor.ReplaceTodoState(restoreTodos)
+ }
+ }
+ goal, err := c.legacyResearchArchive.loadGoalText(legacy.taskID)
+ if err != nil {
+ if epoch, ok := c.goals.blockLegacyRestore(legacy.epoch, legacy.taskID, err.Error()); ok {
+ _, _ = c.persistGoalStateAtEpoch(epoch, restoreTodos)
+ c.advanceLegacyRestoreEpoch(legacy.taskID, legacy.epoch, epoch)
+ c.notice("legacy research archive resume failed: " + err.Error())
+ } else {
+ c.clearLegacyRestore(legacy.taskID, legacy.epoch)
+ }
+ return true
+ }
+ if strings.TrimSpace(c.goals.goalText()) == "" {
+ if epoch, ok := c.goals.fillGoalTextIfEmpty(legacy.epoch, goal); ok {
+ _, persistErr := c.persistGoalStateAtEpoch(epoch, restoreTodos)
+ if persistErr != nil {
+ reason := "persist migrated legacy Goal: " + persistErr.Error()
+ if blockedEpoch, blocked := c.goals.failLegacyRestorePersistence(epoch, legacy.taskID, reason); blocked {
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: legacy.taskID, todos: restoreTodos, epoch: blockedEpoch})
+ c.notice("legacy research archive resume failed: " + reason)
+ } else {
+ c.clearLegacyRestore(legacy.taskID, legacy.epoch)
+ }
+ } else {
+ c.clearLegacyRestore(legacy.taskID, legacy.epoch)
+ }
+ } else {
+ c.clearLegacyRestore(legacy.taskID, legacy.epoch)
+ }
+ }
+ return true
+}
+
+func (c *Controller) retryBlockedLegacyGoal() (handled, resumed bool) {
+ goal, taskID, epoch, ok := c.goals.legacyArchiveRetryToken()
+ if !ok {
+ if _, _, blocked := c.goals.legacyArchiveBlockedState(); blocked {
+ return true, false
+ }
+ return false, false
+ }
+ setup := c.prepareLegacyResearchTask(goal)
+ resolvedGoal, reason := setup.goal, setup.blockReason
+ if !setup.explicit {
+ var err error
+ resolvedGoal, err = c.legacyResearchArchive.loadGoalText(taskID)
+ if err != nil {
+ reason = err.Error()
+ }
+ } else if setup.taskID != taskID {
+ reason = "legacy research archive identity changed during retry"
+ }
+ if reason != "" || strings.TrimSpace(resolvedGoal) == "" {
+ if reason == "" {
+ reason = "legacy research archive could not be recovered"
+ }
+ if nextEpoch, applied := c.goals.blockLegacyRestore(epoch, taskID, reason); applied {
+ _, _ = c.persistGoalStateAtEpoch(nextEpoch, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: taskID, epoch: nextEpoch})
+ }
+ c.notice("legacy research archive resume failed: " + reason)
+ return true, false
+ }
+ todos := c.goalTodos()
+ resumedEpoch, applied := c.goals.resumeLegacyArchive(epoch, resolvedGoal)
+ if !applied {
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ return true, false
+ }
+ persisted, persistErr := c.persistGoalStateAtEpoch(resumedEpoch, todos)
+ if persistErr != nil {
+ reason := "persist migrated legacy Goal: " + persistErr.Error()
+ if blockedEpoch, blocked := c.goals.failLegacyRestorePersistence(resumedEpoch, taskID, reason); blocked {
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: taskID, todos: todos, epoch: blockedEpoch})
+ c.notice("legacy research archive resume failed: " + reason)
+ } else {
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ }
+ return true, false
+ }
+ if !persisted {
+ return true, false
+ }
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ if setup.notice != "" {
+ c.notice(setup.notice)
+ }
+ if c.executor != nil {
+ c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState())
+ }
+ return true, true
+}
diff --git a/internal/control/controller.go b/internal/control/controller.go
index b3e384b7ee..09362602fd 100644
--- a/internal/control/controller.go
+++ b/internal/control/controller.go
@@ -213,6 +213,8 @@ type Controller struct {
// creates or mutates archive state. See
// autoresearch_manager.go.
legacyResearchArchive legacyResearchArchive
+ legacyRestoreMu sync.Mutex
+ legacyRestore legacyGoalRestore
// workspaceRoot is the workspace root: the base for resolving @-refs and slash
// path refs, the working directory for user "!" shell commands and custom
@@ -1540,19 +1542,14 @@ func (c *Controller) applyGoalCommand(input, display string) bool {
return false
}
if cmd.DeprecatedBudgetFlag {
- c.notice("This /goal budget flag is deprecated; Goal now selects its budget automatically.")
+ c.notice(GoalBudgetFlagDeprecatedNotice)
}
switch cmd.Action {
case GoalCommandSet:
c.SetPlanMode(false)
c.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode)
c.GoalStrict(cmd.Strict)
- c.notice(fmt.Sprintf(i18n.M.GoalSetFmt, ShortGoalForNotice(cmd.Text)))
- if c.runner != nil {
- c.runGuarded(func(ctx context.Context) error {
- return c.runGoalLoopWithRawDisplay(ctx, "Start pursuing the active goal now.", cmd.Text, display)
- })
- }
+ c.startGoalCommandTurn(cmd, display)
case GoalCommandClear:
c.ClearGoal()
c.notice(i18n.M.GoalCleared)
@@ -2672,18 +2669,31 @@ func (c *Controller) SetGoal(goal string) {
}
// SetGoalDurable updates the Goal only when its sidecar can be replaced
-// atomically. The second parameter is retained for callers compiled against
-// the old archive-creation transaction contract and is otherwise ignored.
-func (c *Controller) SetGoalDurable(goal, _ string) error {
+// atomically. The optional legacy archive argument is ignored; retaining it as
+// a variadic parameter keeps older source call sites compiling.
+func (c *Controller) SetGoalDurable(goal string, _ ...string) error {
snapshot := c.goals.capture()
+ legacySnapshot, hadLegacySnapshot := c.legacyRestoreSnapshot()
resolved, setup := c.resolveGoalText(goal, GoalResearchAuto)
- path, data, persist := c.goals.set(resolved, setup.mode, c.goalTodos())
+ var path string
+ var data []byte
+ var persist bool
if setup.blockReason != "" {
- path, data, persist = c.goals.stop(GoalStatusBlocked, c.goalTodos())
+ path, data, persist = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.legacyTaskID, setup.blockReason, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken()})
+ } else {
+ path, data, persist = c.goals.set(resolved, setup.budgetClass, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{})
}
if persist {
if err := c.goals.writeStateErr(path, data); err != nil {
c.goals.restore(snapshot)
+ if hadLegacySnapshot {
+ legacySnapshot.epoch = c.goals.continuationToken()
+ c.replaceLegacyRestore(legacySnapshot)
+ } else {
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ }
return err
}
}
@@ -2701,33 +2711,39 @@ func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResea
if setup.notice != "" {
c.notice(setup.notice)
}
- path, data, ok := c.goals.set(resolved, setup.mode, c.goalTodos())
- c.persistGoalState(path, data, ok)
+ var path string
+ var data []byte
+ var ok bool
if setup.blockReason != "" {
- path, data, ok := c.goals.stop(GoalStatusBlocked, c.goalTodos())
- c.persistGoalState(path, data, ok)
+ path, data, ok = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.legacyTaskID, setup.blockReason, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken()})
c.notice("legacy research archive resume failed: " + setup.blockReason)
+ } else {
+ path, data, ok = c.goals.set(resolved, setup.budgetClass, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{})
}
+ c.persistGoalState(path, data, ok)
}
-// goalSetSetup is the resolved objective and budget mode after archive lookup.
+// goalSetSetup is the resolved objective and budget class after archive lookup.
type goalSetSetup struct {
- mode GoalResearchMode
- notice string
- blockReason string
+ budgetClass string
+ notice string
+ blockReason string
+ legacyTaskID string
}
func (c *Controller) resolveGoalText(goal string, researchMode GoalResearchMode) (string, goalSetSetup) {
- setup := goalSetSetup{mode: researchMode}
+ setup := goalSetSetup{budgetClass: budgetClassForLegacyMode(goal, researchMode)}
legacy := c.prepareLegacyResearchTask(goal)
if !legacy.explicit {
return goal, setup
}
- setup.notice, setup.blockReason = legacy.notice, legacy.blockReason
+ setup.notice, setup.blockReason, setup.legacyTaskID = legacy.notice, legacy.blockReason, legacy.taskID
if legacy.blockReason != "" {
return goal, setup
}
- setup.mode = GoalResearchOn
+ setup.budgetClass = budgetClassResearch
return legacy.goal, setup
}
@@ -2735,6 +2751,9 @@ func (c *Controller) resolveGoalText(goal string, researchMode GoalResearchMode)
// delivery evidence scope. A budget-paused Goal gets one extra slice of its
// budget class; accumulated consumption is preserved.
func (c *Controller) ResumeGoal() bool {
+ if handled, resumed := c.retryBlockedLegacyGoal(); handled {
+ return resumed
+ }
path, data, persist, resumed, extended := c.goals.resume(c.goalTodos())
if !resumed {
return false
@@ -2772,7 +2791,7 @@ func (c *Controller) GoalRuntime() GoalRuntimeView {
// turn/budget state, and the last
// continuation reason. Every field is treated as untrusted by the evaluator.
func (c *Controller) goalEvaluatorEvidence() goaleval.GoalEvidence {
- goal, _, _ := c.goals.snapshot()
+ goal, _ := c.goals.snapshot()
ev := goaleval.GoalEvidence{
GoalContract: goal,
LastContinuationReason: c.goals.lastContinuationReasonText(),
@@ -3487,20 +3506,10 @@ func (c *Controller) Resume(s *agent.Session, path string) {
c.ResetPlannerSession()
c.setActiveJobSession(path)
c.rebindCheckpoints(path)
- migPath, migData, migrated, legacyTaskID := c.goals.restoreFromState(path)
- if migrated {
- // Persist omitted autoResearchTaskID / cleared token limits (no provider call).
+ migPath, migData, migrated, legacy := c.goals.restoreFromState(path)
+ if !c.restorePendingLegacyGoal(legacy) && migrated {
c.persistGoalState(migPath, migData, true)
}
- if legacyTaskID != "" && strings.TrimSpace(c.goals.goalText()) == "" {
- if goal, err := c.legacyResearchArchive.loadGoalText(legacyTaskID); err != nil {
- path, data, ok := c.goals.stop(GoalStatusBlocked, c.goalTodos())
- c.persistGoalState(path, data, ok)
- c.notice("legacy research archive resume failed: " + err.Error())
- } else if p, d, ok := c.goals.fillGoalTextIfEmpty(goal, c.goalTodos()); ok {
- c.persistGoalState(p, d, true)
- }
- }
if c.executor != nil {
c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState())
}
diff --git a/internal/control/controller_test.go b/internal/control/controller_test.go
index 39130b05f3..cebd191d89 100644
--- a/internal/control/controller_test.go
+++ b/internal/control/controller_test.go
@@ -539,7 +539,7 @@ func TestGoalStatePersistsNextToSessionPath(t *testing.T) {
if err := json.Unmarshal(data, &state); err != nil {
t.Fatal(err)
}
- if state.Goal != "fix the typo" || state.Status != GoalStatusRunning || state.ResearchMode != GoalResearchOn || !state.Strict {
+ if state.Goal != "fix the typo" || state.Status != GoalStatusRunning || state.BudgetClass != budgetClassResearch || state.ResearchMode != GoalResearchOff || !state.Strict {
t.Fatalf("goal state = %+v, want running strict research goal", state)
}
}
@@ -559,7 +559,7 @@ func TestSetGoalDurableRestoresInMemoryStateWhenSidecarWriteFails(t *testing.T)
}
c.goals.setStatePath(filepath.Join(notDirectory, "goal.json"))
- if err := c.SetGoalDurable("replace the goal", ""); err == nil {
+ if err := c.SetGoalDurable("replace the goal"); err == nil {
t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent")
}
if got := c.Goal(); got != "keep the old goal" {
@@ -592,7 +592,7 @@ func TestSetGoalDurableNeverCreatesLegacyArchive(t *testing.T) {
c.goals.setStatePath(filepath.Join(notDirectory, "goal.json"))
goal := "investigate the root cause and fix the performance regression, then verify with tests"
- if err := c.SetGoalDurable(goal, ""); err == nil {
+ if err := c.SetGoalDurable(goal); err == nil {
t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent")
}
if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) {
diff --git a/internal/control/goal.go b/internal/control/goal.go
index bcb0c1a6a3..b5d4bdaa65 100644
--- a/internal/control/goal.go
+++ b/internal/control/goal.go
@@ -41,11 +41,12 @@ const (
// blocked either way. stopCauseBudgetTokens is only for recognizing and
// auto-resuming old token-limit pauses.
const (
- stopCauseBudgetTurns = "budget_turns"
- stopCauseBudgetTokens = "budget_tokens" // legacy; never written by current runtime
- stopCauseNoProgress = "no_progress"
- stopCauseEvaluator = "evaluator_unavailable"
- stopCauseManual = "manual"
+ stopCauseBudgetTurns = "budget_turns"
+ stopCauseBudgetTokens = "budget_tokens" // legacy; never written by current runtime
+ stopCauseNoProgress = "no_progress"
+ stopCauseEvaluator = "evaluator_unavailable"
+ stopCauseLegacyArchive = "legacy_archive"
+ stopCauseManual = "manual"
)
// budgetQuota returns the default turn quota for a budget class. Token hard
@@ -54,9 +55,9 @@ func budgetQuota(class string) (turns int) {
return taskintent.BudgetTurns(class)
}
-// budgetClassFor derives a Goal budget. GoalResearchMode only decodes legacy
-// sidecars and deprecated CLI flags.
-func budgetClassFor(goal string, researchMode GoalResearchMode) string {
+// budgetClassForLegacyMode translates old sidecars and deprecated CLI flags at
+// the compatibility boundary. The active Goal runtime stores only budgetClass.
+func budgetClassForLegacyMode(goal string, researchMode GoalResearchMode) string {
switch researchMode {
case GoalResearchOn:
return budgetClassResearch
@@ -79,7 +80,6 @@ type goalMachine struct {
mu sync.Mutex
goal string
status string
- researchMode GoalResearchMode
scopeID string
deliveryCheckpoint evidence.DeliveryCheckpoint
block string
@@ -100,6 +100,7 @@ type goalMachine struct {
lastEvaluatorReason string
stopCause string
budgetExtensions int // turn extensions from resume (compat field name)
+ pendingLegacyTaskID string
// statePath is the persisted goal-state sidecar; empty disables persistence.
statePath string
@@ -137,18 +138,6 @@ type goalState struct {
BudgetExtensions int `json:"budgetExtensions,omitempty"`
}
-// goalMachineSnapshot is an in-memory rollback point for durable Goal updates.
-// Persistence paths and mutexes are deliberately excluded.
-type goalMachineSnapshot struct {
- goal string
- status string
- researchMode GoalResearchMode
- scopeID string
- deliveryCheckpoint evidence.DeliveryCheckpoint
- block string
- strict bool
-}
-
// 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
@@ -189,9 +178,8 @@ type goalAdvanceResult struct {
// state admitted for its synthetic turn. The orchestrator uses these captured
// fields throughout the turn instead of re-reading a possibly replaced Goal.
type goalContinuationSnapshot struct {
- goal string
- researchMode GoalResearchMode
- scopeID string
+ goal string
+ scopeID string
}
// goalStatePath derives a session's persisted goal-state sidecar.
@@ -205,31 +193,11 @@ func (g *goalMachine) setStatePath(path string) {
g.mu.Unlock()
}
-func (g *goalMachine) capture() goalMachineSnapshot {
- g.mu.Lock()
- defer g.mu.Unlock()
- return goalMachineSnapshot{
- goal: g.goal, status: g.status, researchMode: g.researchMode,
- scopeID: g.scopeID, deliveryCheckpoint: g.deliveryCheckpoint,
- block: g.block, strict: g.strict,
- }
-}
-
-func (g *goalMachine) restore(snapshot goalMachineSnapshot) {
- g.mu.Lock()
- g.goal, g.status, g.researchMode = snapshot.goal, snapshot.status, snapshot.researchMode
- g.scopeID = snapshot.scopeID
- g.deliveryCheckpoint, g.block = snapshot.deliveryCheckpoint, snapshot.block
- g.strict = snapshot.strict
- g.continuationEpoch++
- g.mu.Unlock()
-}
-
// snapshot returns the fields Compose injects into outgoing turns.
-func (g *goalMachine) snapshot() (goal, status string, mode GoalResearchMode) {
+func (g *goalMachine) snapshot() (goal, status string) {
g.mu.Lock()
defer g.mu.Unlock()
- return g.goal, g.status, g.researchMode
+ return g.goal, g.status
}
func (g *goalMachine) goalText() string {
@@ -307,33 +275,62 @@ func (g *goalMachine) budgetExhausted() bool {
// the per-goal budget/runtime counters, and returns the state to persist. ok is
// false (no persistence) when the goal is unchanged or no state path is
// configured.
-func (g *goalMachine) set(goal string, mode GoalResearchMode, todos []evidence.TodoItem) (string, []byte, bool) {
+func (g *goalMachine) set(goal, preferredBudgetClass string, todos []evidence.TodoItem) (string, []byte, bool) {
goal = strings.TrimSpace(goal)
+ if goal != "" && preferredBudgetClass == "" {
+ preferredBudgetClass = taskintent.ClassifyGoalBudget(goal)
+ }
g.mu.Lock()
defer g.mu.Unlock()
- if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.researchMode == mode {
+ if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.budgetClass == preferredBudgetClass && g.pendingLegacyTaskID == "" {
return "", nil, false
}
+ g.installGoalLocked(goal, preferredBudgetClass)
+ return g.buildStateLocked(todos)
+}
+
+// setLegacyArchiveBlocked atomically installs and blocks an explicit legacy
+// archive goal. A concurrent Goal replacement cannot be blocked between two
+// separate FSM mutations.
+func (g *goalMachine) setLegacyArchiveBlocked(goal, preferredBudgetClass, taskID, reason string, todos []evidence.TodoItem) (string, []byte, bool) {
+ goal = strings.TrimSpace(goal)
+ if goal != "" && preferredBudgetClass == "" {
+ preferredBudgetClass = taskintent.ClassifyGoalBudget(goal)
+ }
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.installGoalLocked(goal, preferredBudgetClass)
+ g.pendingLegacyTaskID = strings.TrimSpace(taskID)
+ if goal != "" {
+ g.status = GoalStatusBlocked
+ }
+ g.stopCause = stopCauseLegacyArchive
+ g.block = clipGoalReason(reason)
+ return g.buildStateLocked(todos)
+}
+
+func (g *goalMachine) installGoalLocked(goal, preferredBudgetClass string) {
g.continuationEpoch++
g.turnsUsed, g.tokensUsed, g.noProgressTurns = 0, 0, 0
g.block = ""
g.lastContinuationReason, g.lastEvaluatorReason = "", ""
g.stopCause = ""
g.budgetExtensions = 0
+ g.pendingLegacyTaskID = ""
if goal == "" {
- g.goal, g.status, g.researchMode = "", GoalStatusStopped, GoalResearchAuto
+ g.goal, g.status = "", GoalStatusStopped
+ g.budgetClass = ""
g.scopeID = ""
g.deliveryCheckpoint = evidence.DeliveryCheckpoint{}
} else {
- g.goal, g.status, g.researchMode = goal, GoalStatusRunning, mode
+ g.goal, g.status = goal, GoalStatusRunning
g.scopeID = newGoalScopeID()
g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID}
- g.budgetClass = budgetClassFor(goal, mode)
+ g.budgetClass = preferredBudgetClass
g.turnsLimit = budgetQuota(g.budgetClass)
g.tokensLimit = 0 // no token hard limit
g.noProgressLimit = defaultNoProgressLimit
}
- return g.buildStateLocked(todos)
}
func (g *goalMachine) setStrict(strict bool, todos []evidence.TodoItem) (string, []byte, bool) {
@@ -400,7 +397,7 @@ func (g *goalMachine) resume(todos []evidence.TodoItem) (path string, data []byt
}
if extend {
if g.budgetClass == "" {
- g.budgetClass = budgetClassFor(g.goal, g.researchMode)
+ g.budgetClass = taskintent.ClassifyGoalBudget(g.goal)
}
g.turnsLimit += budgetQuota(g.budgetClass)
g.budgetExtensions++
@@ -457,9 +454,8 @@ func (g *goalMachine) admitContinuation(res goalAdvanceResult) (goalContinuation
g.scopeID = newGoalScopeID()
}
return goalContinuationSnapshot{
- goal: g.goal,
- researchMode: g.researchMode,
- scopeID: g.scopeID,
+ goal: g.goal,
+ scopeID: g.scopeID,
}, true
}
@@ -471,12 +467,11 @@ func (g *goalMachine) admitContinuation(res goalAdvanceResult) (goalContinuation
// 1. complete + readiness ready (report or evaluator) → complete
// 2. blocked (report or evaluator) → blocked immediately (no triple confirm)
// 3. evaluator failed/uncertain → safe pause (fail closed, never default to continue)
-// 4. evaluator failed/uncertain → safe pause (fail closed, never default to continue)
-// 5. budget exhausted → safe pause (also vetoes complete claims rejected by
+// 4. budget exhausted → safe pause (also vetoes complete claims rejected by
// readiness: those would continue, and continuation past the budget is a
// pause)
-// 6. no-progress limit reached → safe pause
-// 7. otherwise continue, carrying the missing requirements (complete rejected
+// 5. no-progress limit reached → 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.
func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
@@ -632,7 +627,6 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string,
state := goalState{
Goal: g.goal,
Status: g.status,
- ResearchMode: g.researchMode,
ScopeID: g.scopeID,
DeliveryCheckpoint: g.deliveryCheckpoint,
Turns: g.turnsUsed,
@@ -651,6 +645,14 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string,
StopCause: g.stopCause,
BudgetExtensions: g.budgetExtensions,
}
+ if g.pendingLegacyTaskID != "" {
+ state.ResearchMode = GoalResearchOn
+ state.AutoResearchTaskID = g.pendingLegacyTaskID
+ } else {
+ // GoalResearchOff is a downgrade fence: old readers must not infer or
+ // inject the removed AutoResearch runtime. budgetClass is authoritative.
+ state.ResearchMode = GoalResearchOff
+ }
b, err := json.Marshal(state)
if err != nil {
slog.Warn("controller: marshal goal state", "err", err)
@@ -668,6 +670,26 @@ func (g *goalMachine) writeStateErr(path string, data []byte) error {
}
g.writeMu.Lock()
defer g.writeMu.Unlock()
+ return writeGoalStateData(path, data)
+}
+
+func (g *goalMachine) writeStateAtEpoch(epoch uint64, todos []evidence.TodoItem) (bool, error) {
+ g.writeMu.Lock()
+ defer g.writeMu.Unlock()
+ g.mu.Lock()
+ if g.continuationEpoch != epoch {
+ g.mu.Unlock()
+ return false, nil
+ }
+ path, data, ok := g.buildStateLocked(todos)
+ g.mu.Unlock()
+ if !ok {
+ return true, nil
+ }
+ return true, writeGoalStateData(path, data)
+}
+
+func writeGoalStateData(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
@@ -730,9 +752,9 @@ func (g *goalMachine) terminalTodosFromState(sessionPath string) ([]evidence.Tod
// authoritative; missing budget fields are re-derived. migrated means path/data
// need an immediate rewrite (no provider call). legacyTaskID is returned only
// so Controller can fill missing goal text from a historical archive.
-func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []byte, migrated bool, legacyTaskID string) {
+func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []byte, migrated bool, legacy legacyGoalRestore) {
if strings.TrimSpace(sessionPath) == "" {
- return "", nil, false, ""
+ return "", nil, false, legacyGoalRestore{}
}
// Ensure write path is bound even when the controller rebuilds.
if g.statePath == "" {
@@ -743,12 +765,12 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
if !os.IsNotExist(err) {
slog.Warn("controller: read goal state", "err", err)
}
- return "", nil, false, ""
+ return "", nil, false, legacyGoalRestore{}
}
var state goalState
if err := json.Unmarshal(raw, &state); err != nil {
slog.Warn("controller: parse goal state", "err", err)
- return "", nil, false, ""
+ return "", nil, false, legacyGoalRestore{}
}
g.mu.Lock()
defer g.mu.Unlock()
@@ -757,15 +779,23 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
if g.status == "" {
g.status = GoalStatusStopped
}
- g.researchMode = state.ResearchMode
- // Old AutoResearch sidecars only retain AutoResearchTaskID for decode.
- // Active memory never carries the id; the next write omits it.
- legacyTaskID = strings.TrimSpace(state.AutoResearchTaskID)
- if legacyTaskID != "" {
- g.researchMode = GoalResearchOn
+ // Legacy task identity is decode-only compatibility data. It is returned to
+ // the Controller's migration boundary and never enters active Goal memory.
+ legacy = legacyGoalRestore{
+ taskID: strings.TrimSpace(state.AutoResearchTaskID),
+ todos: append([]evidence.TodoItem(nil), state.Todos...),
+ }
+ g.pendingLegacyTaskID = legacy.taskID
+ if g.pendingLegacyTaskID != "" && g.goal != "" {
+ // Sidecars that already carry the Goal objective do not depend on the
+ // historical archive. Complete the migration immediately.
+ g.pendingLegacyTaskID = ""
migrated = true
}
g.scopeID = strings.TrimSpace(state.ScopeID)
+ if g.scopeID == "" {
+ g.scopeID = strings.TrimSpace(state.DeliveryCheckpoint.ScopeID)
+ }
if g.goal != "" && g.scopeID == "" {
g.scopeID = newGoalScopeID()
}
@@ -790,25 +820,29 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
g.turnsUsed = state.Turns
}
g.tokensUsed = state.TokensUsed
+ g.budgetClass = normalizeBudgetClass(g.goal, state.BudgetClass, state.ResearchMode)
+ g.turnsLimit = state.TurnsLimit
+ g.noProgressTurns = state.NoProgressTurns
+ g.noProgressLimit = state.NoProgressLimit
// Token hard limits are gone: keep the field at 0. Old non-zero sidecar
// values are read and ignored so downgrade/upgrade never loses other state.
g.tokensLimit = 0
+ if goalStateNeedsMigration(state, g.budgetClass) {
+ migrated = true
+ }
if g.goal != "" {
- g.budgetClass = state.BudgetClass
if g.budgetClass == "" {
- g.budgetClass = budgetClassFor(g.goal, g.researchMode)
+ g.budgetClass = budgetClassForLegacyMode(g.goal, state.ResearchMode)
+ }
+ if legacy.taskID != "" {
+ g.budgetClass = budgetClassResearch
}
- if state.TurnsLimit > 0 {
- g.turnsLimit = state.TurnsLimit
- } else {
+ if g.turnsLimit == 0 {
g.turnsLimit = budgetQuota(g.budgetClass)
}
- if state.NoProgressLimit > 0 {
- g.noProgressLimit = state.NoProgressLimit
- } else {
+ if g.noProgressLimit == 0 {
g.noProgressLimit = defaultNoProgressLimit
}
- g.noProgressTurns = state.NoProgressTurns
// Auto-clear legacy token-budget pauses so the next user turn can
// continue without a manual resume. Loading itself never calls a
// provider.
@@ -822,52 +856,19 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
}
// Also rewrite sidecars that still store a non-zero tokensLimit so the
// next load does not re-surface the deprecated hard ceiling in status.
- if state.TokensLimit != 0 {
- migrated = true
- }
}
g.continuationEpoch++
- if migrated {
+ legacy.epoch = g.continuationEpoch
+ pendingLegacyGoal := g.pendingLegacyTaskID != "" && g.goal == ""
+ if migrated && !pendingLegacyGoal {
// Migration rewrites only the removed budget state. Preserve the todo
// snapshot carried by the authoritative sidecar instead of clearing it.
path, data, ok := g.buildStateLocked(state.Todos)
if ok {
- return path, data, true, legacyTaskID
+ return path, data, true, legacy
}
}
- return "", nil, false, legacyTaskID
-}
-
-// fillGoalTextIfEmpty installs archive-recovered goal text without resetting counters.
-func (g *goalMachine) fillGoalTextIfEmpty(goal string, todos []evidence.TodoItem) (string, []byte, bool) {
- goal = strings.TrimSpace(goal)
- if goal == "" {
- return "", nil, false
- }
- g.mu.Lock()
- defer g.mu.Unlock()
- if strings.TrimSpace(g.goal) != "" {
- return "", nil, false
- }
- g.goal, g.researchMode = goal, GoalResearchOn
- if g.status == "" {
- g.status = GoalStatusRunning
- }
- if g.budgetClass == "" {
- g.budgetClass = budgetClassResearch
- }
- if g.turnsLimit == 0 {
- g.turnsLimit = budgetQuota(g.budgetClass)
- }
- if g.noProgressLimit == 0 {
- g.noProgressLimit = defaultNoProgressLimit
- }
- if g.scopeID == "" {
- g.scopeID = newGoalScopeID()
- g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID}
- }
- g.continuationEpoch++
- return g.buildStateLocked(todos)
+ return "", nil, false, legacy
}
// formatIncompleteTodos renders the reminder shown when a complete claim
@@ -946,6 +947,14 @@ func (c *Controller) persistGoalState(path string, data []byte, ok bool) {
c.goals.writeState(path, data)
}
+func (c *Controller) persistGoalStateAtEpoch(epoch uint64, todos []evidence.TodoItem) (bool, error) {
+ applied, err := c.goals.writeStateAtEpoch(epoch, todos)
+ if err != nil {
+ slog.Warn("controller: write goal state", "err", err)
+ }
+ return applied, err
+}
+
func (c *Controller) restoreTerminalGoalTodos(sessionPath string) {
if c.executor == nil {
return
diff --git a/internal/control/goal_command.go b/internal/control/goal_command.go
new file mode 100644
index 0000000000..9fb93e2854
--- /dev/null
+++ b/internal/control/goal_command.go
@@ -0,0 +1,20 @@
+package control
+
+import (
+ "context"
+ "fmt"
+
+ "reasonix/internal/i18n"
+)
+
+func (c *Controller) startGoalCommandTurn(cmd GoalCommand, display string) {
+ if !c.goals.active() {
+ return
+ }
+ c.notice(fmt.Sprintf(i18n.M.GoalSetFmt, ShortGoalForNotice(c.Goal())))
+ if c.runner != nil {
+ c.runGuarded(func(ctx context.Context) error {
+ return c.runGoalLoopWithRawDisplay(ctx, "Start pursuing the active goal now.", cmd.Text, display)
+ })
+ }
+}
diff --git a/internal/control/goal_durable.go b/internal/control/goal_durable.go
new file mode 100644
index 0000000000..d128123d4c
--- /dev/null
+++ b/internal/control/goal_durable.go
@@ -0,0 +1,63 @@
+package control
+
+import "reasonix/internal/evidence"
+
+// goalMachineSnapshot is an in-memory rollback point for durable Goal updates.
+// Persistence paths and mutexes are deliberately excluded.
+type goalMachineSnapshot struct {
+ goal string
+ status string
+ scopeID string
+ deliveryCheckpoint evidence.DeliveryCheckpoint
+ block string
+ strict bool
+ budgetClass string
+ turnsUsed int
+ turnsLimit int
+ tokensUsed int
+ tokensLimit int
+ noProgressTurns int
+ noProgressLimit int
+ lastContinuationReason string
+ lastEvaluatorReason string
+ stopCause string
+ budgetExtensions int
+ pendingLegacyTaskID string
+}
+
+func (g *goalMachine) capture() goalMachineSnapshot {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ return goalMachineSnapshot{
+ goal: g.goal, status: g.status,
+ scopeID: g.scopeID, deliveryCheckpoint: g.deliveryCheckpoint,
+ block: g.block, strict: g.strict,
+ budgetClass: g.budgetClass, turnsUsed: g.turnsUsed,
+ turnsLimit: g.turnsLimit, tokensUsed: g.tokensUsed,
+ tokensLimit: g.tokensLimit, noProgressTurns: g.noProgressTurns,
+ noProgressLimit: g.noProgressLimit,
+ lastContinuationReason: g.lastContinuationReason,
+ lastEvaluatorReason: g.lastEvaluatorReason,
+ stopCause: g.stopCause, budgetExtensions: g.budgetExtensions,
+ pendingLegacyTaskID: g.pendingLegacyTaskID,
+ }
+}
+
+func (g *goalMachine) restore(snapshot goalMachineSnapshot) {
+ g.mu.Lock()
+ g.goal, g.status = snapshot.goal, snapshot.status
+ g.scopeID = snapshot.scopeID
+ g.deliveryCheckpoint, g.block = snapshot.deliveryCheckpoint, snapshot.block
+ g.strict = snapshot.strict
+ g.budgetClass = snapshot.budgetClass
+ g.turnsUsed, g.turnsLimit = snapshot.turnsUsed, snapshot.turnsLimit
+ g.tokensUsed, g.tokensLimit = snapshot.tokensUsed, snapshot.tokensLimit
+ 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.pendingLegacyTaskID = snapshot.pendingLegacyTaskID
+ g.continuationEpoch++
+ g.mu.Unlock()
+}
diff --git a/internal/control/goal_durable_test.go b/internal/control/goal_durable_test.go
new file mode 100644
index 0000000000..75c3c33fab
--- /dev/null
+++ b/internal/control/goal_durable_test.go
@@ -0,0 +1,38 @@
+package control
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "reasonix/internal/agent"
+ "reasonix/internal/event"
+)
+
+func TestSetGoalDurableRollsBackAllRuntimeStateOnWriteFailure(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "session.jsonl")
+ exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
+ c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
+ c.SetGoal("keep the old goal")
+ c.goals.mu.Lock()
+ c.goals.turnsUsed = 7
+ c.goals.tokensUsed = 4321
+ c.goals.noProgressTurns = 2
+ c.goals.lastContinuationReason = "preserve this reason"
+ c.goals.budgetExtensions = 1
+ c.goals.mu.Unlock()
+ want := c.GoalRuntime()
+
+ notDirectory := filepath.Join(dir, "not-a-directory")
+ if err := os.WriteFile(notDirectory, []byte("block nested writes"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ c.goals.setStatePath(filepath.Join(notDirectory, "goal.json"))
+ if err := c.SetGoalDurable("replace the goal"); err == nil {
+ t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent")
+ }
+ if got := c.GoalRuntime(); got != want {
+ t.Fatalf("GoalRuntime() after failed durable write = %+v, want %+v", got, want)
+ }
+}
diff --git a/internal/control/goal_legacy.go b/internal/control/goal_legacy.go
new file mode 100644
index 0000000000..1659a7ef2d
--- /dev/null
+++ b/internal/control/goal_legacy.go
@@ -0,0 +1,181 @@
+package control
+
+import (
+ "strings"
+
+ "reasonix/internal/evidence"
+)
+
+type legacyGoalRestore struct {
+ taskID string
+ todos []evidence.TodoItem
+ epoch uint64
+}
+
+func normalizeBudgetClass(goal, class string, legacyMode GoalResearchMode) string {
+ switch class {
+ case budgetClassSimple, budgetClassWrite, budgetClassResearch:
+ return class
+ default:
+ if strings.TrimSpace(goal) == "" && legacyMode != GoalResearchOn {
+ return ""
+ }
+ return budgetClassForLegacyMode(goal, legacyMode)
+ }
+}
+
+func goalStateNeedsMigration(state goalState, normalizedBudgetClass string) bool {
+ expectedMode := GoalResearchOff
+ if strings.TrimSpace(state.AutoResearchTaskID) != "" {
+ expectedMode = GoalResearchOn
+ }
+ return state.TokensLimit != 0 || state.ResearchMode != expectedMode ||
+ (state.BudgetClass != "" && state.BudgetClass != normalizedBudgetClass)
+}
+
+// blockLegacyRestore fails closed only while the decoded sidecar still owns the
+// active Goal epoch. The task id remains durable so a later resume can retry.
+func (g *goalMachine) blockLegacyRestore(expectedEpoch uint64, taskID, reason string) (uint64, bool) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.continuationEpoch != expectedEpoch {
+ return 0, false
+ }
+ g.status = GoalStatusBlocked
+ g.stopCause = stopCauseLegacyArchive
+ g.block = clipGoalReason(reason)
+ g.pendingLegacyTaskID = strings.TrimSpace(taskID)
+ g.continuationEpoch++
+ return g.continuationEpoch, true
+}
+
+// failLegacyRestorePersistence keeps a recovered archive retryable when the
+// sidecar replacement fails. The recovered Goal text may remain in memory, but
+// the Goal stays fail-closed and the legacy task id is retained until a later
+// resume commits the migration durably.
+func (g *goalMachine) failLegacyRestorePersistence(expectedEpoch uint64, taskID, reason string) (uint64, bool) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.continuationEpoch != expectedEpoch {
+ return 0, false
+ }
+ g.status = GoalStatusBlocked
+ g.stopCause = stopCauseLegacyArchive
+ g.block = clipGoalReason(reason)
+ g.pendingLegacyTaskID = strings.TrimSpace(taskID)
+ g.continuationEpoch++
+ return g.continuationEpoch, true
+}
+
+func (g *goalMachine) legacyArchiveRetryToken() (goal, taskID string, epoch uint64, ok bool) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.status != GoalStatusBlocked || g.stopCause != stopCauseLegacyArchive || g.pendingLegacyTaskID == "" {
+ return "", "", 0, false
+ }
+ return g.goal, g.pendingLegacyTaskID, g.continuationEpoch, true
+}
+
+func (g *goalMachine) legacyArchiveBlockedState() (goal string, epoch uint64, ok bool) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.status != GoalStatusBlocked || g.stopCause != stopCauseLegacyArchive {
+ return "", 0, false
+ }
+ return g.goal, g.continuationEpoch, true
+}
+
+func (c *Controller) replaceLegacyRestore(legacy legacyGoalRestore) {
+ c.legacyRestoreMu.Lock()
+ c.legacyRestore = legacy
+ c.legacyRestoreMu.Unlock()
+}
+
+func (c *Controller) legacyRestoreSnapshot() (legacyGoalRestore, bool) {
+ c.legacyRestoreMu.Lock()
+ defer c.legacyRestoreMu.Unlock()
+ legacy := c.legacyRestore
+ return legacy, strings.TrimSpace(legacy.taskID) != ""
+}
+
+func (c *Controller) advanceLegacyRestoreEpoch(taskID string, from, to uint64) {
+ c.legacyRestoreMu.Lock()
+ defer c.legacyRestoreMu.Unlock()
+ if c.legacyRestore.taskID == taskID && c.legacyRestore.epoch == from {
+ c.legacyRestore.epoch = to
+ }
+}
+
+func (c *Controller) clearLegacyRestore(taskID string, epoch uint64) {
+ c.legacyRestoreMu.Lock()
+ defer c.legacyRestoreMu.Unlock()
+ if c.legacyRestore.taskID == taskID && c.legacyRestore.epoch == epoch {
+ c.legacyRestore = legacyGoalRestore{}
+ }
+}
+
+// fillGoalTextIfEmpty installs archive-recovered goal text without resetting counters.
+func (g *goalMachine) fillGoalTextIfEmpty(expectedEpoch uint64, goal string) (uint64, bool) {
+ goal = strings.TrimSpace(goal)
+ if goal == "" {
+ return 0, false
+ }
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.continuationEpoch != expectedEpoch || strings.TrimSpace(g.goal) != "" {
+ return 0, false
+ }
+ g.goal = goal
+ g.pendingLegacyTaskID = ""
+ if g.status == "" || g.stopCause == stopCauseLegacyArchive {
+ g.status = GoalStatusRunning
+ }
+ if g.stopCause == stopCauseLegacyArchive {
+ g.stopCause, g.block = "", ""
+ }
+ g.budgetClass = budgetClassResearch
+ if g.turnsLimit < budgetQuota(g.budgetClass) {
+ g.turnsLimit = budgetQuota(g.budgetClass)
+ }
+ if g.noProgressLimit == 0 {
+ g.noProgressLimit = defaultNoProgressLimit
+ }
+ if g.scopeID == "" {
+ g.scopeID = newGoalScopeID()
+ g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID}
+ }
+ g.continuationEpoch++
+ return g.continuationEpoch, true
+}
+
+// resumeLegacyArchive applies an archive recovery only while the same blocked
+// Goal lifecycle is still current. Archive reads happen off-lock, so the epoch
+// check prevents a stale recovery from replacing a concurrently installed Goal.
+func (g *goalMachine) resumeLegacyArchive(expectedEpoch uint64, goal string) (uint64, bool) {
+ goal = strings.TrimSpace(goal)
+ if goal == "" {
+ return 0, false
+ }
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.continuationEpoch != expectedEpoch || g.status != GoalStatusBlocked || g.stopCause != stopCauseLegacyArchive {
+ return 0, false
+ }
+ g.goal = goal
+ g.status = GoalStatusRunning
+ g.stopCause, g.block = "", ""
+ g.pendingLegacyTaskID = ""
+ g.budgetClass = budgetClassResearch
+ if g.turnsLimit < budgetQuota(g.budgetClass) {
+ g.turnsLimit = budgetQuota(g.budgetClass)
+ }
+ if g.noProgressLimit == 0 {
+ g.noProgressLimit = defaultNoProgressLimit
+ }
+ if g.scopeID == "" {
+ g.scopeID = newGoalScopeID()
+ g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID}
+ }
+ g.continuationEpoch++
+ return g.continuationEpoch, true
+}
diff --git a/internal/control/goal_legacy_restore_test.go b/internal/control/goal_legacy_restore_test.go
new file mode 100644
index 0000000000..31d7c63f26
--- /dev/null
+++ b/internal/control/goal_legacy_restore_test.go
@@ -0,0 +1,619 @@
+package control
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "reasonix/internal/agent"
+ "reasonix/internal/event"
+ "reasonix/internal/evidence"
+)
+
+func writeLegacyGoalArchive(t *testing.T, root, taskID, goal string) string {
+ t.Helper()
+ taskRoot := filepath.Join(root, ".reasonix", "autoresearch", taskID)
+ if err := os.MkdirAll(filepath.Join(taskRoot, "state"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(taskRoot, "logs"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for name, body := range map[string]string{
+ "state/task_spec.json": `{"task_id":"` + taskID + `","goal":"` + goal + `","allowed_operations":{"write":true},"success_criteria":[]}`,
+ "state/progress.json": `{"status":"running","updated_at":"2026-06-30T10:00:00Z"}`,
+ "state/directions_tried.json": "[]\n",
+ "state/findings.jsonl": "",
+ "state/iteration_log.jsonl": "",
+ "logs/heartbeat.jsonl": "",
+ } {
+ if err := os.WriteFile(filepath.Join(taskRoot, name), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return taskRoot
+}
+
+func TestUnknownPersistedBudgetClassFallsBackToGoalClassification(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "session.jsonl")
+ raw, err := json.Marshal(goalState{Goal: "fix the crash in settings", Status: GoalStatusRunning, BudgetClass: "future-budget-class", TurnsLimit: 99})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(goalStatePath(path), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ g := &goalMachine{}
+ g.setStatePath(goalStatePath(path))
+ _, _, migrated, _ := g.restoreFromState(path)
+ if !migrated || g.budgetClass != budgetClassWrite || g.turnsLimit != 99 {
+ t.Fatalf("unknown budget restore = migrated:%v class:%q turns:%d", migrated, g.budgetClass, g.turnsLimit)
+ }
+}
+
+func TestGoalSidecarWriterFencesLegacyAutoResearchForEveryBudget(t *testing.T) {
+ tests := []struct {
+ name string
+ goal string
+ class string
+ }{
+ {name: "simple", goal: "summarize the current status", class: budgetClassSimple},
+ {name: "write", goal: "fix the settings crash", class: budgetClassWrite},
+ {name: "research", goal: "investigate the latency regression thoroughly", class: budgetClassResearch},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ sessionPath := filepath.Join(dir, "session.jsonl")
+ g := &goalMachine{statePath: goalStatePath(sessionPath)}
+ path, raw, ok := g.set(tt.goal, tt.class, nil)
+ if !ok {
+ t.Fatal("set did not produce sidecar data")
+ }
+ var state goalState
+ if err := json.Unmarshal(raw, &state); err != nil {
+ t.Fatal(err)
+ }
+ if state.ResearchMode != GoalResearchOff || state.AutoResearchTaskID != "" {
+ t.Fatalf("legacy reader fence missing: %+v", state)
+ }
+ if state.BudgetClass != tt.class || state.TurnsLimit != budgetQuota(tt.class) {
+ t.Fatalf("budget state = %+v, want %s/%d", state, tt.class, budgetQuota(tt.class))
+ }
+ // Frozen previous readers treated any non-Off mode or retained task id
+ // as an AutoResearch activation signal.
+ var legacyReader struct {
+ ResearchMode GoalResearchMode `json:"researchMode"`
+ AutoResearchTaskID string `json:"autoResearchTaskID"`
+ }
+ if err := json.Unmarshal(raw, &legacyReader); err != nil {
+ t.Fatal(err)
+ }
+ if legacyReader.ResearchMode != GoalResearchOff || strings.TrimSpace(legacyReader.AutoResearchTaskID) != "" {
+ t.Fatal("frozen previous reader would reactivate AutoResearch")
+ }
+ if err := g.writeStateErr(path, raw); err != nil {
+ t.Fatal(err)
+ }
+ reloaded := &goalMachine{}
+ reloaded.restoreFromState(sessionPath)
+ if reloaded.budgetClass != tt.class || reloaded.turnsLimit != budgetQuota(tt.class) {
+ t.Fatalf("reloaded budget = %q/%d, want %q/%d", reloaded.budgetClass, reloaded.turnsLimit, tt.class, budgetQuota(tt.class))
+ }
+ })
+ }
+}
+
+func TestEmptyGoalSidecarStillFencesLegacyAutoResearch(t *testing.T) {
+ g := &goalMachine{statePath: filepath.Join(t.TempDir(), "goal.json")}
+ _, raw, ok := g.set("", "", nil)
+ if !ok {
+ t.Fatal("empty Goal did not produce stopped sidecar state")
+ }
+ var state goalState
+ if err := json.Unmarshal(raw, &state); err != nil {
+ t.Fatal(err)
+ }
+ if state.ResearchMode != GoalResearchOff || state.AutoResearchTaskID != "" || state.BudgetClass != "" {
+ t.Fatalf("empty Goal downgrade fence = %+v", state)
+ }
+}
+
+func TestGoalSetIdempotencyUsesEffectiveBudgetClass(t *testing.T) {
+ g := &goalMachine{statePath: filepath.Join(t.TempDir(), "goal.json")}
+ if _, _, ok := g.set("same goal", budgetClassSimple, nil); !ok {
+ t.Fatal("initial set did not persist")
+ }
+ if _, _, ok := g.set("same goal", budgetClassSimple, nil); ok {
+ t.Fatal("same Goal and budget class was not idempotent")
+ }
+ if _, _, ok := g.set("same goal", budgetClassResearch, nil); !ok {
+ t.Fatal("budget class change was incorrectly treated as idempotent")
+ }
+ if g.budgetClass != budgetClassResearch || g.turnsLimit != budgetQuota(budgetClassResearch) {
+ t.Fatalf("budget upgrade = class:%q turns:%d", g.budgetClass, g.turnsLimit)
+ }
+}
+
+func TestLegacySidecarArchiveFailureIsBlockedAndRetryable(t *testing.T) {
+ root := t.TempDir()
+ if resolved, err := filepath.EvalSymlinks(root); err == nil {
+ root = resolved
+ }
+ sessionPath := filepath.Join(root, "sessions", "s.jsonl")
+ if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ const (
+ taskID = "retry-legacy-archive"
+ scopeID = "legacy-goal-scope"
+ )
+ wantTodo := evidence.TodoItem{Content: "preserve legacy verification", Status: "in_progress"}
+ wantCheckpoint := evidence.DeliveryCheckpoint{ScopeID: scopeID, CriteriaEstablished: true, WorkObserved: true}
+ legacy := goalState{
+ Status: GoalStatusRunning, ResearchMode: GoalResearchOn, AutoResearchTaskID: taskID,
+ ScopeID: scopeID, DeliveryCheckpoint: wantCheckpoint, Todos: []evidence.TodoItem{wantTodo},
+ BudgetClass: budgetClassResearch, TurnsUsed: 3, TurnsLimit: 40, TokensUsed: 1234,
+ NoProgressTurns: 2, NoProgressLimit: defaultNoProgressLimit, BudgetExtensions: 1,
+ LastContinuationReason: "continue verification",
+ }
+ raw, err := json.Marshal(legacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sess := agent.NewSession("sys")
+ exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+ c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
+ c.Resume(sess, sessionPath)
+ if got := c.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("failed legacy restore status = %q, want blocked", got)
+ }
+ failedRaw, err := os.ReadFile(goalStatePath(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var failed goalState
+ if err := json.Unmarshal(failedRaw, &failed); err != nil {
+ t.Fatal(err)
+ }
+ if failed.Status != GoalStatusBlocked || failed.ResearchMode != GoalResearchOn || failed.AutoResearchTaskID != taskID || failed.StopCause != stopCauseLegacyArchive || failed.Block == "" {
+ t.Fatalf("failed restore state = %+v, want retryable blocked legacy migration", failed)
+ }
+ if failed.ScopeID != scopeID || failed.DeliveryCheckpoint != wantCheckpoint || len(failed.Todos) != 1 || failed.Todos[0] != wantTodo {
+ t.Fatalf("failed restore lost goal state: %+v", failed)
+ }
+ if failed.BudgetClass != budgetClassResearch || failed.TurnsUsed != 3 || failed.TurnsLimit != 40 || failed.TokensUsed != 1234 || failed.NoProgressTurns != 2 || failed.BudgetExtensions != 1 {
+ t.Fatalf("failed restore lost runtime state: %+v", failed)
+ }
+ if got := exec.CanonicalTodoState(); len(got) != 1 || got[0] != wantTodo {
+ t.Fatalf("failed restore todos = %+v, want %+v", got, wantTodo)
+ }
+ if runtime := c.GoalRuntime(); runtime.TurnsUsed != 3 || runtime.TurnsLimit != 40 || runtime.TokensUsed != 1234 || runtime.NoProgressTurns != 2 {
+ t.Fatalf("failed restore lost in-memory runtime state: %+v", runtime)
+ }
+ c.Close()
+
+ taskRoot := writeLegacyGoalArchive(t, root, taskID, "recover after archive repair")
+ archiveBefore, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ sess2 := agent.NewSession("sys")
+ exec2 := agent.New(nil, nil, sess2, agent.Options{}, event.Discard)
+ c2 := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec2})
+ c2.Resume(sess2, sessionPath)
+ defer c2.Close()
+ if got := c2.Goal(); got != "recover after archive repair" {
+ t.Fatalf("retried Goal() = %q", got)
+ }
+ if got := c2.GoalStatus(); got != GoalStatusRunning {
+ t.Fatalf("retried status = %q, want running", got)
+ }
+ runtime := c2.GoalRuntime()
+ if runtime.TurnsUsed != 3 || runtime.TurnsLimit != 40 || runtime.TokensUsed != 1234 || runtime.NoProgressTurns != 2 || runtime.BudgetExtensions != 1 {
+ t.Fatalf("retried runtime = %+v, want preserved legacy consumption", runtime)
+ }
+ if got := exec2.CanonicalTodoState(); len(got) != 1 || got[0] != wantTodo {
+ t.Fatalf("retried todos = %+v, want %+v", got, wantTodo)
+ }
+ if got := c2.goals.deliveryState(); got != wantCheckpoint {
+ t.Fatalf("retried delivery checkpoint = %+v, want %+v", got, wantCheckpoint)
+ }
+ retriedRaw, err := os.ReadFile(goalStatePath(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var retried goalState
+ if err := json.Unmarshal(retriedRaw, &retried); err != nil {
+ t.Fatal(err)
+ }
+ if retried.AutoResearchTaskID != "" || retried.StopCause != "" || retried.Block != "" {
+ t.Fatalf("successful retry retained migration-only fields: %+v", retried)
+ }
+ archiveAfter, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(archiveAfter) != string(archiveBefore) {
+ t.Fatal("legacy archive changed during retry")
+ }
+}
+
+func TestLegacySidecarInvalidArchivesRemainRetryableAndReadOnly(t *testing.T) {
+ tests := []struct {
+ name string
+ file string
+ mutate func(taskID string) string
+ }{
+ {name: "corrupt json", file: "state/progress.json", mutate: func(string) string { return "{not-json" }},
+ {name: "invalid schema", file: "state/task_spec.json", mutate: func(string) string {
+ return `{"task_id":"different-task","goal":"schema mismatch","allowed_operations":{"write":true},"success_criteria":[]}`
+ }},
+ {name: "empty goal", file: "state/task_spec.json", mutate: func(taskID string) string {
+ return `{"task_id":"` + taskID + `","goal":"","allowed_operations":{"write":true},"success_criteria":[]}`
+ }},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ root := t.TempDir()
+ taskID := "invalid-" + strings.ReplaceAll(tt.name, " ", "-")
+ taskRoot := writeLegacyGoalArchive(t, root, taskID, "recover only from a valid archive")
+ target := filepath.Join(taskRoot, tt.file)
+ if err := os.WriteFile(target, []byte(tt.mutate(taskID)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ archiveBefore, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sessionPath := filepath.Join(root, "sessions", "s.jsonl")
+ if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := json.Marshal(goalState{
+ Status: GoalStatusRunning, ResearchMode: GoalResearchOn,
+ AutoResearchTaskID: taskID, BudgetClass: budgetClassResearch, TurnsLimit: 40,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sess := agent.NewSession("sys")
+ exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+ c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
+ c.Resume(sess, sessionPath)
+ defer c.Close()
+ if c.GoalStatus() != GoalStatusBlocked || c.ResumeGoal() {
+ t.Fatalf("invalid archive status=%q resumed unexpectedly", c.GoalStatus())
+ }
+ persistedRaw, err := os.ReadFile(goalStatePath(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var persisted goalState
+ if err := json.Unmarshal(persistedRaw, &persisted); err != nil {
+ t.Fatal(err)
+ }
+ if persisted.AutoResearchTaskID != taskID || persisted.ResearchMode != GoalResearchOn || persisted.StopCause != stopCauseLegacyArchive {
+ t.Fatalf("retry state = %+v", persisted)
+ }
+ archiveAfter, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(archiveAfter) != string(archiveBefore) {
+ t.Fatal("invalid legacy archive changed during failed restore")
+ }
+ })
+ }
+}
+
+func TestLegacySidecarArchiveCanRetryInSameController(t *testing.T) {
+ root := t.TempDir()
+ sessionPath := filepath.Join(root, "sessions", "s.jsonl")
+ if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ const taskID = "same-controller-retry"
+ legacy := goalState{
+ Status: GoalStatusRunning, AutoResearchTaskID: taskID, ResearchMode: GoalResearchOn,
+ TurnsUsed: 5, TurnsLimit: 20,
+ }
+ raw, err := json.Marshal(legacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sess := agent.NewSession("sys")
+ exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+ c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
+ c.Resume(sess, sessionPath)
+ defer c.Close()
+ if c.GoalStatus() != GoalStatusBlocked || c.ResumeGoal() {
+ t.Fatal("missing archive did not remain blocked")
+ }
+
+ writeLegacyGoalArchive(t, root, taskID, "recover objective in the same controller")
+ if !c.ResumeGoal() {
+ t.Fatal("repaired sidecar archive did not resume in the same controller")
+ }
+ if got := c.Goal(); got != "recover objective in the same controller" {
+ t.Fatalf("Goal() = %q, want recovered archive objective", got)
+ }
+ if runtime := c.GoalRuntime(); runtime.TurnsUsed != 5 || runtime.TurnsLimit != 40 {
+ t.Fatalf("runtime = %+v, want preserved use with research quota", runtime)
+ }
+ persisted, err := os.ReadFile(goalStatePath(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(persisted), "autoResearchTaskID") {
+ t.Fatalf("successful retry retained legacy task id: %s", persisted)
+ }
+}
+
+func TestLegacyArchiveMigrationWriteFailureRemainsBlockedAndRetryable(t *testing.T) {
+ root := t.TempDir()
+ const taskID = "write-retry"
+ writeLegacyGoalArchive(t, root, taskID, "recover after sidecar write repair")
+
+ sess := agent.NewSession("sys")
+ exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+ c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
+ defer c.Close()
+
+ blockedParent := filepath.Join(root, "not-a-directory")
+ if err := os.WriteFile(blockedParent, []byte("block mkdir"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ c.goals.setStatePath(filepath.Join(blockedParent, "goal.json"))
+ rawGoal := "resume .reasonix/autoresearch/" + taskID + "/"
+ c.goals.setLegacyArchiveBlocked(rawGoal, budgetClassResearch, taskID, "retry migration", nil)
+
+ if c.ResumeGoal() {
+ t.Fatal("migration reported success after its sidecar write failed")
+ }
+ goal, retainedTaskID, _, ok := c.goals.legacyArchiveRetryToken()
+ if !ok || retainedTaskID != taskID || goal != "recover after sidecar write repair" {
+ t.Fatalf("failed write lost retry state: goal=%q task=%q ok=%v", goal, retainedTaskID, ok)
+ }
+ if c.GoalStatus() != GoalStatusBlocked {
+ t.Fatalf("status = %q, want fail-closed blocked", c.GoalStatus())
+ }
+
+ statePath := filepath.Join(root, "sessions", "goal.json")
+ c.goals.setStatePath(statePath)
+ if !c.ResumeGoal() {
+ t.Fatal("migration did not retry after sidecar persistence was repaired")
+ }
+ raw, err := os.ReadFile(statePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var persisted goalState
+ if err := json.Unmarshal(raw, &persisted); err != nil {
+ t.Fatal(err)
+ }
+ if persisted.Status != GoalStatusRunning || persisted.AutoResearchTaskID != "" || persisted.ResearchMode != GoalResearchOff {
+ t.Fatalf("retried migration state = %+v", persisted)
+ }
+}
+
+func TestStaleLegacyArchiveRetryCannotReplaceNewGoal(t *testing.T) {
+ var g goalMachine
+ g.setLegacyArchiveBlocked("resume .reasonix/autoresearch/old/", budgetClassResearch, "old", "missing", nil)
+ _, _, epoch, ok := g.legacyArchiveRetryToken()
+ if !ok {
+ t.Fatal("legacy retry token unavailable")
+ }
+ g.set("new goal", budgetClassWrite, nil)
+ if _, resumed := g.resumeLegacyArchive(epoch, "stale archive goal"); resumed {
+ t.Fatal("stale archive retry replaced a newer Goal")
+ }
+ if got := g.goalText(); got != "new goal" {
+ t.Fatalf("Goal() = %q, want concurrent replacement", got)
+ }
+}
+
+func TestStaleInitialLegacyFailureCannotBlockNewGoal(t *testing.T) {
+ var g goalMachine
+ g.set("legacy goal", budgetClassResearch, nil)
+ epoch := g.continuationToken()
+ g.set("new goal", budgetClassWrite, nil)
+
+ if _, blocked := g.blockLegacyRestore(epoch, "old-task", "archive disappeared"); blocked {
+ t.Fatal("stale archive failure blocked a newer Goal")
+ }
+ if got := g.goalText(); got != "new goal" || g.statusForDisplay() != GoalStatusRunning {
+ t.Fatalf("Goal = %q status=%q, want newer running Goal", got, g.statusForDisplay())
+ }
+}
+
+func TestStaleLegacyMigrationCannotRewriteNewGoalSidecar(t *testing.T) {
+ statePath := filepath.Join(t.TempDir(), "goal.json")
+ g := &goalMachine{statePath: statePath}
+ g.set("legacy goal", budgetClassResearch, nil)
+ legacyEpoch := g.continuationToken()
+ path, data, ok := g.set("new goal", budgetClassWrite, nil)
+ if !ok {
+ t.Fatal("new Goal did not build sidecar state")
+ }
+ if err := g.writeStateErr(path, data); err != nil {
+ t.Fatal(err)
+ }
+ if applied, err := g.writeStateAtEpoch(legacyEpoch, nil); err != nil || applied {
+ t.Fatalf("stale migration write = applied:%v err:%v", applied, err)
+ }
+ raw, err := os.ReadFile(statePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var state goalState
+ if err := json.Unmarshal(raw, &state); err != nil {
+ t.Fatal(err)
+ }
+ if state.Goal != "new goal" {
+ t.Fatalf("sidecar Goal = %q, want new goal", state.Goal)
+ }
+}
+
+func TestLegacySidecarWithGoalMigratesWithoutArchive(t *testing.T) {
+ root := t.TempDir()
+ sessionPath := filepath.Join(root, "sessions", "s.jsonl")
+ if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ legacy := goalState{
+ Goal: "preserve the original goal", Status: GoalStatusRunning,
+ AutoResearchTaskID: "missing-archive", ResearchMode: GoalResearchOn,
+ TurnsUsed: 2, TurnsLimit: 40,
+ }
+ raw, err := json.Marshal(legacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sess := agent.NewSession("sys")
+ exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+ c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
+ c.Resume(sess, sessionPath)
+ defer c.Close()
+ if got := c.Goal(); got != legacy.Goal {
+ t.Fatalf("Goal() = %q, want %q", got, legacy.Goal)
+ }
+ if got := c.GoalStatus(); got != GoalStatusRunning {
+ t.Fatalf("status = %q, want running", got)
+ }
+ if runtime := c.GoalRuntime(); runtime.TurnsUsed != 2 || runtime.TurnsLimit != 40 {
+ t.Fatalf("runtime = %+v, want preserved research budget", runtime)
+ }
+ persistedRaw, err := os.ReadFile(goalStatePath(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var persisted goalState
+ if err := json.Unmarshal(persistedRaw, &persisted); err != nil {
+ t.Fatal(err)
+ }
+ if persisted.AutoResearchTaskID != "" || persisted.ResearchMode != GoalResearchOff || persisted.BudgetClass != budgetClassResearch {
+ t.Fatalf("migrated sidecar = %+v, want Goal-only research state", persisted)
+ }
+}
+
+func TestExplicitLegacyGoalRetryNeverRunsArchivePathAsGoal(t *testing.T) {
+ root := t.TempDir()
+ sessionPath := filepath.Join(root, "sessions", "s.jsonl")
+ sess := agent.NewSession("sys")
+ exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+ c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
+ c.Resume(sess, sessionPath)
+ defer c.Close()
+
+ const taskID = "repair-explicit-archive"
+ rawGoal := "resume .reasonix/autoresearch/" + taskID + "/"
+ c.SetGoal(rawGoal)
+ if got := c.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("initial status = %q, want blocked", got)
+ }
+ persistedRaw, err := os.ReadFile(goalStatePath(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var blocked goalState
+ if err := json.Unmarshal(persistedRaw, &blocked); err != nil {
+ t.Fatal(err)
+ }
+ if blocked.Status != GoalStatusBlocked || blocked.StopCause != stopCauseLegacyArchive {
+ t.Fatalf("blocked sidecar = %+v", blocked)
+ }
+ if c.ResumeGoal() {
+ t.Fatal("resume succeeded while archive was still missing")
+ }
+ if got := c.Goal(); got != rawGoal || c.GoalStatus() != GoalStatusBlocked {
+ t.Fatalf("failed retry changed Goal: goal=%q status=%q", got, c.GoalStatus())
+ }
+
+ writeLegacyGoalArchive(t, root, taskID, "recover the original objective")
+ if !c.ResumeGoal() {
+ t.Fatal("resume did not recover the repaired archive")
+ }
+ if got := c.Goal(); got != "recover the original objective" {
+ t.Fatalf("Goal() = %q, want archive objective", got)
+ }
+ if c.GoalStatus() != GoalStatusRunning || c.GoalRuntime().TurnsLimit != 40 {
+ t.Fatalf("recovered runtime = status:%q %+v", c.GoalStatus(), c.GoalRuntime())
+ }
+}
+
+func TestMalformedLegacyArchivePathCannotResumeAsGoalText(t *testing.T) {
+ c := New(Options{WorkspaceRoot: t.TempDir()})
+ defer c.Close()
+
+ c.SetGoal("resume .reasonix/autoresearch/../escape")
+ if c.GoalStatus() != GoalStatusBlocked {
+ t.Fatalf("status = %q, want blocked", c.GoalStatus())
+ }
+ if c.ResumeGoal() {
+ t.Fatal("malformed archive path resumed as an ordinary Goal")
+ }
+ if c.GoalStatus() != GoalStatusBlocked {
+ t.Fatalf("status after resume = %q, want blocked", c.GoalStatus())
+ }
+}
+
+func TestMissingLegacyGoalCommandDoesNotStartProviderTurn(t *testing.T) {
+ runner := &gatedTurnRunner{started: make(chan struct{}), release: make(chan struct{})}
+ c := New(Options{WorkspaceRoot: t.TempDir(), Runner: runner})
+ t.Cleanup(c.Close)
+
+ if !c.applyGoalCommand("/goal resume .reasonix/autoresearch/missing-task/", "") {
+ t.Fatal("legacy Goal command was not parsed")
+ }
+ if c.Running() {
+ t.Fatal("missing legacy archive started a provider turn")
+ }
+ if got := c.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("GoalStatus() = %q, want blocked", got)
+ }
+}
+
+func TestUnreadableExplicitLegacyArchiveBlocks(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("root can bypass archive file permissions")
+ }
+ root := t.TempDir()
+ const taskID = "unreadable-explicit-archive"
+ taskRoot := writeLegacyGoalArchive(t, root, taskID, "never run an unreadable archive")
+ specPath := filepath.Join(taskRoot, "state", "task_spec.json")
+ if err := os.Chmod(specPath, 0); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.Chmod(specPath, 0o644) })
+ c := New(Options{WorkspaceRoot: root})
+ t.Cleanup(c.Close)
+
+ c.SetGoal("resume .reasonix/autoresearch/" + taskID + "/")
+ if got := c.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("GoalStatus() = %q, want blocked", got)
+ }
+ if got := c.Goal(); got != "resume .reasonix/autoresearch/"+taskID+"/" {
+ t.Fatalf("Goal() = %q, archive goal must not be trusted", got)
+ }
+}
diff --git a/internal/control/goal_runtime_test.go b/internal/control/goal_runtime_test.go
index 5b7c9b717c..33e10aceb0 100644
--- a/internal/control/goal_runtime_test.go
+++ b/internal/control/goal_runtime_test.go
@@ -364,7 +364,7 @@ func TestGoalTurnRecorderProtocol(t *testing.T) {
t.Fatal(err)
}
// The goal is replaced: epoch bumps, scope rotates.
- g.set("replacement", GoalResearchAuto, nil)
+ g.set("replacement", "", nil)
if got := rec.validReport(rec.epoch); got != nil {
t.Fatalf("stale recorder report = %+v, want nil", got)
}
@@ -372,7 +372,7 @@ func TestGoalTurnRecorderProtocol(t *testing.T) {
t.Run("late record after replacement rejected", func(t *testing.T) {
g, rec := newRec(t)
- g.set("replacement", GoalResearchAuto, nil)
+ g.set("replacement", "", nil)
if _, err := rec.RecordGoalReport(report(GoalStatusComplete, "")); err == nil {
t.Fatal("late record on a replaced goal must be rejected")
}
@@ -384,7 +384,7 @@ func TestGoalTurnRecorderProtocol(t *testing.T) {
if g.tokensUsed != 150 {
t.Fatalf("tokensUsed = %d, want 150", g.tokensUsed)
}
- g.set("replacement", GoalResearchAuto, nil)
+ g.set("replacement", "", nil)
rec.addUsage(50)
if g.tokensUsed != 0 {
t.Fatalf("stale usage folded into replacement goal: %d", g.tokensUsed)
@@ -436,7 +436,7 @@ func TestGoalUsageTeeAttributesScopedBillableCallsAndExcludesTitle(t *testing.T)
func TestBudgetClassForBareFaultIsWrite(t *testing.T) {
// User-reported Chinese bare fault → write turn quota (20), no token ceiling.
- class := budgetClassFor("数据模型管理器又出现历史 BUG 了……", GoalResearchAuto)
+ class := budgetClassForLegacyMode("数据模型管理器又出现历史 BUG 了……", GoalResearchAuto)
if class != budgetClassWrite {
t.Fatalf("budget class = %q, want write", class)
}
@@ -450,12 +450,12 @@ func TestBudgetClassForBareFaultIsWrite(t *testing.T) {
"诊断数据库连接失败原因。",
"复现并定位问题,但不要修复。",
} {
- if got := budgetClassFor(goal, GoalResearchAuto); got != budgetClassSimple {
+ if got := budgetClassForLegacyMode(goal, GoalResearchAuto); got != budgetClassSimple {
t.Errorf("budgetClassFor(%q) = %q, want simple", goal, got)
}
}
// Explicit mutation verbs remain write.
- if got := budgetClassFor("fix the crash in settings", GoalResearchAuto); got != budgetClassWrite {
+ if got := budgetClassForLegacyMode("fix the crash in settings", GoalResearchAuto); got != budgetClassWrite {
t.Fatalf("explicit fix class = %q, want write", got)
}
}
diff --git a/internal/control/goal_test.go b/internal/control/goal_test.go
index 431241f3ed..4ab4a288cf 100644
--- a/internal/control/goal_test.go
+++ b/internal/control/goal_test.go
@@ -130,7 +130,7 @@ func toolCallChunk(id, name, args string) provider.Chunk {
}
func TestActiveGoalBlockCarriesTaskContractAndPausePolicy(t *testing.T) {
- block := activeGoalBlock("fix the parser", GoalResearchOff)
+ block := activeGoalBlock("fix the parser")
for _, want := range []string{
"Treat the user's goal as a task contract",
"Context, Request, Output format, Constraints",
@@ -252,6 +252,7 @@ func TestLegacyGoalSidecarMigratesToResearchBudgetWithoutTaskID(t *testing.T) {
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
t.Fatal(err)
}
+ writeLegacyGoalArchive(t, root, "old-task", "archive fallback should not replace sidecar goal")
if err := os.WriteFile(goalStatePath(sessionPath), []byte(`{"goal":"investigate runtime","status":"running","researchMode":1,"autoResearchTaskID":"old-task"}`), 0o644); err != nil {
t.Fatal(err)
}
@@ -263,6 +264,9 @@ func TestLegacyGoalSidecarMigratesToResearchBudgetWithoutTaskID(t *testing.T) {
if got := c.GoalRuntime().TurnsLimit; got != 40 {
t.Fatalf("migrated Goal turns limit = %d, want 40", got)
}
+ if got := c.Goal(); got != "investigate runtime" {
+ t.Fatalf("migrated Goal = %q, want sidecar goal", got)
+ }
raw, err := os.ReadFile(goalStatePath(sessionPath))
if err != nil {
t.Fatal(err)
@@ -287,12 +291,23 @@ func TestMissingExplicitLegacyTaskBlocksWithoutCreatingArchive(t *testing.T) {
if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) {
t.Fatalf("missing legacy task created archive: %v", err)
}
+
+ c.SetGoal("resume .reasonix/autoresearch/missing-task/../../escape")
+ if got := c.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("unsafe legacy path status = %q, want blocked", got)
+ }
+ if got := c.Goal(); got != "resume .reasonix/autoresearch/missing-task/../../escape" {
+ t.Fatalf("unsafe legacy path silently resumed a truncated task: %q", got)
+ }
}
func TestAssistantEvidenceBlockIsIgnoredByUnifiedGoal(t *testing.T) {
root := t.TempDir()
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
- prov := &scriptedTurns{turns: flattenTurns(goalToolTurn(GoalStatusComplete, "", ""))}
+ turns := goalToolTurn(GoalStatusComplete, "", "")
+ const evidenceBlock = `{"id":"legacy-evidence","kind":"verification","summary":"must remain ordinary assistant text"}`
+ turns[len(turns)-1] = textTurn("worked on the goal\n" + evidenceBlock)
+ prov := &scriptedTurns{turns: flattenTurns(turns)}
ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
c := New(Options{WorkspaceRoot: root, SessionPath: sessionPath, Runner: ag, Executor: ag})
defer c.Close()
@@ -301,6 +316,9 @@ func TestAssistantEvidenceBlockIsIgnoredByUnifiedGoal(t *testing.T) {
if got := c.GoalStatus(); got != GoalStatusComplete {
t.Fatalf("GoalStatus = %q, want complete", got)
}
+ if got := lastAssistantText(c.History()); !strings.Contains(got, evidenceBlock) {
+ t.Fatalf("legacy evidence block was interpreted instead of retained as transcript text: %q", got)
+ }
if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) {
t.Fatalf("assistant evidence created archive: %v", err)
}
@@ -666,7 +684,7 @@ func TestGoalInterceptsCompleteWithIncompleteTodos(t *testing.T) {
func TestGoalAdvanceResultCannotCrossGoalLifecycle(t *testing.T) {
newResult := func(t *testing.T, g *goalMachine) goalAdvanceResult {
t.Helper()
- g.set("old goal", GoalResearchAuto, nil)
+ g.set("old goal", "", nil)
res := g.advance(goalAdvanceInput{
report: &goalTurnReport{status: GoalStatusComplete, reason: ""},
todos: []evidence.TodoItem{{
@@ -691,7 +709,7 @@ func TestGoalAdvanceResultCannotCrossGoalLifecycle(t *testing.T) {
t.Run("replacement goal invalidates result", func(t *testing.T) {
var g goalMachine
res := newResult(t, &g)
- g.set("replacement goal", GoalResearchAuto, nil)
+ g.set("replacement goal", "", nil)
if got, ok := g.acceptContinuation(res); ok {
t.Fatalf("replacement goal accepted stale intercept %q", got)
}
@@ -908,45 +926,6 @@ func TestRepeatedCompleteWithIncompleteTodosPausesOnBudget(t *testing.T) {
}
}
-func readJSONFileForTest(t *testing.T, path string, out any) {
- t.Helper()
- data, err := os.ReadFile(path)
- if err != nil {
- t.Fatalf("ReadFile(%s): %v", path, err)
- }
- if err := json.Unmarshal(data, out); err != nil {
- t.Fatalf("Unmarshal(%s): %v", path, err)
- }
-}
-
-func sessionContainsUserText(messages []provider.Message, needles ...string) bool {
- for _, msg := range messages {
- if msg.Role != provider.RoleUser {
- continue
- }
- ok := true
- for _, needle := range needles {
- if !strings.Contains(msg.Content, needle) {
- ok = false
- break
- }
- }
- if ok {
- return true
- }
- }
- return false
-}
-
-func containsNotice(notices []string, needle string) bool {
- for _, notice := range notices {
- if strings.Contains(notice, needle) {
- return true
- }
- }
- return false
-}
-
// TestSessionRotationClearsActiveGoal pins the /new & /clear goal semantics:
// a fresh session starts with no active goal (so the old goal's text stops
// injecting into its first turns), while the OLD session's persisted
diff --git a/internal/control/input.go b/internal/control/input.go
index c1469b98ee..2a1b2eaf4a 100644
--- a/internal/control/input.go
+++ b/internal/control/input.go
@@ -138,14 +138,13 @@ func (c *Controller) Compose(text string) string {
}
func (c *Controller) compose(text, source string, includeHookContext bool) string {
- goal, goalStatus, goalResearchMode := c.goals.snapshot()
+ goal, goalStatus := c.goals.snapshot()
return c.composeWithGoal(
text,
source,
includeHookContext,
goal,
goalStatus,
- goalResearchMode,
)
}
@@ -153,7 +152,6 @@ func (c *Controller) composeWithGoal(
text, source string,
includeHookContext bool,
goal, goalStatus string,
- goalResearchMode GoalResearchMode,
) string {
c.mu.Lock()
plan := c.planMode
@@ -163,7 +161,7 @@ func (c *Controller) composeWithGoal(
notes := c.memory.drainPending()
if strings.TrimSpace(goal) != "" && goalStatus == GoalStatusRunning {
- prefix := activeGoalBlock(goal, goalResearchMode)
+ prefix := activeGoalBlock(goal)
text = prefix + "\n\n" + text
}
if plan {
@@ -298,8 +296,7 @@ func (c *Controller) ComposeSynthetic(text string) string {
return agent.WithReasoningLanguageForSource(text, lang, text)
}
-func activeGoalBlock(goal string, researchMode GoalResearchMode) string {
- _ = researchMode // retained for call-site stability; budget selection is host-side only
+func activeGoalBlock(goal string) string {
goal = strings.TrimSpace(goal)
goal = strings.ReplaceAll(goal, activeGoalClose, "<\\/active-goal>")
var b strings.Builder
@@ -369,6 +366,8 @@ type GoalCommand struct {
DeprecatedBudgetFlag bool
}
+const GoalBudgetFlagDeprecatedNotice = "This /goal budget flag is deprecated; Goal now selects its budget automatically."
+
func ParseGoalCommand(input string) (GoalCommand, bool) {
trimmed := strings.TrimSpace(input)
if trimmed != "/goal" && !strings.HasPrefix(trimmed, "/goal ") && !strings.HasPrefix(trimmed, "/goal\t") {
diff --git a/internal/control/planner_gate_test.go b/internal/control/planner_gate_test.go
index cacc4948cd..a58e7854ee 100644
--- a/internal/control/planner_gate_test.go
+++ b/internal/control/planner_gate_test.go
@@ -72,8 +72,8 @@ func TestTaskWarrantsPlanner(t *testing.T) {
{"explain how to migrate from v1 to v2", true},
{goalContinueTurn, false},
{"Goal signaled complete but issues remain:\n- the following tasks are still incomplete:\n - Fix login (in_progress)\nFix or use todo_write/complete_step to mark done, then report complete again via update_goal.", false},
- {activeGoalBlock("execute plan: fix the parser", GoalResearchAuto) + "\n\n" + goalContinueTurn, false},
- {activeGoalBlock("implement the new caching layer", GoalResearchAuto) + "\n\nimplement the new caching layer across the backend", true},
+ {activeGoalBlock("execute plan: fix the parser") + "\n\n" + goalContinueTurn, false},
+ {activeGoalBlock("implement the new caching layer") + "\n\nimplement the new caching layer across the backend", true},
}
for _, c := range cases {
if got := TaskWarrantsPlanner(c.input); got != c.want {
@@ -454,7 +454,7 @@ func TestPlannerPolicyUsesPristineMetadataInsteadOfInjectedContext(t *testing.T)
ctx := withPlannerTurnMetadata(context.Background(), plannerTurnMetadata{
UserText: "fix typo in README",
})
- input := activeGoalBlock("migrate authentication across the backend", GoalResearchAuto) +
+ input := activeGoalBlock("migrate authentication across the backend") +
"\n\n\nhigh risk migration\n\n\nfix typo in README"
got := DecidePlannerRoute(ctx, input)
if got.Route != agent.PlannerRouteExecutorOnly || got.Reason != plannerReasonAtomicEdit {
diff --git a/internal/control/port.go b/internal/control/port.go
index 861caef406..dd64e30724 100644
--- a/internal/control/port.go
+++ b/internal/control/port.go
@@ -100,6 +100,8 @@ type Goals interface {
Goal() string
GoalStatus() string
SetGoal(goal string)
+ // SetGoalWithResearchMode is retained for deprecated CLI budget flags. The
+ // mode is translated at the boundary and is not stored in the Goal runtime.
SetGoalWithResearchMode(goal string, researchMode GoalResearchMode)
ResumeGoal() bool
PauseGoal() bool
diff --git a/internal/control/turn_orchestrator.go b/internal/control/turn_orchestrator.go
index 83ce2de242..cf96b93199 100644
--- a/internal/control/turn_orchestrator.go
+++ b/internal/control/turn_orchestrator.go
@@ -205,7 +205,6 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
false,
continuation.goal,
GoalStatusRunning,
- continuation.researchMode,
)
} else {
input = c.compose(turn.input, turn.raw, !turn.synthetic)
diff --git a/internal/memory/queue.go b/internal/memory/queue.go
index 36db70a881..c31ca11dc4 100644
--- a/internal/memory/queue.go
+++ b/internal/memory/queue.go
@@ -17,12 +17,20 @@ type autoMemoryWriteClaimer interface {
}
type queueKey struct{}
+type noQueue struct{}
// WithQueue stamps q onto ctx for the remember/forget tools to find.
func WithQueue(ctx context.Context, q Queue) context.Context {
return context.WithValue(ctx, queueKey{}, q)
}
+// WithoutQueue shadows an ancestor queue while preserving cancellation and
+// unrelated context values. Sub-agents use it to avoid injecting memory changes
+// directly into their parent's current-session prompt tail.
+func WithoutQueue(ctx context.Context) context.Context {
+ return context.WithValue(ctx, queueKey{}, noQueue{})
+}
+
// QueueFromContext returns the memory queue the agent stamped, if any.
func QueueFromContext(ctx context.Context) (Queue, bool) {
q, ok := ctx.Value(queueKey{}).(Queue)
diff --git a/internal/memory/queue_test.go b/internal/memory/queue_test.go
new file mode 100644
index 0000000000..0683c9e8ba
--- /dev/null
+++ b/internal/memory/queue_test.go
@@ -0,0 +1,28 @@
+package memory
+
+import (
+ "context"
+ "testing"
+)
+
+type testQueue struct{}
+
+func (testQueue) QueueMemory(string) {}
+
+type preservedQueueContextKey struct{}
+
+func TestWithoutQueueShadowsOnlyQueue(t *testing.T) {
+ parent := context.WithValue(WithQueue(context.Background(), testQueue{}), preservedQueueContextKey{}, "preserved")
+ child := WithoutQueue(parent)
+ if _, ok := QueueFromContext(child); ok {
+ t.Fatal("child context inherited the parent memory queue")
+ }
+ if got := child.Value(preservedQueueContextKey{}); got != "preserved" {
+ t.Fatalf("unrelated context value = %v, want preserved", got)
+ }
+
+ owned := WithQueue(child, testQueue{})
+ if _, ok := QueueFromContext(owned); !ok {
+ t.Fatal("child-owned memory queue did not override the shadow value")
+ }
+}
diff --git a/internal/tool/tool.go b/internal/tool/tool.go
index 09610a3565..e219f9016a 100644
--- a/internal/tool/tool.go
+++ b/internal/tool/tool.go
@@ -526,7 +526,7 @@ func (r *Registry) Names() []string {
// Schemas exports tool definitions in stable name order for the provider.
func (r *Registry) Schemas() []provider.ToolSchema {
- return r.schemasForContext(nil, false)
+ return r.schemasForContext(context.Background(), false)
}
// SchemasForContext exports only tools available during ctx. Tools without a
diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json
index dbe140cfb7..ade285aeea 100644
--- a/tools/repolint/baseline.json
+++ b/tools/repolint/baseline.json
@@ -2,14 +2,14 @@
"limits": {
"banner": 0,
"commented-code": 0,
- "complexity": 2047,
- "essay": 4006,
- "file-size": 107833,
- "function-size": 9094,
+ "complexity": 2048,
+ "essay": 4005,
+ "file-size": 107873,
+ "function-size": 9102,
"layering": 1,
"marker": 0,
"narrative": 61,
- "test-file-size": 68025
+ "test-file-size": 67810
},
"files": {
"cmd/e2ebench/main.go": {
@@ -446,11 +446,10 @@
},
"internal/agent/coordinator_test.go": {
"essay": 3,
- "test-file-size": 1316
+ "test-file-size": 1239
},
"internal/agent/delivery_hardening_test.go": {
- "essay": 5,
- "test-file-size": 152
+ "essay": 5
},
"internal/agent/delivery_scope_test.go": {
"essay": 1
@@ -480,7 +479,7 @@
"internal/agent/extensions_test.go": {
"essay": 4,
"narrative": 1,
- "test-file-size": 1022
+ "test-file-size": 1057
},
"internal/agent/fleet.go": {
"essay": 4,
@@ -546,10 +545,10 @@
"essay": 1
},
"internal/agent/run_loop.go": {
- "complexity": 5,
+ "complexity": 6,
"essay": 45,
- "file-size": 318,
- "function-size": 48
+ "file-size": 326,
+ "function-size": 57
},
"internal/agent/save.go": {
"complexity": 44,
@@ -604,7 +603,7 @@
},
"internal/agent/subagent_store.go": {
"essay": 5,
- "file-size": 171
+ "file-size": 179
},
"internal/agent/subagent_store_test.go": {
"test-file-size": 250
@@ -612,7 +611,7 @@
"internal/agent/task.go": {
"complexity": 25,
"essay": 56,
- "file-size": 1388,
+ "file-size": 1390,
"function-size": 114
},
"internal/agent/task_test.go": {
@@ -776,7 +775,7 @@
"internal/cli/chat_tui.go": {
"complexity": 300,
"essay": 110,
- "file-size": 4551,
+ "file-size": 4555,
"function-size": 1196
},
"internal/cli/chat_tui_paste.go": {
@@ -1034,7 +1033,7 @@
"internal/control/controller.go": {
"complexity": 11,
"essay": 170,
- "file-size": 5334,
+ "file-size": 5343,
"function-size": 77,
"narrative": 4
},
@@ -1058,15 +1057,15 @@
},
"internal/control/goal.go": {
"complexity": 7,
- "essay": 11,
- "file-size": 318,
+ "essay": 10,
+ "file-size": 327,
"function-size": 7
},
"internal/control/goal_runtime_test.go": {
"test-file-size": 14
},
"internal/control/goal_test.go": {
- "test-file-size": 264
+ "test-file-size": 243
},
"internal/control/goalusage.go": {
"essay": 2
@@ -1132,7 +1131,7 @@
"internal/control/turn_orchestrator.go": {
"complexity": 3,
"essay": 13,
- "function-size": 64
+ "function-size": 63
},
"internal/control/turn_orchestrator_test.go": {
"essay": 1,
From a8ba82216966d755d8ff84b343c5d33d955992ca Mon Sep 17 00:00:00 2001
From: SivanCola <32437197+SivanCola@users.noreply.github.com>
Date: Sun, 9 Aug 2026 03:45:04 +0800
Subject: [PATCH 06/12] fix(goal): close legacy restore and schema isolation
gaps
Problem: the Goal-only runtime still had provider-context schema filtering assumptions, and an explicit legacy archive could lose its recovery token after a Controller restart.\n\nRoot cause: execution isolation was coupled to provider-visible tool removal, while explicit archive identity lived only in memory and was not distinguished from a sidecar Goal text.\n\nFix: keep stable Registry schemas and enforce Goal, Jobs, and memory boundaries at execution time; add epoch-fenced read-only archive recovery with explicit-path restart handling; preserve unknown Finding kinds and add focused regression coverage.\n\nVerification: go test ./...; go test -race ./internal/control ./internal/agent; cd desktop/frontend && pnpm typecheck && pnpm test:all && pnpm build; scripts/cache-guard.sh; scripts/check-cache-impact.sh; go run ./tools/repolint; git diff --check.
---
desktop/goal_delivery_yolo_test.go | 4 +-
internal/agent/agent.go | 12 -
internal/agent/coordinator.go | 3 -
internal/agent/coordinator_test.go | 79 +--
internal/agent/delivery_hardening_test.go | 181 +------
internal/agent/extensions_schema_test.go | 48 ++
internal/agent/goal_schema_isolation_test.go | 254 +++++++++
internal/agent/planmode_test.go | 109 +---
internal/agent/run_loop.go | 43 +-
internal/agent/sampling_request.go | 3 +-
internal/agent/subagent_context.go | 15 +
.../agent/subagent_context_isolation_test.go | 74 +++
internal/agent/subagent_readonly.go | 12 +
internal/agent/task.go | 23 +-
internal/autoresearch/fixture_test.go | 25 +-
internal/autoresearch/store.go | 333 ++++++++++--
internal/autoresearch/store_test.go | 148 ++++++
internal/autoresearch/task.go | 13 -
internal/boot/boot_test.go | 23 +-
internal/cli/chat_tui.go | 8 +-
internal/cli/chat_tui_goal.go | 9 +
internal/cli/chat_tui_goal_test.go | 36 ++
internal/control/autoresearch_manager.go | 121 ++++-
internal/control/controller.go | 90 +---
internal/control/controller_test.go | 6 +-
internal/control/goal.go | 235 ++++----
internal/control/goal_command.go | 20 +
internal/control/goal_durable.go | 60 +++
internal/control/goal_durable_test.go | 38 ++
internal/control/goal_legacy.go | 160 ++++++
internal/control/goal_legacy_restore_test.go | 503 ++++++++++++++++++
internal/control/goal_runtime_test.go | 12 +-
internal/control/goal_set.go | 80 +++
internal/control/goal_test.go | 65 +--
internal/control/input.go | 11 +-
internal/control/planner_gate_test.go | 6 +-
internal/control/port.go | 2 +
internal/control/turn_orchestrator.go | 1 -
internal/jobs/context.go | 12 +
internal/jobs/context_test.go | 23 +
internal/jobs/jobs.go | 8 -
internal/jobs/jobs_test.go | 15 -
internal/memory/queue.go | 8 +
internal/memory/queue_test.go | 28 +
internal/tool/builtin/bgjobs.go | 15 -
internal/tool/builtin/bgjobs_test.go | 26 -
internal/tool/builtin/completestep.go | 8 -
.../tool/builtin/completestep_schema_test.go | 16 +
internal/tool/builtin/completestep_test.go | 14 -
internal/tool/builtin/updategoal.go | 5 -
internal/tool/builtin/updategoal_test.go | 14 +-
internal/tool/contract_lock_test.go | 58 --
internal/tool/contract_test.go | 12 -
internal/tool/tool.go | 45 +-
tools/repolint/baseline.json | 104 ++--
55 files changed, 2228 insertions(+), 1048 deletions(-)
create mode 100644 internal/agent/extensions_schema_test.go
create mode 100644 internal/agent/goal_schema_isolation_test.go
create mode 100644 internal/agent/subagent_context.go
create mode 100644 internal/agent/subagent_context_isolation_test.go
create mode 100644 internal/agent/subagent_readonly.go
create mode 100644 internal/cli/chat_tui_goal.go
create mode 100644 internal/cli/chat_tui_goal_test.go
create mode 100644 internal/control/goal_command.go
create mode 100644 internal/control/goal_durable.go
create mode 100644 internal/control/goal_durable_test.go
create mode 100644 internal/control/goal_legacy.go
create mode 100644 internal/control/goal_legacy_restore_test.go
create mode 100644 internal/control/goal_set.go
create mode 100644 internal/jobs/context.go
create mode 100644 internal/jobs/context_test.go
create mode 100644 internal/memory/queue_test.go
create mode 100644 internal/tool/builtin/completestep_schema_test.go
diff --git a/desktop/goal_delivery_yolo_test.go b/desktop/goal_delivery_yolo_test.go
index 2ce256d974..3161496807 100644
--- a/desktop/goal_delivery_yolo_test.go
+++ b/desktop/goal_delivery_yolo_test.go
@@ -54,8 +54,8 @@ func newGoalDeliveryYoloTestApp(t *testing.T, goalStatus string) (*App, *Workspa
state := map[string]any{
"goal": "ship the combined mode",
"status": goalStatus,
- "researchMode": control.GoalResearchOn,
- "autoResearchTaskID": "research-task-1",
+ "budgetClass": "research",
+ "turnsLimit": 40,
"scopeID": checkpoint.ScopeID,
"deliveryCheckpoint": checkpoint,
}
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index bc6e4c8aba..924044da48 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -139,18 +139,6 @@ func PlanModeFromContext(ctx context.Context) bool {
return ok && cc.planMode
}
-func (a *Agent) withAgentContext(ctx context.Context) context.Context {
- if a == nil {
- return ctx
- }
- if a.jobs != nil {
- ctx = jobs.WithManager(ctx, a.jobs)
- } else {
- ctx = jobs.WithoutManager(ctx)
- }
- return planmode.WithActive(ctx, a.planMode.Load())
-}
-
// WithParentSession stamps the active parent session ID onto a turn context so
// persisted sub-agents can record and enforce their owning conversation.
func WithParentSession(ctx context.Context, parentSession string) context.Context {
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index 939874840a..f751633c86 100644
--- a/internal/agent/coordinator.go
+++ b/internal/agent/coordinator.go
@@ -361,9 +361,6 @@ func (c *Coordinator) Run(ctx context.Context, input string) error {
return c.executor.Run(ctx, input)
}
c.sink.Emit(event.Event{Kind: event.Phase, Text: c.planner.Name() + " · planning", Detail: routeDetail, Source: event.UsageSourcePlanner})
- // The planner researches and proposes work but does not own the root Goal
- // turn's disposition. Hide the recorder only for planning; the executor
- // still receives the original context and can report after doing the work.
plannerCtx := tool.WithoutGoalTurnRecorder(ctx)
if decision.MaxResearchRounds > 0 {
plannerCtx = withRunStepLimit(plannerCtx, decision.MaxResearchRounds, "planner research rounds")
diff --git a/internal/agent/coordinator_test.go b/internal/agent/coordinator_test.go
index 3272012780..5ca880ab60 100644
--- a/internal/agent/coordinator_test.go
+++ b/internal/agent/coordinator_test.go
@@ -85,67 +85,6 @@ func TestCoordinatorHandsPlanToExecutor(t *testing.T) {
}
}
-type coordinatorGoalRecorder struct {
- reports []tool.GoalReport
-}
-
-func (r *coordinatorGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
- r.reports = append(r.reports, report)
- return "recorded " + report.Status, nil
-}
-
-func TestCoordinatorPlannerCannotReportExecutorGoalDisposition(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
- {toolCallChunk("planner-goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
- {{Type: provider.ChunkText, Text: "1. inspect the implementation\n2. apply and verify the fix"}, {Type: provider.ChunkDone}},
- }}
- exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
- {Type: provider.ChunkText, Text: "Implemented and verified."},
- {Type: provider.ChunkDone},
- }}
- plannerSess := NewSession("planner-sys")
- executor := New(exec, reg, NewSession("exec-sys"), Options{}, event.Discard)
- customPlannerReg := tool.NewRegistry()
- customPlannerReg.Add(goalTool)
- coord := NewCoordinator(planner, plannerSess, nil, customPlannerReg, Options{}, executor, 0, event.Discard, nil)
- recorder := &coordinatorGoalRecorder{}
- ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
-
- if err := coord.Run(ctx, "fix the goal bug"); err != nil {
- t.Fatalf("Run: %v", err)
- }
- if len(planner.requests) != 2 {
- t.Fatalf("planner requests = %d, want hallucinated call plus repair", len(planner.requests))
- }
- for i, req := range planner.requests {
- for _, schema := range req.Tools {
- if schema.Name == "update_goal" {
- t.Fatalf("planner request %d exposed update_goal", i+1)
- }
- }
- }
- if got := lastToolResult(plannerSess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
- t.Fatalf("planner update_goal result = %q", got)
- }
- if len(exec.requests) == 0 {
- t.Fatal("executor made no requests")
- }
- for i, req := range exec.requests {
- if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
- t.Fatalf("executor request %d lost update_goal after planner isolation: %v", i+1, toolSchemaNames(req.Tools))
- }
- }
- if len(recorder.reports) != 0 {
- t.Fatalf("planner wrote reports into executor Goal recorder: %+v", recorder.reports)
- }
-}
-
type coordinatorApprovalGate struct {
calls int
allow bool
@@ -775,19 +714,6 @@ func (t coordinatorTestTool) Execute(context.Context, json.RawMessage) (string,
}
func (t coordinatorTestTool) ReadOnly() bool { return t.readOnly }
-type plannerPhaseOnlyTool struct{}
-
-func (plannerPhaseOnlyTool) Name() string { return "planner_phase_only" }
-func (plannerPhaseOnlyTool) Description() string { return "planner phase-only test tool" }
-func (plannerPhaseOnlyTool) Schema() json.RawMessage {
- return json.RawMessage(`{"type":"object"}`)
-}
-func (plannerPhaseOnlyTool) Execute(context.Context, json.RawMessage) (string, error) {
- return "phase-only", nil
-}
-func (plannerPhaseOnlyTool) ReadOnly() bool { return true }
-func (plannerPhaseOnlyTool) PlanModeSafe() bool { return false }
-
func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
{
@@ -808,9 +734,6 @@ func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "Rule: keep changes narrow."})
parentReg.Add(coordinatorTestTool{name: "write_file", readOnly: false})
parentReg.Add(coordinatorTestTool{name: "todo_write", readOnly: true})
- parentReg.Add(mustBuiltinTool(t, "complete_step"))
- parentReg.Add(mustBuiltinTool(t, "update_goal"))
- parentReg.Add(plannerPhaseOnlyTool{})
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession(PlannerPromptWithContext("Rule: keep changes narrow."))
@@ -827,7 +750,7 @@ func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
if !contains(tools, "read_file") {
t.Fatalf("planner tools = %v, want read_file", tools)
}
- for _, forbidden := range []string{"write_file", "todo_write", "complete_step", "update_goal", "planner_phase_only"} {
+ for _, forbidden := range []string{"write_file", "todo_write"} {
if contains(tools, forbidden) {
t.Fatalf("planner tools = %v, must not include %s", tools, forbidden)
}
diff --git a/internal/agent/delivery_hardening_test.go b/internal/agent/delivery_hardening_test.go
index 3a1ae2607b..928be1d813 100644
--- a/internal/agent/delivery_hardening_test.go
+++ b/internal/agent/delivery_hardening_test.go
@@ -12,7 +12,6 @@ import (
"reasonix/internal/capability"
"reasonix/internal/event"
"reasonix/internal/evidence"
- "reasonix/internal/jobs"
"reasonix/internal/provider"
"reasonix/internal/taskintent"
"reasonix/internal/tool"
@@ -199,158 +198,6 @@ func TestDeliveryDurableMemoryRequiresRememberWithoutCodeCeremony(t *testing.T)
}
}
-func TestNonGoalRequestDoesNotExposeUpdateGoal(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
- {{Type: provider.ChunkText, Text: "Here is the answer."}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- if err := a.Run(context.Background(), "answer normally"); err != nil {
- t.Fatalf("non-Goal answer: %v", err)
- }
- if len(prov.requests) != 1 {
- t.Fatalf("provider requests = %d, want 1", len(prov.requests))
- }
- for _, schema := range prov.requests[0].Tools {
- if schema.Name == "update_goal" {
- t.Fatal("non-Goal provider request exposed update_goal")
- }
- }
- if got := lastAssistantContent(a.Session()); got != "Here is the answer." {
- t.Fatalf("last assistant text = %q", got)
- }
-}
-
-func TestAgentWithoutJobsDoesNotExposeBackgroundTools(t *testing.T) {
- reg := tool.NewRegistry()
- for _, name := range []string{"wait", "bash_output", "kill_shell"} {
- jobTool, ok := tool.LookupBuiltin(name)
- if !ok {
- t.Fatalf("%s builtin not registered", name)
- }
- reg.Add(jobTool)
- }
- manager := jobs.NewManager(event.Discard)
- defer manager.Close()
- ctx := jobs.WithManager(context.Background(), manager)
- prov := &scriptedProvider{name: "no-jobs", turns: [][]provider.Chunk{
- {{Type: provider.ChunkText, Text: "No background work."}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- if err := a.Run(ctx, "answer normally"); err != nil {
- t.Fatalf("no-Jobs answer: %v", err)
- }
- if len(prov.requests) != 1 {
- t.Fatalf("provider requests = %d, want 1", len(prov.requests))
- }
- if len(prov.requests[0].Tools) != 0 {
- t.Fatalf("no-Jobs provider tools = %v, want background tools hidden", prov.requests[0].Tools)
- }
-
- withJobsProv := &scriptedProvider{name: "with-jobs", turns: [][]provider.Chunk{
- {{Type: provider.ChunkText, Text: "Background tools available."}, {Type: provider.ChunkDone}},
- }}
- withJobs := New(withJobsProv, reg, NewSession("sys"), Options{Jobs: manager}, event.Discard)
- if err := withJobs.Run(context.Background(), "answer normally"); err != nil {
- t.Fatalf("with-Jobs answer: %v", err)
- }
- visible := make(map[string]bool)
- for _, schema := range withJobsProv.requests[0].Tools {
- visible[schema.Name] = true
- }
- for _, name := range []string{"wait", "bash_output", "kill_shell"} {
- if !visible[name] {
- t.Fatalf("with-Jobs provider tools = %v, missing %s", visible, name)
- }
- }
-}
-
-type requestGoalRecorder struct{}
-
-func (requestGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
- return "recorded " + report.Status, nil
-}
-
-type childIsolationGoalRecorder struct {
- reports []tool.GoalReport
-}
-
-func (r *childIsolationGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
- r.reports = append(r.reports, report)
- return "recorded " + report.Status, nil
-}
-
-func TestGoalRequestExposesUpdateGoal(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
- {{Type: provider.ChunkText, Text: "Goal work continues."}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
- if err := a.Run(ctx, "continue goal"); err != nil {
- t.Fatalf("Goal answer: %v", err)
- }
- if len(prov.requests) != 1 {
- t.Fatalf("provider requests = %d, want 1", len(prov.requests))
- }
- for _, schema := range prov.requests[0].Tools {
- if schema.Name == "update_goal" {
- return
- }
- }
- t.Fatal("Goal provider request did not expose update_goal")
-}
-
-func TestSubAgentDoesNotInheritParentGoalRecorder(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- prov := &scriptedProvider{name: "goal-child", turns: [][]provider.Chunk{
- {toolCallChunk("goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
- {{Type: provider.ChunkText, Text: "Child result."}, {Type: provider.ChunkDone}},
- }}
- recorder := &childIsolationGoalRecorder{}
- ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
- sess := NewSession("child system")
-
- answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the task", Options{}, event.Discard)
- if err != nil {
- t.Fatalf("Goal child: %v", err)
- }
- if answer != "Child result." {
- t.Fatalf("Goal child answer = %q", answer)
- }
- if len(prov.requests) != 2 {
- t.Fatalf("provider requests = %d, want hallucinated call plus repair", len(prov.requests))
- }
- for i, req := range prov.requests {
- for _, schema := range req.Tools {
- if schema.Name == "update_goal" {
- t.Fatalf("child provider request %d exposed update_goal", i+1)
- }
- }
- }
- if len(recorder.reports) != 0 {
- t.Fatalf("child wrote reports into parent Goal recorder: %+v", recorder.reports)
- }
- if got := lastToolResult(sess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
- t.Fatalf("child update_goal result = %q", got)
- }
-}
-
func TestNonGoalHallucinatedUpdateGoalWithVisibleTextDoesNotSpendRepairRound(t *testing.T) {
goalTool, ok := tool.LookupBuiltin("update_goal")
if !ok {
@@ -377,32 +224,6 @@ func TestNonGoalHallucinatedUpdateGoalWithVisibleTextDoesNotSpendRepairRound(t *
}
}
-func TestNonGoalToolOnlyUpdateGoalNudgesVisibleAnswer(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
- {toolCallChunk("goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
- {{Type: provider.ChunkText, Text: "Here is the recovered answer."}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- if err := a.Run(context.Background(), "answer normally"); err != nil {
- t.Fatalf("non-Goal update_goal repair: %v", err)
- }
- if len(prov.requests) != 2 {
- t.Fatalf("provider requests = %d, want repair round", len(prov.requests))
- }
- if got := lastUser(prov.requests[1]); !strings.Contains(got, "visible answer text") {
- t.Fatalf("repair instruction = %q, want visible-answer nudge", got)
- }
- if got := lastAssistantContent(a.Session()); got != "Here is the recovered answer." {
- t.Fatalf("last assistant text = %q", got)
- }
-}
-
func TestNonGoalToolOnlyUpdateGoalGetsAtMostOneRepairRound(t *testing.T) {
goalTool, ok := tool.LookupBuiltin("update_goal")
if !ok {
@@ -417,7 +238,7 @@ func TestNonGoalToolOnlyUpdateGoalGetsAtMostOneRepairRound(t *testing.T) {
}}
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
err := a.Run(context.Background(), "answer normally")
- if err == nil || !strings.Contains(err.Error(), "repeatedly called context-unavailable tools") {
+ if err == nil || !strings.Contains(err.Error(), "repeatedly called update_goal outside Goal mode") {
t.Fatalf("repeated tool-only misuse error = %v", err)
}
if prov.call != 2 {
diff --git a/internal/agent/extensions_schema_test.go b/internal/agent/extensions_schema_test.go
new file mode 100644
index 0000000000..0833b7ae60
--- /dev/null
+++ b/internal/agent/extensions_schema_test.go
@@ -0,0 +1,48 @@
+package agent
+
+import (
+ "context"
+ "testing"
+
+ "reasonix/internal/event"
+ "reasonix/internal/extension"
+ "reasonix/internal/extension/dispatch"
+ "reasonix/internal/extension/protocol"
+ "reasonix/internal/provider"
+ "reasonix/internal/tool"
+)
+
+func TestAgentBeforeStartToolCountUsesStableSchemas(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+
+ run := func(ctx context.Context) dispatch.AgentStartPayload {
+ t.Helper()
+ client := &fakeDispatchClient{}
+ d := newExtDispatcher(client, true, nil, extension.PointAgentBeforeStart)
+ mp := &mockProvider{name: "p", chunks: []provider.Chunk{
+ {Type: provider.ChunkText, Text: "hi"}, {Type: provider.ChunkDone},
+ }}
+ a := New(mp, reg, NewSession("sys"), Options{Extensions: d}, event.Discard)
+ if err := a.Run(ctx, "hello"); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ var payload dispatch.AgentStartPayload
+ if !client.interceptPayloadFor(protocol.EventAgentBeforeStart, &payload) {
+ t.Fatal("agent.before_start did not fire")
+ }
+ return payload
+ }
+
+ if got := run(context.Background()).ToolCount; got != 1 {
+ t.Fatalf("ordinary ToolCount = %d, want stable update_goal schema", got)
+ }
+ ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ if got := run(ctx).ToolCount; got != 1 {
+ t.Fatalf("Goal ToolCount = %d, want stable update_goal schema", got)
+ }
+}
diff --git a/internal/agent/goal_schema_isolation_test.go b/internal/agent/goal_schema_isolation_test.go
new file mode 100644
index 0000000000..9a245e5ab1
--- /dev/null
+++ b/internal/agent/goal_schema_isolation_test.go
@@ -0,0 +1,254 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "slices"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "reasonix/internal/event"
+ "reasonix/internal/provider"
+ "reasonix/internal/tool"
+)
+
+type requestGoalRecorder struct{}
+
+func (requestGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
+ return "recorded " + report.Status, nil
+}
+
+type childIsolationGoalRecorder struct {
+ reports []tool.GoalReport
+}
+
+func (r *childIsolationGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
+ r.reports = append(r.reports, report)
+ return "recorded " + report.Status, nil
+}
+
+func TestGoalContextKeepsProviderSchemasStable(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ ordinary := &scriptedProvider{name: "ordinary", turns: [][]provider.Chunk{
+ {{Type: provider.ChunkText, Text: "ordinary"}, {Type: provider.ChunkDone}},
+ }}
+ ordinaryAgent := New(ordinary, reg, NewSession("sys"), Options{}, event.Discard)
+ if err := ordinaryAgent.Run(context.Background(), "answer normally"); err != nil {
+ t.Fatalf("ordinary Run: %v", err)
+ }
+ goal := &scriptedProvider{name: "goal", turns: [][]provider.Chunk{
+ {{Type: provider.ChunkText, Text: "goal"}, {Type: provider.ChunkDone}},
+ }}
+ goalAgent := New(goal, reg, NewSession("sys"), Options{}, event.Discard)
+ ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ if err := goalAgent.Run(ctx, "continue goal"); err != nil {
+ t.Fatalf("Goal Run: %v", err)
+ }
+ ordinarySchemas, err := json.Marshal(ordinary.requests[0].Tools)
+ if err != nil {
+ t.Fatal(err)
+ }
+ goalSchemas, err := json.Marshal(goal.requests[0].Tools)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(ordinarySchemas) != string(goalSchemas) {
+ t.Fatalf("Goal context changed provider schemas:\nordinary=%s\ngoal=%s", ordinarySchemas, goalSchemas)
+ }
+ if !slices.Contains(toolSchemaNames(ordinary.requests[0].Tools), "update_goal") || !slices.Contains(toolSchemaNames(goal.requests[0].Tools), "update_goal") {
+ t.Fatalf("stable requests lost update_goal: ordinary=%s goal=%s", ordinarySchemas, goalSchemas)
+ }
+}
+
+func TestGoalRequestExposesUpdateGoal(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
+ {{Type: provider.ChunkText, Text: "Goal work continues."}, {Type: provider.ChunkDone}},
+ }}
+ a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
+ ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ if err := a.Run(ctx, "continue goal"); err != nil {
+ t.Fatalf("Goal answer: %v", err)
+ }
+ if len(prov.requests) != 1 {
+ t.Fatalf("provider requests = %d, want 1", len(prov.requests))
+ }
+ if !slices.Contains(toolSchemaNames(prov.requests[0].Tools), "update_goal") {
+ t.Fatal("Goal provider request did not expose update_goal")
+ }
+}
+
+func TestMixedOutOfContextGoalBatchExecutesValidToolsWithStableSchemas(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ var validCalls int32
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &validCalls})
+ prov := &scriptedProvider{name: "mixed", turns: [][]provider.Chunk{
+ {
+ toolCallChunk("goal", "update_goal", `{"status":"complete"}`),
+ toolCallChunk("read", "read_file", `{}`),
+ {Type: provider.ChunkDone},
+ },
+ {{Type: provider.ChunkText, Text: "Visible answer after collecting the valid result."}, {Type: provider.ChunkDone}},
+ }}
+ sess := NewSession("sys")
+ a := New(prov, reg, sess, Options{}, event.Discard)
+
+ if err := a.Run(context.Background(), "inspect and answer"); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if got := atomic.LoadInt32(&validCalls); got != 1 {
+ t.Fatalf("valid tool calls = %d, want 1", got)
+ }
+ if len(prov.requests) != 2 {
+ t.Fatalf("provider requests = %d, want one repair", len(prov.requests))
+ }
+ if got := lastUser(prov.requests[1]); got != "inspect and answer" {
+ t.Fatalf("stable request unexpectedly added a schema repair instruction = %q", got)
+ }
+ if !slices.Contains(toolSchemaNames(prov.requests[1].Tools), "update_goal") || !slices.Contains(toolSchemaNames(prov.requests[1].Tools), "read_file") {
+ t.Fatalf("stable schemas = %v", toolSchemaNames(prov.requests[1].Tools))
+ }
+ if got := toolResultByID(sess, "goal"); !strings.Contains(got, "only available while an active goal turn") {
+ t.Fatalf("unavailable result = %q", got)
+ }
+ if got := toolResultByID(sess, "read"); got != "read_file done" {
+ t.Fatalf("valid result = %q", got)
+ }
+}
+
+func TestSubAgentDoesNotInheritParentGoalRecorder(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ prov := &scriptedProvider{name: "goal-child", turns: [][]provider.Chunk{
+ {toolCallChunk("goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "Child result."}, {Type: provider.ChunkDone}},
+ }}
+ recorder := &childIsolationGoalRecorder{}
+ ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
+ sess := NewSession("child system")
+
+ answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the task", Options{}, event.Discard)
+ if err != nil {
+ t.Fatalf("Goal child: %v", err)
+ }
+ if answer != "Child result." {
+ t.Fatalf("Goal child answer = %q", answer)
+ }
+ if len(prov.requests) != 2 {
+ t.Fatalf("provider requests = %d, want hallucinated call plus repair", len(prov.requests))
+ }
+ for i, req := range prov.requests {
+ if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
+ t.Fatalf("child provider request %d lost stable update_goal schema: %v", i+1, toolSchemaNames(req.Tools))
+ }
+ }
+ if len(recorder.reports) != 0 {
+ t.Fatalf("child wrote reports into parent Goal recorder: %+v", recorder.reports)
+ }
+ if got := lastToolResult(sess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
+ t.Fatalf("child update_goal result = %q", got)
+ }
+}
+
+type coordinatorGoalRecorder struct {
+ reports []tool.GoalReport
+}
+
+func (r *coordinatorGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
+ r.reports = append(r.reports, report)
+ return "recorded " + report.Status, nil
+}
+
+func TestCoordinatorPlannerCannotReportExecutorGoalDisposition(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
+ {toolCallChunk("planner-goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "1. inspect the implementation\n2. apply and verify the fix"}, {Type: provider.ChunkDone}},
+ }}
+ exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
+ {Type: provider.ChunkText, Text: "Implemented and verified."},
+ {Type: provider.ChunkDone},
+ }}
+ plannerSess := NewSession("planner-sys")
+ executor := New(exec, reg, NewSession("exec-sys"), Options{}, event.Discard)
+ customPlannerReg := tool.NewRegistry()
+ customPlannerReg.Add(goalTool)
+ coord := NewCoordinator(planner, plannerSess, nil, customPlannerReg, Options{}, executor, 0, event.Discard, nil)
+ recorder := &coordinatorGoalRecorder{}
+ ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
+
+ if err := coord.Run(ctx, "fix the goal bug"); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if len(planner.requests) != 2 {
+ t.Fatalf("planner requests = %d, want hallucinated call plus repair", len(planner.requests))
+ }
+ for i, req := range planner.requests {
+ if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
+ t.Fatalf("planner request %d lost stable update_goal schema: %v", i+1, toolSchemaNames(req.Tools))
+ }
+ }
+ if got := lastToolResult(plannerSess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
+ t.Fatalf("planner update_goal result = %q", got)
+ }
+ if len(exec.requests) == 0 {
+ t.Fatal("executor made no requests")
+ }
+ for i, req := range exec.requests {
+ if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
+ t.Fatalf("executor request %d lost update_goal after planner isolation: %v", i+1, toolSchemaNames(req.Tools))
+ }
+ }
+ if len(recorder.reports) != 0 {
+ t.Fatalf("planner wrote reports into executor Goal recorder: %+v", recorder.reports)
+ }
+}
+
+func TestSubagentIdentityUsesEffectiveChildToolSchemas(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ reg.Add(fakeTool{name: "read_file", readOnly: true})
+ store := NewSubagentStore(t.TempDir())
+ task := &TaskTool{transcripts: store, sysPrompt: "child system", workspaceRoot: t.TempDir()}
+ run, err := task.prepareTranscriptRunWithPrompt(reg, "model", "medium", "parent-session", "call-1", "", "", "child system", "task", "inspect")
+ if err != nil {
+ t.Fatalf("prepareTranscriptRunWithPrompt: %v", err)
+ }
+ defer run.Release()
+ if !slices.Contains(run.Meta.ToolScope, "update_goal") || !slices.Contains(run.Meta.ToolScope, "read_file") {
+ t.Fatalf("subagent tool scope = %v, want stable registry schemas", run.Meta.ToolScope)
+ }
+ _, wantHash := toolIdentity(reg)
+ if run.Meta.ToolSchemaHash != wantHash {
+ t.Fatalf("subagent schema hash = %q, want %q", run.Meta.ToolSchemaHash, wantHash)
+ }
+}
diff --git a/internal/agent/planmode_test.go b/internal/agent/planmode_test.go
index b135c03cb7..4d4524a5d9 100644
--- a/internal/agent/planmode_test.go
+++ b/internal/agent/planmode_test.go
@@ -3,7 +3,6 @@ package agent
import (
"context"
"encoding/json"
- "slices"
"strings"
"testing"
@@ -268,10 +267,9 @@ func TestPlanModeCanReplacePriorExecutionTodoState(t *testing.T) {
}
}
-// TestPlanModePreservesSystemAndOrdinaryTools is the cache-stability test for
-// non-contextual tools. Phase-only tools are the intentional exception and are
-// covered by TestPlanModeRequestHidesCompleteStepUntilExecution.
-func TestPlanModePreservesSystemAndOrdinaryTools(t *testing.T) {
+// TestPlanModeDoesNotMutateSystemOrTools guards the provider-visible cache
+// prefix. Plan-only execution policy must not change system or tool bytes.
+func TestPlanModeDoesNotMutateSystemOrTools(t *testing.T) {
prov := &mockProvider{name: "p", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "ok"},
{Type: provider.ChunkDone},
@@ -303,107 +301,6 @@ func TestPlanModePreservesSystemAndOrdinaryTools(t *testing.T) {
}
}
-func TestPlanModeRequestHidesCompleteStepUntilExecution(t *testing.T) {
- prov := &mockProvider{name: "p", chunks: []provider.Chunk{
- {Type: provider.ChunkText, Text: "ok"},
- {Type: provider.ChunkDone},
- }}
- reg := tool.NewRegistry()
- reg.Add(fakeTool{name: "read_file", readOnly: true})
- reg.Add(mustBuiltinTool(t, "complete_step"))
- a := New(prov, reg, NewSession("STABLE-SYS"), Options{}, event.Discard)
-
- if err := a.Run(context.Background(), "execution"); err != nil {
- t.Fatalf("execution Run: %v", err)
- }
- if !slices.Contains(toolSchemaNames(prov.lastReq.Tools), "complete_step") {
- t.Fatalf("execution request missing complete_step: %v", toolSchemaNames(prov.lastReq.Tools))
- }
-
- prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "plan"}, {Type: provider.ChunkDone}}
- a.SetPlanMode(true)
- if err := a.Run(context.Background(), "plan first"); err != nil {
- t.Fatalf("Plan Run: %v", err)
- }
- planTools := toolSchemaNames(prov.lastReq.Tools)
- if slices.Contains(planTools, "complete_step") {
- t.Fatalf("Plan request exposed complete_step: %v", planTools)
- }
- if !slices.Contains(planTools, "read_file") {
- t.Fatalf("Plan request lost ordinary tool: %v", planTools)
- }
- stablePlanTools := serializeToolSchemas(t, prov.lastReq.Tools)
- prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "plan again"}, {Type: provider.ChunkDone}}
- if err := a.Run(context.Background(), "refine plan"); err != nil {
- t.Fatalf("second Plan Run: %v", err)
- }
- if got := serializeToolSchemas(t, prov.lastReq.Tools); got != stablePlanTools {
- t.Fatalf("Plan tool schemas changed within the same mode:\nfirst=%s\nsecond=%s", stablePlanTools, got)
- }
-
- prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "execute"}, {Type: provider.ChunkDone}}
- a.SetPlanMode(false)
- if err := a.Run(context.Background(), "execute approved plan"); err != nil {
- t.Fatalf("post-approval Run: %v", err)
- }
- if !slices.Contains(toolSchemaNames(prov.lastReq.Tools), "complete_step") {
- t.Fatalf("post-approval request missing complete_step: %v", toolSchemaNames(prov.lastReq.Tools))
- }
-}
-
-func TestPlanModeHallucinatedCompleteStepPreservesVisibleAnswer(t *testing.T) {
- reg := tool.NewRegistry()
- reg.Add(mustBuiltinTool(t, "complete_step"))
- prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
- {
- {Type: provider.ChunkText, Text: "Here is the plan."},
- toolCallChunk("step", "complete_step", `{}`),
- {Type: provider.ChunkDone},
- },
- {{Type: provider.ChunkText, Text: "unexpected repair"}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- a.SetPlanMode(true)
- if err := a.Run(context.Background(), "plan the change"); err != nil {
- t.Fatalf("Plan Run: %v", err)
- }
- if prov.call != 1 {
- t.Fatalf("provider calls = %d, want no repair round", prov.call)
- }
- if got := lastAssistantContent(a.Session()); got != "Here is the plan." {
- t.Fatalf("last assistant text = %q", got)
- }
- if got := lastToolResult(a.Session(), "complete_step"); !strings.Contains(got, "only available after plan approval") {
- t.Fatalf("complete_step result = %q", got)
- }
-}
-
-func TestPlanModeToolOnlyCompleteStepNudgesVisibleAnswer(t *testing.T) {
- reg := tool.NewRegistry()
- reg.Add(mustBuiltinTool(t, "complete_step"))
- prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
- {toolCallChunk("step", "complete_step", `{}`), {Type: provider.ChunkDone}},
- {{Type: provider.ChunkText, Text: "Here is the recovered plan."}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- a.SetPlanMode(true)
- if err := a.Run(context.Background(), "plan the change"); err != nil {
- t.Fatalf("Plan repair: %v", err)
- }
- if len(prov.requests) != 2 {
- t.Fatalf("provider requests = %d, want repair round", len(prov.requests))
- }
- if got := lastUser(prov.requests[1]); !strings.Contains(got, "complete_step") || !strings.Contains(got, "visible answer text") {
- t.Fatalf("repair instruction = %q", got)
- }
- if slices.Contains(toolSchemaNames(prov.requests[1].Tools), "complete_step") {
- t.Fatalf("repair request re-exposed complete_step: %v", toolSchemaNames(prov.requests[1].Tools))
- }
- if got := lastAssistantContent(a.Session()); got != "Here is the recovered plan." {
- t.Fatalf("last assistant text = %q", got)
- }
-}
-
func serializeToolSchemas(t *testing.T, schemas []provider.ToolSchema) string {
t.Helper()
b, err := json.Marshal(schemas)
diff --git a/internal/agent/run_loop.go b/internal/agent/run_loop.go
index 5b143f57a8..a33dcf499a 100644
--- a/internal/agent/run_loop.go
+++ b/internal/agent/run_loop.go
@@ -27,7 +27,7 @@ type runLoopState struct {
emptyFinalBlocks int
handoffNudges int
usedAnyTool bool
- contextToolRepairs int
+ goalToolRepairs int
graceRound bool
recoveryGraceRound bool
@@ -327,7 +327,6 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
// runToolLoop owns the main tool-round budget and dispatches each streamed
// 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++ {
// Consume a queued steer and persist it to the session so it
// survives tab switches and history replay. The model sees it as
@@ -337,7 +336,7 @@ func (a *Agent) runToolLoop(ctx context.Context, state *runLoopState) error {
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(midTurnSteerMessage(text))})
a.sink.Emit(event.Event{Kind: event.Steer, Text: text})
}
- schemas := a.tools.SchemasForContext(ctx)
+ schemas := a.tools.Schemas()
prefixShape := a.capturePrefixShape(schemas)
prevPrefixShape := a.lastPrefixShape
if !a.haveLastPrefixShape {
@@ -956,7 +955,7 @@ func (a *Agent) handleFinalResponse(ctx context.Context, state *runLoopState, te
func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step int, text, reasoning string, calls []provider.ToolCall, usage *provider.Usage) (cont bool, err error) {
state.emptyFinalBlocks = 0
state.usedAnyTool = true
- unavailableContextTools, contextualOnly := a.unavailableContextualToolCalls(ctx, calls)
+ outOfContextGoalOnly := toolCallsAreOutOfContextGoalReports(ctx, calls)
// Grace round guard: if we already gave the model one extra response
// and it still wants to call tools, stop here.
@@ -987,7 +986,6 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
StopReason: reason,
}
}
-
receiptMark := 0
if a.evidence != nil {
receiptMark = a.evidence.Len()
@@ -1013,19 +1011,16 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
a.recordInterruptedDisplay("", "", nil, true, state.workDurationMs())
return false, ctx.Err()
}
- if contextualOnly {
+ if outOfContextGoalOnly {
if hasVisibleFinalAnswer(text) {
- // Keep the assistant tool call and host error paired in the transcript,
- // but accept the co-streamed answer instead of spending another request
- // repairing a phase-only bookkeeping call.
+ // Keep the assistant tool call and host error paired instead of spending
+ // another model request repairing harmless Goal bookkeeping outside Goal mode.
return a.handleFinalResponse(ctx, state, text, reasoning, usage)
}
- state.contextToolRepairs++
- if state.contextToolRepairs > 1 {
- return false, fmt.Errorf("model repeatedly called context-unavailable tools without a visible answer: %s", strings.Join(unavailableContextTools, ", "))
+ state.goalToolRepairs++
+ if state.goalToolRepairs > 1 {
+ return false, fmt.Errorf("model repeatedly called update_goal outside Goal mode without a visible answer")
}
- 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()
@@ -1098,21 +1093,17 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
return true, nil
}
-func (a *Agent) unavailableContextualToolCalls(ctx context.Context, calls []provider.ToolCall) ([]string, bool) {
+func toolCallsAreOutOfContextGoalReports(ctx context.Context, calls []provider.ToolCall) bool {
if len(calls) == 0 {
- return nil, false
+ return false
+ }
+ if _, ok := tool.GoalTurnRecorderFromContext(ctx); ok {
+ return false
}
- names := make([]string, 0, len(calls))
for _, call := range calls {
- t, ok := a.tools.Get(call.Name)
- if !ok {
- return nil, false
- }
- contextual, ok := t.(tool.ContextualTool)
- if !ok || contextual.ProviderVisible(ctx) {
- return nil, false
+ if call.Name != "update_goal" {
+ return false
}
- names = append(names, call.Name)
}
- return names, true
+ return true
}
diff --git a/internal/agent/sampling_request.go b/internal/agent/sampling_request.go
index 665cb1bd43..0f94150767 100644
--- a/internal/agent/sampling_request.go
+++ b/internal/agent/sampling_request.go
@@ -16,7 +16,6 @@ type samplingRequest struct {
// prepareSamplingRequest freezes one model-round request (preflight + interceptors).
func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, error) {
- ctx = a.withAgentContext(ctx)
// CreatedAt is durable UI metadata, not model input. Strip it from the
// transport copy so wall-clock differences never invalidate the provider's
// prompt-cache prefix (and custom providers cannot accidentally send it).
@@ -36,7 +35,7 @@ func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, er
}
req := provider.Request{
Messages: requestMessages,
- Tools: a.tools.SchemasForContext(ctx),
+ Tools: a.tools.Schemas(),
MaxTokens: a.maxOutputTokens,
Temperature: provider.OptionalTemperature(a.temperature),
ResponseFormat: responseFormatFromRequest(ctx),
diff --git a/internal/agent/subagent_context.go b/internal/agent/subagent_context.go
new file mode 100644
index 0000000000..9c111968e9
--- /dev/null
+++ b/internal/agent/subagent_context.go
@@ -0,0 +1,15 @@
+package agent
+
+import (
+ "context"
+
+ "reasonix/internal/jobs"
+ "reasonix/internal/memory"
+ "reasonix/internal/tool"
+)
+
+func subagentProviderContext(ctx context.Context) context.Context {
+ ctx = tool.WithoutGoalTurnRecorder(ctx)
+ ctx = jobs.WithoutManager(ctx)
+ return memory.WithoutQueue(ctx)
+}
diff --git a/internal/agent/subagent_context_isolation_test.go b/internal/agent/subagent_context_isolation_test.go
new file mode 100644
index 0000000000..2c93837a2c
--- /dev/null
+++ b/internal/agent/subagent_context_isolation_test.go
@@ -0,0 +1,74 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "slices"
+ "testing"
+
+ "reasonix/internal/event"
+ "reasonix/internal/jobs"
+ "reasonix/internal/memory"
+ "reasonix/internal/provider"
+ "reasonix/internal/tool"
+)
+
+type recordingMemoryQueue struct {
+ notes []string
+}
+
+func (q *recordingMemoryQueue) QueueMemory(note string) {
+ q.notes = append(q.notes, note)
+}
+
+type memoryQueueProbeTool struct{}
+
+func (memoryQueueProbeTool) Name() string { return "memory_queue_probe" }
+func (memoryQueueProbeTool) Description() string { return "probe child memory context" }
+func (memoryQueueProbeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
+func (memoryQueueProbeTool) ReadOnly() bool { return true }
+func (memoryQueueProbeTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
+ if q, ok := memory.QueueFromContext(ctx); ok {
+ q.QueueMemory("child injected into parent")
+ return "queue present", nil
+ }
+ return "queue absent", nil
+}
+
+func TestSubAgentMasksParentJobsAndMemoryContexts(t *testing.T) {
+ reg := tool.NewRegistry()
+ reg.Add(memoryQueueProbeTool{})
+ waitTool, ok := tool.LookupBuiltin("wait")
+ if !ok {
+ t.Fatal("wait builtin not registered")
+ }
+ reg.Add(waitTool)
+ prov := &scriptedProvider{name: "child-context", turns: [][]provider.Chunk{
+ {toolCallChunk("probe", "memory_queue_probe", `{}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "Child result."}, {Type: provider.ChunkDone}},
+ }}
+ parentQueue := &recordingMemoryQueue{}
+ manager := jobs.NewManager(event.Discard)
+ defer manager.Close()
+ ctx := memory.WithQueue(jobs.WithManager(context.Background(), manager), parentQueue)
+ sess := NewSession("child system")
+
+ answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the task", Options{}, event.Discard)
+ if err != nil {
+ t.Fatalf("RunSubAgentWithSession: %v", err)
+ }
+ if answer != "Child result." {
+ t.Fatalf("answer = %q", answer)
+ }
+ if len(parentQueue.notes) != 0 {
+ t.Fatalf("child injected memory notes into parent queue: %v", parentQueue.notes)
+ }
+ if got := toolResultByID(sess, "probe"); got != "queue absent" {
+ t.Fatalf("memory queue probe result = %q", got)
+ }
+ for i, req := range prov.requests {
+ if !slices.Contains(toolSchemaNames(req.Tools), "wait") {
+ t.Fatalf("child request %d lost stable wait schema: %v", i+1, toolSchemaNames(req.Tools))
+ }
+ }
+}
diff --git a/internal/agent/subagent_readonly.go b/internal/agent/subagent_readonly.go
new file mode 100644
index 0000000000..08f1c11396
--- /dev/null
+++ b/internal/agent/subagent_readonly.go
@@ -0,0 +1,12 @@
+package agent
+
+import "reasonix/internal/tool"
+
+// readOnlyAgentConstruction is the single pairing every strictly read-only
+// loop shares: the permanent ReadOnlyExecution flag plus the final registry
+// filter. Batch children and legacy read-only call sites use this boundary.
+func readOnlyAgentConstruction(reg *tool.Registry, opts Options) (*tool.Registry, Options) {
+ opts.ReadOnlyExecution = true
+ opts.PlannerMCPExecution = false
+ return strictReadOnlyExecutionRegistry(reg), opts
+}
diff --git a/internal/agent/task.go b/internal/agent/task.go
index 5a6068971c..311c2f8bde 100644
--- a/internal/agent/task.go
+++ b/internal/agent/task.go
@@ -1529,12 +1529,6 @@ func PlannerToolRegistry(parent *tool.Registry) *tool.Registry {
continue
}
if tl, ok := base.Get(name); ok {
- if classifier, ok := tl.(tool.PlanModeClassifier); ok && !classifier.PlanModeSafe() {
- // The two-model planner is a planning-phase agent even when
- // the controller's explicit Plan mode flag is off. Do not let
- // read-only execution sign-offs leak into its provider schema.
- continue
- }
sub.Add(tl)
}
}
@@ -1879,11 +1873,8 @@ func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *to
if sess == nil {
return "", fmt.Errorf("sub-agent session is nil")
}
- // A child may run inside a parent Goal turn, but only the root working
- // model owns that turn's disposition. Keep cancellation and other parent
- // context while preventing the child from seeing or writing its recorder.
- ctx = tool.WithoutGoalTurnRecorder(ctx)
// Isolate temporary files for this run before any tool execution.
+ ctx = subagentProviderContext(ctx)
ctx, releaseTemp := withSubagentSessionTemp(ctx)
defer releaseTemp()
if opts.SubagentDepth > 0 {
@@ -1947,18 +1938,6 @@ func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *to
return "", fmt.Errorf("sub-agent finished without producing a final answer")
}
-// readOnlyAgentConstruction is the single pairing every strictly read-only
-// loop shares: the permanent ReadOnlyExecution flag plus the final registry
-// filter. Batch children (RunReadOnlySubAgentWithSession) and legacy call sites
-// that still use NewReadOnlyAgent build through it, so a missed call site
-// cannot set only half the boundary. The interactive two-model planner uses
-// NewPlannerAgent instead (PlannerMCPExecution).
-func readOnlyAgentConstruction(reg *tool.Registry, opts Options) (*tool.Registry, Options) {
- opts.ReadOnlyExecution = true
- opts.PlannerMCPExecution = false
- return strictReadOnlyExecutionRegistry(reg), opts
-}
-
// NewReadOnlyAgent constructs a long-lived, strictly read-only agent through
// the shared construction boundary. Prefer NewPlannerAgent for the two-model
// planner so authorized non-destructive MCP can run via use_capability.
diff --git a/internal/autoresearch/fixture_test.go b/internal/autoresearch/fixture_test.go
index 9c65e1134e..e95b941182 100644
--- a/internal/autoresearch/fixture_test.go
+++ b/internal/autoresearch/fixture_test.go
@@ -93,11 +93,6 @@ func writeDirections(t *testing.T, taskRoot string, directions []DirectionTried)
writeJSON(t, filepath.Join(taskRoot, "state", "directions_tried.json"), directions)
}
-func writeTaskSpec(t *testing.T, taskRoot string, spec TaskSpec) {
- t.Helper()
- writeJSON(t, filepath.Join(taskRoot, "state", "task_spec.json"), spec)
-}
-
func appendHeartbeatLine(t *testing.T, taskRoot string, h Heartbeat) {
t.Helper()
data, err := json.Marshal(h)
@@ -141,3 +136,23 @@ func hashTree(t *testing.T, root string) map[string]string {
}
return out
}
+
+func modTimes(t *testing.T, root string) map[string]time.Time {
+ t.Helper()
+ out := map[string]time.Time{}
+ err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ return err
+ }
+ out[rel] = info.ModTime()
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("stat tree: %v", err)
+ }
+ return out
+}
diff --git a/internal/autoresearch/store.go b/internal/autoresearch/store.go
index 5b76d32be0..d1c3505b70 100644
--- a/internal/autoresearch/store.go
+++ b/internal/autoresearch/store.go
@@ -8,17 +8,20 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
+ "unicode"
fileencoding "reasonix/internal/fileutil/encoding"
)
var safeTaskID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
-var explicitTaskPath = regexp.MustCompile(`\.reasonix/autoresearch/([A-Za-z0-9][A-Za-z0-9._-]*)/?`)
+
+const explicitTaskPathPrefix = ".reasonix/autoresearch/"
// Store is a fail-closed reader over a workspace's legacy AutoResearch root.
type Store struct {
@@ -42,13 +45,26 @@ func (s *Store) Root() string {
}
func (s *Store) ListSummaries() ([]Summary, error) {
- entries, err := os.ReadDir(s.root)
+ storeRoot, err := s.openArchiveRoot()
if err != nil {
if os.IsNotExist(err) {
return []Summary{}, nil
}
return nil, fmt.Errorf("autoresearch: list tasks: %w", err)
}
+ defer storeRoot.Close()
+ dir, err := storeRoot.Open(".")
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: open task list: %w", err)
+ }
+ entries, err := dir.ReadDir(-1)
+ closeErr := dir.Close()
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: read task list: %w", err)
+ }
+ if closeErr != nil {
+ return nil, fmt.Errorf("autoresearch: close task list: %w", closeErr)
+ }
ids := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
@@ -78,22 +94,9 @@ func (s *Store) LoadTask(taskID string) (*Task, error) {
return nil, err
}
defer storeRoot.Close()
- info, err := storeRoot.Lstat(taskRel)
- if err != nil {
- if os.IsNotExist(err) {
- return nil, fmt.Errorf("autoresearch: task %s not found", taskID)
- }
- return nil, fmt.Errorf("autoresearch: stat task %s: %w", taskID, err)
- }
- if info.Mode()&os.ModeSymlink != 0 {
- return nil, fmt.Errorf("autoresearch: task %s is a symlink", taskID)
- }
- if !info.IsDir() {
- return nil, fmt.Errorf("autoresearch: task %s is not a directory", taskID)
- }
- var spec TaskSpec
- if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil {
- return nil, err
+ spec, report := validateTaskRoot(storeRoot, taskRel, taskID)
+ if !report.Valid {
+ return nil, fmt.Errorf("autoresearch: task %s is invalid: %v", taskID, report.Errors)
}
return &Task{ID: taskID, Root: s.taskRoot(taskID), Spec: spec}, nil
}
@@ -102,30 +105,39 @@ func (s *Store) LoadTask(taskID string) (*Task, error) {
// `.reasonix/autoresearch//` path. ok is true when a path was found;
// err is non-nil when that path is missing, corrupt, a symlink, or invalid.
func (s *Store) ResumeFromGoalText(goal string) (*Task, bool, error) {
- match := explicitTaskPath.FindStringSubmatch(goal)
- if len(match) < 2 {
- return nil, false, nil
+ taskID, found, err := ExplicitTaskID(goal)
+ if !found || err != nil {
+ return nil, found, err
}
- task, err := s.LoadTask(match[1])
+ task, err := s.LoadTask(taskID)
if err != nil {
return nil, true, err
}
- if report, err := s.ValidateTask(task.ID); err != nil {
- return nil, true, err
- } else if !report.Valid {
- return nil, true, fmt.Errorf("autoresearch: task %s is invalid: %v", task.ID, report.Errors)
- }
return task, true, nil
}
-// ExplicitTaskID extracts a legacy archive id from free-form goal text without
-// loading the archive.
-func ExplicitTaskID(goal string) (string, bool) {
- match := explicitTaskPath.FindStringSubmatch(goal)
- if len(match) < 2 {
- return "", false
+// ExplicitTaskID extracts one complete legacy archive path token from goal
+// text. Once the prefix is present, malformed IDs and additional path
+// components are errors rather than ordinary goal text.
+func ExplicitTaskID(goal string) (string, bool, error) {
+ _, tail, found := strings.Cut(goal, explicitTaskPathPrefix)
+ if !found {
+ return "", false, nil
}
- return match[1], true
+ if end := strings.IndexFunc(tail, unicode.IsSpace); end >= 0 {
+ tail = tail[:end]
+ }
+ taskID := strings.TrimSuffix(tail, "/")
+ if taskID == "" {
+ return "", true, errors.New("autoresearch: explicit task path is missing a task id")
+ }
+ if strings.ContainsAny(taskID, `/\`) {
+ return "", true, fmt.Errorf("autoresearch: explicit task path has extra components: %q", tail)
+ }
+ if err := validateTaskID(taskID); err != nil {
+ return "", true, err
+ }
+ return taskID, true, nil
}
func (s *Store) Findings(taskID string, limit int) ([]Finding, error) {
@@ -214,22 +226,29 @@ func (s *Store) ValidateTask(taskID string) (*ValidationReport, error) {
return nil, err
}
defer storeRoot.Close()
+ _, report := validateTaskRoot(storeRoot, taskRel, taskID)
+ return report, nil
+}
+
+// validateTaskRoot reads and validates a task through one already-open root.
+// The task directory cannot be swapped between validation and goal extraction.
+func validateTaskRoot(storeRoot *os.Root, taskRel, taskID string) (TaskSpec, *ValidationReport) {
report := &ValidationReport{Valid: true}
info, err := storeRoot.Lstat(taskRel)
if err != nil {
report.add("task", "", err.Error())
report.Valid = false
- return report, nil
+ return TaskSpec{}, report
}
if info.Mode()&os.ModeSymlink != 0 {
report.add("task", "", "task directory must not be a symlink")
report.Valid = false
- return report, nil
+ return TaskSpec{}, report
}
if !info.IsDir() {
report.add("task", "", "task path is not a directory")
report.Valid = false
- return report, nil
+ return TaskSpec{}, report
}
var spec TaskSpec
if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), &spec); err != nil {
@@ -243,18 +262,63 @@ func (s *Store) ValidateTask(taskID string) (*ValidationReport, error) {
} else {
validateProgress(report, progress)
}
- for _, rel := range []string{
- "state/directions_tried.json",
- "state/findings.jsonl",
- "state/iteration_log.jsonl",
- "logs/heartbeat.jsonl",
- } {
- if _, err := storeRoot.Stat(filepath.Join(taskRel, rel)); err != nil {
+ validateDirections := func() error {
+ path := filepath.Join(taskRel, "state", "directions_tried.json")
+ data, err := readArchiveFile(storeRoot, path)
+ if err != nil {
+ return err
+ }
+ data = fileencoding.DecodeToUTF8(data)
+ if strings.TrimSpace(string(data)) == "" {
+ return nil
+ }
+ var directions []DirectionTried
+ if err := json.Unmarshal(data, &directions); err != nil {
+ return fmt.Errorf("parse %s: %w", path, err)
+ }
+ return nil
+ }
+ if err := validateDirections(); err != nil {
+ report.add("directions_tried.json", "", err.Error())
+ }
+ validateJSONL := func(rel string, each func([]byte) error) {
+ path := filepath.Join(taskRel, rel)
+ if err := readJSONL(storeRoot, path, each); err != nil {
report.add(filepath.Base(rel), "", err.Error())
}
}
+ validateJSONL("state/findings.jsonl", func(data []byte) error {
+ var finding Finding
+ if err := json.Unmarshal(fileencoding.DecodeToUTF8(data), &finding); err != nil {
+ return err
+ }
+ return validateFinding(finding)
+ })
+ validateJSONL("state/iteration_log.jsonl", func(data []byte) error {
+ var entry json.RawMessage
+ if err := json.Unmarshal(fileencoding.DecodeToUTF8(data), &entry); err != nil {
+ return err
+ }
+ return nil
+ })
+ validateJSONL("logs/heartbeat.jsonl", func(data []byte) error {
+ var heartbeat Heartbeat
+ if err := json.Unmarshal(fileencoding.DecodeToUTF8(data), &heartbeat); err != nil {
+ return err
+ }
+ if strings.TrimSpace(heartbeat.Status) == "" {
+ return errors.New("heartbeat status is required")
+ }
+ if heartbeat.Iteration < 0 {
+ return errors.New("heartbeat iteration must not be negative")
+ }
+ if heartbeat.CreatedAt.IsZero() {
+ return errors.New("heartbeat created_at is required")
+ }
+ return nil
+ })
report.Valid = len(report.Errors) == 0
- return report, nil
+ return spec, report
}
func (s *Store) taskRoot(taskID string) string {
@@ -278,14 +342,109 @@ func (s *Store) openTaskRoot(taskID string) (*os.Root, string, error) {
if err != nil {
return nil, "", err
}
- storeRoot, err := os.OpenRoot(s.root)
+ storeRoot, err := s.openArchiveRoot()
if err != nil {
if os.IsNotExist(err) {
return nil, "", fmt.Errorf("autoresearch: task %s not found", taskID)
}
return nil, "", fmt.Errorf("autoresearch: open root dir: %w", err)
}
- return storeRoot, taskRel, nil
+ info, err := storeRoot.Lstat(taskRel)
+ if err != nil {
+ storeRoot.Close()
+ if os.IsNotExist(err) {
+ return nil, "", fmt.Errorf("autoresearch: task %s not found", taskID)
+ }
+ return nil, "", fmt.Errorf("autoresearch: stat task %s: %w", taskID, err)
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ storeRoot.Close()
+ return nil, "", fmt.Errorf("autoresearch: task %s is a symlink", taskID)
+ }
+ if !info.IsDir() {
+ storeRoot.Close()
+ return nil, "", fmt.Errorf("autoresearch: task %s is not a directory", taskID)
+ }
+ taskRoot, err := storeRoot.OpenRoot(taskRel)
+ if err != nil {
+ storeRoot.Close()
+ return nil, "", fmt.Errorf("autoresearch: open task %s: %w", taskID, err)
+ }
+ opened, err := taskRoot.Stat(".")
+ if err != nil || !os.SameFile(info, opened) {
+ taskRoot.Close()
+ storeRoot.Close()
+ if err != nil {
+ return nil, "", fmt.Errorf("autoresearch: verify task %s: %w", taskID, err)
+ }
+ return nil, "", fmt.Errorf("autoresearch: task %s changed while opening", taskID)
+ }
+ current, err := storeRoot.Lstat(taskRel)
+ if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(info, current) {
+ taskRoot.Close()
+ storeRoot.Close()
+ if err != nil {
+ return nil, "", fmt.Errorf("autoresearch: recheck task %s: %w", taskID, err)
+ }
+ return nil, "", fmt.Errorf("autoresearch: task %s changed while opening", taskID)
+ }
+ if err := storeRoot.Close(); err != nil {
+ taskRoot.Close()
+ return nil, "", fmt.Errorf("autoresearch: close archive root: %w", err)
+ }
+ return taskRoot, ".", nil
+}
+
+// openArchiveRoot anchors every archive read to the resolved workspace root.
+// os.Root prevents a concurrent symlink swap from escaping the workspace; the
+// explicit Lstat/SameFile checks additionally reject symlinked archive roots.
+func (s *Store) openArchiveRoot() (*os.Root, error) {
+ workspace, err := os.OpenRoot(s.workspaceRoot)
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: open workspace root: %w", err)
+ }
+ defer workspace.Close()
+
+ archiveRel := filepath.Join(".reasonix", "autoresearch")
+ rels := []string{".reasonix", archiveRel}
+ infos := make([]os.FileInfo, len(rels))
+ for i, rel := range rels {
+ info, err := workspace.Lstat(rel)
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: stat archive path %s: %w", rel, err)
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ return nil, fmt.Errorf("autoresearch: archive path %s must not be a symlink", rel)
+ }
+ if !info.IsDir() {
+ return nil, fmt.Errorf("autoresearch: archive path %s is not a directory", rel)
+ }
+ infos[i] = info
+ }
+
+ archive, err := workspace.OpenRoot(archiveRel)
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: open archive root: %w", err)
+ }
+ opened, err := archive.Stat(".")
+ if err != nil || !os.SameFile(infos[len(infos)-1], opened) {
+ archive.Close()
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: verify archive root: %w", err)
+ }
+ return nil, errors.New("autoresearch: archive root changed while opening")
+ }
+ for i, rel := range rels {
+ current, err := workspace.Lstat(rel)
+ if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(infos[i], current) {
+ archive.Close()
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: recheck archive path %s: %w", rel, err)
+ }
+ return nil, fmt.Errorf("autoresearch: archive path %s changed while opening", rel)
+ }
+ }
+ return archive, nil
}
func validateTaskID(id string) error {
@@ -317,9 +476,9 @@ func validateFinding(f Finding) error {
}
func readJSONFile(root *os.Root, path string, out any) error {
- data, err := root.ReadFile(path)
+ data, err := readArchiveFile(root, path)
if err != nil {
- return fmt.Errorf("read %s: %w", path, err)
+ return err
}
data = fileencoding.DecodeToUTF8(data)
if err := json.Unmarshal(data, out); err != nil {
@@ -329,7 +488,7 @@ func readJSONFile(root *os.Root, path string, out any) error {
}
func readJSONL(root *os.Root, path string, each func([]byte) error) error {
- f, err := root.Open(path)
+ f, err := openArchiveFile(root, path)
if err != nil {
return fmt.Errorf("autoresearch: open %s: %w", path, err)
}
@@ -369,7 +528,7 @@ func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) {
}
return lines, nil
}
- f, err := root.Open(path)
+ f, err := openArchiveFile(root, path)
if err != nil {
return nil, fmt.Errorf("autoresearch: open %s: %w", path, err)
}
@@ -413,6 +572,78 @@ func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) {
return lines, nil
}
+func readArchiveFile(root *os.Root, path string) ([]byte, error) {
+ f, err := openArchiveFile(root, path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ data, err := io.ReadAll(f)
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: read %s: %w", path, err)
+ }
+ return data, nil
+}
+
+// openArchiveFile rejects symlinks and non-regular files at every path
+// component, then binds parsing to the verified file descriptor. The second
+// identity check closes the Lstat/open replacement window without holding a
+// process-global directory or changing the archive.
+func openArchiveFile(root *os.Root, path string) (*os.File, error) {
+ path = filepath.Clean(path)
+ if !filepath.IsLocal(path) || path == "." {
+ return nil, fmt.Errorf("autoresearch: unsafe archive file path %q", path)
+ }
+ parts := strings.Split(path, string(filepath.Separator))
+ infos := make([]os.FileInfo, len(parts))
+ current := ""
+ for i, part := range parts {
+ current = filepath.Join(current, part)
+ info, err := root.Lstat(current)
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: stat %s: %w", current, err)
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ return nil, fmt.Errorf("autoresearch: archive path %s must not be a symlink", current)
+ }
+ if i < len(parts)-1 {
+ if !info.IsDir() {
+ return nil, fmt.Errorf("autoresearch: archive path %s is not a directory", current)
+ }
+ } else if !info.Mode().IsRegular() {
+ return nil, fmt.Errorf("autoresearch: archive path %s is not a regular file", current)
+ }
+ infos[i] = info
+ }
+
+ f, err := root.Open(path)
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: open %s: %w", path, err)
+ }
+ opened, err := f.Stat()
+ if err != nil || !opened.Mode().IsRegular() || !os.SameFile(infos[len(infos)-1], opened) {
+ f.Close()
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: verify %s: %w", path, err)
+ }
+ return nil, fmt.Errorf("autoresearch: archive path %s changed while opening", path)
+ }
+
+ current = ""
+ for i, part := range parts {
+ current = filepath.Join(current, part)
+ info, err := root.Lstat(current)
+ if err != nil || info.Mode()&os.ModeSymlink != 0 || !os.SameFile(infos[i], info) {
+ f.Close()
+ if err != nil {
+ return nil, fmt.Errorf("autoresearch: recheck %s: %w", current, err)
+ }
+ return nil, fmt.Errorf("autoresearch: archive path %s changed while opening", current)
+ }
+ }
+ return f, nil
+}
+
func countCompleteTailLines(buf []byte, atStart bool) int {
segments := strings.Split(string(buf), "\n")
if !atStart && len(segments) > 0 {
diff --git a/internal/autoresearch/store_test.go b/internal/autoresearch/store_test.go
index 7737558fd8..c8cde8eaa3 100644
--- a/internal/autoresearch/store_test.go
+++ b/internal/autoresearch/store_test.go
@@ -61,6 +61,101 @@ func TestLoadTaskRejectsSymlinkAndUnsafeIDs(t *testing.T) {
}
}
+func TestLoadTaskRejectsSymlinkedArchiveRoot(t *testing.T) {
+ root := t.TempDir()
+ if resolved, err := filepath.EvalSymlinks(root); err == nil {
+ root = resolved
+ }
+ outside := t.TempDir()
+ if resolved, err := filepath.EvalSymlinks(outside); err == nil {
+ outside = resolved
+ }
+ const taskID = "outside-task"
+ writeArchiveFixture(t, outside, taskID, "outside workspace goal", nil)
+ if err := os.MkdirAll(filepath.Join(root, ".reasonix"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ outsideRoot := filepath.Join(outside, ".reasonix", "autoresearch")
+ if err := os.Symlink(outsideRoot, filepath.Join(root, ".reasonix", "autoresearch")); err != nil {
+ t.Fatal(err)
+ }
+
+ store := NewStore(root)
+ if _, err := store.LoadTask(taskID); err == nil {
+ t.Fatal("LoadTask accepted a symlinked archive root outside the workspace")
+ }
+ if _, err := store.ListSummaries(); err == nil {
+ t.Fatal("ListSummaries accepted a symlinked archive root outside the workspace")
+ }
+}
+
+func TestArchiveReaderRejectsSymlinkedTaskContent(t *testing.T) {
+ t.Run("state directory", func(t *testing.T) {
+ root := t.TempDir()
+ writeArchiveFixture(t, root, "source-task", "source goal", nil)
+ victimRoot := writeArchiveFixture(t, root, "victim-task", "victim goal", nil)
+ if err := os.RemoveAll(filepath.Join(victimRoot, "state")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(filepath.Join("..", "source-task", "state"), filepath.Join(victimRoot, "state")); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := NewStore(root).LoadTask("victim-task"); err == nil {
+ t.Fatal("LoadTask followed a state-directory symlink into another task")
+ }
+ })
+
+ t.Run("task spec file", func(t *testing.T) {
+ root := t.TempDir()
+ taskRoot := writeArchiveFixture(t, root, "file-link-task", "linked goal", nil)
+ specPath := filepath.Join(taskRoot, "state", "task_spec.json")
+ if err := os.Rename(specPath, filepath.Join(taskRoot, "state", "task_spec.real.json")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("task_spec.real.json", specPath); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := NewStore(root).LoadTask("file-link-task"); err == nil {
+ t.Fatal("LoadTask followed a task_spec symlink")
+ }
+ })
+
+ t.Run("validation file", func(t *testing.T) {
+ root := t.TempDir()
+ taskRoot := writeArchiveFixture(t, root, "progress-link-task", "linked progress", nil)
+ progressPath := filepath.Join(taskRoot, "state", "progress.json")
+ if err := os.Rename(progressPath, filepath.Join(taskRoot, "state", "progress.real.json")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("progress.real.json", progressPath); err != nil {
+ t.Fatal(err)
+ }
+ report, err := NewStore(root).ValidateTask("progress-link-task")
+ if err != nil {
+ t.Fatalf("ValidateTask: %v", err)
+ }
+ if report.Valid {
+ t.Fatal("ValidateTask accepted a symlinked progress file")
+ }
+ })
+}
+
+func TestLoadTaskRejectsUnreadableArchiveFile(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("root can bypass archive file permissions")
+ }
+ root := t.TempDir()
+ taskRoot := writeArchiveFixture(t, root, "permission-task", "permission goal", nil)
+ specPath := filepath.Join(taskRoot, "state", "task_spec.json")
+ if err := os.Chmod(specPath, 0); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.Chmod(specPath, 0o644) })
+ if _, err := NewStore(root).LoadTask("permission-task"); err == nil {
+ t.Fatal("LoadTask accepted an unreadable task_spec.json")
+ }
+}
+
func TestFindingsPreserveVerificationAndUnknownKinds(t *testing.T) {
root := t.TempDir()
taskID := "findings-kinds"
@@ -158,6 +253,17 @@ func TestResumeFromGoalTextLoadsExplicitTaskPath(t *testing.T) {
if _, ok, err := store.ResumeFromGoalText("resume .reasonix/autoresearch/missing-task/"); !ok || err == nil {
t.Fatalf("missing task should fail closed: ok=%v err=%v", ok, err)
}
+ for _, input := range []string{
+ "resume .reasonix/autoresearch/../escape",
+ "resume .reasonix/autoresearch/" + taskID + "/../../escape",
+ "resume .reasonix/autoresearch/" + taskID + "/extra",
+ "resume .reasonix/autoresearch/" + taskID + `\extra`,
+ "resume .reasonix/autoresearch/",
+ } {
+ if _, ok, err := store.ResumeFromGoalText(input); !ok || err == nil {
+ t.Errorf("unsafe explicit path %q did not fail closed: ok=%v err=%v", input, ok, err)
+ }
+ }
}
func TestListSummariesAndSummaryAreReadOnly(t *testing.T) {
@@ -180,6 +286,7 @@ func TestListSummariesAndSummaryAreReadOnly(t *testing.T) {
CreatedAt: time.Date(2026, 6, 30, 11, 0, 0, 0, time.UTC),
})
before := hashTree(t, filepath.Join(root, ".reasonix", "autoresearch"))
+ beforeModTimes := modTimes(t, filepath.Join(root, ".reasonix", "autoresearch"))
store := NewStore(root)
list, err := store.ListSummaries()
if err != nil {
@@ -204,6 +311,12 @@ func TestListSummariesAndSummaryAreReadOnly(t *testing.T) {
t.Fatalf("archive mutated at %s", path)
}
}
+ afterModTimes := modTimes(t, filepath.Join(root, ".reasonix", "autoresearch"))
+ for path, modTime := range beforeModTimes {
+ if !afterModTimes[path].Equal(modTime) {
+ t.Fatalf("archive modification time changed at %s", path)
+ }
+ }
}
func TestValidateTaskRejectsCorruptJSON(t *testing.T) {
@@ -223,6 +336,41 @@ func TestValidateTaskRejectsCorruptJSON(t *testing.T) {
}
}
+func TestValidateTaskRejectsCorruptArchiveLogsButAcceptsUnknownFindingKinds(t *testing.T) {
+ t.Run("corrupt finding JSON", func(t *testing.T) {
+ root := t.TempDir()
+ taskID := "corrupt-finding-json"
+ taskRoot := writeArchiveFixture(t, root, taskID, "Validate finding JSON", nil)
+ if err := os.WriteFile(filepath.Join(taskRoot, "state", "findings.jsonl"), []byte("{not-json\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ report, err := NewStore(root).ValidateTask(taskID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.Valid {
+ t.Fatal("corrupt finding JSON reported valid")
+ }
+ })
+
+ t.Run("unknown finding kind", func(t *testing.T) {
+ root := t.TempDir()
+ taskID := "unknown-finding-kind"
+ taskRoot := writeArchiveFixture(t, root, taskID, "Accept future finding kind", nil)
+ appendFindingLine(t, taskRoot, Finding{
+ ID: "future", Kind: "future-kind", Summary: "preserve me", Accepted: true,
+ CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC),
+ })
+ report, err := NewStore(root).ValidateTask(taskID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !report.Valid {
+ t.Fatalf("unknown finding kind rejected: %+v", report.Errors)
+ }
+ })
+}
+
func TestHeartbeatsTailRead(t *testing.T) {
root := t.TempDir()
taskID := "heartbeats"
diff --git a/internal/autoresearch/task.go b/internal/autoresearch/task.go
index c63a8a2fa0..a3b1714100 100644
--- a/internal/autoresearch/task.go
+++ b/internal/autoresearch/task.go
@@ -49,19 +49,6 @@ type Progress struct {
UpdatedAt time.Time `json:"updated_at"`
}
-// Historical finding kinds are free-form strings. The constants below are
-// retained only as documentation of values that older writers produced; the
-// reader accepts any non-empty kind without enumeration.
-const (
- FindingKindCommand = "command"
- FindingKindFile = "file"
- FindingKindTest = "test"
- FindingKindBenchmark = "benchmark"
- FindingKindManual = "manual"
- FindingKindReview = "review"
- FindingKindVerification = "verification"
-)
-
const (
FindingSourceCommand = "command"
FindingSourceFile = "file"
diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go
index abcf4dfb2c..426de85366 100644
--- a/internal/boot/boot_test.go
+++ b/internal/boot/boot_test.go
@@ -2049,7 +2049,7 @@ model = "x"
}
}
-func TestBootToolContractCoversProviderVisibleSurface(t *testing.T) {
+func TestBootToolContractMatchesProviderVisibleSurface(t *testing.T) {
for _, tc := range []struct {
name string
tokenMode string
@@ -2081,21 +2081,11 @@ model = "x"
if got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantNames) {
t.Fatalf("%s provider-visible tool surface changed\ngot %v\nwant %v", tc.name, got, wantNames)
}
- entryByName := make(map[string]tool.ContractEntry, len(entries))
- for _, entry := range entries {
- entryByName[entry.Name] = entry
+ if len(entries) != len(req.Tools) {
+ t.Fatalf("contract entries = %d, provider tools = %d\ncontract=%v\nprovider=%v", len(entries), len(req.Tools), contractEntryNames(entries), toolSchemaNames(req.Tools))
}
- if _, ok := entryByName["update_goal"]; !ok {
- t.Fatalf("static contract must retain contextual update_goal: %v", contractEntryNames(entries))
- }
- if len(entries) != len(req.Tools)+1 {
- t.Fatalf("contract entries = %d, provider tools = %d; want only contextual update_goal hidden\ncontract=%v\nprovider=%v", len(entries), len(req.Tools), contractEntryNames(entries), toolSchemaNames(req.Tools))
- }
- for i, s := range req.Tools {
- e, ok := entryByName[s.Name]
- if !ok {
- t.Fatalf("provider tool %q missing from static contract", s.Name)
- }
+ for i, e := range entries {
+ s := req.Tools[i]
if e.Name != s.Name {
t.Fatalf("tool[%d] name = %q, want %q\ncontract=%v\nprovider=%v", i, e.Name, s.Name, contractEntryNames(entries), toolSchemaNames(req.Tools))
}
@@ -2232,6 +2222,7 @@ func defaultFullBootToolNames() []string {
"slash_command",
"task",
"todo_write",
+ "update_goal",
"wait",
"web_fetch",
"write_file",
@@ -2247,6 +2238,7 @@ func economyBootToolNames() []string {
"edit_file",
"kill_shell",
"read_file",
+ "update_goal",
"wait",
"write_file",
}
@@ -2298,6 +2290,7 @@ command = "reasonix-missing-mockmcp"
"edit_file",
"kill_shell",
"read_file",
+ "update_goal",
"wait",
"write_file",
}
diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go
index 4d37314bf3..c124c1d883 100644
--- a/internal/cli/chat_tui.go
+++ b/internal/cli/chat_tui.go
@@ -4834,13 +4834,17 @@ func (m *chatTUI) runGoalSubcommand(input string) tea.Cmd {
m.notice(i18n.M.GoalEmpty)
return nil
}
- switch cmd.Action {
+ switch m.noticeDeprecatedGoalBudget(cmd); cmd.Action {
case control.GoalCommandSet:
m.planMode = false
m.ctrl.SetPlanMode(false)
m.ctrl.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode)
m.ctrl.GoalStrict(cmd.Strict)
- m.notice(fmt.Sprintf(i18n.M.GoalSetFmt, control.ShortGoalForNotice(cmd.Text)))
+ if m.ctrl.GoalStatus() != control.GoalStatusRunning {
+ m.echoLocalCommand(input)
+ return nil
+ }
+ m.notice(fmt.Sprintf(i18n.M.GoalSetFmt, control.ShortGoalForNotice(m.ctrl.Goal())))
return m.startTurn("Start pursuing the active goal now.", input, input)
case control.GoalCommandClear:
m.echoLocalCommand(input)
diff --git a/internal/cli/chat_tui_goal.go b/internal/cli/chat_tui_goal.go
new file mode 100644
index 0000000000..4c59083074
--- /dev/null
+++ b/internal/cli/chat_tui_goal.go
@@ -0,0 +1,9 @@
+package cli
+
+import "reasonix/internal/control"
+
+func (m *chatTUI) noticeDeprecatedGoalBudget(cmd control.GoalCommand) {
+ if cmd.DeprecatedBudgetFlag {
+ m.notice(control.GoalBudgetFlagDeprecatedNotice)
+ }
+}
diff --git a/internal/cli/chat_tui_goal_test.go b/internal/cli/chat_tui_goal_test.go
new file mode 100644
index 0000000000..692f2e6a91
--- /dev/null
+++ b/internal/cli/chat_tui_goal_test.go
@@ -0,0 +1,36 @@
+package cli
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+
+ "reasonix/internal/control"
+)
+
+func TestGoalLegacyBudgetFlagNoticesExactlyOnce(t *testing.T) {
+ m := newTestChatTUI()
+ m.ctrl = control.New(control.Options{})
+ t.Cleanup(m.ctrl.Close)
+
+ m.runGoalSubcommand("/goal --research investigate the failure")
+
+ joined := ansi.Strip(strings.Join(*m.pendingCommit, "\n"))
+ if got := strings.Count(joined, control.GoalBudgetFlagDeprecatedNotice); got != 1 {
+ t.Fatalf("deprecated budget notices = %d, want 1:\n%s", got, joined)
+ }
+}
+
+func TestMissingLegacyGoalCommandDoesNotStartTUITurn(t *testing.T) {
+ m := newTestChatTUI()
+ m.ctrl = control.New(control.Options{WorkspaceRoot: t.TempDir()})
+ t.Cleanup(m.ctrl.Close)
+
+ if cmd := m.runGoalSubcommand("/goal resume .reasonix/autoresearch/missing-task/"); cmd != nil {
+ t.Fatal("missing legacy archive returned a provider turn command")
+ }
+ if got := m.ctrl.GoalStatus(); got != control.GoalStatusBlocked {
+ t.Fatalf("GoalStatus() = %q, want blocked", got)
+ }
+}
diff --git a/internal/control/autoresearch_manager.go b/internal/control/autoresearch_manager.go
index ab1f33a70c..b3e3064fdc 100644
--- a/internal/control/autoresearch_manager.go
+++ b/internal/control/autoresearch_manager.go
@@ -9,6 +9,7 @@ import (
"strings"
"reasonix/internal/autoresearch"
+ "reasonix/internal/evidence"
)
type legacyResearchSetup struct {
@@ -28,22 +29,24 @@ type legacyResearchArchive struct {
// prepare reads an explicitly referenced legacy task. It has no create path
// and never mutates the archive, even when validation fails.
func (m legacyResearchArchive) prepare(goal string) legacyResearchSetup {
- if m.store == nil {
- if _, ok := autoresearch.ExplicitTaskID(goal); ok {
- return legacyResearchSetup{
- explicit: true,
- blockReason: "legacy research archive is unavailable for this workspace",
- }
- }
+ taskID, found, parseErr := autoresearch.ExplicitTaskID(goal)
+ if !found {
return legacyResearchSetup{}
}
- task, ok, err := m.store.ResumeFromGoalText(goal)
- if !ok {
- return legacyResearchSetup{}
+ if parseErr != nil {
+ return legacyResearchSetup{explicit: true, blockReason: parseErr.Error()}
+ }
+ if m.store == nil {
+ return legacyResearchSetup{
+ explicit: true,
+ taskID: taskID,
+ blockReason: "legacy research archive is unavailable for this workspace",
+ }
}
+ task, err := m.store.LoadTask(taskID)
if err != nil {
slog.Warn("controller: resume legacy autoresearch task", "err", err)
- return legacyResearchSetup{explicit: true, blockReason: err.Error()}
+ return legacyResearchSetup{explicit: true, taskID: taskID, blockReason: err.Error()}
}
original := strings.TrimSpace(task.Spec.Goal)
if original == "" {
@@ -70,11 +73,6 @@ func (m legacyResearchArchive) loadGoalText(taskID string) (string, error) {
if err != nil {
return "", err
}
- if report, err := m.store.ValidateTask(task.ID); err != nil {
- return "", err
- } else if !report.Valid {
- return "", errLegacyArchiveInvalid
- }
goal := strings.TrimSpace(task.Spec.Goal)
if goal == "" {
return "", errLegacyArchiveMissingGoal
@@ -84,7 +82,6 @@ func (m legacyResearchArchive) loadGoalText(taskID string) (string, error) {
var (
errLegacyArchiveUnavailable = errString("legacy research archive is unavailable for this workspace")
- errLegacyArchiveInvalid = errString("legacy research archive is invalid")
errLegacyArchiveMissingGoal = errString("legacy research archive is missing goal text")
)
@@ -95,3 +92,93 @@ func (e errString) Error() string { return string(e) }
func (c *Controller) prepareLegacyResearchTask(goal string) legacyResearchSetup {
return c.legacyResearchArchive.prepare(goal)
}
+
+func (c *Controller) restorePendingLegacyGoal(legacy legacyGoalRestore) bool {
+ if legacy.taskID == "" {
+ goal, epoch, ok := c.goals.legacyArchiveBlockedState()
+ if ok {
+ setup := c.prepareLegacyResearchTask(goal)
+ if setup.explicit && setup.taskID != "" {
+ legacy = legacyGoalRestore{taskID: setup.taskID, epoch: epoch, explicit: true}
+ }
+ }
+ }
+ if legacy.taskID == "" || (strings.TrimSpace(c.goals.goalText()) != "" && !legacy.explicit) {
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ return false
+ }
+ c.replaceLegacyRestore(legacy)
+ restoreTodos := c.goalTodos()
+ if len(legacy.todos) > 0 {
+ restoreTodos = append([]evidence.TodoItem(nil), legacy.todos...)
+ if c.executor != nil {
+ c.executor.ReplaceTodoState(restoreTodos)
+ }
+ }
+ goal, err := c.legacyResearchArchive.loadGoalText(legacy.taskID)
+ if err != nil {
+ if epoch, ok := c.goals.blockLegacyRestore(legacy.epoch, err.Error()); ok {
+ c.advanceLegacyRestoreEpoch(legacy.taskID, legacy.epoch, epoch)
+ c.notice("legacy research archive resume failed: " + err.Error())
+ }
+ return true
+ }
+ if legacy.explicit {
+ if epoch, ok := c.goals.resumeLegacyArchive(legacy.epoch, goal); ok {
+ c.persistGoalStateAtEpoch(epoch, restoreTodos)
+ c.clearLegacyRestore(legacy.taskID, legacy.epoch)
+ }
+ return true
+ }
+ if strings.TrimSpace(c.goals.goalText()) == "" {
+ if epoch, ok := c.goals.fillGoalTextIfEmpty(legacy.epoch, goal); ok {
+ c.persistGoalStateAtEpoch(epoch, restoreTodos)
+ c.clearLegacyRestore(legacy.taskID, legacy.epoch)
+ }
+ }
+ return true
+}
+
+func (c *Controller) retryBlockedLegacyGoal() (handled, resumed bool) {
+ legacy, ok := c.legacyRestoreSnapshot()
+ if !ok {
+ return false, false
+ }
+ goal, ok := c.goals.legacyArchiveRetryToken(legacy.epoch)
+ if !ok {
+ c.clearLegacyRestore(legacy.taskID, legacy.epoch)
+ return false, false
+ }
+ taskID, epoch := legacy.taskID, legacy.epoch
+ setup := c.prepareLegacyResearchTask(goal)
+ resolvedGoal := setup.goal
+ if !setup.explicit {
+ var err error
+ resolvedGoal, err = c.legacyResearchArchive.loadGoalText(taskID)
+ if err != nil {
+ setup.blockReason = err.Error()
+ } else if strings.TrimSpace(goal) != "" {
+ resolvedGoal = goal
+ }
+ }
+ if setup.blockReason != "" || strings.TrimSpace(resolvedGoal) == "" {
+ reason := setup.blockReason
+ if reason == "" {
+ reason = "legacy research archive could not be recovered"
+ }
+ c.notice("legacy research archive resume failed: " + reason)
+ return true, false
+ }
+ todos := c.goalTodos()
+ resumedEpoch, applied := c.goals.resumeLegacyArchive(epoch, resolvedGoal)
+ if !applied {
+ return true, false
+ }
+ c.persistGoalStateAtEpoch(resumedEpoch, todos)
+ c.clearLegacyRestore(taskID, epoch)
+ c.notice(setup.notice)
+ if c.executor != nil {
+ c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState())
+ }
+ return true, true
+}
diff --git a/internal/control/controller.go b/internal/control/controller.go
index b3e384b7ee..5a012ac4e2 100644
--- a/internal/control/controller.go
+++ b/internal/control/controller.go
@@ -213,6 +213,8 @@ type Controller struct {
// creates or mutates archive state. See
// autoresearch_manager.go.
legacyResearchArchive legacyResearchArchive
+ legacyRestoreMu sync.Mutex
+ legacyRestore legacyGoalRestore
// workspaceRoot is the workspace root: the base for resolving @-refs and slash
// path refs, the working directory for user "!" shell commands and custom
@@ -1540,19 +1542,14 @@ func (c *Controller) applyGoalCommand(input, display string) bool {
return false
}
if cmd.DeprecatedBudgetFlag {
- c.notice("This /goal budget flag is deprecated; Goal now selects its budget automatically.")
+ c.notice(GoalBudgetFlagDeprecatedNotice)
}
switch cmd.Action {
case GoalCommandSet:
c.SetPlanMode(false)
c.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode)
c.GoalStrict(cmd.Strict)
- c.notice(fmt.Sprintf(i18n.M.GoalSetFmt, ShortGoalForNotice(cmd.Text)))
- if c.runner != nil {
- c.runGuarded(func(ctx context.Context) error {
- return c.runGoalLoopWithRawDisplay(ctx, "Start pursuing the active goal now.", cmd.Text, display)
- })
- }
+ c.startGoalCommandTurn(cmd, display)
case GoalCommandClear:
c.ClearGoal()
c.notice(i18n.M.GoalCleared)
@@ -2671,70 +2668,13 @@ func (c *Controller) SetGoal(goal string) {
c.SetGoalWithResearchMode(goal, GoalResearchAuto)
}
-// SetGoalDurable updates the Goal only when its sidecar can be replaced
-// atomically. The second parameter is retained for callers compiled against
-// the old archive-creation transaction contract and is otherwise ignored.
-func (c *Controller) SetGoalDurable(goal, _ string) error {
- snapshot := c.goals.capture()
- resolved, setup := c.resolveGoalText(goal, GoalResearchAuto)
- path, data, persist := c.goals.set(resolved, setup.mode, c.goalTodos())
- if setup.blockReason != "" {
- path, data, persist = c.goals.stop(GoalStatusBlocked, c.goalTodos())
- }
- if persist {
- if err := c.goals.writeStateErr(path, data); err != nil {
- c.goals.restore(snapshot)
- return err
- }
- }
- if setup.notice != "" {
- c.notice(setup.notice)
- }
- if setup.blockReason != "" {
- c.notice("legacy research archive resume failed: " + setup.blockReason)
- }
- return nil
-}
-
-func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResearchMode) {
- resolved, setup := c.resolveGoalText(goal, researchMode)
- if setup.notice != "" {
- c.notice(setup.notice)
- }
- path, data, ok := c.goals.set(resolved, setup.mode, c.goalTodos())
- c.persistGoalState(path, data, ok)
- if setup.blockReason != "" {
- path, data, ok := c.goals.stop(GoalStatusBlocked, c.goalTodos())
- c.persistGoalState(path, data, ok)
- c.notice("legacy research archive resume failed: " + setup.blockReason)
- }
-}
-
-// goalSetSetup is the resolved objective and budget mode after archive lookup.
-type goalSetSetup struct {
- mode GoalResearchMode
- notice string
- blockReason string
-}
-
-func (c *Controller) resolveGoalText(goal string, researchMode GoalResearchMode) (string, goalSetSetup) {
- setup := goalSetSetup{mode: researchMode}
- legacy := c.prepareLegacyResearchTask(goal)
- if !legacy.explicit {
- return goal, setup
- }
- setup.notice, setup.blockReason = legacy.notice, legacy.blockReason
- if legacy.blockReason != "" {
- return goal, setup
- }
- setup.mode = GoalResearchOn
- return legacy.goal, setup
-}
-
// ResumeGoal re-enters a recoverable blocked/stopped Goal without resetting its
// delivery evidence scope. A budget-paused Goal gets one extra slice of its
// budget class; accumulated consumption is preserved.
func (c *Controller) ResumeGoal() bool {
+ if handled, resumed := c.retryBlockedLegacyGoal(); handled {
+ return resumed
+ }
path, data, persist, resumed, extended := c.goals.resume(c.goalTodos())
if !resumed {
return false
@@ -2772,7 +2712,7 @@ func (c *Controller) GoalRuntime() GoalRuntimeView {
// turn/budget state, and the last
// continuation reason. Every field is treated as untrusted by the evaluator.
func (c *Controller) goalEvaluatorEvidence() goaleval.GoalEvidence {
- goal, _, _ := c.goals.snapshot()
+ goal, _ := c.goals.snapshot()
ev := goaleval.GoalEvidence{
GoalContract: goal,
LastContinuationReason: c.goals.lastContinuationReasonText(),
@@ -3487,20 +3427,10 @@ func (c *Controller) Resume(s *agent.Session, path string) {
c.ResetPlannerSession()
c.setActiveJobSession(path)
c.rebindCheckpoints(path)
- migPath, migData, migrated, legacyTaskID := c.goals.restoreFromState(path)
- if migrated {
- // Persist omitted autoResearchTaskID / cleared token limits (no provider call).
+ migPath, migData, migrated, legacy := c.goals.restoreFromState(path)
+ if !c.restorePendingLegacyGoal(legacy) && migrated {
c.persistGoalState(migPath, migData, true)
}
- if legacyTaskID != "" && strings.TrimSpace(c.goals.goalText()) == "" {
- if goal, err := c.legacyResearchArchive.loadGoalText(legacyTaskID); err != nil {
- path, data, ok := c.goals.stop(GoalStatusBlocked, c.goalTodos())
- c.persistGoalState(path, data, ok)
- c.notice("legacy research archive resume failed: " + err.Error())
- } else if p, d, ok := c.goals.fillGoalTextIfEmpty(goal, c.goalTodos()); ok {
- c.persistGoalState(p, d, true)
- }
- }
if c.executor != nil {
c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState())
}
diff --git a/internal/control/controller_test.go b/internal/control/controller_test.go
index 39130b05f3..cebd191d89 100644
--- a/internal/control/controller_test.go
+++ b/internal/control/controller_test.go
@@ -539,7 +539,7 @@ func TestGoalStatePersistsNextToSessionPath(t *testing.T) {
if err := json.Unmarshal(data, &state); err != nil {
t.Fatal(err)
}
- if state.Goal != "fix the typo" || state.Status != GoalStatusRunning || state.ResearchMode != GoalResearchOn || !state.Strict {
+ if state.Goal != "fix the typo" || state.Status != GoalStatusRunning || state.BudgetClass != budgetClassResearch || state.ResearchMode != GoalResearchOff || !state.Strict {
t.Fatalf("goal state = %+v, want running strict research goal", state)
}
}
@@ -559,7 +559,7 @@ func TestSetGoalDurableRestoresInMemoryStateWhenSidecarWriteFails(t *testing.T)
}
c.goals.setStatePath(filepath.Join(notDirectory, "goal.json"))
- if err := c.SetGoalDurable("replace the goal", ""); err == nil {
+ if err := c.SetGoalDurable("replace the goal"); err == nil {
t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent")
}
if got := c.Goal(); got != "keep the old goal" {
@@ -592,7 +592,7 @@ func TestSetGoalDurableNeverCreatesLegacyArchive(t *testing.T) {
c.goals.setStatePath(filepath.Join(notDirectory, "goal.json"))
goal := "investigate the root cause and fix the performance regression, then verify with tests"
- if err := c.SetGoalDurable(goal, ""); err == nil {
+ if err := c.SetGoalDurable(goal); err == nil {
t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent")
}
if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) {
diff --git a/internal/control/goal.go b/internal/control/goal.go
index bcb0c1a6a3..7dfe1745df 100644
--- a/internal/control/goal.go
+++ b/internal/control/goal.go
@@ -41,11 +41,12 @@ const (
// blocked either way. stopCauseBudgetTokens is only for recognizing and
// auto-resuming old token-limit pauses.
const (
- stopCauseBudgetTurns = "budget_turns"
- stopCauseBudgetTokens = "budget_tokens" // legacy; never written by current runtime
- stopCauseNoProgress = "no_progress"
- stopCauseEvaluator = "evaluator_unavailable"
- stopCauseManual = "manual"
+ stopCauseBudgetTurns = "budget_turns"
+ stopCauseBudgetTokens = "budget_tokens" // legacy; never written by current runtime
+ stopCauseNoProgress = "no_progress"
+ stopCauseEvaluator = "evaluator_unavailable"
+ stopCauseLegacyArchive = "legacy_archive"
+ stopCauseManual = "manual"
)
// budgetQuota returns the default turn quota for a budget class. Token hard
@@ -54,9 +55,9 @@ func budgetQuota(class string) (turns int) {
return taskintent.BudgetTurns(class)
}
-// budgetClassFor derives a Goal budget. GoalResearchMode only decodes legacy
-// sidecars and deprecated CLI flags.
-func budgetClassFor(goal string, researchMode GoalResearchMode) string {
+// budgetClassForLegacyMode translates old sidecars and deprecated CLI flags at
+// the compatibility boundary. The active Goal runtime stores only budgetClass.
+func budgetClassForLegacyMode(goal string, researchMode GoalResearchMode) string {
switch researchMode {
case GoalResearchOn:
return budgetClassResearch
@@ -79,7 +80,6 @@ type goalMachine struct {
mu sync.Mutex
goal string
status string
- researchMode GoalResearchMode
scopeID string
deliveryCheckpoint evidence.DeliveryCheckpoint
block string
@@ -137,18 +137,6 @@ type goalState struct {
BudgetExtensions int `json:"budgetExtensions,omitempty"`
}
-// goalMachineSnapshot is an in-memory rollback point for durable Goal updates.
-// Persistence paths and mutexes are deliberately excluded.
-type goalMachineSnapshot struct {
- goal string
- status string
- researchMode GoalResearchMode
- scopeID string
- deliveryCheckpoint evidence.DeliveryCheckpoint
- block string
- strict bool
-}
-
// 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
@@ -189,9 +177,8 @@ type goalAdvanceResult struct {
// state admitted for its synthetic turn. The orchestrator uses these captured
// fields throughout the turn instead of re-reading a possibly replaced Goal.
type goalContinuationSnapshot struct {
- goal string
- researchMode GoalResearchMode
- scopeID string
+ goal string
+ scopeID string
}
// goalStatePath derives a session's persisted goal-state sidecar.
@@ -205,31 +192,11 @@ func (g *goalMachine) setStatePath(path string) {
g.mu.Unlock()
}
-func (g *goalMachine) capture() goalMachineSnapshot {
- g.mu.Lock()
- defer g.mu.Unlock()
- return goalMachineSnapshot{
- goal: g.goal, status: g.status, researchMode: g.researchMode,
- scopeID: g.scopeID, deliveryCheckpoint: g.deliveryCheckpoint,
- block: g.block, strict: g.strict,
- }
-}
-
-func (g *goalMachine) restore(snapshot goalMachineSnapshot) {
- g.mu.Lock()
- g.goal, g.status, g.researchMode = snapshot.goal, snapshot.status, snapshot.researchMode
- g.scopeID = snapshot.scopeID
- g.deliveryCheckpoint, g.block = snapshot.deliveryCheckpoint, snapshot.block
- g.strict = snapshot.strict
- g.continuationEpoch++
- g.mu.Unlock()
-}
-
// snapshot returns the fields Compose injects into outgoing turns.
-func (g *goalMachine) snapshot() (goal, status string, mode GoalResearchMode) {
+func (g *goalMachine) snapshot() (goal, status string) {
g.mu.Lock()
defer g.mu.Unlock()
- return g.goal, g.status, g.researchMode
+ return g.goal, g.status
}
func (g *goalMachine) goalText() string {
@@ -307,13 +274,40 @@ func (g *goalMachine) budgetExhausted() bool {
// the per-goal budget/runtime counters, and returns the state to persist. ok is
// false (no persistence) when the goal is unchanged or no state path is
// configured.
-func (g *goalMachine) set(goal string, mode GoalResearchMode, todos []evidence.TodoItem) (string, []byte, bool) {
+func (g *goalMachine) set(goal, preferredBudgetClass string, todos []evidence.TodoItem) (string, []byte, bool) {
goal = strings.TrimSpace(goal)
+ if goal != "" && preferredBudgetClass == "" {
+ preferredBudgetClass = taskintent.ClassifyGoalBudget(goal)
+ }
g.mu.Lock()
defer g.mu.Unlock()
- if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.researchMode == mode {
+ if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.budgetClass == preferredBudgetClass {
return "", nil, false
}
+ g.installGoalLocked(goal, preferredBudgetClass)
+ return g.buildStateLocked(todos)
+}
+
+// setLegacyArchiveBlocked atomically installs and blocks an explicit legacy
+// archive goal. A concurrent Goal replacement cannot be blocked between two
+// separate FSM mutations.
+func (g *goalMachine) setLegacyArchiveBlocked(goal, preferredBudgetClass, reason string, todos []evidence.TodoItem) (string, []byte, bool) {
+ goal = strings.TrimSpace(goal)
+ if goal != "" && preferredBudgetClass == "" {
+ preferredBudgetClass = taskintent.ClassifyGoalBudget(goal)
+ }
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ g.installGoalLocked(goal, preferredBudgetClass)
+ if goal != "" {
+ g.status = GoalStatusBlocked
+ }
+ g.stopCause = stopCauseLegacyArchive
+ g.block = clipGoalReason(reason)
+ return g.buildStateLocked(todos)
+}
+
+func (g *goalMachine) installGoalLocked(goal, preferredBudgetClass string) {
g.continuationEpoch++
g.turnsUsed, g.tokensUsed, g.noProgressTurns = 0, 0, 0
g.block = ""
@@ -321,19 +315,19 @@ func (g *goalMachine) set(goal string, mode GoalResearchMode, todos []evidence.T
g.stopCause = ""
g.budgetExtensions = 0
if goal == "" {
- g.goal, g.status, g.researchMode = "", GoalStatusStopped, GoalResearchAuto
+ g.goal, g.status = "", GoalStatusStopped
+ g.budgetClass = ""
g.scopeID = ""
g.deliveryCheckpoint = evidence.DeliveryCheckpoint{}
} else {
- g.goal, g.status, g.researchMode = goal, GoalStatusRunning, mode
+ g.goal, g.status = goal, GoalStatusRunning
g.scopeID = newGoalScopeID()
g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID}
- g.budgetClass = budgetClassFor(goal, mode)
+ g.budgetClass = preferredBudgetClass
g.turnsLimit = budgetQuota(g.budgetClass)
g.tokensLimit = 0 // no token hard limit
g.noProgressLimit = defaultNoProgressLimit
}
- return g.buildStateLocked(todos)
}
func (g *goalMachine) setStrict(strict bool, todos []evidence.TodoItem) (string, []byte, bool) {
@@ -400,7 +394,7 @@ func (g *goalMachine) resume(todos []evidence.TodoItem) (path string, data []byt
}
if extend {
if g.budgetClass == "" {
- g.budgetClass = budgetClassFor(g.goal, g.researchMode)
+ g.budgetClass = taskintent.ClassifyGoalBudget(g.goal)
}
g.turnsLimit += budgetQuota(g.budgetClass)
g.budgetExtensions++
@@ -457,9 +451,8 @@ func (g *goalMachine) admitContinuation(res goalAdvanceResult) (goalContinuation
g.scopeID = newGoalScopeID()
}
return goalContinuationSnapshot{
- goal: g.goal,
- researchMode: g.researchMode,
- scopeID: g.scopeID,
+ goal: g.goal,
+ scopeID: g.scopeID,
}, true
}
@@ -471,12 +464,11 @@ func (g *goalMachine) admitContinuation(res goalAdvanceResult) (goalContinuation
// 1. complete + readiness ready (report or evaluator) → complete
// 2. blocked (report or evaluator) → blocked immediately (no triple confirm)
// 3. evaluator failed/uncertain → safe pause (fail closed, never default to continue)
-// 4. evaluator failed/uncertain → safe pause (fail closed, never default to continue)
-// 5. budget exhausted → safe pause (also vetoes complete claims rejected by
+// 4. budget exhausted → safe pause (also vetoes complete claims rejected by
// readiness: those would continue, and continuation past the budget is a
// pause)
-// 6. no-progress limit reached → safe pause
-// 7. otherwise continue, carrying the missing requirements (complete rejected
+// 5. no-progress limit reached → 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.
func (g *goalMachine) advance(in goalAdvanceInput) goalAdvanceResult {
@@ -632,7 +624,6 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string,
state := goalState{
Goal: g.goal,
Status: g.status,
- ResearchMode: g.researchMode,
ScopeID: g.scopeID,
DeliveryCheckpoint: g.deliveryCheckpoint,
Turns: g.turnsUsed,
@@ -651,6 +642,11 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string,
StopCause: g.stopCause,
BudgetExtensions: g.budgetExtensions,
}
+ if strings.TrimSpace(g.goal) != "" {
+ // GoalResearchOff is a downgrade fence: old readers must not infer or
+ // inject the removed AutoResearch runtime. budgetClass is authoritative.
+ state.ResearchMode = GoalResearchOff
+ }
b, err := json.Marshal(state)
if err != nil {
slog.Warn("controller: marshal goal state", "err", err)
@@ -668,6 +664,26 @@ func (g *goalMachine) writeStateErr(path string, data []byte) error {
}
g.writeMu.Lock()
defer g.writeMu.Unlock()
+ return writeGoalStateData(path, data)
+}
+
+func (g *goalMachine) writeStateAtEpoch(epoch uint64, todos []evidence.TodoItem) (bool, error) {
+ g.writeMu.Lock()
+ defer g.writeMu.Unlock()
+ g.mu.Lock()
+ if g.continuationEpoch != epoch {
+ g.mu.Unlock()
+ return false, nil
+ }
+ path, data, ok := g.buildStateLocked(todos)
+ g.mu.Unlock()
+ if !ok {
+ return true, nil
+ }
+ return true, writeGoalStateData(path, data)
+}
+
+func writeGoalStateData(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
@@ -730,9 +746,9 @@ func (g *goalMachine) terminalTodosFromState(sessionPath string) ([]evidence.Tod
// authoritative; missing budget fields are re-derived. migrated means path/data
// need an immediate rewrite (no provider call). legacyTaskID is returned only
// so Controller can fill missing goal text from a historical archive.
-func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []byte, migrated bool, legacyTaskID string) {
+func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []byte, migrated bool, legacy legacyGoalRestore) {
if strings.TrimSpace(sessionPath) == "" {
- return "", nil, false, ""
+ return "", nil, false, legacyGoalRestore{}
}
// Ensure write path is bound even when the controller rebuilds.
if g.statePath == "" {
@@ -743,12 +759,12 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
if !os.IsNotExist(err) {
slog.Warn("controller: read goal state", "err", err)
}
- return "", nil, false, ""
+ return "", nil, false, legacyGoalRestore{}
}
var state goalState
if err := json.Unmarshal(raw, &state); err != nil {
slog.Warn("controller: parse goal state", "err", err)
- return "", nil, false, ""
+ return "", nil, false, legacyGoalRestore{}
}
g.mu.Lock()
defer g.mu.Unlock()
@@ -757,15 +773,19 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
if g.status == "" {
g.status = GoalStatusStopped
}
- g.researchMode = state.ResearchMode
- // Old AutoResearch sidecars only retain AutoResearchTaskID for decode.
- // Active memory never carries the id; the next write omits it.
- legacyTaskID = strings.TrimSpace(state.AutoResearchTaskID)
- if legacyTaskID != "" {
- g.researchMode = GoalResearchOn
- migrated = true
+ // Legacy task identity is decode-only compatibility data. It is returned to
+ // the Controller's migration boundary and never enters active Goal memory.
+ legacy = legacyGoalRestore{
+ taskID: strings.TrimSpace(state.AutoResearchTaskID),
+ todos: append([]evidence.TodoItem(nil), state.Todos...),
+ }
+ if legacy.taskID != "" {
+ migrated = g.goal != ""
}
g.scopeID = strings.TrimSpace(state.ScopeID)
+ if g.scopeID == "" {
+ g.scopeID = strings.TrimSpace(state.DeliveryCheckpoint.ScopeID)
+ }
if g.goal != "" && g.scopeID == "" {
g.scopeID = newGoalScopeID()
}
@@ -790,25 +810,29 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
g.turnsUsed = state.Turns
}
g.tokensUsed = state.TokensUsed
+ g.budgetClass = normalizeBudgetClass(g.goal, state.BudgetClass, state.ResearchMode)
+ g.turnsLimit = state.TurnsLimit
+ g.noProgressTurns = state.NoProgressTurns
+ g.noProgressLimit = state.NoProgressLimit
// Token hard limits are gone: keep the field at 0. Old non-zero sidecar
// values are read and ignored so downgrade/upgrade never loses other state.
g.tokensLimit = 0
+ if goalStateNeedsMigration(state, g.budgetClass) {
+ migrated = true
+ }
if g.goal != "" {
- g.budgetClass = state.BudgetClass
if g.budgetClass == "" {
- g.budgetClass = budgetClassFor(g.goal, g.researchMode)
+ g.budgetClass = budgetClassForLegacyMode(g.goal, state.ResearchMode)
+ }
+ if legacy.taskID != "" {
+ g.budgetClass = budgetClassResearch
}
- if state.TurnsLimit > 0 {
- g.turnsLimit = state.TurnsLimit
- } else {
+ if g.turnsLimit == 0 {
g.turnsLimit = budgetQuota(g.budgetClass)
}
- if state.NoProgressLimit > 0 {
- g.noProgressLimit = state.NoProgressLimit
- } else {
+ if g.noProgressLimit == 0 {
g.noProgressLimit = defaultNoProgressLimit
}
- g.noProgressTurns = state.NoProgressTurns
// Auto-clear legacy token-budget pauses so the next user turn can
// continue without a manual resume. Loading itself never calls a
// provider.
@@ -822,52 +846,19 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
}
// Also rewrite sidecars that still store a non-zero tokensLimit so the
// next load does not re-surface the deprecated hard ceiling in status.
- if state.TokensLimit != 0 {
- migrated = true
- }
}
g.continuationEpoch++
- if migrated {
+ legacy.epoch = g.continuationEpoch
+ pendingLegacyGoal := legacy.taskID != "" && g.goal == ""
+ if migrated && !pendingLegacyGoal {
// Migration rewrites only the removed budget state. Preserve the todo
// snapshot carried by the authoritative sidecar instead of clearing it.
path, data, ok := g.buildStateLocked(state.Todos)
if ok {
- return path, data, true, legacyTaskID
+ return path, data, true, legacy
}
}
- return "", nil, false, legacyTaskID
-}
-
-// fillGoalTextIfEmpty installs archive-recovered goal text without resetting counters.
-func (g *goalMachine) fillGoalTextIfEmpty(goal string, todos []evidence.TodoItem) (string, []byte, bool) {
- goal = strings.TrimSpace(goal)
- if goal == "" {
- return "", nil, false
- }
- g.mu.Lock()
- defer g.mu.Unlock()
- if strings.TrimSpace(g.goal) != "" {
- return "", nil, false
- }
- g.goal, g.researchMode = goal, GoalResearchOn
- if g.status == "" {
- g.status = GoalStatusRunning
- }
- if g.budgetClass == "" {
- g.budgetClass = budgetClassResearch
- }
- if g.turnsLimit == 0 {
- g.turnsLimit = budgetQuota(g.budgetClass)
- }
- if g.noProgressLimit == 0 {
- g.noProgressLimit = defaultNoProgressLimit
- }
- if g.scopeID == "" {
- g.scopeID = newGoalScopeID()
- g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID}
- }
- g.continuationEpoch++
- return g.buildStateLocked(todos)
+ return "", nil, false, legacy
}
// formatIncompleteTodos renders the reminder shown when a complete claim
@@ -946,6 +937,12 @@ func (c *Controller) persistGoalState(path string, data []byte, ok bool) {
c.goals.writeState(path, data)
}
+func (c *Controller) persistGoalStateAtEpoch(epoch uint64, todos []evidence.TodoItem) {
+ if _, err := c.goals.writeStateAtEpoch(epoch, todos); err != nil {
+ slog.Warn("controller: write goal state", "err", err)
+ }
+}
+
func (c *Controller) restoreTerminalGoalTodos(sessionPath string) {
if c.executor == nil {
return
diff --git a/internal/control/goal_command.go b/internal/control/goal_command.go
new file mode 100644
index 0000000000..9fb93e2854
--- /dev/null
+++ b/internal/control/goal_command.go
@@ -0,0 +1,20 @@
+package control
+
+import (
+ "context"
+ "fmt"
+
+ "reasonix/internal/i18n"
+)
+
+func (c *Controller) startGoalCommandTurn(cmd GoalCommand, display string) {
+ if !c.goals.active() {
+ return
+ }
+ c.notice(fmt.Sprintf(i18n.M.GoalSetFmt, ShortGoalForNotice(c.Goal())))
+ if c.runner != nil {
+ c.runGuarded(func(ctx context.Context) error {
+ return c.runGoalLoopWithRawDisplay(ctx, "Start pursuing the active goal now.", cmd.Text, display)
+ })
+ }
+}
diff --git a/internal/control/goal_durable.go b/internal/control/goal_durable.go
new file mode 100644
index 0000000000..7708f1c1aa
--- /dev/null
+++ b/internal/control/goal_durable.go
@@ -0,0 +1,60 @@
+package control
+
+import "reasonix/internal/evidence"
+
+// goalMachineSnapshot is an in-memory rollback point for durable Goal updates.
+// Persistence paths and mutexes are deliberately excluded.
+type goalMachineSnapshot struct {
+ goal string
+ status string
+ scopeID string
+ deliveryCheckpoint evidence.DeliveryCheckpoint
+ block string
+ strict bool
+ budgetClass string
+ turnsUsed int
+ turnsLimit int
+ tokensUsed int
+ tokensLimit int
+ noProgressTurns int
+ noProgressLimit int
+ lastContinuationReason string
+ lastEvaluatorReason string
+ stopCause string
+ budgetExtensions int
+}
+
+func (g *goalMachine) capture() goalMachineSnapshot {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ return goalMachineSnapshot{
+ goal: g.goal, status: g.status,
+ scopeID: g.scopeID, deliveryCheckpoint: g.deliveryCheckpoint,
+ block: g.block, strict: g.strict,
+ budgetClass: g.budgetClass, turnsUsed: g.turnsUsed,
+ turnsLimit: g.turnsLimit, tokensUsed: g.tokensUsed,
+ tokensLimit: g.tokensLimit, noProgressTurns: g.noProgressTurns,
+ noProgressLimit: g.noProgressLimit,
+ lastContinuationReason: g.lastContinuationReason,
+ lastEvaluatorReason: g.lastEvaluatorReason,
+ stopCause: g.stopCause, budgetExtensions: g.budgetExtensions,
+ }
+}
+
+func (g *goalMachine) restore(snapshot goalMachineSnapshot) {
+ g.mu.Lock()
+ g.goal, g.status = snapshot.goal, snapshot.status
+ g.scopeID = snapshot.scopeID
+ g.deliveryCheckpoint, g.block = snapshot.deliveryCheckpoint, snapshot.block
+ g.strict = snapshot.strict
+ g.budgetClass = snapshot.budgetClass
+ g.turnsUsed, g.turnsLimit = snapshot.turnsUsed, snapshot.turnsLimit
+ g.tokensUsed, g.tokensLimit = snapshot.tokensUsed, snapshot.tokensLimit
+ 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.continuationEpoch++
+ g.mu.Unlock()
+}
diff --git a/internal/control/goal_durable_test.go b/internal/control/goal_durable_test.go
new file mode 100644
index 0000000000..75c3c33fab
--- /dev/null
+++ b/internal/control/goal_durable_test.go
@@ -0,0 +1,38 @@
+package control
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "reasonix/internal/agent"
+ "reasonix/internal/event"
+)
+
+func TestSetGoalDurableRollsBackAllRuntimeStateOnWriteFailure(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "session.jsonl")
+ exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
+ c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
+ c.SetGoal("keep the old goal")
+ c.goals.mu.Lock()
+ c.goals.turnsUsed = 7
+ c.goals.tokensUsed = 4321
+ c.goals.noProgressTurns = 2
+ c.goals.lastContinuationReason = "preserve this reason"
+ c.goals.budgetExtensions = 1
+ c.goals.mu.Unlock()
+ want := c.GoalRuntime()
+
+ notDirectory := filepath.Join(dir, "not-a-directory")
+ if err := os.WriteFile(notDirectory, []byte("block nested writes"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ c.goals.setStatePath(filepath.Join(notDirectory, "goal.json"))
+ if err := c.SetGoalDurable("replace the goal"); err == nil {
+ t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent")
+ }
+ if got := c.GoalRuntime(); got != want {
+ t.Fatalf("GoalRuntime() after failed durable write = %+v, want %+v", got, want)
+ }
+}
diff --git a/internal/control/goal_legacy.go b/internal/control/goal_legacy.go
new file mode 100644
index 0000000000..513dea7be0
--- /dev/null
+++ b/internal/control/goal_legacy.go
@@ -0,0 +1,160 @@
+package control
+
+import (
+ "strings"
+
+ "reasonix/internal/evidence"
+)
+
+type legacyGoalRestore struct {
+ taskID string
+ todos []evidence.TodoItem
+ epoch uint64
+ explicit bool
+}
+
+func normalizeBudgetClass(goal, class string, legacyMode GoalResearchMode) string {
+ switch class {
+ case budgetClassSimple, budgetClassWrite, budgetClassResearch:
+ return class
+ default:
+ return budgetClassForLegacyMode(goal, legacyMode)
+ }
+}
+
+func goalStateNeedsMigration(state goalState, normalizedBudgetClass string) bool {
+ expectedMode := GoalResearchAuto
+ if strings.TrimSpace(state.AutoResearchTaskID) != "" {
+ expectedMode = GoalResearchOn
+ } else if strings.TrimSpace(state.Goal) != "" {
+ expectedMode = GoalResearchOff
+ }
+ return state.TokensLimit != 0 || state.ResearchMode != expectedMode ||
+ (state.BudgetClass != "" && state.BudgetClass != normalizedBudgetClass)
+}
+
+// blockLegacyRestore fails closed only while the decoded sidecar still owns the
+// active Goal epoch. The task id remains in the Controller's legacy reader.
+func (g *goalMachine) blockLegacyRestore(expectedEpoch uint64, reason string) (uint64, bool) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.continuationEpoch != expectedEpoch {
+ return 0, false
+ }
+ g.status = GoalStatusBlocked
+ g.stopCause = stopCauseLegacyArchive
+ g.block = clipGoalReason(reason)
+ g.continuationEpoch++
+ return g.continuationEpoch, true
+}
+
+func (g *goalMachine) legacyArchiveRetryToken(expectedEpoch uint64) (goal string, ok bool) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.continuationEpoch != expectedEpoch || g.status != GoalStatusBlocked || g.stopCause != stopCauseLegacyArchive {
+ return "", false
+ }
+ return g.goal, true
+}
+
+func (g *goalMachine) legacyArchiveBlockedState() (goal string, epoch uint64, ok bool) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.status != GoalStatusBlocked || g.stopCause != stopCauseLegacyArchive {
+ return "", 0, false
+ }
+ return g.goal, g.continuationEpoch, true
+}
+
+func (c *Controller) replaceLegacyRestore(legacy legacyGoalRestore) {
+ c.legacyRestoreMu.Lock()
+ c.legacyRestore = legacy
+ c.legacyRestoreMu.Unlock()
+}
+
+func (c *Controller) legacyRestoreSnapshot() (legacyGoalRestore, bool) {
+ c.legacyRestoreMu.Lock()
+ defer c.legacyRestoreMu.Unlock()
+ legacy := c.legacyRestore
+ return legacy, strings.TrimSpace(legacy.taskID) != ""
+}
+
+func (c *Controller) advanceLegacyRestoreEpoch(taskID string, from, to uint64) {
+ c.legacyRestoreMu.Lock()
+ defer c.legacyRestoreMu.Unlock()
+ if c.legacyRestore.taskID == taskID && c.legacyRestore.epoch == from {
+ c.legacyRestore.epoch = to
+ }
+}
+
+func (c *Controller) clearLegacyRestore(taskID string, epoch uint64) {
+ c.legacyRestoreMu.Lock()
+ defer c.legacyRestoreMu.Unlock()
+ if c.legacyRestore.taskID == taskID && c.legacyRestore.epoch == epoch {
+ c.legacyRestore = legacyGoalRestore{}
+ }
+}
+
+// fillGoalTextIfEmpty installs archive-recovered goal text without resetting counters.
+func (g *goalMachine) fillGoalTextIfEmpty(expectedEpoch uint64, goal string) (uint64, bool) {
+ goal = strings.TrimSpace(goal)
+ if goal == "" {
+ return 0, false
+ }
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.continuationEpoch != expectedEpoch || strings.TrimSpace(g.goal) != "" {
+ return 0, false
+ }
+ g.goal = goal
+ if g.status == "" || g.stopCause == stopCauseLegacyArchive {
+ g.status = GoalStatusRunning
+ }
+ if g.stopCause == stopCauseLegacyArchive {
+ g.stopCause, g.block = "", ""
+ }
+ g.budgetClass = budgetClassResearch
+ if g.turnsLimit < budgetQuota(g.budgetClass) {
+ g.turnsLimit = budgetQuota(g.budgetClass)
+ }
+ if g.noProgressLimit == 0 {
+ g.noProgressLimit = defaultNoProgressLimit
+ }
+ if g.scopeID == "" {
+ g.scopeID = newGoalScopeID()
+ g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID}
+ }
+ g.continuationEpoch++
+ return g.continuationEpoch, true
+}
+
+// resumeLegacyArchive applies an archive recovery only while the same blocked
+// Goal lifecycle is still current. Archive reads happen off-lock, so the epoch
+// check prevents a stale recovery from replacing a concurrently installed Goal.
+func (g *goalMachine) resumeLegacyArchive(expectedEpoch uint64, goal string) (uint64, bool) {
+ goal = strings.TrimSpace(goal)
+ if goal == "" {
+ return 0, false
+ }
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.continuationEpoch != expectedEpoch || g.status != GoalStatusBlocked || g.stopCause != stopCauseLegacyArchive {
+ return 0, false
+ }
+ g.goal = goal
+ g.status = GoalStatusRunning
+ g.stopCause, g.block = "", ""
+ g.budgetClass = budgetClassResearch
+ if g.turnsLimit < budgetQuota(g.budgetClass) {
+ g.turnsLimit = budgetQuota(g.budgetClass)
+ }
+ if g.noProgressLimit == 0 {
+ g.noProgressLimit = defaultNoProgressLimit
+ }
+ if g.scopeID == "" {
+ g.scopeID = newGoalScopeID()
+ g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID}
+ }
+ g.continuationEpoch++
+ return g.continuationEpoch, true
+}
diff --git a/internal/control/goal_legacy_restore_test.go b/internal/control/goal_legacy_restore_test.go
new file mode 100644
index 0000000000..7f3057c4dd
--- /dev/null
+++ b/internal/control/goal_legacy_restore_test.go
@@ -0,0 +1,503 @@
+package control
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "reasonix/internal/agent"
+ "reasonix/internal/event"
+ "reasonix/internal/evidence"
+)
+
+func writeLegacyGoalArchive(t *testing.T, root, taskID, goal string) string {
+ t.Helper()
+ taskRoot := filepath.Join(root, ".reasonix", "autoresearch", taskID)
+ if err := os.MkdirAll(filepath.Join(taskRoot, "state"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(taskRoot, "logs"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for name, body := range map[string]string{
+ "state/task_spec.json": `{"task_id":"` + taskID + `","goal":"` + goal + `","allowed_operations":{"write":true},"success_criteria":[]}`,
+ "state/progress.json": `{"status":"running","updated_at":"2026-06-30T10:00:00Z"}`,
+ "state/directions_tried.json": "[]\n",
+ "state/findings.jsonl": "",
+ "state/iteration_log.jsonl": "",
+ "logs/heartbeat.jsonl": "",
+ } {
+ if err := os.WriteFile(filepath.Join(taskRoot, name), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return taskRoot
+}
+
+func TestUnknownPersistedBudgetClassFallsBackToGoalClassification(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "session.jsonl")
+ raw, err := json.Marshal(goalState{Goal: "fix the crash in settings", Status: GoalStatusRunning, BudgetClass: "future-budget-class", TurnsLimit: 99})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(goalStatePath(path), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ g := &goalMachine{}
+ g.setStatePath(goalStatePath(path))
+ _, _, migrated, _ := g.restoreFromState(path)
+ if !migrated || g.budgetClass != budgetClassWrite || g.turnsLimit != 99 {
+ t.Fatalf("unknown budget restore = migrated:%v class:%q turns:%d", migrated, g.budgetClass, g.turnsLimit)
+ }
+}
+
+func TestGoalSidecarWriterFencesLegacyAutoResearchForEveryBudget(t *testing.T) {
+ tests := []struct {
+ name string
+ goal string
+ class string
+ }{
+ {name: "simple", goal: "summarize the current status", class: budgetClassSimple},
+ {name: "write", goal: "fix the settings crash", class: budgetClassWrite},
+ {name: "research", goal: "investigate the latency regression thoroughly", class: budgetClassResearch},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ g := &goalMachine{statePath: filepath.Join(t.TempDir(), "goal.json")}
+ _, raw, ok := g.set(tt.goal, tt.class, nil)
+ if !ok {
+ t.Fatal("set did not produce sidecar data")
+ }
+ var state goalState
+ if err := json.Unmarshal(raw, &state); err != nil {
+ t.Fatal(err)
+ }
+ if state.ResearchMode != GoalResearchOff || state.AutoResearchTaskID != "" {
+ t.Fatalf("legacy reader fence missing: %+v", state)
+ }
+ if state.BudgetClass != tt.class || state.TurnsLimit != budgetQuota(tt.class) {
+ t.Fatalf("budget state = %+v, want %s/%d", state, tt.class, budgetQuota(tt.class))
+ }
+ // Frozen previous readers treated any non-Off mode or retained task id
+ // as an AutoResearch activation signal.
+ var legacyReader struct {
+ ResearchMode GoalResearchMode `json:"researchMode"`
+ AutoResearchTaskID string `json:"autoResearchTaskID"`
+ }
+ if err := json.Unmarshal(raw, &legacyReader); err != nil {
+ t.Fatal(err)
+ }
+ if legacyReader.ResearchMode != GoalResearchOff || strings.TrimSpace(legacyReader.AutoResearchTaskID) != "" {
+ t.Fatal("frozen previous reader would reactivate AutoResearch")
+ }
+ })
+ }
+}
+
+func TestGoalSetIdempotencyUsesEffectiveBudgetClass(t *testing.T) {
+ g := &goalMachine{statePath: filepath.Join(t.TempDir(), "goal.json")}
+ if _, _, ok := g.set("same goal", budgetClassSimple, nil); !ok {
+ t.Fatal("initial set did not persist")
+ }
+ if _, _, ok := g.set("same goal", budgetClassSimple, nil); ok {
+ t.Fatal("same Goal and budget class was not idempotent")
+ }
+ if _, _, ok := g.set("same goal", budgetClassResearch, nil); !ok {
+ t.Fatal("budget class change was incorrectly treated as idempotent")
+ }
+ if g.budgetClass != budgetClassResearch || g.turnsLimit != budgetQuota(budgetClassResearch) {
+ t.Fatalf("budget upgrade = class:%q turns:%d", g.budgetClass, g.turnsLimit)
+ }
+}
+
+func TestLegacySidecarArchiveFailureIsBlockedAndRetryable(t *testing.T) {
+ root := t.TempDir()
+ if resolved, err := filepath.EvalSymlinks(root); err == nil {
+ root = resolved
+ }
+ sessionPath := filepath.Join(root, "sessions", "s.jsonl")
+ if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ const (
+ taskID = "retry-legacy-archive"
+ scopeID = "legacy-goal-scope"
+ )
+ wantTodo := evidence.TodoItem{Content: "preserve legacy verification", Status: "in_progress"}
+ wantCheckpoint := evidence.DeliveryCheckpoint{ScopeID: scopeID, CriteriaEstablished: true, WorkObserved: true}
+ legacy := goalState{
+ Status: GoalStatusRunning, ResearchMode: GoalResearchOn, AutoResearchTaskID: taskID,
+ ScopeID: scopeID, DeliveryCheckpoint: wantCheckpoint, Todos: []evidence.TodoItem{wantTodo},
+ BudgetClass: budgetClassResearch, TurnsUsed: 3, TurnsLimit: 40, TokensUsed: 1234,
+ NoProgressTurns: 2, NoProgressLimit: defaultNoProgressLimit, BudgetExtensions: 1,
+ LastContinuationReason: "continue verification",
+ }
+ raw, err := json.Marshal(legacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sess := agent.NewSession("sys")
+ exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+ c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
+ c.Resume(sess, sessionPath)
+ if got := c.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("failed legacy restore status = %q, want blocked", got)
+ }
+ failedRaw, err := os.ReadFile(goalStatePath(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var failed goalState
+ if err := json.Unmarshal(failedRaw, &failed); err != nil {
+ t.Fatal(err)
+ }
+ if failed.Status != GoalStatusRunning || failed.ResearchMode != GoalResearchOn || failed.AutoResearchTaskID != taskID {
+ t.Fatalf("failed restore sidecar = %+v, want original legacy sidecar preserved for retry", failed)
+ }
+ if got := c.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("failed restore runtime status = %q, want blocked", got)
+ }
+ if failed.ScopeID != scopeID || failed.DeliveryCheckpoint != wantCheckpoint || len(failed.Todos) != 1 || failed.Todos[0] != wantTodo {
+ t.Fatalf("failed restore lost goal state: %+v", failed)
+ }
+ if failed.BudgetClass != budgetClassResearch || failed.TurnsUsed != 3 || failed.TurnsLimit != 40 || failed.TokensUsed != 1234 || failed.NoProgressTurns != 2 || failed.BudgetExtensions != 1 {
+ t.Fatalf("failed restore lost runtime state: %+v", failed)
+ }
+ if got := exec.CanonicalTodoState(); len(got) != 1 || got[0] != wantTodo {
+ t.Fatalf("failed restore todos = %+v, want %+v", got, wantTodo)
+ }
+ if runtime := c.GoalRuntime(); runtime.TurnsUsed != 3 || runtime.TurnsLimit != 40 || runtime.TokensUsed != 1234 || runtime.NoProgressTurns != 2 {
+ t.Fatalf("failed restore lost in-memory runtime state: %+v", runtime)
+ }
+ c.Close()
+
+ taskRoot := writeLegacyGoalArchive(t, root, taskID, "recover after archive repair")
+ archiveBefore, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ sess2 := agent.NewSession("sys")
+ exec2 := agent.New(nil, nil, sess2, agent.Options{}, event.Discard)
+ c2 := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec2})
+ c2.Resume(sess2, sessionPath)
+ defer c2.Close()
+ if got := c2.Goal(); got != "recover after archive repair" {
+ t.Fatalf("retried Goal() = %q", got)
+ }
+ if got := c2.GoalStatus(); got != GoalStatusRunning {
+ t.Fatalf("retried status = %q, want running", got)
+ }
+ runtime := c2.GoalRuntime()
+ if runtime.TurnsUsed != 3 || runtime.TurnsLimit != 40 || runtime.TokensUsed != 1234 || runtime.NoProgressTurns != 2 || runtime.BudgetExtensions != 1 {
+ t.Fatalf("retried runtime = %+v, want preserved legacy consumption", runtime)
+ }
+ if got := exec2.CanonicalTodoState(); len(got) != 1 || got[0] != wantTodo {
+ t.Fatalf("retried todos = %+v, want %+v", got, wantTodo)
+ }
+ if got := c2.goals.deliveryState(); got != wantCheckpoint {
+ t.Fatalf("retried delivery checkpoint = %+v, want %+v", got, wantCheckpoint)
+ }
+ retriedRaw, err := os.ReadFile(goalStatePath(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var retried goalState
+ if err := json.Unmarshal(retriedRaw, &retried); err != nil {
+ t.Fatal(err)
+ }
+ if retried.AutoResearchTaskID != "" || retried.StopCause != "" || retried.Block != "" {
+ t.Fatalf("successful retry retained migration-only fields: %+v", retried)
+ }
+ archiveAfter, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(archiveAfter) != string(archiveBefore) {
+ t.Fatal("legacy archive changed during retry")
+ }
+}
+
+func TestLegacySidecarArchiveCanRetryInSameController(t *testing.T) {
+ root := t.TempDir()
+ sessionPath := filepath.Join(root, "sessions", "s.jsonl")
+ if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ const taskID = "same-controller-retry"
+ legacy := goalState{
+ Status: GoalStatusRunning, AutoResearchTaskID: taskID, ResearchMode: GoalResearchOn,
+ TurnsUsed: 5, TurnsLimit: 20,
+ }
+ raw, err := json.Marshal(legacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sess := agent.NewSession("sys")
+ exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+ c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
+ c.Resume(sess, sessionPath)
+ defer c.Close()
+ if c.GoalStatus() != GoalStatusBlocked || c.ResumeGoal() {
+ t.Fatal("missing archive did not remain blocked")
+ }
+
+ writeLegacyGoalArchive(t, root, taskID, "recover objective in the same controller")
+ if !c.ResumeGoal() {
+ t.Fatal("repaired sidecar archive did not resume in the same controller")
+ }
+ if got := c.Goal(); got != "recover objective in the same controller" {
+ t.Fatalf("Goal() = %q, want recovered archive objective", got)
+ }
+ if runtime := c.GoalRuntime(); runtime.TurnsUsed != 5 || runtime.TurnsLimit != 40 {
+ t.Fatalf("runtime = %+v, want preserved use with research quota", runtime)
+ }
+ persisted, err := os.ReadFile(goalStatePath(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(persisted), "autoResearchTaskID") {
+ t.Fatalf("successful retry retained legacy task id: %s", persisted)
+ }
+}
+
+func TestStaleLegacyArchiveRetryCannotReplaceNewGoal(t *testing.T) {
+ var g goalMachine
+ g.setLegacyArchiveBlocked("resume .reasonix/autoresearch/old/", budgetClassResearch, "missing", nil)
+ epoch := g.continuationToken()
+ _, ok := g.legacyArchiveRetryToken(epoch)
+ if !ok {
+ t.Fatal("legacy retry token unavailable")
+ }
+ g.set("new goal", budgetClassWrite, nil)
+ if _, resumed := g.resumeLegacyArchive(epoch, "stale archive goal"); resumed {
+ t.Fatal("stale archive retry replaced a newer Goal")
+ }
+ if got := g.goalText(); got != "new goal" {
+ t.Fatalf("Goal() = %q, want concurrent replacement", got)
+ }
+}
+
+func TestStaleInitialLegacyFailureCannotBlockNewGoal(t *testing.T) {
+ var g goalMachine
+ g.set("legacy goal", budgetClassResearch, nil)
+ epoch := g.continuationToken()
+ g.set("new goal", budgetClassWrite, nil)
+
+ if _, blocked := g.blockLegacyRestore(epoch, "archive disappeared"); blocked {
+ t.Fatal("stale archive failure blocked a newer Goal")
+ }
+ if got := g.goalText(); got != "new goal" || g.statusForDisplay() != GoalStatusRunning {
+ t.Fatalf("Goal = %q status=%q, want newer running Goal", got, g.statusForDisplay())
+ }
+}
+
+func TestStaleLegacyMigrationCannotRewriteNewGoalSidecar(t *testing.T) {
+ statePath := filepath.Join(t.TempDir(), "goal.json")
+ g := &goalMachine{statePath: statePath}
+ g.set("legacy goal", budgetClassResearch, nil)
+ legacyEpoch := g.continuationToken()
+ path, data, ok := g.set("new goal", budgetClassWrite, nil)
+ if !ok {
+ t.Fatal("new Goal did not build sidecar state")
+ }
+ if err := g.writeStateErr(path, data); err != nil {
+ t.Fatal(err)
+ }
+ if applied, err := g.writeStateAtEpoch(legacyEpoch, nil); err != nil || applied {
+ t.Fatalf("stale migration write = applied:%v err:%v", applied, err)
+ }
+ raw, err := os.ReadFile(statePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var state goalState
+ if err := json.Unmarshal(raw, &state); err != nil {
+ t.Fatal(err)
+ }
+ if state.Goal != "new goal" {
+ t.Fatalf("sidecar Goal = %q, want new goal", state.Goal)
+ }
+}
+
+func TestLegacySidecarWithGoalMigratesWithoutArchive(t *testing.T) {
+ root := t.TempDir()
+ sessionPath := filepath.Join(root, "sessions", "s.jsonl")
+ if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ legacy := goalState{
+ Goal: "preserve the original goal", Status: GoalStatusRunning,
+ AutoResearchTaskID: "missing-archive", ResearchMode: GoalResearchOn,
+ TurnsUsed: 2, TurnsLimit: 40,
+ }
+ raw, err := json.Marshal(legacy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sess := agent.NewSession("sys")
+ exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+ c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
+ c.Resume(sess, sessionPath)
+ defer c.Close()
+ if got := c.Goal(); got != legacy.Goal {
+ t.Fatalf("Goal() = %q, want %q", got, legacy.Goal)
+ }
+ if got := c.GoalStatus(); got != GoalStatusRunning {
+ t.Fatalf("status = %q, want running", got)
+ }
+ if runtime := c.GoalRuntime(); runtime.TurnsUsed != 2 || runtime.TurnsLimit != 40 {
+ t.Fatalf("runtime = %+v, want preserved research budget", runtime)
+ }
+ persistedRaw, err := os.ReadFile(goalStatePath(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var persisted goalState
+ if err := json.Unmarshal(persistedRaw, &persisted); err != nil {
+ t.Fatal(err)
+ }
+ if persisted.AutoResearchTaskID != "" || persisted.ResearchMode != GoalResearchOff || persisted.BudgetClass != budgetClassResearch {
+ t.Fatalf("migrated sidecar = %+v, want Goal-only research state", persisted)
+ }
+}
+
+func TestExplicitLegacyGoalRetryNeverRunsArchivePathAsGoal(t *testing.T) {
+ root := t.TempDir()
+ sessionPath := filepath.Join(root, "sessions", "s.jsonl")
+ sess := agent.NewSession("sys")
+ exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+ c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
+ c.Resume(sess, sessionPath)
+ defer c.Close()
+
+ const taskID = "repair-explicit-archive"
+ rawGoal := "resume .reasonix/autoresearch/" + taskID + "/"
+ c.SetGoal(rawGoal)
+ if got := c.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("initial status = %q, want blocked", got)
+ }
+ persistedRaw, err := os.ReadFile(goalStatePath(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var blocked goalState
+ if err := json.Unmarshal(persistedRaw, &blocked); err != nil {
+ t.Fatal(err)
+ }
+ if blocked.Status != GoalStatusBlocked || blocked.StopCause != stopCauseLegacyArchive {
+ t.Fatalf("blocked sidecar = %+v", blocked)
+ }
+ if c.ResumeGoal() {
+ t.Fatal("resume succeeded while archive was still missing")
+ }
+ if got := c.Goal(); got != rawGoal || c.GoalStatus() != GoalStatusBlocked {
+ t.Fatalf("failed retry changed Goal: goal=%q status=%q", got, c.GoalStatus())
+ }
+
+ writeLegacyGoalArchive(t, root, taskID, "recover the original objective")
+ if !c.ResumeGoal() {
+ t.Fatal("resume did not recover the repaired archive")
+ }
+ if got := c.Goal(); got != "recover the original objective" {
+ t.Fatalf("Goal() = %q, want archive objective", got)
+ }
+ if c.GoalStatus() != GoalStatusRunning || c.GoalRuntime().TurnsLimit != 40 {
+ t.Fatalf("recovered runtime = status:%q %+v", c.GoalStatus(), c.GoalRuntime())
+ }
+}
+
+func TestExplicitLegacyGoalRetryCanRecoverAfterRestart(t *testing.T) {
+ root := t.TempDir()
+ sessionPath := filepath.Join(root, "sessions", "s.jsonl")
+ const taskID = "restart-explicit-archive"
+ rawGoal := "resume .reasonix/autoresearch/" + taskID + "/"
+
+ exec1 := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
+ c1 := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec1})
+ c1.Resume(agent.NewSession("sys"), sessionPath)
+ c1.SetGoal(rawGoal)
+ if got := c1.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("initial status = %q, want blocked", got)
+ }
+ c1.Close()
+
+ c2 := New(Options{WorkspaceRoot: root, SessionDir: root})
+ c2.Resume(agent.NewSession("sys"), sessionPath)
+ defer c2.Close()
+ if got := c2.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("restart status = %q, want blocked", got)
+ }
+ if c2.ResumeGoal() {
+ t.Fatal("restart resume succeeded while archive was missing")
+ }
+ if got := c2.Goal(); got != rawGoal {
+ t.Fatalf("restart failure changed Goal = %q, want %q", got, rawGoal)
+ }
+
+ writeLegacyGoalArchive(t, root, taskID, "recover the original objective after restart")
+ if !c2.ResumeGoal() {
+ t.Fatal("restart resume did not recover repaired archive")
+ }
+ if got := c2.Goal(); got != "recover the original objective after restart" {
+ t.Fatalf("recovered Goal = %q", got)
+ }
+ if c2.GoalStatus() != GoalStatusRunning || c2.GoalRuntime().TurnsLimit != 40 {
+ t.Fatalf("recovered runtime = status:%q %+v", c2.GoalStatus(), c2.GoalRuntime())
+ }
+}
+
+func TestMissingLegacyGoalCommandDoesNotStartProviderTurn(t *testing.T) {
+ runner := &gatedTurnRunner{started: make(chan struct{}), release: make(chan struct{})}
+ c := New(Options{WorkspaceRoot: t.TempDir(), Runner: runner})
+ t.Cleanup(c.Close)
+
+ if !c.applyGoalCommand("/goal resume .reasonix/autoresearch/missing-task/", "") {
+ t.Fatal("legacy Goal command was not parsed")
+ }
+ if c.Running() {
+ t.Fatal("missing legacy archive started a provider turn")
+ }
+ if got := c.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("GoalStatus() = %q, want blocked", got)
+ }
+}
+
+func TestUnreadableExplicitLegacyArchiveBlocks(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("root can bypass archive file permissions")
+ }
+ root := t.TempDir()
+ const taskID = "unreadable-explicit-archive"
+ taskRoot := writeLegacyGoalArchive(t, root, taskID, "never run an unreadable archive")
+ specPath := filepath.Join(taskRoot, "state", "task_spec.json")
+ if err := os.Chmod(specPath, 0); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = os.Chmod(specPath, 0o644) })
+ c := New(Options{WorkspaceRoot: root})
+ t.Cleanup(c.Close)
+
+ c.SetGoal("resume .reasonix/autoresearch/" + taskID + "/")
+ if got := c.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("GoalStatus() = %q, want blocked", got)
+ }
+ if got := c.Goal(); got != "resume .reasonix/autoresearch/"+taskID+"/" {
+ t.Fatalf("Goal() = %q, archive goal must not be trusted", got)
+ }
+}
diff --git a/internal/control/goal_runtime_test.go b/internal/control/goal_runtime_test.go
index 5b7c9b717c..33e10aceb0 100644
--- a/internal/control/goal_runtime_test.go
+++ b/internal/control/goal_runtime_test.go
@@ -364,7 +364,7 @@ func TestGoalTurnRecorderProtocol(t *testing.T) {
t.Fatal(err)
}
// The goal is replaced: epoch bumps, scope rotates.
- g.set("replacement", GoalResearchAuto, nil)
+ g.set("replacement", "", nil)
if got := rec.validReport(rec.epoch); got != nil {
t.Fatalf("stale recorder report = %+v, want nil", got)
}
@@ -372,7 +372,7 @@ func TestGoalTurnRecorderProtocol(t *testing.T) {
t.Run("late record after replacement rejected", func(t *testing.T) {
g, rec := newRec(t)
- g.set("replacement", GoalResearchAuto, nil)
+ g.set("replacement", "", nil)
if _, err := rec.RecordGoalReport(report(GoalStatusComplete, "")); err == nil {
t.Fatal("late record on a replaced goal must be rejected")
}
@@ -384,7 +384,7 @@ func TestGoalTurnRecorderProtocol(t *testing.T) {
if g.tokensUsed != 150 {
t.Fatalf("tokensUsed = %d, want 150", g.tokensUsed)
}
- g.set("replacement", GoalResearchAuto, nil)
+ g.set("replacement", "", nil)
rec.addUsage(50)
if g.tokensUsed != 0 {
t.Fatalf("stale usage folded into replacement goal: %d", g.tokensUsed)
@@ -436,7 +436,7 @@ func TestGoalUsageTeeAttributesScopedBillableCallsAndExcludesTitle(t *testing.T)
func TestBudgetClassForBareFaultIsWrite(t *testing.T) {
// User-reported Chinese bare fault → write turn quota (20), no token ceiling.
- class := budgetClassFor("数据模型管理器又出现历史 BUG 了……", GoalResearchAuto)
+ class := budgetClassForLegacyMode("数据模型管理器又出现历史 BUG 了……", GoalResearchAuto)
if class != budgetClassWrite {
t.Fatalf("budget class = %q, want write", class)
}
@@ -450,12 +450,12 @@ func TestBudgetClassForBareFaultIsWrite(t *testing.T) {
"诊断数据库连接失败原因。",
"复现并定位问题,但不要修复。",
} {
- if got := budgetClassFor(goal, GoalResearchAuto); got != budgetClassSimple {
+ if got := budgetClassForLegacyMode(goal, GoalResearchAuto); got != budgetClassSimple {
t.Errorf("budgetClassFor(%q) = %q, want simple", goal, got)
}
}
// Explicit mutation verbs remain write.
- if got := budgetClassFor("fix the crash in settings", GoalResearchAuto); got != budgetClassWrite {
+ if got := budgetClassForLegacyMode("fix the crash in settings", GoalResearchAuto); got != budgetClassWrite {
t.Fatalf("explicit fix class = %q, want write", got)
}
}
diff --git a/internal/control/goal_set.go b/internal/control/goal_set.go
new file mode 100644
index 0000000000..acd0925911
--- /dev/null
+++ b/internal/control/goal_set.go
@@ -0,0 +1,80 @@
+package control
+
+// SetGoalDurable updates the Goal only when its sidecar can be replaced
+// atomically.
+func (c *Controller) SetGoalDurable(goal string) error {
+ snapshot := c.goals.capture()
+ legacySnapshot, hadLegacySnapshot := c.legacyRestoreSnapshot()
+ resolved, setup := c.resolveGoalText(goal, GoalResearchAuto)
+ var path string
+ var data []byte
+ var persist bool
+ if setup.blockReason != "" {
+ path, data, persist = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.blockReason, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken(), explicit: setup.explicit})
+ } else {
+ path, data, persist = c.goals.set(resolved, setup.budgetClass, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ }
+ if persist {
+ if err := c.goals.writeStateErr(path, data); err != nil {
+ c.goals.restore(snapshot)
+ if hadLegacySnapshot {
+ legacySnapshot.epoch = c.goals.continuationToken()
+ c.replaceLegacyRestore(legacySnapshot)
+ } else {
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ }
+ return err
+ }
+ }
+ if setup.notice != "" {
+ c.notice(setup.notice)
+ }
+ if setup.blockReason != "" {
+ c.notice("legacy research archive resume failed: " + setup.blockReason)
+ }
+ return nil
+}
+
+func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResearchMode) {
+ resolved, setup := c.resolveGoalText(goal, researchMode)
+ if setup.notice != "" {
+ c.notice(setup.notice)
+ }
+ var path string
+ var data []byte
+ var ok bool
+ if setup.blockReason != "" {
+ path, data, ok = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.blockReason, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken(), explicit: setup.explicit})
+ c.notice("legacy research archive resume failed: " + setup.blockReason)
+ } else {
+ path, data, ok = c.goals.set(resolved, setup.budgetClass, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ }
+ c.persistGoalState(path, data, ok)
+}
+
+// goalSetSetup is the resolved objective and budget class after archive lookup.
+type goalSetSetup struct {
+ budgetClass string
+ notice string
+ blockReason string
+ legacyTaskID string
+ explicit bool
+}
+
+func (c *Controller) resolveGoalText(goal string, researchMode GoalResearchMode) (string, goalSetSetup) {
+ setup := goalSetSetup{budgetClass: budgetClassForLegacyMode(goal, researchMode)}
+ legacy := c.prepareLegacyResearchTask(goal)
+ if !legacy.explicit {
+ return goal, setup
+ }
+ setup.notice, setup.blockReason, setup.legacyTaskID, setup.explicit = legacy.notice, legacy.blockReason, legacy.taskID, legacy.explicit
+ if legacy.blockReason != "" {
+ return goal, setup
+ }
+ setup.budgetClass = budgetClassResearch
+ return legacy.goal, setup
+}
diff --git a/internal/control/goal_test.go b/internal/control/goal_test.go
index 431241f3ed..4ab4a288cf 100644
--- a/internal/control/goal_test.go
+++ b/internal/control/goal_test.go
@@ -130,7 +130,7 @@ func toolCallChunk(id, name, args string) provider.Chunk {
}
func TestActiveGoalBlockCarriesTaskContractAndPausePolicy(t *testing.T) {
- block := activeGoalBlock("fix the parser", GoalResearchOff)
+ block := activeGoalBlock("fix the parser")
for _, want := range []string{
"Treat the user's goal as a task contract",
"Context, Request, Output format, Constraints",
@@ -252,6 +252,7 @@ func TestLegacyGoalSidecarMigratesToResearchBudgetWithoutTaskID(t *testing.T) {
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
t.Fatal(err)
}
+ writeLegacyGoalArchive(t, root, "old-task", "archive fallback should not replace sidecar goal")
if err := os.WriteFile(goalStatePath(sessionPath), []byte(`{"goal":"investigate runtime","status":"running","researchMode":1,"autoResearchTaskID":"old-task"}`), 0o644); err != nil {
t.Fatal(err)
}
@@ -263,6 +264,9 @@ func TestLegacyGoalSidecarMigratesToResearchBudgetWithoutTaskID(t *testing.T) {
if got := c.GoalRuntime().TurnsLimit; got != 40 {
t.Fatalf("migrated Goal turns limit = %d, want 40", got)
}
+ if got := c.Goal(); got != "investigate runtime" {
+ t.Fatalf("migrated Goal = %q, want sidecar goal", got)
+ }
raw, err := os.ReadFile(goalStatePath(sessionPath))
if err != nil {
t.Fatal(err)
@@ -287,12 +291,23 @@ func TestMissingExplicitLegacyTaskBlocksWithoutCreatingArchive(t *testing.T) {
if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) {
t.Fatalf("missing legacy task created archive: %v", err)
}
+
+ c.SetGoal("resume .reasonix/autoresearch/missing-task/../../escape")
+ if got := c.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("unsafe legacy path status = %q, want blocked", got)
+ }
+ if got := c.Goal(); got != "resume .reasonix/autoresearch/missing-task/../../escape" {
+ t.Fatalf("unsafe legacy path silently resumed a truncated task: %q", got)
+ }
}
func TestAssistantEvidenceBlockIsIgnoredByUnifiedGoal(t *testing.T) {
root := t.TempDir()
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
- prov := &scriptedTurns{turns: flattenTurns(goalToolTurn(GoalStatusComplete, "", ""))}
+ turns := goalToolTurn(GoalStatusComplete, "", "")
+ const evidenceBlock = `{"id":"legacy-evidence","kind":"verification","summary":"must remain ordinary assistant text"}`
+ turns[len(turns)-1] = textTurn("worked on the goal\n" + evidenceBlock)
+ prov := &scriptedTurns{turns: flattenTurns(turns)}
ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
c := New(Options{WorkspaceRoot: root, SessionPath: sessionPath, Runner: ag, Executor: ag})
defer c.Close()
@@ -301,6 +316,9 @@ func TestAssistantEvidenceBlockIsIgnoredByUnifiedGoal(t *testing.T) {
if got := c.GoalStatus(); got != GoalStatusComplete {
t.Fatalf("GoalStatus = %q, want complete", got)
}
+ if got := lastAssistantText(c.History()); !strings.Contains(got, evidenceBlock) {
+ t.Fatalf("legacy evidence block was interpreted instead of retained as transcript text: %q", got)
+ }
if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) {
t.Fatalf("assistant evidence created archive: %v", err)
}
@@ -666,7 +684,7 @@ func TestGoalInterceptsCompleteWithIncompleteTodos(t *testing.T) {
func TestGoalAdvanceResultCannotCrossGoalLifecycle(t *testing.T) {
newResult := func(t *testing.T, g *goalMachine) goalAdvanceResult {
t.Helper()
- g.set("old goal", GoalResearchAuto, nil)
+ g.set("old goal", "", nil)
res := g.advance(goalAdvanceInput{
report: &goalTurnReport{status: GoalStatusComplete, reason: ""},
todos: []evidence.TodoItem{{
@@ -691,7 +709,7 @@ func TestGoalAdvanceResultCannotCrossGoalLifecycle(t *testing.T) {
t.Run("replacement goal invalidates result", func(t *testing.T) {
var g goalMachine
res := newResult(t, &g)
- g.set("replacement goal", GoalResearchAuto, nil)
+ g.set("replacement goal", "", nil)
if got, ok := g.acceptContinuation(res); ok {
t.Fatalf("replacement goal accepted stale intercept %q", got)
}
@@ -908,45 +926,6 @@ func TestRepeatedCompleteWithIncompleteTodosPausesOnBudget(t *testing.T) {
}
}
-func readJSONFileForTest(t *testing.T, path string, out any) {
- t.Helper()
- data, err := os.ReadFile(path)
- if err != nil {
- t.Fatalf("ReadFile(%s): %v", path, err)
- }
- if err := json.Unmarshal(data, out); err != nil {
- t.Fatalf("Unmarshal(%s): %v", path, err)
- }
-}
-
-func sessionContainsUserText(messages []provider.Message, needles ...string) bool {
- for _, msg := range messages {
- if msg.Role != provider.RoleUser {
- continue
- }
- ok := true
- for _, needle := range needles {
- if !strings.Contains(msg.Content, needle) {
- ok = false
- break
- }
- }
- if ok {
- return true
- }
- }
- return false
-}
-
-func containsNotice(notices []string, needle string) bool {
- for _, notice := range notices {
- if strings.Contains(notice, needle) {
- return true
- }
- }
- return false
-}
-
// TestSessionRotationClearsActiveGoal pins the /new & /clear goal semantics:
// a fresh session starts with no active goal (so the old goal's text stops
// injecting into its first turns), while the OLD session's persisted
diff --git a/internal/control/input.go b/internal/control/input.go
index c1469b98ee..2a1b2eaf4a 100644
--- a/internal/control/input.go
+++ b/internal/control/input.go
@@ -138,14 +138,13 @@ func (c *Controller) Compose(text string) string {
}
func (c *Controller) compose(text, source string, includeHookContext bool) string {
- goal, goalStatus, goalResearchMode := c.goals.snapshot()
+ goal, goalStatus := c.goals.snapshot()
return c.composeWithGoal(
text,
source,
includeHookContext,
goal,
goalStatus,
- goalResearchMode,
)
}
@@ -153,7 +152,6 @@ func (c *Controller) composeWithGoal(
text, source string,
includeHookContext bool,
goal, goalStatus string,
- goalResearchMode GoalResearchMode,
) string {
c.mu.Lock()
plan := c.planMode
@@ -163,7 +161,7 @@ func (c *Controller) composeWithGoal(
notes := c.memory.drainPending()
if strings.TrimSpace(goal) != "" && goalStatus == GoalStatusRunning {
- prefix := activeGoalBlock(goal, goalResearchMode)
+ prefix := activeGoalBlock(goal)
text = prefix + "\n\n" + text
}
if plan {
@@ -298,8 +296,7 @@ func (c *Controller) ComposeSynthetic(text string) string {
return agent.WithReasoningLanguageForSource(text, lang, text)
}
-func activeGoalBlock(goal string, researchMode GoalResearchMode) string {
- _ = researchMode // retained for call-site stability; budget selection is host-side only
+func activeGoalBlock(goal string) string {
goal = strings.TrimSpace(goal)
goal = strings.ReplaceAll(goal, activeGoalClose, "<\\/active-goal>")
var b strings.Builder
@@ -369,6 +366,8 @@ type GoalCommand struct {
DeprecatedBudgetFlag bool
}
+const GoalBudgetFlagDeprecatedNotice = "This /goal budget flag is deprecated; Goal now selects its budget automatically."
+
func ParseGoalCommand(input string) (GoalCommand, bool) {
trimmed := strings.TrimSpace(input)
if trimmed != "/goal" && !strings.HasPrefix(trimmed, "/goal ") && !strings.HasPrefix(trimmed, "/goal\t") {
diff --git a/internal/control/planner_gate_test.go b/internal/control/planner_gate_test.go
index cacc4948cd..a58e7854ee 100644
--- a/internal/control/planner_gate_test.go
+++ b/internal/control/planner_gate_test.go
@@ -72,8 +72,8 @@ func TestTaskWarrantsPlanner(t *testing.T) {
{"explain how to migrate from v1 to v2", true},
{goalContinueTurn, false},
{"Goal signaled complete but issues remain:\n- the following tasks are still incomplete:\n - Fix login (in_progress)\nFix or use todo_write/complete_step to mark done, then report complete again via update_goal.", false},
- {activeGoalBlock("execute plan: fix the parser", GoalResearchAuto) + "\n\n" + goalContinueTurn, false},
- {activeGoalBlock("implement the new caching layer", GoalResearchAuto) + "\n\nimplement the new caching layer across the backend", true},
+ {activeGoalBlock("execute plan: fix the parser") + "\n\n" + goalContinueTurn, false},
+ {activeGoalBlock("implement the new caching layer") + "\n\nimplement the new caching layer across the backend", true},
}
for _, c := range cases {
if got := TaskWarrantsPlanner(c.input); got != c.want {
@@ -454,7 +454,7 @@ func TestPlannerPolicyUsesPristineMetadataInsteadOfInjectedContext(t *testing.T)
ctx := withPlannerTurnMetadata(context.Background(), plannerTurnMetadata{
UserText: "fix typo in README",
})
- input := activeGoalBlock("migrate authentication across the backend", GoalResearchAuto) +
+ input := activeGoalBlock("migrate authentication across the backend") +
"\n\n\nhigh risk migration\n\n\nfix typo in README"
got := DecidePlannerRoute(ctx, input)
if got.Route != agent.PlannerRouteExecutorOnly || got.Reason != plannerReasonAtomicEdit {
diff --git a/internal/control/port.go b/internal/control/port.go
index 861caef406..dd64e30724 100644
--- a/internal/control/port.go
+++ b/internal/control/port.go
@@ -100,6 +100,8 @@ type Goals interface {
Goal() string
GoalStatus() string
SetGoal(goal string)
+ // SetGoalWithResearchMode is retained for deprecated CLI budget flags. The
+ // mode is translated at the boundary and is not stored in the Goal runtime.
SetGoalWithResearchMode(goal string, researchMode GoalResearchMode)
ResumeGoal() bool
PauseGoal() bool
diff --git a/internal/control/turn_orchestrator.go b/internal/control/turn_orchestrator.go
index 83ce2de242..cf96b93199 100644
--- a/internal/control/turn_orchestrator.go
+++ b/internal/control/turn_orchestrator.go
@@ -205,7 +205,6 @@ func (o *turnOrchestrator) runOrchestratedTurn(ctx context.Context, turn orchest
false,
continuation.goal,
GoalStatusRunning,
- continuation.researchMode,
)
} else {
input = c.compose(turn.input, turn.raw, !turn.synthetic)
diff --git a/internal/jobs/context.go b/internal/jobs/context.go
new file mode 100644
index 0000000000..536c138089
--- /dev/null
+++ b/internal/jobs/context.go
@@ -0,0 +1,12 @@
+package jobs
+
+import "context"
+
+type noManager struct{}
+
+// WithoutManager shadows an ancestor manager while preserving the rest of the
+// context chain. Agents without Jobs must not accidentally operate a parent's
+// background jobs through inherited call context.
+func WithoutManager(ctx context.Context) context.Context {
+ return context.WithValue(ctx, ctxKey{}, noManager{})
+}
diff --git a/internal/jobs/context_test.go b/internal/jobs/context_test.go
new file mode 100644
index 0000000000..26783e5dd2
--- /dev/null
+++ b/internal/jobs/context_test.go
@@ -0,0 +1,23 @@
+package jobs
+
+import (
+ "context"
+ "testing"
+
+ "reasonix/internal/event"
+)
+
+type preservedContextKey struct{}
+
+func TestWithoutManagerShadowsOnlyManager(t *testing.T) {
+ manager := NewManager(event.Discard)
+ defer manager.Close()
+ parent := context.WithValue(WithManager(context.Background(), manager), preservedContextKey{}, "preserved")
+ child := WithoutManager(parent)
+ if _, ok := FromContext(child); ok {
+ t.Fatal("child context inherited a disabled parent job manager")
+ }
+ if got := child.Value(preservedContextKey{}); got != "preserved" {
+ t.Fatalf("unrelated context value = %v, want preserved", got)
+ }
+}
diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go
index 2b17635c05..bfe75fbfd1 100644
--- a/internal/jobs/jobs.go
+++ b/internal/jobs/jobs.go
@@ -1911,7 +1911,6 @@ func jobKey(parentSession, id string) string {
type ctxKey struct{}
type sessionCtxKey struct{}
type jobCtxKey struct{}
-type noManager struct{}
// WithManager stamps ctx with the job manager so tools can reach it via
// FromContext. The agent sets this on every tool call's context.
@@ -1919,13 +1918,6 @@ func WithManager(ctx context.Context, m *Manager) context.Context {
return context.WithValue(ctx, ctxKey{}, m)
}
-// WithoutManager shadows an ancestor manager while preserving the rest of the
-// context chain. Agents without Jobs must not accidentally operate a parent's
-// background jobs through inherited call context.
-func WithoutManager(ctx context.Context) context.Context {
- return context.WithValue(ctx, ctxKey{}, noManager{})
-}
-
// FromContext returns the job manager set by the agent, if any. ok is false for a
// plain context (headless tests, calls outside the run loop).
func FromContext(ctx context.Context) (*Manager, bool) {
diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go
index 292f537aec..fc1d8c15ba 100644
--- a/internal/jobs/jobs_test.go
+++ b/internal/jobs/jobs_test.go
@@ -41,8 +41,6 @@ type blockingFinishedSink struct {
once sync.Once
}
-type preservedContextKey struct{}
-
func (s *blockingFinishedSink) Emit(ev event.Event) {
if strings.Contains(ev.Text, "background bash finished") {
s.once.Do(func() { close(s.entered) })
@@ -81,19 +79,6 @@ func TestStartForSessionStampsJobContext(t *testing.T) {
}
}
-func TestWithoutManagerShadowsOnlyManager(t *testing.T) {
- manager := NewManager(event.Discard)
- defer manager.Close()
- parent := context.WithValue(WithManager(context.Background(), manager), preservedContextKey{}, "preserved")
- child := WithoutManager(parent)
- if _, ok := FromContext(child); ok {
- t.Fatal("child context inherited a disabled parent job manager")
- }
- if got := child.Value(preservedContextKey{}); got != "preserved" {
- t.Fatalf("unrelated context value = %v, want preserved", got)
- }
-}
-
func TestJobStartObserverSeesLifetimeUntilTerminal(t *testing.T) {
observed := make(chan (<-chan struct{}), 1)
release := make(chan struct{})
diff --git a/internal/memory/queue.go b/internal/memory/queue.go
index 36db70a881..c31ca11dc4 100644
--- a/internal/memory/queue.go
+++ b/internal/memory/queue.go
@@ -17,12 +17,20 @@ type autoMemoryWriteClaimer interface {
}
type queueKey struct{}
+type noQueue struct{}
// WithQueue stamps q onto ctx for the remember/forget tools to find.
func WithQueue(ctx context.Context, q Queue) context.Context {
return context.WithValue(ctx, queueKey{}, q)
}
+// WithoutQueue shadows an ancestor queue while preserving cancellation and
+// unrelated context values. Sub-agents use it to avoid injecting memory changes
+// directly into their parent's current-session prompt tail.
+func WithoutQueue(ctx context.Context) context.Context {
+ return context.WithValue(ctx, queueKey{}, noQueue{})
+}
+
// QueueFromContext returns the memory queue the agent stamped, if any.
func QueueFromContext(ctx context.Context) (Queue, bool) {
q, ok := ctx.Value(queueKey{}).(Queue)
diff --git a/internal/memory/queue_test.go b/internal/memory/queue_test.go
new file mode 100644
index 0000000000..0683c9e8ba
--- /dev/null
+++ b/internal/memory/queue_test.go
@@ -0,0 +1,28 @@
+package memory
+
+import (
+ "context"
+ "testing"
+)
+
+type testQueue struct{}
+
+func (testQueue) QueueMemory(string) {}
+
+type preservedQueueContextKey struct{}
+
+func TestWithoutQueueShadowsOnlyQueue(t *testing.T) {
+ parent := context.WithValue(WithQueue(context.Background(), testQueue{}), preservedQueueContextKey{}, "preserved")
+ child := WithoutQueue(parent)
+ if _, ok := QueueFromContext(child); ok {
+ t.Fatal("child context inherited the parent memory queue")
+ }
+ if got := child.Value(preservedQueueContextKey{}); got != "preserved" {
+ t.Fatalf("unrelated context value = %v, want preserved", got)
+ }
+
+ owned := WithQueue(child, testQueue{})
+ if _, ok := QueueFromContext(owned); !ok {
+ t.Fatal("child-owned memory queue did not override the shadow value")
+ }
+}
diff --git a/internal/tool/builtin/bgjobs.go b/internal/tool/builtin/bgjobs.go
index 1f1d9edda3..226bd75e2c 100644
--- a/internal/tool/builtin/bgjobs.go
+++ b/internal/tool/builtin/bgjobs.go
@@ -42,11 +42,6 @@ func (bashOutput) Schema() json.RawMessage {
func (bashOutput) ReadOnly() bool { return true }
-func (bashOutput) ProviderVisible(ctx context.Context) bool {
- _, ok := jobs.FromContext(ctx)
- return ok
-}
-
func (bashOutput) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
JobID string `json:"job_id"`
@@ -114,11 +109,6 @@ func (killShell) Schema() json.RawMessage {
func (killShell) ReadOnly() bool { return false }
-func (killShell) ProviderVisible(ctx context.Context) bool {
- _, ok := jobs.FromContext(ctx)
- return ok
-}
-
func (killShell) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
JobID string `json:"job_id"`
@@ -155,11 +145,6 @@ func (waitJob) Schema() json.RawMessage {
func (waitJob) ReadOnly() bool { return true }
-func (waitJob) ProviderVisible(ctx context.Context) bool {
- _, ok := jobs.FromContext(ctx)
- return ok
-}
-
func (waitJob) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
JobIDs []string `json:"job_ids"`
diff --git a/internal/tool/builtin/bgjobs_test.go b/internal/tool/builtin/bgjobs_test.go
index bdeff1c629..48f3031620 100644
--- a/internal/tool/builtin/bgjobs_test.go
+++ b/internal/tool/builtin/bgjobs_test.go
@@ -12,32 +12,6 @@ import (
"reasonix/internal/planmode"
)
-func TestBackgroundJobToolsVisibleOnlyWithManager(t *testing.T) {
- plain := context.Background()
- for name, visible := range map[string]func(context.Context) bool{
- "bash_output": bashOutput{}.ProviderVisible,
- "kill_shell": killShell{}.ProviderVisible,
- "wait": waitJob{}.ProviderVisible,
- } {
- if visible(plain) {
- t.Fatalf("%s visible without a job manager", name)
- }
- }
-
- manager := jobs.NewManager(event.Discard)
- defer manager.Close()
- ctx := jobs.WithManager(plain, manager)
- for name, visible := range map[string]func(context.Context) bool{
- "bash_output": bashOutput{}.ProviderVisible,
- "kill_shell": killShell{}.ProviderVisible,
- "wait": waitJob{}.ProviderVisible,
- } {
- if !visible(ctx) {
- t.Fatalf("%s hidden despite an active job manager", name)
- }
- }
-}
-
// End-to-end through the actual tools: a background bash job runs under a manager
// injected on the context, the wait tool collects its output, and bash_output
// reads it — the same path the agent drives.
diff --git a/internal/tool/builtin/completestep.go b/internal/tool/builtin/completestep.go
index a4b2355aa2..c9704e0867 100644
--- a/internal/tool/builtin/completestep.go
+++ b/internal/tool/builtin/completestep.go
@@ -9,7 +9,6 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/instruction"
- "reasonix/internal/planmode"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
@@ -81,13 +80,6 @@ func (completeStep) Schema() json.RawMessage {
// effect), so it never needs approval and stays available alongside todo_write.
func (completeStep) ReadOnly() bool { return true }
-// ProviderVisible hides execution-only sign-off from planning requests. The
-// execution gate remains authoritative for stale transcripts and hallucinated
-// calls that still reach the host.
-func (completeStep) ProviderVisible(ctx context.Context) bool {
- return !planmode.Active(ctx)
-}
-
// PlanModeSafe reports false: although complete_step is read-only, it signs off a
// completed execution step, which is meaningful only after plan approval — not
// during planning. This explicit phase opt-out is the Plan gate's enforced
diff --git a/internal/tool/builtin/completestep_schema_test.go b/internal/tool/builtin/completestep_schema_test.go
new file mode 100644
index 0000000000..2221512a44
--- /dev/null
+++ b/internal/tool/builtin/completestep_schema_test.go
@@ -0,0 +1,16 @@
+package builtin
+
+import (
+ "testing"
+
+ "reasonix/internal/tool"
+)
+
+func TestCompleteStepSchemaStableAcrossPlanModes(t *testing.T) {
+ reg := tool.NewRegistry()
+ reg.Add(completeStep{})
+ got := reg.Schemas()
+ if len(got) != 1 || got[0].Name != "complete_step" {
+ t.Fatalf("provider schemas = %+v, want stable complete_step schema", got)
+ }
+}
diff --git a/internal/tool/builtin/completestep_test.go b/internal/tool/builtin/completestep_test.go
index d81497d573..1b2861e30c 100644
--- a/internal/tool/builtin/completestep_test.go
+++ b/internal/tool/builtin/completestep_test.go
@@ -8,9 +8,7 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/instruction"
- "reasonix/internal/planmode"
"reasonix/internal/provider"
- "reasonix/internal/tool"
)
func TestTodoInventoryListsTurnTodos(t *testing.T) {
@@ -490,18 +488,6 @@ func TestCompleteStepReadOnlyForPermissionLayer(t *testing.T) {
}
}
-func TestCompleteStepSchemaOnlyVisibleAfterPlanApproval(t *testing.T) {
- reg := tool.NewRegistry()
- reg.Add(completeStep{})
- if got := reg.SchemasForContext(planmode.WithActive(context.Background(), true)); len(got) != 0 {
- t.Fatalf("Plan-mode schemas = %+v, want complete_step hidden", got)
- }
- got := reg.SchemasForContext(planmode.WithActive(context.Background(), false))
- if len(got) != 1 || got[0].Name != "complete_step" {
- t.Fatalf("execution schemas = %+v, want complete_step", got)
- }
-}
-
// Replays of real complete_step rejections captured from local sessions (2026-06-02) and issue #2917.
func TestCompleteStepMatchesParaphrasedCommands(t *testing.T) {
cases := []struct {
diff --git a/internal/tool/builtin/updategoal.go b/internal/tool/builtin/updategoal.go
index 16a62a78ce..2cc255ef23 100644
--- a/internal/tool/builtin/updategoal.go
+++ b/internal/tool/builtin/updategoal.go
@@ -43,11 +43,6 @@ func (updateGoal) Schema() json.RawMessage {
// tool permissions or bypass sandbox policy.
func (updateGoal) ReadOnly() bool { return true }
-func (updateGoal) ProviderVisible(ctx context.Context) bool {
- _, ok := tool.GoalTurnRecorderFromContext(ctx)
- return ok
-}
-
// PlanModeSafe reports true: the tool is read-only host bookkeeping. It is
// provider-visible only during an active goal turn, and Execute also fails
// closed if a stale or hallucinated call reaches an ordinary turn.
diff --git a/internal/tool/builtin/updategoal_test.go b/internal/tool/builtin/updategoal_test.go
index 428c9b416d..63912a1bbb 100644
--- a/internal/tool/builtin/updategoal_test.go
+++ b/internal/tool/builtin/updategoal_test.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
+ "reflect"
"strings"
"testing"
@@ -75,17 +76,16 @@ func TestUpdateGoalFailsClosedOutsideActiveGoalTurn(t *testing.T) {
}
}
-func TestUpdateGoalSchemaOnlyVisibleDuringActiveGoalTurn(t *testing.T) {
+func TestUpdateGoalSchemaStableAcrossGoalContexts(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(updateGoal{})
- if got := reg.SchemasForContext(context.Background()); len(got) != 0 {
- t.Fatalf("ordinary turn schemas = %+v, want update_goal hidden", got)
+ ordinary := reg.Schemas()
+ if len(ordinary) != 1 || ordinary[0].Name != "update_goal" {
+ t.Fatalf("ordinary turn schemas = %+v, want stable update_goal schema", ordinary)
}
- _, _, ctx := goalTool(t)
- got := reg.SchemasForContext(ctx)
- if len(got) != 1 || got[0].Name != "update_goal" {
- t.Fatalf("goal turn schemas = %+v, want update_goal", got)
+ if got := reg.Schemas(); !reflect.DeepEqual(got, ordinary) {
+ t.Fatalf("goal context changed provider schemas: got %+v want %+v", got, ordinary)
}
}
diff --git a/internal/tool/contract_lock_test.go b/internal/tool/contract_lock_test.go
index 99ed961905..de78eea88f 100644
--- a/internal/tool/contract_lock_test.go
+++ b/internal/tool/contract_lock_test.go
@@ -5,8 +5,6 @@ import (
"encoding/json"
"testing"
"time"
-
- "reasonix/internal/provider"
)
// blockingReadOnlyTool lets a test park ContractEntries inside the per-tool
@@ -17,27 +15,6 @@ type blockingReadOnlyTool struct {
release <-chan struct{}
}
-type blockingContextualTool struct {
- name string
- entered chan<- struct{}
- release <-chan struct{}
-}
-
-func (t *blockingContextualTool) Name() string { return t.name }
-func (t *blockingContextualTool) Description() string { return "blocking contextual test tool" }
-func (t *blockingContextualTool) Schema() json.RawMessage {
- return json.RawMessage(`{"type":"object","properties":{}}`)
-}
-func (t *blockingContextualTool) Execute(context.Context, json.RawMessage) (string, error) {
- return "ok", nil
-}
-func (t *blockingContextualTool) ReadOnly() bool { return true }
-func (t *blockingContextualTool) ProviderVisible(context.Context) bool {
- close(t.entered)
- <-t.release
- return true
-}
-
func (t *blockingReadOnlyTool) Name() string { return t.name }
func (t *blockingReadOnlyTool) Description() string { return "blocking test tool" }
func (t *blockingReadOnlyTool) Schema() json.RawMessage {
@@ -95,38 +72,3 @@ func TestContractEntriesDoesNotHoldRegistryLockAcrossToolCallbacks(t *testing.T)
t.Fatalf("ContractEntries returned %+v, want one read-only blocking_tool", entries)
}
}
-
-func TestSchemasForContextDoesNotHoldRegistryLockAcrossAvailability(t *testing.T) {
- reg := NewRegistry()
- entered := make(chan struct{})
- release := make(chan struct{})
- reg.Add(&blockingContextualTool{name: "contextual", entered: entered, release: release})
-
- schemasCh := make(chan []provider.ToolSchema, 1)
- go func() {
- schemasCh <- reg.SchemasForContext(context.Background())
- }()
-
- select {
- case <-entered:
- case <-time.After(5 * time.Second):
- t.Fatal("SchemasForContext never reached the availability callback")
- }
-
- addDone := make(chan struct{})
- go func() {
- reg.Add(stubTool{name: "writer_tool"})
- close(addDone)
- }()
- select {
- case <-addDone:
- case <-time.After(5 * time.Second):
- t.Fatal("registry writer blocked while SchemasForContext checked availability")
- }
-
- close(release)
- schemas := <-schemasCh
- if len(schemas) != 1 || schemas[0].Name != "contextual" {
- t.Fatalf("SchemasForContext returned %+v, want contextual snapshot", schemas)
- }
-}
diff --git a/internal/tool/contract_test.go b/internal/tool/contract_test.go
index 61b7ab2249..f1ca0ae8fa 100644
--- a/internal/tool/contract_test.go
+++ b/internal/tool/contract_test.go
@@ -85,15 +85,3 @@ func TestEveryBuiltinDeclaresSnipStance(t *testing.T) {
}
}
}
-
-func TestPlanModeUnsafeBuiltinsDeclareContextualVisibility(t *testing.T) {
- for _, builtin := range tool.Builtins() {
- classifier, ok := builtin.(tool.PlanModeClassifier)
- if !ok || classifier.PlanModeSafe() {
- continue
- }
- if _, ok := builtin.(tool.ContextualTool); !ok {
- t.Errorf("Plan-mode-unsafe builtin %q must hide itself from provider schemas while unavailable", builtin.Name())
- }
- }
-}
diff --git a/internal/tool/tool.go b/internal/tool/tool.go
index 09610a3565..90512f95d5 100644
--- a/internal/tool/tool.go
+++ b/internal/tool/tool.go
@@ -33,13 +33,6 @@ type Tool interface {
ReadOnly() bool
}
-// ContextualTool can hide a registered tool from provider requests when the
-// current turn cannot execute it. Execute must still validate the context so
-// stale transcripts and provider-hallucinated calls fail closed.
-type ContextualTool interface {
- ProviderVisible(context.Context) bool
-}
-
// Previewer is an optional capability a writer Tool may implement: given the
// same raw JSON args Execute would receive, compute the file change the call
// *would* make — without touching disk. ctx must be Execute's, so the preview
@@ -526,41 +519,23 @@ func (r *Registry) Names() []string {
// Schemas exports tool definitions in stable name order for the provider.
func (r *Registry) Schemas() []provider.ToolSchema {
- return r.schemasForContext(nil, false)
-}
-
-// SchemasForContext exports only tools available during ctx. Tools without a
-// contextual availability contract remain visible as before.
-func (r *Registry) SchemasForContext(ctx context.Context) []provider.ToolSchema {
- return r.schemasForContext(ctx, true)
-}
-
-func (r *Registry) schemasForContext(ctx context.Context, filterContextual bool) []provider.ToolSchema {
r.mu.RLock()
- type schemaEntry struct {
- name string
- tool Tool
- canonical json.RawMessage
- }
- entries := make([]schemaEntry, 0, len(r.order))
- for _, name := range r.order {
- if t := r.tools[name]; t != nil {
- entries = append(entries, schemaEntry{name: name, tool: t, canonical: r.canon[name]})
- }
- }
- r.mu.RUnlock()
- sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name })
+ defer r.mu.RUnlock()
+
+ names := make([]string, len(r.order))
+ copy(names, r.order)
+ sort.Strings(names)
- out := make([]provider.ToolSchema, 0, len(entries))
- for _, entry := range entries {
- t := entry.tool
- if contextual, ok := t.(ContextualTool); filterContextual && ok && !contextual.ProviderVisible(ctx) {
+ out := make([]provider.ToolSchema, 0, len(names))
+ for _, name := range names {
+ t := r.tools[name]
+ if t == nil {
continue
}
out = append(out, provider.ToolSchema{
Name: t.Name(),
Description: t.Description(),
- Parameters: entry.canonical,
+ Parameters: r.canon[name],
})
}
return out
diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json
index dbe140cfb7..4cc8a71330 100644
--- a/tools/repolint/baseline.json
+++ b/tools/repolint/baseline.json
@@ -2,19 +2,19 @@
"limits": {
"banner": 0,
"commented-code": 0,
- "complexity": 2047,
- "essay": 4006,
- "file-size": 107833,
- "function-size": 9094,
+ "complexity": 2056,
+ "essay": 4028,
+ "file-size": 108472,
+ "function-size": 9127,
"layering": 1,
"marker": 0,
"narrative": 61,
- "test-file-size": 68025
+ "test-file-size": 68117
},
"files": {
"cmd/e2ebench/main.go": {
"essay": 1,
- "function-size": 3
+ "function-size": 5
},
"cmd/e2ebench/mutation.go": {
"essay": 1
@@ -28,7 +28,7 @@
"desktop/app.go": {
"complexity": 64,
"essay": 90,
- "file-size": 11264,
+ "file-size": 11538,
"function-size": 361
},
"desktop/app_autosave_test.go": {
@@ -84,13 +84,13 @@
"essay": 2
},
"desktop/frontend/src/App.tsx": {
- "file-size": 4762
+ "file-size": 4764
},
"desktop/frontend/src/__tests__/app-chrome-tabs.test.ts": {
"test-file-size": 19
},
"desktop/frontend/src/__tests__/capabilities-panel-actions.test.ts": {
- "test-file-size": 307
+ "test-file-size": 308
},
"desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx": {
"test-file-size": 1701
@@ -114,7 +114,7 @@
"file-size": 255
},
"desktop/frontend/src/components/CapabilitiesPanel.tsx": {
- "file-size": 2626
+ "file-size": 2633
},
"desktop/frontend/src/components/Composer.tsx": {
"file-size": 3963
@@ -156,16 +156,16 @@
"file-size": 413
},
"desktop/frontend/src/lib/bridge.ts": {
- "file-size": 4368
+ "file-size": 4453
},
"desktop/frontend/src/lib/crash.ts": {
"file-size": 179
},
"desktop/frontend/src/lib/types.ts": {
- "file-size": 1318
+ "file-size": 1369
},
"desktop/frontend/src/lib/useController.ts": {
- "file-size": 3499
+ "file-size": 3503
},
"desktop/heartbeat.go": {
"essay": 18,
@@ -285,7 +285,7 @@
"desktop/tabs.go": {
"complexity": 71,
"essay": 123,
- "file-size": 8802,
+ "file-size": 8804,
"function-size": 620
},
"desktop/tabs_order_test.go": {
@@ -405,8 +405,8 @@
},
"internal/agent/agent.go": {
"complexity": 61,
- "essay": 109,
- "file-size": 2730,
+ "essay": 113,
+ "file-size": 2719,
"function-size": 124
},
"internal/agent/ask.go": {
@@ -441,16 +441,14 @@
},
"internal/agent/coordinator.go": {
"essay": 17,
- "file-size": 270,
- "function-size": 3
+ "file-size": 267
},
"internal/agent/coordinator_test.go": {
"essay": 3,
- "test-file-size": 1316
+ "test-file-size": 1239
},
"internal/agent/delivery_hardening_test.go": {
- "essay": 5,
- "test-file-size": 152
+ "essay": 5
},
"internal/agent/delivery_scope_test.go": {
"essay": 1
@@ -548,8 +546,8 @@
"internal/agent/run_loop.go": {
"complexity": 5,
"essay": 45,
- "file-size": 318,
- "function-size": 48
+ "file-size": 311,
+ "function-size": 46
},
"internal/agent/save.go": {
"complexity": 44,
@@ -612,7 +610,7 @@
"internal/agent/task.go": {
"complexity": 25,
"essay": 56,
- "file-size": 1388,
+ "file-size": 1377,
"function-size": 114
},
"internal/agent/task_test.go": {
@@ -637,16 +635,20 @@
"internal/agent/width.go": {
"essay": 1
},
+ "internal/autoresearch/store.go": {
+ "essay": 3,
+ "file-size": 216
+ },
"internal/boot/boot.go": {
"complexity": 287,
"essay": 102,
- "file-size": 2191,
+ "file-size": 2204,
"function-size": 1818,
"narrative": 3
},
"internal/boot/boot_test.go": {
"essay": 10,
- "test-file-size": 4338
+ "test-file-size": 4331
},
"internal/boot/extension_dispatch_test.go": {
"essay": 3,
@@ -676,7 +678,8 @@
"essay": 4
},
"internal/boot/rebuild_subgraph.go": {
- "function-size": 7
+ "complexity": 2,
+ "function-size": 17
},
"internal/boot/reload.go": {
"essay": 32
@@ -774,10 +777,10 @@
"essay": 15
},
"internal/cli/chat_tui.go": {
- "complexity": 300,
+ "complexity": 302,
"essay": 110,
- "file-size": 4551,
- "function-size": 1196
+ "file-size": 4555,
+ "function-size": 1200
},
"internal/cli/chat_tui_paste.go": {
"essay": 9
@@ -827,7 +830,7 @@
"internal/cli/mcp.go": {
"complexity": 8,
"essay": 5,
- "file-size": 66,
+ "file-size": 85,
"function-size": 7
},
"internal/cli/mcp_manager.go": {
@@ -969,8 +972,9 @@
"test-file-size": 2194
},
"internal/config/effort.go": {
- "complexity": 9,
- "essay": 11
+ "complexity": 10,
+ "essay": 11,
+ "function-size": 5
},
"internal/config/effort_test.go": {
"essay": 2
@@ -1028,6 +1032,9 @@
"internal/control/approval.go": {
"essay": 19
},
+ "internal/control/autoresearch_manager.go": {
+ "essay": 2
+ },
"internal/control/checkpoint.go": {
"essay": 10
},
@@ -1040,7 +1047,7 @@
},
"internal/control/controller_test.go": {
"essay": 10,
- "test-file-size": 4506
+ "test-file-size": 4507
},
"internal/control/errmsg.go": {
"essay": 2
@@ -1058,7 +1065,7 @@
},
"internal/control/goal.go": {
"complexity": 7,
- "essay": 11,
+ "essay": 20,
"file-size": 318,
"function-size": 7
},
@@ -1066,7 +1073,7 @@
"test-file-size": 14
},
"internal/control/goal_test.go": {
- "test-file-size": 264
+ "test-file-size": 607
},
"internal/control/goalusage.go": {
"essay": 2
@@ -1079,7 +1086,7 @@
},
"internal/control/input_test.go": {
"essay": 4,
- "test-file-size": 773
+ "test-file-size": 779
},
"internal/control/mcp.go": {
"essay": 7
@@ -1130,9 +1137,9 @@
"essay": 1
},
"internal/control/turn_orchestrator.go": {
- "complexity": 3,
+ "complexity": 4,
"essay": 13,
- "function-size": 64
+ "function-size": 78
},
"internal/control/turn_orchestrator_test.go": {
"essay": 1,
@@ -1372,7 +1379,7 @@
},
"internal/jobs/jobs.go": {
"essay": 42,
- "file-size": 1272,
+ "file-size": 1264,
"function-size": 12
},
"internal/jobs/jobs_extra_test.go": {
@@ -1382,7 +1389,7 @@
"essay": 4
},
"internal/jobs/jobs_test.go": {
- "test-file-size": 27
+ "test-file-size": 12
},
"internal/memory/doc.go": {
"essay": 1
@@ -1431,7 +1438,7 @@
"test-file-size": 275
},
"internal/plugin/plugin.go": {
- "essay": 29,
+ "essay": 30,
"file-size": 1315,
"function-size": 14
},
@@ -1504,10 +1511,10 @@
"essay": 8
},
"internal/provider/openai/openai.go": {
- "complexity": 61,
- "essay": 40,
- "file-size": 531,
- "function-size": 252
+ "complexity": 64,
+ "essay": 42,
+ "file-size": 554,
+ "function-size": 255
},
"internal/provider/openai/openai_test.go": {
"essay": 5,
@@ -1517,7 +1524,7 @@
"essay": 7
},
"internal/provider/provider.go": {
- "essay": 50,
+ "essay": 51,
"file-size": 353
},
"internal/provider/responses/responses.go": {
@@ -1785,9 +1792,6 @@
"internal/tool/builtin/completestep.go": {
"essay": 3
},
- "internal/tool/builtin/completestep_test.go": {
- "test-file-size": 8
- },
"internal/tool/builtin/confine.go": {
"essay": 16
},
From de9293acb87706b970a805ca663f6e2e006e40e1 Mon Sep 17 00:00:00 2001
From: SivanCola <32437197+SivanCola@users.noreply.github.com>
Date: Sun, 9 Aug 2026 03:56:50 +0800
Subject: [PATCH 07/12] test(goal): make unreadable archive fixture portable
Problem:
The explicit legacy archive fail-closed test passed on Unix but failed on Windows because chmod zero does not make a file unreadable there.
Root cause:
The fixture relied on Unix permission semantics instead of creating a platform-independent archive read failure.
Fix:
Replace task_spec.json with a directory so archive decoding fails deterministically on every supported platform.
Verification:
go test ./internal/control -count=1
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c ./internal/control
---
internal/control/goal_legacy_restore_test.go | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/internal/control/goal_legacy_restore_test.go b/internal/control/goal_legacy_restore_test.go
index 31d7c63f26..b69c284811 100644
--- a/internal/control/goal_legacy_restore_test.go
+++ b/internal/control/goal_legacy_restore_test.go
@@ -595,17 +595,16 @@ func TestMissingLegacyGoalCommandDoesNotStartProviderTurn(t *testing.T) {
}
func TestUnreadableExplicitLegacyArchiveBlocks(t *testing.T) {
- if os.Geteuid() == 0 {
- t.Skip("root can bypass archive file permissions")
- }
root := t.TempDir()
const taskID = "unreadable-explicit-archive"
taskRoot := writeLegacyGoalArchive(t, root, taskID, "never run an unreadable archive")
specPath := filepath.Join(taskRoot, "state", "task_spec.json")
- if err := os.Chmod(specPath, 0); err != nil {
+ if err := os.Remove(specPath); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(specPath, 0o755); err != nil {
t.Fatal(err)
}
- t.Cleanup(func() { _ = os.Chmod(specPath, 0o644) })
c := New(Options{WorkspaceRoot: root})
t.Cleanup(c.Close)
From bf47eedce3473c1fd368ce005e2051dc1729cafe Mon Sep 17 00:00:00 2001
From: SivanCola <32437197+SivanCola@users.noreply.github.com>
Date: Sun, 9 Aug 2026 04:20:57 +0800
Subject: [PATCH 08/12] fix(goal): restore contextual tool and migration
contracts
Problem:
A concurrent branch reconciliation kept static provider schemas and moved legacy recovery state outside the Goal machine, diverging from the approved #7959 behavior after the latest-base merge.
Root cause:
The merge resolved overlapping Goal, Jobs, child-context, and tool-registry owner files in favor of an alternative execution-only isolation design.
Fix:
Restore ContextualTool provider visibility, bounded mixed-call repair, transactional retryable legacy migration, downgrade fencing, contextual metadata, and child Goal/Jobs/memory isolation on top of the merged base. Keep the portable Windows archive-read fixture.
Verification:
go test ./... -count=1
go test -race ./internal/control ./internal/agent ./internal/jobs ./internal/tool ./internal/tool/builtin ./internal/memory ./internal/autoresearch -count=1
go vet ./...
golangci-lint run --timeout=5m
cd desktop && go test ./... -count=1
scripts/cache-guard.sh
scripts/check-cache-impact.sh
go run ./tools/repolint
git diff --check
---
CHANGELOG.md | 9 +-
internal/agent/agent.go | 12 +
internal/agent/coordinator.go | 3 +
internal/agent/delivery_hardening_test.go | 2 +-
internal/agent/extensions.go | 3 +-
internal/agent/extensions_schema_test.go | 48 ----
internal/agent/extensions_test.go | 35 +++
internal/agent/goal_schema_isolation_test.go | 132 +++++++++--
internal/agent/planmode_test.go | 109 ++++++++-
internal/agent/run_loop.go | 55 +++--
internal/agent/sampling_request.go | 3 +-
internal/agent/subagent_context.go | 15 --
.../agent/subagent_context_isolation_test.go | 4 +-
internal/agent/subagent_readonly.go | 12 -
internal/agent/subagent_store.go | 16 +-
internal/agent/task.go | 27 ++-
internal/boot/boot_test.go | 23 +-
internal/control/autoresearch_manager.go | 103 +++++----
internal/control/controller.go | 79 +++++++
internal/control/goal.go | 28 ++-
internal/control/goal_durable.go | 3 +
internal/control/goal_legacy.go | 47 ++--
internal/control/goal_legacy_restore_test.go | 206 ++++++++++++++----
internal/control/goal_set.go | 80 -------
internal/jobs/context.go | 12 -
internal/jobs/context_test.go | 23 --
internal/jobs/jobs.go | 8 +
internal/jobs/jobs_test.go | 15 ++
internal/tool/builtin/bgjobs.go | 15 ++
internal/tool/builtin/bgjobs_test.go | 26 +++
internal/tool/builtin/completestep.go | 8 +
.../tool/builtin/completestep_schema_test.go | 16 --
internal/tool/builtin/completestep_test.go | 14 ++
internal/tool/builtin/updategoal.go | 5 +
internal/tool/builtin/updategoal_test.go | 14 +-
internal/tool/contract_lock_test.go | 58 +++++
internal/tool/contract_test.go | 12 +
internal/tool/tool.go | 45 +++-
tools/repolint/baseline.json | 107 +++++----
39 files changed, 978 insertions(+), 454 deletions(-)
delete mode 100644 internal/agent/extensions_schema_test.go
delete mode 100644 internal/agent/subagent_context.go
delete mode 100644 internal/agent/subagent_readonly.go
delete mode 100644 internal/control/goal_set.go
delete mode 100644 internal/jobs/context.go
delete mode 100644 internal/jobs/context_test.go
delete mode 100644 internal/tool/builtin/completestep_schema_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3205c5019e..75e6b2089d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,11 +12,10 @@ branch.
migrate transactionally into research-budget Goals, retain their archive id
for retry when recovery fails, and write an explicit legacy-reader fence so
downgrading cannot reactivate the removed AutoResearch runtime.
-- Workflow-only tools keep a stable provider-visible schema and are rejected at
- execution time when their required Goal, Plan, or background-job context is
- absent. Mixed valid/unavailable tool batches receive one bounded repair, while
- sub-agents no longer inherit parent Goal reports, background jobs, or
- immediate memory-queue injection.
+- Workflow-only tools are exposed to models only while their required Goal,
+ Plan, or background-job context is active. Mixed valid/unavailable tool
+ batches receive one bounded repair, while sub-agents no longer inherit parent
+ Goal reports, background jobs, or immediate memory-queue injection.
- **Issue #7575:** Linux Bash under bubblewrap no longer mounts a fresh empty
`--tmpfs /tmp` on every call. Consecutive commands in the same logical session
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 924044da48..bc6e4c8aba 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -139,6 +139,18 @@ func PlanModeFromContext(ctx context.Context) bool {
return ok && cc.planMode
}
+func (a *Agent) withAgentContext(ctx context.Context) context.Context {
+ if a == nil {
+ return ctx
+ }
+ if a.jobs != nil {
+ ctx = jobs.WithManager(ctx, a.jobs)
+ } else {
+ ctx = jobs.WithoutManager(ctx)
+ }
+ return planmode.WithActive(ctx, a.planMode.Load())
+}
+
// WithParentSession stamps the active parent session ID onto a turn context so
// persisted sub-agents can record and enforce their owning conversation.
func WithParentSession(ctx context.Context, parentSession string) context.Context {
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index f751633c86..939874840a 100644
--- a/internal/agent/coordinator.go
+++ b/internal/agent/coordinator.go
@@ -361,6 +361,9 @@ func (c *Coordinator) Run(ctx context.Context, input string) error {
return c.executor.Run(ctx, input)
}
c.sink.Emit(event.Event{Kind: event.Phase, Text: c.planner.Name() + " · planning", Detail: routeDetail, Source: event.UsageSourcePlanner})
+ // The planner researches and proposes work but does not own the root Goal
+ // turn's disposition. Hide the recorder only for planning; the executor
+ // still receives the original context and can report after doing the work.
plannerCtx := tool.WithoutGoalTurnRecorder(ctx)
if decision.MaxResearchRounds > 0 {
plannerCtx = withRunStepLimit(plannerCtx, decision.MaxResearchRounds, "planner research rounds")
diff --git a/internal/agent/delivery_hardening_test.go b/internal/agent/delivery_hardening_test.go
index 928be1d813..707bc5c6b9 100644
--- a/internal/agent/delivery_hardening_test.go
+++ b/internal/agent/delivery_hardening_test.go
@@ -238,7 +238,7 @@ func TestNonGoalToolOnlyUpdateGoalGetsAtMostOneRepairRound(t *testing.T) {
}}
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
err := a.Run(context.Background(), "answer normally")
- if err == nil || !strings.Contains(err.Error(), "repeatedly called update_goal outside Goal mode") {
+ if err == nil || !strings.Contains(err.Error(), "repeatedly called context-unavailable tools") {
t.Fatalf("repeated tool-only misuse error = %v", err)
}
if prov.call != 2 {
diff --git a/internal/agent/extensions.go b/internal/agent/extensions.go
index 20a8131f62..16eae72788 100644
--- a/internal/agent/extensions.go
+++ b/internal/agent/extensions.go
@@ -111,9 +111,10 @@ func (a *Agent) interceptAgentStart(ctx context.Context) error {
if d == nil {
return nil
}
+ providerCtx := a.withAgentContext(ctx)
payload := dispatch.AgentStartPayload{
Model: a.prov.Name(),
- ToolCount: len(a.tools.Schemas()),
+ ToolCount: len(a.tools.SchemasForContext(providerCtx)),
SessionID: ParentSession(ctx),
}
result, err := d.Intercept(ctx, extension.PointAgentBeforeStart, &payload)
diff --git a/internal/agent/extensions_schema_test.go b/internal/agent/extensions_schema_test.go
deleted file mode 100644
index 0833b7ae60..0000000000
--- a/internal/agent/extensions_schema_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package agent
-
-import (
- "context"
- "testing"
-
- "reasonix/internal/event"
- "reasonix/internal/extension"
- "reasonix/internal/extension/dispatch"
- "reasonix/internal/extension/protocol"
- "reasonix/internal/provider"
- "reasonix/internal/tool"
-)
-
-func TestAgentBeforeStartToolCountUsesStableSchemas(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
-
- run := func(ctx context.Context) dispatch.AgentStartPayload {
- t.Helper()
- client := &fakeDispatchClient{}
- d := newExtDispatcher(client, true, nil, extension.PointAgentBeforeStart)
- mp := &mockProvider{name: "p", chunks: []provider.Chunk{
- {Type: provider.ChunkText, Text: "hi"}, {Type: provider.ChunkDone},
- }}
- a := New(mp, reg, NewSession("sys"), Options{Extensions: d}, event.Discard)
- if err := a.Run(ctx, "hello"); err != nil {
- t.Fatalf("Run: %v", err)
- }
- var payload dispatch.AgentStartPayload
- if !client.interceptPayloadFor(protocol.EventAgentBeforeStart, &payload) {
- t.Fatal("agent.before_start did not fire")
- }
- return payload
- }
-
- if got := run(context.Background()).ToolCount; got != 1 {
- t.Fatalf("ordinary ToolCount = %d, want stable update_goal schema", got)
- }
- ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
- if got := run(ctx).ToolCount; got != 1 {
- t.Fatalf("Goal ToolCount = %d, want stable update_goal schema", got)
- }
-}
diff --git a/internal/agent/extensions_test.go b/internal/agent/extensions_test.go
index 43cc36b4f3..8f935cc51c 100644
--- a/internal/agent/extensions_test.go
+++ b/internal/agent/extensions_test.go
@@ -272,6 +272,41 @@ func TestAgentBeforeStartReplace(t *testing.T) {
}
}
+func TestAgentBeforeStartToolCountUsesContextualSchemas(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+
+ run := func(ctx context.Context) dispatch.AgentStartPayload {
+ t.Helper()
+ client := &fakeDispatchClient{}
+ d := newExtDispatcher(client, true, nil, extension.PointAgentBeforeStart)
+ mp := &mockProvider{name: "p", chunks: []provider.Chunk{
+ {Type: provider.ChunkText, Text: "hi"}, {Type: provider.ChunkDone},
+ }}
+ a := New(mp, reg, NewSession("sys"), Options{Extensions: d}, event.Discard)
+ if err := a.Run(ctx, "hello"); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ var payload dispatch.AgentStartPayload
+ if !client.interceptPayloadFor(protocol.EventAgentBeforeStart, &payload) {
+ t.Fatal("agent.before_start did not fire")
+ }
+ return payload
+ }
+
+ if got := run(context.Background()).ToolCount; got != 0 {
+ t.Fatalf("ordinary ToolCount = %d, want update_goal hidden", got)
+ }
+ ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ if got := run(ctx).ToolCount; got != 1 {
+ t.Fatalf("Goal ToolCount = %d, want update_goal visible", got)
+ }
+}
+
func TestAgentBeforeStartFailurePolicy(t *testing.T) {
boom := errors.New("sidecar timeout")
t.Run("required fails the run", func(t *testing.T) {
diff --git a/internal/agent/goal_schema_isolation_test.go b/internal/agent/goal_schema_isolation_test.go
index 9a245e5ab1..f4b9cd2127 100644
--- a/internal/agent/goal_schema_isolation_test.go
+++ b/internal/agent/goal_schema_isolation_test.go
@@ -28,7 +28,28 @@ func (r *childIsolationGoalRecorder) RecordGoalReport(report tool.GoalReport) (s
return "recorded " + report.Status, nil
}
-func TestGoalContextKeepsProviderSchemasStable(t *testing.T) {
+type plannerPhaseOnlyTool struct{}
+
+func (plannerPhaseOnlyTool) Name() string { return "planner_phase_only" }
+func (plannerPhaseOnlyTool) Description() string { return "planner phase-only test tool" }
+func (plannerPhaseOnlyTool) Schema() json.RawMessage {
+ return json.RawMessage(`{"type":"object"}`)
+}
+func (plannerPhaseOnlyTool) Execute(context.Context, json.RawMessage) (string, error) {
+ return "phase-only", nil
+}
+func (plannerPhaseOnlyTool) ReadOnly() bool { return true }
+func (plannerPhaseOnlyTool) PlanModeSafe() bool { return false }
+
+func TestPlannerToolRegistryExcludesNonContextualPlanUnsafeTools(t *testing.T) {
+ parent := tool.NewRegistry()
+ parent.Add(plannerPhaseOnlyTool{})
+ if _, ok := PlannerToolRegistry(parent).Get("planner_phase_only"); ok {
+ t.Fatal("two-model Planner exposed a PlanModeSafe=false custom tool")
+ }
+}
+
+func TestGoalContextChangesOnlyUpdateGoalVisibility(t *testing.T) {
goalTool, ok := tool.LookupBuiltin("update_goal")
if !ok {
t.Fatal("update_goal builtin not registered")
@@ -58,11 +79,46 @@ func TestGoalContextKeepsProviderSchemasStable(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if string(ordinarySchemas) != string(goalSchemas) {
- t.Fatalf("Goal context changed provider schemas:\nordinary=%s\ngoal=%s", ordinarySchemas, goalSchemas)
+ if string(ordinarySchemas) == string(goalSchemas) {
+ t.Fatalf("Goal context did not expose update_goal:\nordinary=%s\ngoal=%s", ordinarySchemas, goalSchemas)
+ }
+ if slices.Contains(toolSchemaNames(ordinary.requests[0].Tools), "update_goal") {
+ t.Fatalf("ordinary request exposed update_goal: %s", ordinarySchemas)
+ }
+ if !slices.Contains(toolSchemaNames(goal.requests[0].Tools), "update_goal") {
+ t.Fatalf("Goal request hid update_goal: %s", goalSchemas)
+ }
+}
+
+func TestContextualToolSchemasStayStableWithinEachGoalPhase(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ reg.Add(fakeTool{name: "read_file", readOnly: true})
+
+ marshal := func(ctx context.Context) string {
+ t.Helper()
+ raw, err := json.Marshal(reg.SchemasForContext(ctx))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(raw)
+ }
+ ordinaryCtx := context.Background()
+ goalCtx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ ordinary := marshal(ordinaryCtx)
+ goal := marshal(goalCtx)
+ if ordinary != marshal(ordinaryCtx) {
+ t.Fatal("ordinary-phase schema bytes changed between identical requests")
+ }
+ if goal != marshal(goalCtx) {
+ t.Fatal("Goal-phase schema bytes changed between identical requests")
}
- if !slices.Contains(toolSchemaNames(ordinary.requests[0].Tools), "update_goal") || !slices.Contains(toolSchemaNames(goal.requests[0].Tools), "update_goal") {
- t.Fatalf("stable requests lost update_goal: ordinary=%s goal=%s", ordinarySchemas, goalSchemas)
+ if ordinary == goal {
+ t.Fatal("Goal phase transition did not produce the expected one-time schema difference")
}
}
@@ -89,7 +145,7 @@ func TestGoalRequestExposesUpdateGoal(t *testing.T) {
}
}
-func TestMixedOutOfContextGoalBatchExecutesValidToolsWithStableSchemas(t *testing.T) {
+func TestMixedContextUnavailableBatchExecutesValidToolsAndRepairsOnce(t *testing.T) {
goalTool, ok := tool.LookupBuiltin("update_goal")
if !ok {
t.Fatal("update_goal builtin not registered")
@@ -118,11 +174,11 @@ func TestMixedOutOfContextGoalBatchExecutesValidToolsWithStableSchemas(t *testin
if len(prov.requests) != 2 {
t.Fatalf("provider requests = %d, want one repair", len(prov.requests))
}
- if got := lastUser(prov.requests[1]); got != "inspect and answer" {
- t.Fatalf("stable request unexpectedly added a schema repair instruction = %q", got)
+ if got := lastUser(prov.requests[1]); !strings.Contains(got, "update_goal") || !strings.Contains(got, "visible answer text") {
+ t.Fatalf("repair instruction = %q", got)
}
- if !slices.Contains(toolSchemaNames(prov.requests[1].Tools), "update_goal") || !slices.Contains(toolSchemaNames(prov.requests[1].Tools), "read_file") {
- t.Fatalf("stable schemas = %v", toolSchemaNames(prov.requests[1].Tools))
+ if slices.Contains(toolSchemaNames(prov.requests[1].Tools), "update_goal") || !slices.Contains(toolSchemaNames(prov.requests[1].Tools), "read_file") {
+ t.Fatalf("repair schemas = %v", toolSchemaNames(prov.requests[1].Tools))
}
if got := toolResultByID(sess, "goal"); !strings.Contains(got, "only available while an active goal turn") {
t.Fatalf("unavailable result = %q", got)
@@ -132,6 +188,41 @@ func TestMixedOutOfContextGoalBatchExecutesValidToolsWithStableSchemas(t *testin
}
}
+func TestRepeatedMixedContextUnavailableBatchStopsBeforeReexecution(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ var validCalls int32
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &validCalls})
+ firstMixed := []provider.Chunk{
+ toolCallChunk("goal", "update_goal", `{"status":"complete"}`),
+ toolCallChunk("read", "read_file", `{}`),
+ {Type: provider.ChunkDone},
+ }
+ secondMixed := []provider.Chunk{
+ toolCallChunk("goal-2", "update_goal", `{"status":"complete"}`),
+ toolCallChunk("read-2", "read_file", `{}`),
+ {Type: provider.ChunkDone},
+ }
+ prov := &scriptedProvider{name: "repeated-mixed", turns: [][]provider.Chunk{firstMixed, secondMixed}}
+ sess := NewSession("sys")
+ a := New(prov, reg, sess, Options{MaxSteps: 1}, event.Discard)
+
+ err := a.Run(context.Background(), "inspect and answer")
+ if err == nil || !strings.Contains(err.Error(), "repeatedly called context-unavailable tools") {
+ t.Fatalf("Run error = %v, want repeated contextual misuse", err)
+ }
+ if got := atomic.LoadInt32(&validCalls); got != 1 {
+ t.Fatalf("valid tool calls = %d, want second mixed batch blocked before execution", got)
+ }
+ if got := toolResultByID(sess, "read-2"); !strings.Contains(got, "called again after the repair instruction") {
+ t.Fatalf("second batch pairing result = %q", got)
+ }
+}
+
func TestSubAgentDoesNotInheritParentGoalRecorder(t *testing.T) {
goalTool, ok := tool.LookupBuiltin("update_goal")
if !ok {
@@ -158,8 +249,8 @@ func TestSubAgentDoesNotInheritParentGoalRecorder(t *testing.T) {
t.Fatalf("provider requests = %d, want hallucinated call plus repair", len(prov.requests))
}
for i, req := range prov.requests {
- if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
- t.Fatalf("child provider request %d lost stable update_goal schema: %v", i+1, toolSchemaNames(req.Tools))
+ if slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
+ t.Fatalf("child provider request %d exposed update_goal: %v", i+1, toolSchemaNames(req.Tools))
}
}
if len(recorder.reports) != 0 {
@@ -209,8 +300,8 @@ func TestCoordinatorPlannerCannotReportExecutorGoalDisposition(t *testing.T) {
t.Fatalf("planner requests = %d, want hallucinated call plus repair", len(planner.requests))
}
for i, req := range planner.requests {
- if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
- t.Fatalf("planner request %d lost stable update_goal schema: %v", i+1, toolSchemaNames(req.Tools))
+ if slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
+ t.Fatalf("planner request %d exposed update_goal: %v", i+1, toolSchemaNames(req.Tools))
}
}
if got := lastToolResult(plannerSess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
@@ -239,16 +330,21 @@ func TestSubagentIdentityUsesEffectiveChildToolSchemas(t *testing.T) {
reg.Add(fakeTool{name: "read_file", readOnly: true})
store := NewSubagentStore(t.TempDir())
task := &TaskTool{transcripts: store, sysPrompt: "child system", workspaceRoot: t.TempDir()}
- run, err := task.prepareTranscriptRunWithPrompt(reg, "model", "medium", "parent-session", "call-1", "", "", "child system", "task", "inspect")
+ ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ run, err := task.prepareTranscriptRunWithPrompt(ctx, reg, "model", "medium", "parent-session", "call-1", "", "", "child system", "task", "inspect")
if err != nil {
t.Fatalf("prepareTranscriptRunWithPrompt: %v", err)
}
defer run.Release()
- if !slices.Contains(run.Meta.ToolScope, "update_goal") || !slices.Contains(run.Meta.ToolScope, "read_file") {
- t.Fatalf("subagent tool scope = %v, want stable registry schemas", run.Meta.ToolScope)
+ if slices.Contains(run.Meta.ToolScope, "update_goal") || !slices.Contains(run.Meta.ToolScope, "read_file") {
+ t.Fatalf("subagent tool scope = %v, want only child-visible tools", run.Meta.ToolScope)
}
- _, wantHash := toolIdentity(reg)
+ _, wantHash := toolIdentity(reg, reg.SchemasForContext(subagentProviderContext(ctx)))
if run.Meta.ToolSchemaHash != wantHash {
t.Fatalf("subagent schema hash = %q, want %q", run.Meta.ToolSchemaHash, wantHash)
}
+ _, staticHash := toolIdentity(reg, reg.Schemas())
+ if run.Meta.ToolSchemaHash == staticHash {
+ t.Fatal("subagent identity used static schemas and included parent-only update_goal")
+ }
}
diff --git a/internal/agent/planmode_test.go b/internal/agent/planmode_test.go
index 4d4524a5d9..b135c03cb7 100644
--- a/internal/agent/planmode_test.go
+++ b/internal/agent/planmode_test.go
@@ -3,6 +3,7 @@ package agent
import (
"context"
"encoding/json"
+ "slices"
"strings"
"testing"
@@ -267,9 +268,10 @@ func TestPlanModeCanReplacePriorExecutionTodoState(t *testing.T) {
}
}
-// TestPlanModeDoesNotMutateSystemOrTools guards the provider-visible cache
-// prefix. Plan-only execution policy must not change system or tool bytes.
-func TestPlanModeDoesNotMutateSystemOrTools(t *testing.T) {
+// TestPlanModePreservesSystemAndOrdinaryTools is the cache-stability test for
+// non-contextual tools. Phase-only tools are the intentional exception and are
+// covered by TestPlanModeRequestHidesCompleteStepUntilExecution.
+func TestPlanModePreservesSystemAndOrdinaryTools(t *testing.T) {
prov := &mockProvider{name: "p", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "ok"},
{Type: provider.ChunkDone},
@@ -301,6 +303,107 @@ func TestPlanModeDoesNotMutateSystemOrTools(t *testing.T) {
}
}
+func TestPlanModeRequestHidesCompleteStepUntilExecution(t *testing.T) {
+ prov := &mockProvider{name: "p", chunks: []provider.Chunk{
+ {Type: provider.ChunkText, Text: "ok"},
+ {Type: provider.ChunkDone},
+ }}
+ reg := tool.NewRegistry()
+ reg.Add(fakeTool{name: "read_file", readOnly: true})
+ reg.Add(mustBuiltinTool(t, "complete_step"))
+ a := New(prov, reg, NewSession("STABLE-SYS"), Options{}, event.Discard)
+
+ if err := a.Run(context.Background(), "execution"); err != nil {
+ t.Fatalf("execution Run: %v", err)
+ }
+ if !slices.Contains(toolSchemaNames(prov.lastReq.Tools), "complete_step") {
+ t.Fatalf("execution request missing complete_step: %v", toolSchemaNames(prov.lastReq.Tools))
+ }
+
+ prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "plan"}, {Type: provider.ChunkDone}}
+ a.SetPlanMode(true)
+ if err := a.Run(context.Background(), "plan first"); err != nil {
+ t.Fatalf("Plan Run: %v", err)
+ }
+ planTools := toolSchemaNames(prov.lastReq.Tools)
+ if slices.Contains(planTools, "complete_step") {
+ t.Fatalf("Plan request exposed complete_step: %v", planTools)
+ }
+ if !slices.Contains(planTools, "read_file") {
+ t.Fatalf("Plan request lost ordinary tool: %v", planTools)
+ }
+ stablePlanTools := serializeToolSchemas(t, prov.lastReq.Tools)
+ prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "plan again"}, {Type: provider.ChunkDone}}
+ if err := a.Run(context.Background(), "refine plan"); err != nil {
+ t.Fatalf("second Plan Run: %v", err)
+ }
+ if got := serializeToolSchemas(t, prov.lastReq.Tools); got != stablePlanTools {
+ t.Fatalf("Plan tool schemas changed within the same mode:\nfirst=%s\nsecond=%s", stablePlanTools, got)
+ }
+
+ prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "execute"}, {Type: provider.ChunkDone}}
+ a.SetPlanMode(false)
+ if err := a.Run(context.Background(), "execute approved plan"); err != nil {
+ t.Fatalf("post-approval Run: %v", err)
+ }
+ if !slices.Contains(toolSchemaNames(prov.lastReq.Tools), "complete_step") {
+ t.Fatalf("post-approval request missing complete_step: %v", toolSchemaNames(prov.lastReq.Tools))
+ }
+}
+
+func TestPlanModeHallucinatedCompleteStepPreservesVisibleAnswer(t *testing.T) {
+ reg := tool.NewRegistry()
+ reg.Add(mustBuiltinTool(t, "complete_step"))
+ prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
+ {
+ {Type: provider.ChunkText, Text: "Here is the plan."},
+ toolCallChunk("step", "complete_step", `{}`),
+ {Type: provider.ChunkDone},
+ },
+ {{Type: provider.ChunkText, Text: "unexpected repair"}, {Type: provider.ChunkDone}},
+ }}
+ a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
+ a.SetPlanMode(true)
+ if err := a.Run(context.Background(), "plan the change"); err != nil {
+ t.Fatalf("Plan Run: %v", err)
+ }
+ if prov.call != 1 {
+ t.Fatalf("provider calls = %d, want no repair round", prov.call)
+ }
+ if got := lastAssistantContent(a.Session()); got != "Here is the plan." {
+ t.Fatalf("last assistant text = %q", got)
+ }
+ if got := lastToolResult(a.Session(), "complete_step"); !strings.Contains(got, "only available after plan approval") {
+ t.Fatalf("complete_step result = %q", got)
+ }
+}
+
+func TestPlanModeToolOnlyCompleteStepNudgesVisibleAnswer(t *testing.T) {
+ reg := tool.NewRegistry()
+ reg.Add(mustBuiltinTool(t, "complete_step"))
+ prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
+ {toolCallChunk("step", "complete_step", `{}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "Here is the recovered plan."}, {Type: provider.ChunkDone}},
+ }}
+ a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
+ a.SetPlanMode(true)
+ if err := a.Run(context.Background(), "plan the change"); err != nil {
+ t.Fatalf("Plan repair: %v", err)
+ }
+ if len(prov.requests) != 2 {
+ t.Fatalf("provider requests = %d, want repair round", len(prov.requests))
+ }
+ if got := lastUser(prov.requests[1]); !strings.Contains(got, "complete_step") || !strings.Contains(got, "visible answer text") {
+ t.Fatalf("repair instruction = %q", got)
+ }
+ if slices.Contains(toolSchemaNames(prov.requests[1].Tools), "complete_step") {
+ t.Fatalf("repair request re-exposed complete_step: %v", toolSchemaNames(prov.requests[1].Tools))
+ }
+ if got := lastAssistantContent(a.Session()); got != "Here is the recovered plan." {
+ t.Fatalf("last assistant text = %q", got)
+ }
+}
+
func serializeToolSchemas(t *testing.T, schemas []provider.ToolSchema) string {
t.Helper()
b, err := json.Marshal(schemas)
diff --git a/internal/agent/run_loop.go b/internal/agent/run_loop.go
index a33dcf499a..5c92617e81 100644
--- a/internal/agent/run_loop.go
+++ b/internal/agent/run_loop.go
@@ -27,7 +27,7 @@ type runLoopState struct {
emptyFinalBlocks int
handoffNudges int
usedAnyTool bool
- goalToolRepairs int
+ contextToolRepairs int
graceRound bool
recoveryGraceRound bool
@@ -327,6 +327,7 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
// runToolLoop owns the main tool-round budget and dispatches each streamed
// 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++ {
// Consume a queued steer and persist it to the session so it
// survives tab switches and history replay. The model sees it as
@@ -336,7 +337,7 @@ func (a *Agent) runToolLoop(ctx context.Context, state *runLoopState) error {
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(midTurnSteerMessage(text))})
a.sink.Emit(event.Event{Kind: event.Steer, Text: text})
}
- schemas := a.tools.Schemas()
+ schemas := a.tools.SchemasForContext(ctx)
prefixShape := a.capturePrefixShape(schemas)
prevPrefixShape := a.lastPrefixShape
if !a.haveLastPrefixShape {
@@ -955,8 +956,20 @@ func (a *Agent) handleFinalResponse(ctx context.Context, state *runLoopState, te
func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step int, text, reasoning string, calls []provider.ToolCall, usage *provider.Usage) (cont bool, err error) {
state.emptyFinalBlocks = 0
state.usedAnyTool = true
- outOfContextGoalOnly := toolCallsAreOutOfContextGoalReports(ctx, calls)
+ unavailableContextTools, contextualOnly := a.unavailableContextualToolCalls(ctx, calls)
+ if len(unavailableContextTools) > 0 && state.contextToolRepairs > 0 {
+ msg := fmt.Sprintf("blocked: context-unavailable tools were called again after the repair instruction: %s", strings.Join(unavailableContextTools, ", "))
+ for _, call := range calls {
+ a.session.Add(provider.Message{
+ Role: provider.RoleTool,
+ Content: msg,
+ ToolCallID: call.ID,
+ Name: call.Name,
+ })
+ }
+ 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 {
@@ -1011,16 +1024,17 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
a.recordInterruptedDisplay("", "", nil, true, state.workDurationMs())
return false, ctx.Err()
}
- if outOfContextGoalOnly {
+ if len(unavailableContextTools) > 0 {
if hasVisibleFinalAnswer(text) {
- // Keep the assistant tool call and host error paired instead of spending
- // another model request repairing harmless Goal bookkeeping outside Goal mode.
- return a.handleFinalResponse(ctx, state, text, reasoning, usage)
- }
- state.goalToolRepairs++
- if state.goalToolRepairs > 1 {
- return false, fmt.Errorf("model repeatedly called update_goal outside Goal mode without a visible answer")
+ if contextualOnly {
+ // Keep the assistant tool call and host error paired in the transcript,
+ // but accept the co-streamed answer when every call was unavailable.
+ return a.handleFinalResponse(ctx, state, text, reasoning, usage)
+ }
}
+ state.contextToolRepairs++
+ 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()
@@ -1093,17 +1107,20 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
return true, nil
}
-func toolCallsAreOutOfContextGoalReports(ctx context.Context, calls []provider.ToolCall) bool {
+func (a *Agent) unavailableContextualToolCalls(ctx context.Context, calls []provider.ToolCall) ([]string, bool) {
if len(calls) == 0 {
- return false
- }
- if _, ok := tool.GoalTurnRecorderFromContext(ctx); ok {
- return false
+ return nil, false
}
+ names := make([]string, 0, len(calls))
for _, call := range calls {
- if call.Name != "update_goal" {
- return false
+ t, ok := a.tools.Get(call.Name)
+ if !ok {
+ continue
+ }
+ contextual, ok := t.(tool.ContextualTool)
+ if ok && !contextual.ProviderVisible(ctx) {
+ names = append(names, call.Name)
}
}
- return true
+ return names, len(names) == len(calls)
}
diff --git a/internal/agent/sampling_request.go b/internal/agent/sampling_request.go
index 0f94150767..665cb1bd43 100644
--- a/internal/agent/sampling_request.go
+++ b/internal/agent/sampling_request.go
@@ -16,6 +16,7 @@ type samplingRequest struct {
// prepareSamplingRequest freezes one model-round request (preflight + interceptors).
func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, error) {
+ ctx = a.withAgentContext(ctx)
// CreatedAt is durable UI metadata, not model input. Strip it from the
// transport copy so wall-clock differences never invalidate the provider's
// prompt-cache prefix (and custom providers cannot accidentally send it).
@@ -35,7 +36,7 @@ func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, er
}
req := provider.Request{
Messages: requestMessages,
- Tools: a.tools.Schemas(),
+ Tools: a.tools.SchemasForContext(ctx),
MaxTokens: a.maxOutputTokens,
Temperature: provider.OptionalTemperature(a.temperature),
ResponseFormat: responseFormatFromRequest(ctx),
diff --git a/internal/agent/subagent_context.go b/internal/agent/subagent_context.go
deleted file mode 100644
index 9c111968e9..0000000000
--- a/internal/agent/subagent_context.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package agent
-
-import (
- "context"
-
- "reasonix/internal/jobs"
- "reasonix/internal/memory"
- "reasonix/internal/tool"
-)
-
-func subagentProviderContext(ctx context.Context) context.Context {
- ctx = tool.WithoutGoalTurnRecorder(ctx)
- ctx = jobs.WithoutManager(ctx)
- return memory.WithoutQueue(ctx)
-}
diff --git a/internal/agent/subagent_context_isolation_test.go b/internal/agent/subagent_context_isolation_test.go
index 2c93837a2c..624e3811cf 100644
--- a/internal/agent/subagent_context_isolation_test.go
+++ b/internal/agent/subagent_context_isolation_test.go
@@ -67,8 +67,8 @@ func TestSubAgentMasksParentJobsAndMemoryContexts(t *testing.T) {
t.Fatalf("memory queue probe result = %q", got)
}
for i, req := range prov.requests {
- if !slices.Contains(toolSchemaNames(req.Tools), "wait") {
- t.Fatalf("child request %d lost stable wait schema: %v", i+1, toolSchemaNames(req.Tools))
+ if slices.Contains(toolSchemaNames(req.Tools), "wait") {
+ t.Fatalf("child request %d inherited parent Jobs manager: %v", i+1, toolSchemaNames(req.Tools))
}
}
}
diff --git a/internal/agent/subagent_readonly.go b/internal/agent/subagent_readonly.go
deleted file mode 100644
index 08f1c11396..0000000000
--- a/internal/agent/subagent_readonly.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package agent
-
-import "reasonix/internal/tool"
-
-// readOnlyAgentConstruction is the single pairing every strictly read-only
-// loop shares: the permanent ReadOnlyExecution flag plus the final registry
-// filter. Batch children and legacy read-only call sites use this boundary.
-func readOnlyAgentConstruction(reg *tool.Registry, opts Options) (*tool.Registry, Options) {
- opts.ReadOnlyExecution = true
- opts.PlannerMCPExecution = false
- return strictReadOnlyExecutionRegistry(reg), opts
-}
diff --git a/internal/agent/subagent_store.go b/internal/agent/subagent_store.go
index 4e5e697226..4fa19a2fba 100644
--- a/internal/agent/subagent_store.go
+++ b/internal/agent/subagent_store.go
@@ -16,6 +16,7 @@ import (
"reasonix/internal/fileutil"
fileencoding "reasonix/internal/fileutil/encoding"
+ "reasonix/internal/provider"
"reasonix/internal/store"
"reasonix/internal/tool"
)
@@ -77,6 +78,7 @@ type SubagentSpec struct {
ParentToolCallID string
SystemPrompt string
Registry *tool.Registry
+ ToolSchemas []provider.ToolSchema
Model string
Effort string
}
@@ -742,7 +744,7 @@ func (s *SubagentStore) LoadMeta(ref string) (SubagentMeta, error) {
}
func metaFromSpec(ref string, status SubagentStatus, created, updated time.Time, spec SubagentSpec) SubagentMeta {
- scope, schemaHash := toolIdentity(spec.Registry)
+ scope, schemaHash := toolIdentity(spec.Registry, spec.ToolSchemas)
return SubagentMeta{
Ref: ref,
CreatedAt: created,
@@ -942,13 +944,19 @@ func validSubagentRef(ref string) bool {
return true
}
-func toolIdentity(reg *tool.Registry) ([]string, string) {
+func toolIdentity(reg *tool.Registry, schemas []provider.ToolSchema) ([]string, string) {
if reg == nil {
return nil, bytesHash(nil)
}
- names := reg.Names()
+ if schemas == nil {
+ schemas = reg.Schemas()
+ }
+ names := make([]string, 0, len(schemas))
+ for _, schema := range schemas {
+ names = append(names, schema.Name)
+ }
sort.Strings(names)
- schemas := normalizeToolSchemas(reg.Schemas())
+ schemas = normalizeToolSchemas(schemas)
data, _ := json.Marshal(schemas)
return names, bytesHash(data)
}
diff --git a/internal/agent/task.go b/internal/agent/task.go
index 311c2f8bde..35ee63c746 100644
--- a/internal/agent/task.go
+++ b/internal/agent/task.go
@@ -19,6 +19,7 @@ import (
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/jobs"
+ "reasonix/internal/memory"
"reasonix/internal/permission"
"reasonix/internal/planmode"
"reasonix/internal/provider"
@@ -873,7 +874,7 @@ func (t *TaskTool) RunProfileSpec(ctx context.Context, spec ProfileExecSpec) (re
modelRef, effortRef := spec.Model, spec.Effort
usageModelRef := t.usageModelRef(modelRef, effortRef)
parentID, _, _, _ := CallContext(ctx)
- run, err := t.prepareTranscriptRunWithPrompt(subReg, modelRef, effortRef, ParentSession(ctx), parentID, spec.ContinueFrom, spec.ForkFrom, spec.SystemPrompt, spec.Kind, spec.Name)
+ run, err := t.prepareTranscriptRunWithPrompt(ctx, subReg, modelRef, effortRef, ParentSession(ctx), parentID, spec.ContinueFrom, spec.ForkFrom, spec.SystemPrompt, spec.Kind, spec.Name)
if err != nil {
return "", err
}
@@ -1055,7 +1056,7 @@ func (t *TaskTool) bashCanEnforceWriteRoots() bool {
return false
}
-func (t *TaskTool) prepareTranscriptRunWithPrompt(subReg *tool.Registry, modelRef, effortRef, parentSession, parentID, continueFrom, legacyForkFrom, systemPrompt, kind, name string) (*SubagentRun, error) {
+func (t *TaskTool) prepareTranscriptRunWithPrompt(ctx context.Context, subReg *tool.Registry, modelRef, effortRef, parentSession, parentID, continueFrom, legacyForkFrom, systemPrompt, kind, name string) (*SubagentRun, error) {
continueFrom = strings.TrimSpace(continueFrom)
legacyForkFrom = strings.TrimSpace(legacyForkFrom)
parentSession = strings.TrimSpace(parentSession)
@@ -1089,6 +1090,7 @@ func (t *TaskTool) prepareTranscriptRunWithPrompt(subReg *tool.Registry, modelRe
ParentToolCallID: parentID,
SystemPrompt: systemPrompt,
Registry: subReg,
+ ToolSchemas: subReg.SchemasForContext(subagentProviderContext(ctx)),
Model: identityModel,
Effort: identityEffort,
}
@@ -1529,6 +1531,9 @@ func PlannerToolRegistry(parent *tool.Registry) *tool.Registry {
continue
}
if tl, ok := base.Get(name); ok {
+ if classifier, ok := tl.(tool.PlanModeClassifier); ok && !classifier.PlanModeSafe() {
+ continue
+ }
sub.Add(tl)
}
}
@@ -1938,6 +1943,24 @@ func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *to
return "", fmt.Errorf("sub-agent finished without producing a final answer")
}
+func subagentProviderContext(ctx context.Context) context.Context {
+ ctx = tool.WithoutGoalTurnRecorder(ctx)
+ ctx = jobs.WithoutManager(ctx)
+ return memory.WithoutQueue(ctx)
+}
+
+// readOnlyAgentConstruction is the single pairing every strictly read-only
+// loop shares: the permanent ReadOnlyExecution flag plus the final registry
+// filter. Batch children (RunReadOnlySubAgentWithSession) and legacy call sites
+// that still use NewReadOnlyAgent build through it, so a missed call site
+// cannot set only half the boundary. The interactive two-model planner uses
+// NewPlannerAgent instead (PlannerMCPExecution).
+func readOnlyAgentConstruction(reg *tool.Registry, opts Options) (*tool.Registry, Options) {
+ opts.ReadOnlyExecution = true
+ opts.PlannerMCPExecution = false
+ return strictReadOnlyExecutionRegistry(reg), opts
+}
+
// NewReadOnlyAgent constructs a long-lived, strictly read-only agent through
// the shared construction boundary. Prefer NewPlannerAgent for the two-model
// planner so authorized non-destructive MCP can run via use_capability.
diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go
index 426de85366..abcf4dfb2c 100644
--- a/internal/boot/boot_test.go
+++ b/internal/boot/boot_test.go
@@ -2049,7 +2049,7 @@ model = "x"
}
}
-func TestBootToolContractMatchesProviderVisibleSurface(t *testing.T) {
+func TestBootToolContractCoversProviderVisibleSurface(t *testing.T) {
for _, tc := range []struct {
name string
tokenMode string
@@ -2081,11 +2081,21 @@ model = "x"
if got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantNames) {
t.Fatalf("%s provider-visible tool surface changed\ngot %v\nwant %v", tc.name, got, wantNames)
}
- if len(entries) != len(req.Tools) {
- t.Fatalf("contract entries = %d, provider tools = %d\ncontract=%v\nprovider=%v", len(entries), len(req.Tools), contractEntryNames(entries), toolSchemaNames(req.Tools))
+ entryByName := make(map[string]tool.ContractEntry, len(entries))
+ for _, entry := range entries {
+ entryByName[entry.Name] = entry
}
- for i, e := range entries {
- s := req.Tools[i]
+ if _, ok := entryByName["update_goal"]; !ok {
+ t.Fatalf("static contract must retain contextual update_goal: %v", contractEntryNames(entries))
+ }
+ if len(entries) != len(req.Tools)+1 {
+ t.Fatalf("contract entries = %d, provider tools = %d; want only contextual update_goal hidden\ncontract=%v\nprovider=%v", len(entries), len(req.Tools), contractEntryNames(entries), toolSchemaNames(req.Tools))
+ }
+ for i, s := range req.Tools {
+ e, ok := entryByName[s.Name]
+ if !ok {
+ t.Fatalf("provider tool %q missing from static contract", s.Name)
+ }
if e.Name != s.Name {
t.Fatalf("tool[%d] name = %q, want %q\ncontract=%v\nprovider=%v", i, e.Name, s.Name, contractEntryNames(entries), toolSchemaNames(req.Tools))
}
@@ -2222,7 +2232,6 @@ func defaultFullBootToolNames() []string {
"slash_command",
"task",
"todo_write",
- "update_goal",
"wait",
"web_fetch",
"write_file",
@@ -2238,7 +2247,6 @@ func economyBootToolNames() []string {
"edit_file",
"kill_shell",
"read_file",
- "update_goal",
"wait",
"write_file",
}
@@ -2290,7 +2298,6 @@ command = "reasonix-missing-mockmcp"
"edit_file",
"kill_shell",
"read_file",
- "update_goal",
"wait",
"write_file",
}
diff --git a/internal/control/autoresearch_manager.go b/internal/control/autoresearch_manager.go
index b3e3064fdc..93ed5fa8c2 100644
--- a/internal/control/autoresearch_manager.go
+++ b/internal/control/autoresearch_manager.go
@@ -43,23 +43,15 @@ func (m legacyResearchArchive) prepare(goal string) legacyResearchSetup {
blockReason: "legacy research archive is unavailable for this workspace",
}
}
- task, err := m.store.LoadTask(taskID)
+ original, err := m.loadGoalText(taskID)
if err != nil {
slog.Warn("controller: resume legacy autoresearch task", "err", err)
return legacyResearchSetup{explicit: true, taskID: taskID, blockReason: err.Error()}
}
- original := strings.TrimSpace(task.Spec.Goal)
- if original == "" {
- return legacyResearchSetup{
- explicit: true,
- taskID: task.ID,
- blockReason: "legacy research archive is missing goal text",
- }
- }
return legacyResearchSetup{
goal: original,
- taskID: task.ID,
- notice: "legacy research archive loaded: " + task.ID,
+ taskID: taskID,
+ notice: "legacy research archive loaded: " + taskID,
explicit: true,
}
}
@@ -73,6 +65,11 @@ func (m legacyResearchArchive) loadGoalText(taskID string) (string, error) {
if err != nil {
return "", err
}
+ if report, err := m.store.ValidateTask(task.ID); err != nil {
+ return "", err
+ } else if !report.Valid {
+ return "", errLegacyArchiveInvalid
+ }
goal := strings.TrimSpace(task.Spec.Goal)
if goal == "" {
return "", errLegacyArchiveMissingGoal
@@ -82,6 +79,7 @@ func (m legacyResearchArchive) loadGoalText(taskID string) (string, error) {
var (
errLegacyArchiveUnavailable = errString("legacy research archive is unavailable for this workspace")
+ errLegacyArchiveInvalid = errString("legacy research archive is invalid")
errLegacyArchiveMissingGoal = errString("legacy research archive is missing goal text")
)
@@ -94,16 +92,7 @@ func (c *Controller) prepareLegacyResearchTask(goal string) legacyResearchSetup
}
func (c *Controller) restorePendingLegacyGoal(legacy legacyGoalRestore) bool {
- if legacy.taskID == "" {
- goal, epoch, ok := c.goals.legacyArchiveBlockedState()
- if ok {
- setup := c.prepareLegacyResearchTask(goal)
- if setup.explicit && setup.taskID != "" {
- legacy = legacyGoalRestore{taskID: setup.taskID, epoch: epoch, explicit: true}
- }
- }
- }
- if legacy.taskID == "" || (strings.TrimSpace(c.goals.goalText()) != "" && !legacy.explicit) {
+ if legacy.taskID == "" || strings.TrimSpace(c.goals.goalText()) != "" {
c.replaceLegacyRestore(legacyGoalRestore{})
return false
}
@@ -117,22 +106,30 @@ func (c *Controller) restorePendingLegacyGoal(legacy legacyGoalRestore) bool {
}
goal, err := c.legacyResearchArchive.loadGoalText(legacy.taskID)
if err != nil {
- if epoch, ok := c.goals.blockLegacyRestore(legacy.epoch, err.Error()); ok {
+ if epoch, ok := c.goals.blockLegacyRestore(legacy.epoch, legacy.taskID, err.Error()); ok {
+ _, _ = c.persistGoalStateAtEpoch(epoch, restoreTodos)
c.advanceLegacyRestoreEpoch(legacy.taskID, legacy.epoch, epoch)
c.notice("legacy research archive resume failed: " + err.Error())
- }
- return true
- }
- if legacy.explicit {
- if epoch, ok := c.goals.resumeLegacyArchive(legacy.epoch, goal); ok {
- c.persistGoalStateAtEpoch(epoch, restoreTodos)
+ } else {
c.clearLegacyRestore(legacy.taskID, legacy.epoch)
}
return true
}
if strings.TrimSpace(c.goals.goalText()) == "" {
if epoch, ok := c.goals.fillGoalTextIfEmpty(legacy.epoch, goal); ok {
- c.persistGoalStateAtEpoch(epoch, restoreTodos)
+ _, persistErr := c.persistGoalStateAtEpoch(epoch, restoreTodos)
+ if persistErr != nil {
+ reason := "persist migrated legacy Goal: " + persistErr.Error()
+ if blockedEpoch, blocked := c.goals.failLegacyRestorePersistence(epoch, legacy.taskID, reason); blocked {
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: legacy.taskID, todos: restoreTodos, epoch: blockedEpoch})
+ c.notice("legacy research archive resume failed: " + reason)
+ } else {
+ c.clearLegacyRestore(legacy.taskID, legacy.epoch)
+ }
+ } else {
+ c.clearLegacyRestore(legacy.taskID, legacy.epoch)
+ }
+ } else {
c.clearLegacyRestore(legacy.taskID, legacy.epoch)
}
}
@@ -140,43 +137,59 @@ func (c *Controller) restorePendingLegacyGoal(legacy legacyGoalRestore) bool {
}
func (c *Controller) retryBlockedLegacyGoal() (handled, resumed bool) {
- legacy, ok := c.legacyRestoreSnapshot()
- if !ok {
- return false, false
- }
- goal, ok := c.goals.legacyArchiveRetryToken(legacy.epoch)
+ goal, taskID, epoch, ok := c.goals.legacyArchiveRetryToken()
if !ok {
- c.clearLegacyRestore(legacy.taskID, legacy.epoch)
+ if _, _, blocked := c.goals.legacyArchiveBlockedState(); blocked {
+ return true, false
+ }
return false, false
}
- taskID, epoch := legacy.taskID, legacy.epoch
setup := c.prepareLegacyResearchTask(goal)
- resolvedGoal := setup.goal
+ resolvedGoal, reason := setup.goal, setup.blockReason
if !setup.explicit {
var err error
resolvedGoal, err = c.legacyResearchArchive.loadGoalText(taskID)
if err != nil {
- setup.blockReason = err.Error()
- } else if strings.TrimSpace(goal) != "" {
- resolvedGoal = goal
+ reason = err.Error()
}
+ } else if setup.taskID != taskID {
+ reason = "legacy research archive identity changed during retry"
}
- if setup.blockReason != "" || strings.TrimSpace(resolvedGoal) == "" {
- reason := setup.blockReason
+ if reason != "" || strings.TrimSpace(resolvedGoal) == "" {
if reason == "" {
reason = "legacy research archive could not be recovered"
}
+ if nextEpoch, applied := c.goals.blockLegacyRestore(epoch, taskID, reason); applied {
+ _, _ = c.persistGoalStateAtEpoch(nextEpoch, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: taskID, epoch: nextEpoch})
+ }
c.notice("legacy research archive resume failed: " + reason)
return true, false
}
todos := c.goalTodos()
resumedEpoch, applied := c.goals.resumeLegacyArchive(epoch, resolvedGoal)
if !applied {
+ c.replaceLegacyRestore(legacyGoalRestore{})
return true, false
}
- c.persistGoalStateAtEpoch(resumedEpoch, todos)
- c.clearLegacyRestore(taskID, epoch)
- c.notice(setup.notice)
+ persisted, persistErr := c.persistGoalStateAtEpoch(resumedEpoch, todos)
+ if persistErr != nil {
+ reason := "persist migrated legacy Goal: " + persistErr.Error()
+ if blockedEpoch, blocked := c.goals.failLegacyRestorePersistence(resumedEpoch, taskID, reason); blocked {
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: taskID, todos: todos, epoch: blockedEpoch})
+ c.notice("legacy research archive resume failed: " + reason)
+ } else {
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ }
+ return true, false
+ }
+ if !persisted {
+ return true, false
+ }
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ if setup.notice != "" {
+ c.notice(setup.notice)
+ }
if c.executor != nil {
c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState())
}
diff --git a/internal/control/controller.go b/internal/control/controller.go
index 5a012ac4e2..09362602fd 100644
--- a/internal/control/controller.go
+++ b/internal/control/controller.go
@@ -2668,6 +2668,85 @@ func (c *Controller) SetGoal(goal string) {
c.SetGoalWithResearchMode(goal, GoalResearchAuto)
}
+// SetGoalDurable updates the Goal only when its sidecar can be replaced
+// atomically. The optional legacy archive argument is ignored; retaining it as
+// a variadic parameter keeps older source call sites compiling.
+func (c *Controller) SetGoalDurable(goal string, _ ...string) error {
+ snapshot := c.goals.capture()
+ legacySnapshot, hadLegacySnapshot := c.legacyRestoreSnapshot()
+ resolved, setup := c.resolveGoalText(goal, GoalResearchAuto)
+ var path string
+ var data []byte
+ var persist bool
+ if setup.blockReason != "" {
+ path, data, persist = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.legacyTaskID, setup.blockReason, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken()})
+ } else {
+ path, data, persist = c.goals.set(resolved, setup.budgetClass, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ }
+ if persist {
+ if err := c.goals.writeStateErr(path, data); err != nil {
+ c.goals.restore(snapshot)
+ if hadLegacySnapshot {
+ legacySnapshot.epoch = c.goals.continuationToken()
+ c.replaceLegacyRestore(legacySnapshot)
+ } else {
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ }
+ return err
+ }
+ }
+ if setup.notice != "" {
+ c.notice(setup.notice)
+ }
+ if setup.blockReason != "" {
+ c.notice("legacy research archive resume failed: " + setup.blockReason)
+ }
+ return nil
+}
+
+func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResearchMode) {
+ resolved, setup := c.resolveGoalText(goal, researchMode)
+ if setup.notice != "" {
+ c.notice(setup.notice)
+ }
+ var path string
+ var data []byte
+ var ok bool
+ if setup.blockReason != "" {
+ path, data, ok = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.legacyTaskID, setup.blockReason, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken()})
+ c.notice("legacy research archive resume failed: " + setup.blockReason)
+ } else {
+ path, data, ok = c.goals.set(resolved, setup.budgetClass, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{})
+ }
+ c.persistGoalState(path, data, ok)
+}
+
+// goalSetSetup is the resolved objective and budget class after archive lookup.
+type goalSetSetup struct {
+ budgetClass string
+ notice string
+ blockReason string
+ legacyTaskID string
+}
+
+func (c *Controller) resolveGoalText(goal string, researchMode GoalResearchMode) (string, goalSetSetup) {
+ setup := goalSetSetup{budgetClass: budgetClassForLegacyMode(goal, researchMode)}
+ legacy := c.prepareLegacyResearchTask(goal)
+ if !legacy.explicit {
+ return goal, setup
+ }
+ setup.notice, setup.blockReason, setup.legacyTaskID = legacy.notice, legacy.blockReason, legacy.taskID
+ if legacy.blockReason != "" {
+ return goal, setup
+ }
+ setup.budgetClass = budgetClassResearch
+ return legacy.goal, setup
+}
+
// ResumeGoal re-enters a recoverable blocked/stopped Goal without resetting its
// delivery evidence scope. A budget-paused Goal gets one extra slice of its
// budget class; accumulated consumption is preserved.
diff --git a/internal/control/goal.go b/internal/control/goal.go
index 7dfe1745df..b5d4bdaa65 100644
--- a/internal/control/goal.go
+++ b/internal/control/goal.go
@@ -100,6 +100,7 @@ type goalMachine struct {
lastEvaluatorReason string
stopCause string
budgetExtensions int // turn extensions from resume (compat field name)
+ pendingLegacyTaskID string
// statePath is the persisted goal-state sidecar; empty disables persistence.
statePath string
@@ -281,7 +282,7 @@ func (g *goalMachine) set(goal, preferredBudgetClass string, todos []evidence.To
}
g.mu.Lock()
defer g.mu.Unlock()
- if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.budgetClass == preferredBudgetClass {
+ if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.budgetClass == preferredBudgetClass && g.pendingLegacyTaskID == "" {
return "", nil, false
}
g.installGoalLocked(goal, preferredBudgetClass)
@@ -291,7 +292,7 @@ func (g *goalMachine) set(goal, preferredBudgetClass string, todos []evidence.To
// setLegacyArchiveBlocked atomically installs and blocks an explicit legacy
// archive goal. A concurrent Goal replacement cannot be blocked between two
// separate FSM mutations.
-func (g *goalMachine) setLegacyArchiveBlocked(goal, preferredBudgetClass, reason string, todos []evidence.TodoItem) (string, []byte, bool) {
+func (g *goalMachine) setLegacyArchiveBlocked(goal, preferredBudgetClass, taskID, reason string, todos []evidence.TodoItem) (string, []byte, bool) {
goal = strings.TrimSpace(goal)
if goal != "" && preferredBudgetClass == "" {
preferredBudgetClass = taskintent.ClassifyGoalBudget(goal)
@@ -299,6 +300,7 @@ func (g *goalMachine) setLegacyArchiveBlocked(goal, preferredBudgetClass, reason
g.mu.Lock()
defer g.mu.Unlock()
g.installGoalLocked(goal, preferredBudgetClass)
+ g.pendingLegacyTaskID = strings.TrimSpace(taskID)
if goal != "" {
g.status = GoalStatusBlocked
}
@@ -314,6 +316,7 @@ func (g *goalMachine) installGoalLocked(goal, preferredBudgetClass string) {
g.lastContinuationReason, g.lastEvaluatorReason = "", ""
g.stopCause = ""
g.budgetExtensions = 0
+ g.pendingLegacyTaskID = ""
if goal == "" {
g.goal, g.status = "", GoalStatusStopped
g.budgetClass = ""
@@ -642,7 +645,10 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string,
StopCause: g.stopCause,
BudgetExtensions: g.budgetExtensions,
}
- if strings.TrimSpace(g.goal) != "" {
+ if g.pendingLegacyTaskID != "" {
+ state.ResearchMode = GoalResearchOn
+ state.AutoResearchTaskID = g.pendingLegacyTaskID
+ } else {
// GoalResearchOff is a downgrade fence: old readers must not infer or
// inject the removed AutoResearch runtime. budgetClass is authoritative.
state.ResearchMode = GoalResearchOff
@@ -779,8 +785,12 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
taskID: strings.TrimSpace(state.AutoResearchTaskID),
todos: append([]evidence.TodoItem(nil), state.Todos...),
}
- if legacy.taskID != "" {
- migrated = g.goal != ""
+ g.pendingLegacyTaskID = legacy.taskID
+ if g.pendingLegacyTaskID != "" && g.goal != "" {
+ // Sidecars that already carry the Goal objective do not depend on the
+ // historical archive. Complete the migration immediately.
+ g.pendingLegacyTaskID = ""
+ migrated = true
}
g.scopeID = strings.TrimSpace(state.ScopeID)
if g.scopeID == "" {
@@ -849,7 +859,7 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
}
g.continuationEpoch++
legacy.epoch = g.continuationEpoch
- pendingLegacyGoal := legacy.taskID != "" && g.goal == ""
+ pendingLegacyGoal := g.pendingLegacyTaskID != "" && g.goal == ""
if migrated && !pendingLegacyGoal {
// Migration rewrites only the removed budget state. Preserve the todo
// snapshot carried by the authoritative sidecar instead of clearing it.
@@ -937,10 +947,12 @@ func (c *Controller) persistGoalState(path string, data []byte, ok bool) {
c.goals.writeState(path, data)
}
-func (c *Controller) persistGoalStateAtEpoch(epoch uint64, todos []evidence.TodoItem) {
- if _, err := c.goals.writeStateAtEpoch(epoch, todos); err != nil {
+func (c *Controller) persistGoalStateAtEpoch(epoch uint64, todos []evidence.TodoItem) (bool, error) {
+ applied, err := c.goals.writeStateAtEpoch(epoch, todos)
+ if err != nil {
slog.Warn("controller: write goal state", "err", err)
}
+ return applied, err
}
func (c *Controller) restoreTerminalGoalTodos(sessionPath string) {
diff --git a/internal/control/goal_durable.go b/internal/control/goal_durable.go
index 7708f1c1aa..d128123d4c 100644
--- a/internal/control/goal_durable.go
+++ b/internal/control/goal_durable.go
@@ -22,6 +22,7 @@ type goalMachineSnapshot struct {
lastEvaluatorReason string
stopCause string
budgetExtensions int
+ pendingLegacyTaskID string
}
func (g *goalMachine) capture() goalMachineSnapshot {
@@ -38,6 +39,7 @@ func (g *goalMachine) capture() goalMachineSnapshot {
lastContinuationReason: g.lastContinuationReason,
lastEvaluatorReason: g.lastEvaluatorReason,
stopCause: g.stopCause, budgetExtensions: g.budgetExtensions,
+ pendingLegacyTaskID: g.pendingLegacyTaskID,
}
}
@@ -55,6 +57,7 @@ func (g *goalMachine) restore(snapshot goalMachineSnapshot) {
g.lastEvaluatorReason = snapshot.lastEvaluatorReason
g.stopCause = snapshot.stopCause
g.budgetExtensions = snapshot.budgetExtensions
+ g.pendingLegacyTaskID = snapshot.pendingLegacyTaskID
g.continuationEpoch++
g.mu.Unlock()
}
diff --git a/internal/control/goal_legacy.go b/internal/control/goal_legacy.go
index 513dea7be0..1659a7ef2d 100644
--- a/internal/control/goal_legacy.go
+++ b/internal/control/goal_legacy.go
@@ -7,10 +7,9 @@ import (
)
type legacyGoalRestore struct {
- taskID string
- todos []evidence.TodoItem
- epoch uint64
- explicit bool
+ taskID string
+ todos []evidence.TodoItem
+ epoch uint64
}
func normalizeBudgetClass(goal, class string, legacyMode GoalResearchMode) string {
@@ -18,24 +17,25 @@ func normalizeBudgetClass(goal, class string, legacyMode GoalResearchMode) strin
case budgetClassSimple, budgetClassWrite, budgetClassResearch:
return class
default:
+ if strings.TrimSpace(goal) == "" && legacyMode != GoalResearchOn {
+ return ""
+ }
return budgetClassForLegacyMode(goal, legacyMode)
}
}
func goalStateNeedsMigration(state goalState, normalizedBudgetClass string) bool {
- expectedMode := GoalResearchAuto
+ expectedMode := GoalResearchOff
if strings.TrimSpace(state.AutoResearchTaskID) != "" {
expectedMode = GoalResearchOn
- } else if strings.TrimSpace(state.Goal) != "" {
- expectedMode = GoalResearchOff
}
return state.TokensLimit != 0 || state.ResearchMode != expectedMode ||
(state.BudgetClass != "" && state.BudgetClass != normalizedBudgetClass)
}
// blockLegacyRestore fails closed only while the decoded sidecar still owns the
-// active Goal epoch. The task id remains in the Controller's legacy reader.
-func (g *goalMachine) blockLegacyRestore(expectedEpoch uint64, reason string) (uint64, bool) {
+// active Goal epoch. The task id remains durable so a later resume can retry.
+func (g *goalMachine) blockLegacyRestore(expectedEpoch uint64, taskID, reason string) (uint64, bool) {
g.mu.Lock()
defer g.mu.Unlock()
if g.continuationEpoch != expectedEpoch {
@@ -44,17 +44,36 @@ func (g *goalMachine) blockLegacyRestore(expectedEpoch uint64, reason string) (u
g.status = GoalStatusBlocked
g.stopCause = stopCauseLegacyArchive
g.block = clipGoalReason(reason)
+ g.pendingLegacyTaskID = strings.TrimSpace(taskID)
g.continuationEpoch++
return g.continuationEpoch, true
}
-func (g *goalMachine) legacyArchiveRetryToken(expectedEpoch uint64) (goal string, ok bool) {
+// failLegacyRestorePersistence keeps a recovered archive retryable when the
+// sidecar replacement fails. The recovered Goal text may remain in memory, but
+// the Goal stays fail-closed and the legacy task id is retained until a later
+// resume commits the migration durably.
+func (g *goalMachine) failLegacyRestorePersistence(expectedEpoch uint64, taskID, reason string) (uint64, bool) {
g.mu.Lock()
defer g.mu.Unlock()
- if g.continuationEpoch != expectedEpoch || g.status != GoalStatusBlocked || g.stopCause != stopCauseLegacyArchive {
- return "", false
+ if g.continuationEpoch != expectedEpoch {
+ return 0, false
+ }
+ g.status = GoalStatusBlocked
+ g.stopCause = stopCauseLegacyArchive
+ g.block = clipGoalReason(reason)
+ g.pendingLegacyTaskID = strings.TrimSpace(taskID)
+ g.continuationEpoch++
+ return g.continuationEpoch, true
+}
+
+func (g *goalMachine) legacyArchiveRetryToken() (goal, taskID string, epoch uint64, ok bool) {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.status != GoalStatusBlocked || g.stopCause != stopCauseLegacyArchive || g.pendingLegacyTaskID == "" {
+ return "", "", 0, false
}
- return g.goal, true
+ return g.goal, g.pendingLegacyTaskID, g.continuationEpoch, true
}
func (g *goalMachine) legacyArchiveBlockedState() (goal string, epoch uint64, ok bool) {
@@ -107,6 +126,7 @@ func (g *goalMachine) fillGoalTextIfEmpty(expectedEpoch uint64, goal string) (ui
return 0, false
}
g.goal = goal
+ g.pendingLegacyTaskID = ""
if g.status == "" || g.stopCause == stopCauseLegacyArchive {
g.status = GoalStatusRunning
}
@@ -144,6 +164,7 @@ func (g *goalMachine) resumeLegacyArchive(expectedEpoch uint64, goal string) (ui
g.goal = goal
g.status = GoalStatusRunning
g.stopCause, g.block = "", ""
+ g.pendingLegacyTaskID = ""
g.budgetClass = budgetClassResearch
if g.turnsLimit < budgetQuota(g.budgetClass) {
g.turnsLimit = budgetQuota(g.budgetClass)
diff --git a/internal/control/goal_legacy_restore_test.go b/internal/control/goal_legacy_restore_test.go
index ffed904ea7..b69c284811 100644
--- a/internal/control/goal_legacy_restore_test.go
+++ b/internal/control/goal_legacy_restore_test.go
@@ -66,8 +66,10 @@ func TestGoalSidecarWriterFencesLegacyAutoResearchForEveryBudget(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- g := &goalMachine{statePath: filepath.Join(t.TempDir(), "goal.json")}
- _, raw, ok := g.set(tt.goal, tt.class, nil)
+ dir := t.TempDir()
+ sessionPath := filepath.Join(dir, "session.jsonl")
+ g := &goalMachine{statePath: goalStatePath(sessionPath)}
+ path, raw, ok := g.set(tt.goal, tt.class, nil)
if !ok {
t.Fatal("set did not produce sidecar data")
}
@@ -93,10 +95,33 @@ func TestGoalSidecarWriterFencesLegacyAutoResearchForEveryBudget(t *testing.T) {
if legacyReader.ResearchMode != GoalResearchOff || strings.TrimSpace(legacyReader.AutoResearchTaskID) != "" {
t.Fatal("frozen previous reader would reactivate AutoResearch")
}
+ if err := g.writeStateErr(path, raw); err != nil {
+ t.Fatal(err)
+ }
+ reloaded := &goalMachine{}
+ reloaded.restoreFromState(sessionPath)
+ if reloaded.budgetClass != tt.class || reloaded.turnsLimit != budgetQuota(tt.class) {
+ t.Fatalf("reloaded budget = %q/%d, want %q/%d", reloaded.budgetClass, reloaded.turnsLimit, tt.class, budgetQuota(tt.class))
+ }
})
}
}
+func TestEmptyGoalSidecarStillFencesLegacyAutoResearch(t *testing.T) {
+ g := &goalMachine{statePath: filepath.Join(t.TempDir(), "goal.json")}
+ _, raw, ok := g.set("", "", nil)
+ if !ok {
+ t.Fatal("empty Goal did not produce stopped sidecar state")
+ }
+ var state goalState
+ if err := json.Unmarshal(raw, &state); err != nil {
+ t.Fatal(err)
+ }
+ if state.ResearchMode != GoalResearchOff || state.AutoResearchTaskID != "" || state.BudgetClass != "" {
+ t.Fatalf("empty Goal downgrade fence = %+v", state)
+ }
+}
+
func TestGoalSetIdempotencyUsesEffectiveBudgetClass(t *testing.T) {
g := &goalMachine{statePath: filepath.Join(t.TempDir(), "goal.json")}
if _, _, ok := g.set("same goal", budgetClassSimple, nil); !ok {
@@ -158,11 +183,8 @@ func TestLegacySidecarArchiveFailureIsBlockedAndRetryable(t *testing.T) {
if err := json.Unmarshal(failedRaw, &failed); err != nil {
t.Fatal(err)
}
- if failed.Status != GoalStatusRunning || failed.ResearchMode != GoalResearchOn || failed.AutoResearchTaskID != taskID {
- t.Fatalf("failed restore sidecar = %+v, want original legacy sidecar preserved for retry", failed)
- }
- if got := c.GoalStatus(); got != GoalStatusBlocked {
- t.Fatalf("failed restore runtime status = %q, want blocked", got)
+ if failed.Status != GoalStatusBlocked || failed.ResearchMode != GoalResearchOn || failed.AutoResearchTaskID != taskID || failed.StopCause != stopCauseLegacyArchive || failed.Block == "" {
+ t.Fatalf("failed restore state = %+v, want retryable blocked legacy migration", failed)
}
if failed.ScopeID != scopeID || failed.DeliveryCheckpoint != wantCheckpoint || len(failed.Todos) != 1 || failed.Todos[0] != wantTodo {
t.Fatalf("failed restore lost goal state: %+v", failed)
@@ -225,6 +247,78 @@ func TestLegacySidecarArchiveFailureIsBlockedAndRetryable(t *testing.T) {
}
}
+func TestLegacySidecarInvalidArchivesRemainRetryableAndReadOnly(t *testing.T) {
+ tests := []struct {
+ name string
+ file string
+ mutate func(taskID string) string
+ }{
+ {name: "corrupt json", file: "state/progress.json", mutate: func(string) string { return "{not-json" }},
+ {name: "invalid schema", file: "state/task_spec.json", mutate: func(string) string {
+ return `{"task_id":"different-task","goal":"schema mismatch","allowed_operations":{"write":true},"success_criteria":[]}`
+ }},
+ {name: "empty goal", file: "state/task_spec.json", mutate: func(taskID string) string {
+ return `{"task_id":"` + taskID + `","goal":"","allowed_operations":{"write":true},"success_criteria":[]}`
+ }},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ root := t.TempDir()
+ taskID := "invalid-" + strings.ReplaceAll(tt.name, " ", "-")
+ taskRoot := writeLegacyGoalArchive(t, root, taskID, "recover only from a valid archive")
+ target := filepath.Join(taskRoot, tt.file)
+ if err := os.WriteFile(target, []byte(tt.mutate(taskID)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ archiveBefore, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sessionPath := filepath.Join(root, "sessions", "s.jsonl")
+ if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := json.Marshal(goalState{
+ Status: GoalStatusRunning, ResearchMode: GoalResearchOn,
+ AutoResearchTaskID: taskID, BudgetClass: budgetClassResearch, TurnsLimit: 40,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sess := agent.NewSession("sys")
+ exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+ c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
+ c.Resume(sess, sessionPath)
+ defer c.Close()
+ if c.GoalStatus() != GoalStatusBlocked || c.ResumeGoal() {
+ t.Fatalf("invalid archive status=%q resumed unexpectedly", c.GoalStatus())
+ }
+ persistedRaw, err := os.ReadFile(goalStatePath(sessionPath))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var persisted goalState
+ if err := json.Unmarshal(persistedRaw, &persisted); err != nil {
+ t.Fatal(err)
+ }
+ if persisted.AutoResearchTaskID != taskID || persisted.ResearchMode != GoalResearchOn || persisted.StopCause != stopCauseLegacyArchive {
+ t.Fatalf("retry state = %+v", persisted)
+ }
+ archiveAfter, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(archiveAfter) != string(archiveBefore) {
+ t.Fatal("invalid legacy archive changed during failed restore")
+ }
+ })
+ }
+}
+
func TestLegacySidecarArchiveCanRetryInSameController(t *testing.T) {
root := t.TempDir()
sessionPath := filepath.Join(root, "sessions", "s.jsonl")
@@ -272,11 +366,57 @@ func TestLegacySidecarArchiveCanRetryInSameController(t *testing.T) {
}
}
+func TestLegacyArchiveMigrationWriteFailureRemainsBlockedAndRetryable(t *testing.T) {
+ root := t.TempDir()
+ const taskID = "write-retry"
+ writeLegacyGoalArchive(t, root, taskID, "recover after sidecar write repair")
+
+ sess := agent.NewSession("sys")
+ exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
+ c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
+ defer c.Close()
+
+ blockedParent := filepath.Join(root, "not-a-directory")
+ if err := os.WriteFile(blockedParent, []byte("block mkdir"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ c.goals.setStatePath(filepath.Join(blockedParent, "goal.json"))
+ rawGoal := "resume .reasonix/autoresearch/" + taskID + "/"
+ c.goals.setLegacyArchiveBlocked(rawGoal, budgetClassResearch, taskID, "retry migration", nil)
+
+ if c.ResumeGoal() {
+ t.Fatal("migration reported success after its sidecar write failed")
+ }
+ goal, retainedTaskID, _, ok := c.goals.legacyArchiveRetryToken()
+ if !ok || retainedTaskID != taskID || goal != "recover after sidecar write repair" {
+ t.Fatalf("failed write lost retry state: goal=%q task=%q ok=%v", goal, retainedTaskID, ok)
+ }
+ if c.GoalStatus() != GoalStatusBlocked {
+ t.Fatalf("status = %q, want fail-closed blocked", c.GoalStatus())
+ }
+
+ statePath := filepath.Join(root, "sessions", "goal.json")
+ c.goals.setStatePath(statePath)
+ if !c.ResumeGoal() {
+ t.Fatal("migration did not retry after sidecar persistence was repaired")
+ }
+ raw, err := os.ReadFile(statePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var persisted goalState
+ if err := json.Unmarshal(raw, &persisted); err != nil {
+ t.Fatal(err)
+ }
+ if persisted.Status != GoalStatusRunning || persisted.AutoResearchTaskID != "" || persisted.ResearchMode != GoalResearchOff {
+ t.Fatalf("retried migration state = %+v", persisted)
+ }
+}
+
func TestStaleLegacyArchiveRetryCannotReplaceNewGoal(t *testing.T) {
var g goalMachine
- g.setLegacyArchiveBlocked("resume .reasonix/autoresearch/old/", budgetClassResearch, "missing", nil)
- epoch := g.continuationToken()
- _, ok := g.legacyArchiveRetryToken(epoch)
+ g.setLegacyArchiveBlocked("resume .reasonix/autoresearch/old/", budgetClassResearch, "old", "missing", nil)
+ _, _, epoch, ok := g.legacyArchiveRetryToken()
if !ok {
t.Fatal("legacy retry token unavailable")
}
@@ -295,7 +435,7 @@ func TestStaleInitialLegacyFailureCannotBlockNewGoal(t *testing.T) {
epoch := g.continuationToken()
g.set("new goal", budgetClassWrite, nil)
- if _, blocked := g.blockLegacyRestore(epoch, "archive disappeared"); blocked {
+ if _, blocked := g.blockLegacyRestore(epoch, "old-task", "archive disappeared"); blocked {
t.Fatal("stale archive failure blocked a newer Goal")
}
if got := g.goalText(); got != "new goal" || g.statusForDisplay() != GoalStatusRunning {
@@ -422,43 +562,19 @@ func TestExplicitLegacyGoalRetryNeverRunsArchivePathAsGoal(t *testing.T) {
}
}
-func TestExplicitLegacyGoalRetryCanRecoverAfterRestart(t *testing.T) {
- root := t.TempDir()
- sessionPath := filepath.Join(root, "sessions", "s.jsonl")
- const taskID = "restart-explicit-archive"
- rawGoal := "resume .reasonix/autoresearch/" + taskID + "/"
-
- exec1 := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
- c1 := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec1})
- c1.Resume(agent.NewSession("sys"), sessionPath)
- c1.SetGoal(rawGoal)
- if got := c1.GoalStatus(); got != GoalStatusBlocked {
- t.Fatalf("initial status = %q, want blocked", got)
- }
- c1.Close()
-
- c2 := New(Options{WorkspaceRoot: root, SessionDir: root})
- c2.Resume(agent.NewSession("sys"), sessionPath)
- defer c2.Close()
- if got := c2.GoalStatus(); got != GoalStatusBlocked {
- t.Fatalf("restart status = %q, want blocked", got)
- }
- if c2.ResumeGoal() {
- t.Fatal("restart resume succeeded while archive was missing")
- }
- if got := c2.Goal(); got != rawGoal {
- t.Fatalf("restart failure changed Goal = %q, want %q", got, rawGoal)
- }
+func TestMalformedLegacyArchivePathCannotResumeAsGoalText(t *testing.T) {
+ c := New(Options{WorkspaceRoot: t.TempDir()})
+ defer c.Close()
- writeLegacyGoalArchive(t, root, taskID, "recover the original objective after restart")
- if !c2.ResumeGoal() {
- t.Fatal("restart resume did not recover repaired archive")
+ c.SetGoal("resume .reasonix/autoresearch/../escape")
+ if c.GoalStatus() != GoalStatusBlocked {
+ t.Fatalf("status = %q, want blocked", c.GoalStatus())
}
- if got := c2.Goal(); got != "recover the original objective after restart" {
- t.Fatalf("recovered Goal = %q", got)
+ if c.ResumeGoal() {
+ t.Fatal("malformed archive path resumed as an ordinary Goal")
}
- if c2.GoalStatus() != GoalStatusRunning || c2.GoalRuntime().TurnsLimit != 40 {
- t.Fatalf("recovered runtime = status:%q %+v", c2.GoalStatus(), c2.GoalRuntime())
+ if c.GoalStatus() != GoalStatusBlocked {
+ t.Fatalf("status after resume = %q, want blocked", c.GoalStatus())
}
}
diff --git a/internal/control/goal_set.go b/internal/control/goal_set.go
deleted file mode 100644
index acd0925911..0000000000
--- a/internal/control/goal_set.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package control
-
-// SetGoalDurable updates the Goal only when its sidecar can be replaced
-// atomically.
-func (c *Controller) SetGoalDurable(goal string) error {
- snapshot := c.goals.capture()
- legacySnapshot, hadLegacySnapshot := c.legacyRestoreSnapshot()
- resolved, setup := c.resolveGoalText(goal, GoalResearchAuto)
- var path string
- var data []byte
- var persist bool
- if setup.blockReason != "" {
- path, data, persist = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.blockReason, c.goalTodos())
- c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken(), explicit: setup.explicit})
- } else {
- path, data, persist = c.goals.set(resolved, setup.budgetClass, c.goalTodos())
- c.replaceLegacyRestore(legacyGoalRestore{})
- }
- if persist {
- if err := c.goals.writeStateErr(path, data); err != nil {
- c.goals.restore(snapshot)
- if hadLegacySnapshot {
- legacySnapshot.epoch = c.goals.continuationToken()
- c.replaceLegacyRestore(legacySnapshot)
- } else {
- c.replaceLegacyRestore(legacyGoalRestore{})
- }
- return err
- }
- }
- if setup.notice != "" {
- c.notice(setup.notice)
- }
- if setup.blockReason != "" {
- c.notice("legacy research archive resume failed: " + setup.blockReason)
- }
- return nil
-}
-
-func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResearchMode) {
- resolved, setup := c.resolveGoalText(goal, researchMode)
- if setup.notice != "" {
- c.notice(setup.notice)
- }
- var path string
- var data []byte
- var ok bool
- if setup.blockReason != "" {
- path, data, ok = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.blockReason, c.goalTodos())
- c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken(), explicit: setup.explicit})
- c.notice("legacy research archive resume failed: " + setup.blockReason)
- } else {
- path, data, ok = c.goals.set(resolved, setup.budgetClass, c.goalTodos())
- c.replaceLegacyRestore(legacyGoalRestore{})
- }
- c.persistGoalState(path, data, ok)
-}
-
-// goalSetSetup is the resolved objective and budget class after archive lookup.
-type goalSetSetup struct {
- budgetClass string
- notice string
- blockReason string
- legacyTaskID string
- explicit bool
-}
-
-func (c *Controller) resolveGoalText(goal string, researchMode GoalResearchMode) (string, goalSetSetup) {
- setup := goalSetSetup{budgetClass: budgetClassForLegacyMode(goal, researchMode)}
- legacy := c.prepareLegacyResearchTask(goal)
- if !legacy.explicit {
- return goal, setup
- }
- setup.notice, setup.blockReason, setup.legacyTaskID, setup.explicit = legacy.notice, legacy.blockReason, legacy.taskID, legacy.explicit
- if legacy.blockReason != "" {
- return goal, setup
- }
- setup.budgetClass = budgetClassResearch
- return legacy.goal, setup
-}
diff --git a/internal/jobs/context.go b/internal/jobs/context.go
deleted file mode 100644
index 536c138089..0000000000
--- a/internal/jobs/context.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package jobs
-
-import "context"
-
-type noManager struct{}
-
-// WithoutManager shadows an ancestor manager while preserving the rest of the
-// context chain. Agents without Jobs must not accidentally operate a parent's
-// background jobs through inherited call context.
-func WithoutManager(ctx context.Context) context.Context {
- return context.WithValue(ctx, ctxKey{}, noManager{})
-}
diff --git a/internal/jobs/context_test.go b/internal/jobs/context_test.go
deleted file mode 100644
index 26783e5dd2..0000000000
--- a/internal/jobs/context_test.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package jobs
-
-import (
- "context"
- "testing"
-
- "reasonix/internal/event"
-)
-
-type preservedContextKey struct{}
-
-func TestWithoutManagerShadowsOnlyManager(t *testing.T) {
- manager := NewManager(event.Discard)
- defer manager.Close()
- parent := context.WithValue(WithManager(context.Background(), manager), preservedContextKey{}, "preserved")
- child := WithoutManager(parent)
- if _, ok := FromContext(child); ok {
- t.Fatal("child context inherited a disabled parent job manager")
- }
- if got := child.Value(preservedContextKey{}); got != "preserved" {
- t.Fatalf("unrelated context value = %v, want preserved", got)
- }
-}
diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go
index bfe75fbfd1..2b17635c05 100644
--- a/internal/jobs/jobs.go
+++ b/internal/jobs/jobs.go
@@ -1911,6 +1911,7 @@ func jobKey(parentSession, id string) string {
type ctxKey struct{}
type sessionCtxKey struct{}
type jobCtxKey struct{}
+type noManager struct{}
// WithManager stamps ctx with the job manager so tools can reach it via
// FromContext. The agent sets this on every tool call's context.
@@ -1918,6 +1919,13 @@ func WithManager(ctx context.Context, m *Manager) context.Context {
return context.WithValue(ctx, ctxKey{}, m)
}
+// WithoutManager shadows an ancestor manager while preserving the rest of the
+// context chain. Agents without Jobs must not accidentally operate a parent's
+// background jobs through inherited call context.
+func WithoutManager(ctx context.Context) context.Context {
+ return context.WithValue(ctx, ctxKey{}, noManager{})
+}
+
// FromContext returns the job manager set by the agent, if any. ok is false for a
// plain context (headless tests, calls outside the run loop).
func FromContext(ctx context.Context) (*Manager, bool) {
diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go
index fc1d8c15ba..292f537aec 100644
--- a/internal/jobs/jobs_test.go
+++ b/internal/jobs/jobs_test.go
@@ -41,6 +41,8 @@ type blockingFinishedSink struct {
once sync.Once
}
+type preservedContextKey struct{}
+
func (s *blockingFinishedSink) Emit(ev event.Event) {
if strings.Contains(ev.Text, "background bash finished") {
s.once.Do(func() { close(s.entered) })
@@ -79,6 +81,19 @@ func TestStartForSessionStampsJobContext(t *testing.T) {
}
}
+func TestWithoutManagerShadowsOnlyManager(t *testing.T) {
+ manager := NewManager(event.Discard)
+ defer manager.Close()
+ parent := context.WithValue(WithManager(context.Background(), manager), preservedContextKey{}, "preserved")
+ child := WithoutManager(parent)
+ if _, ok := FromContext(child); ok {
+ t.Fatal("child context inherited a disabled parent job manager")
+ }
+ if got := child.Value(preservedContextKey{}); got != "preserved" {
+ t.Fatalf("unrelated context value = %v, want preserved", got)
+ }
+}
+
func TestJobStartObserverSeesLifetimeUntilTerminal(t *testing.T) {
observed := make(chan (<-chan struct{}), 1)
release := make(chan struct{})
diff --git a/internal/tool/builtin/bgjobs.go b/internal/tool/builtin/bgjobs.go
index 226bd75e2c..1f1d9edda3 100644
--- a/internal/tool/builtin/bgjobs.go
+++ b/internal/tool/builtin/bgjobs.go
@@ -42,6 +42,11 @@ func (bashOutput) Schema() json.RawMessage {
func (bashOutput) ReadOnly() bool { return true }
+func (bashOutput) ProviderVisible(ctx context.Context) bool {
+ _, ok := jobs.FromContext(ctx)
+ return ok
+}
+
func (bashOutput) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
JobID string `json:"job_id"`
@@ -109,6 +114,11 @@ func (killShell) Schema() json.RawMessage {
func (killShell) ReadOnly() bool { return false }
+func (killShell) ProviderVisible(ctx context.Context) bool {
+ _, ok := jobs.FromContext(ctx)
+ return ok
+}
+
func (killShell) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
JobID string `json:"job_id"`
@@ -145,6 +155,11 @@ func (waitJob) Schema() json.RawMessage {
func (waitJob) ReadOnly() bool { return true }
+func (waitJob) ProviderVisible(ctx context.Context) bool {
+ _, ok := jobs.FromContext(ctx)
+ return ok
+}
+
func (waitJob) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
JobIDs []string `json:"job_ids"`
diff --git a/internal/tool/builtin/bgjobs_test.go b/internal/tool/builtin/bgjobs_test.go
index 48f3031620..bdeff1c629 100644
--- a/internal/tool/builtin/bgjobs_test.go
+++ b/internal/tool/builtin/bgjobs_test.go
@@ -12,6 +12,32 @@ import (
"reasonix/internal/planmode"
)
+func TestBackgroundJobToolsVisibleOnlyWithManager(t *testing.T) {
+ plain := context.Background()
+ for name, visible := range map[string]func(context.Context) bool{
+ "bash_output": bashOutput{}.ProviderVisible,
+ "kill_shell": killShell{}.ProviderVisible,
+ "wait": waitJob{}.ProviderVisible,
+ } {
+ if visible(plain) {
+ t.Fatalf("%s visible without a job manager", name)
+ }
+ }
+
+ manager := jobs.NewManager(event.Discard)
+ defer manager.Close()
+ ctx := jobs.WithManager(plain, manager)
+ for name, visible := range map[string]func(context.Context) bool{
+ "bash_output": bashOutput{}.ProviderVisible,
+ "kill_shell": killShell{}.ProviderVisible,
+ "wait": waitJob{}.ProviderVisible,
+ } {
+ if !visible(ctx) {
+ t.Fatalf("%s hidden despite an active job manager", name)
+ }
+ }
+}
+
// End-to-end through the actual tools: a background bash job runs under a manager
// injected on the context, the wait tool collects its output, and bash_output
// reads it — the same path the agent drives.
diff --git a/internal/tool/builtin/completestep.go b/internal/tool/builtin/completestep.go
index c9704e0867..a4b2355aa2 100644
--- a/internal/tool/builtin/completestep.go
+++ b/internal/tool/builtin/completestep.go
@@ -9,6 +9,7 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/instruction"
+ "reasonix/internal/planmode"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
@@ -80,6 +81,13 @@ func (completeStep) Schema() json.RawMessage {
// effect), so it never needs approval and stays available alongside todo_write.
func (completeStep) ReadOnly() bool { return true }
+// ProviderVisible hides execution-only sign-off from planning requests. The
+// execution gate remains authoritative for stale transcripts and hallucinated
+// calls that still reach the host.
+func (completeStep) ProviderVisible(ctx context.Context) bool {
+ return !planmode.Active(ctx)
+}
+
// PlanModeSafe reports false: although complete_step is read-only, it signs off a
// completed execution step, which is meaningful only after plan approval — not
// during planning. This explicit phase opt-out is the Plan gate's enforced
diff --git a/internal/tool/builtin/completestep_schema_test.go b/internal/tool/builtin/completestep_schema_test.go
deleted file mode 100644
index 2221512a44..0000000000
--- a/internal/tool/builtin/completestep_schema_test.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package builtin
-
-import (
- "testing"
-
- "reasonix/internal/tool"
-)
-
-func TestCompleteStepSchemaStableAcrossPlanModes(t *testing.T) {
- reg := tool.NewRegistry()
- reg.Add(completeStep{})
- got := reg.Schemas()
- if len(got) != 1 || got[0].Name != "complete_step" {
- t.Fatalf("provider schemas = %+v, want stable complete_step schema", got)
- }
-}
diff --git a/internal/tool/builtin/completestep_test.go b/internal/tool/builtin/completestep_test.go
index 1b2861e30c..d81497d573 100644
--- a/internal/tool/builtin/completestep_test.go
+++ b/internal/tool/builtin/completestep_test.go
@@ -8,7 +8,9 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/instruction"
+ "reasonix/internal/planmode"
"reasonix/internal/provider"
+ "reasonix/internal/tool"
)
func TestTodoInventoryListsTurnTodos(t *testing.T) {
@@ -488,6 +490,18 @@ func TestCompleteStepReadOnlyForPermissionLayer(t *testing.T) {
}
}
+func TestCompleteStepSchemaOnlyVisibleAfterPlanApproval(t *testing.T) {
+ reg := tool.NewRegistry()
+ reg.Add(completeStep{})
+ if got := reg.SchemasForContext(planmode.WithActive(context.Background(), true)); len(got) != 0 {
+ t.Fatalf("Plan-mode schemas = %+v, want complete_step hidden", got)
+ }
+ got := reg.SchemasForContext(planmode.WithActive(context.Background(), false))
+ if len(got) != 1 || got[0].Name != "complete_step" {
+ t.Fatalf("execution schemas = %+v, want complete_step", got)
+ }
+}
+
// Replays of real complete_step rejections captured from local sessions (2026-06-02) and issue #2917.
func TestCompleteStepMatchesParaphrasedCommands(t *testing.T) {
cases := []struct {
diff --git a/internal/tool/builtin/updategoal.go b/internal/tool/builtin/updategoal.go
index 2cc255ef23..16a62a78ce 100644
--- a/internal/tool/builtin/updategoal.go
+++ b/internal/tool/builtin/updategoal.go
@@ -43,6 +43,11 @@ func (updateGoal) Schema() json.RawMessage {
// tool permissions or bypass sandbox policy.
func (updateGoal) ReadOnly() bool { return true }
+func (updateGoal) ProviderVisible(ctx context.Context) bool {
+ _, ok := tool.GoalTurnRecorderFromContext(ctx)
+ return ok
+}
+
// PlanModeSafe reports true: the tool is read-only host bookkeeping. It is
// provider-visible only during an active goal turn, and Execute also fails
// closed if a stale or hallucinated call reaches an ordinary turn.
diff --git a/internal/tool/builtin/updategoal_test.go b/internal/tool/builtin/updategoal_test.go
index 63912a1bbb..428c9b416d 100644
--- a/internal/tool/builtin/updategoal_test.go
+++ b/internal/tool/builtin/updategoal_test.go
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
- "reflect"
"strings"
"testing"
@@ -76,16 +75,17 @@ func TestUpdateGoalFailsClosedOutsideActiveGoalTurn(t *testing.T) {
}
}
-func TestUpdateGoalSchemaStableAcrossGoalContexts(t *testing.T) {
+func TestUpdateGoalSchemaOnlyVisibleDuringActiveGoalTurn(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(updateGoal{})
- ordinary := reg.Schemas()
- if len(ordinary) != 1 || ordinary[0].Name != "update_goal" {
- t.Fatalf("ordinary turn schemas = %+v, want stable update_goal schema", ordinary)
+ if got := reg.SchemasForContext(context.Background()); len(got) != 0 {
+ t.Fatalf("ordinary turn schemas = %+v, want update_goal hidden", got)
}
- if got := reg.Schemas(); !reflect.DeepEqual(got, ordinary) {
- t.Fatalf("goal context changed provider schemas: got %+v want %+v", got, ordinary)
+ _, _, ctx := goalTool(t)
+ got := reg.SchemasForContext(ctx)
+ if len(got) != 1 || got[0].Name != "update_goal" {
+ t.Fatalf("goal turn schemas = %+v, want update_goal", got)
}
}
diff --git a/internal/tool/contract_lock_test.go b/internal/tool/contract_lock_test.go
index de78eea88f..99ed961905 100644
--- a/internal/tool/contract_lock_test.go
+++ b/internal/tool/contract_lock_test.go
@@ -5,6 +5,8 @@ import (
"encoding/json"
"testing"
"time"
+
+ "reasonix/internal/provider"
)
// blockingReadOnlyTool lets a test park ContractEntries inside the per-tool
@@ -15,6 +17,27 @@ type blockingReadOnlyTool struct {
release <-chan struct{}
}
+type blockingContextualTool struct {
+ name string
+ entered chan<- struct{}
+ release <-chan struct{}
+}
+
+func (t *blockingContextualTool) Name() string { return t.name }
+func (t *blockingContextualTool) Description() string { return "blocking contextual test tool" }
+func (t *blockingContextualTool) Schema() json.RawMessage {
+ return json.RawMessage(`{"type":"object","properties":{}}`)
+}
+func (t *blockingContextualTool) Execute(context.Context, json.RawMessage) (string, error) {
+ return "ok", nil
+}
+func (t *blockingContextualTool) ReadOnly() bool { return true }
+func (t *blockingContextualTool) ProviderVisible(context.Context) bool {
+ close(t.entered)
+ <-t.release
+ return true
+}
+
func (t *blockingReadOnlyTool) Name() string { return t.name }
func (t *blockingReadOnlyTool) Description() string { return "blocking test tool" }
func (t *blockingReadOnlyTool) Schema() json.RawMessage {
@@ -72,3 +95,38 @@ func TestContractEntriesDoesNotHoldRegistryLockAcrossToolCallbacks(t *testing.T)
t.Fatalf("ContractEntries returned %+v, want one read-only blocking_tool", entries)
}
}
+
+func TestSchemasForContextDoesNotHoldRegistryLockAcrossAvailability(t *testing.T) {
+ reg := NewRegistry()
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ reg.Add(&blockingContextualTool{name: "contextual", entered: entered, release: release})
+
+ schemasCh := make(chan []provider.ToolSchema, 1)
+ go func() {
+ schemasCh <- reg.SchemasForContext(context.Background())
+ }()
+
+ select {
+ case <-entered:
+ case <-time.After(5 * time.Second):
+ t.Fatal("SchemasForContext never reached the availability callback")
+ }
+
+ addDone := make(chan struct{})
+ go func() {
+ reg.Add(stubTool{name: "writer_tool"})
+ close(addDone)
+ }()
+ select {
+ case <-addDone:
+ case <-time.After(5 * time.Second):
+ t.Fatal("registry writer blocked while SchemasForContext checked availability")
+ }
+
+ close(release)
+ schemas := <-schemasCh
+ if len(schemas) != 1 || schemas[0].Name != "contextual" {
+ t.Fatalf("SchemasForContext returned %+v, want contextual snapshot", schemas)
+ }
+}
diff --git a/internal/tool/contract_test.go b/internal/tool/contract_test.go
index f1ca0ae8fa..61b7ab2249 100644
--- a/internal/tool/contract_test.go
+++ b/internal/tool/contract_test.go
@@ -85,3 +85,15 @@ func TestEveryBuiltinDeclaresSnipStance(t *testing.T) {
}
}
}
+
+func TestPlanModeUnsafeBuiltinsDeclareContextualVisibility(t *testing.T) {
+ for _, builtin := range tool.Builtins() {
+ classifier, ok := builtin.(tool.PlanModeClassifier)
+ if !ok || classifier.PlanModeSafe() {
+ continue
+ }
+ if _, ok := builtin.(tool.ContextualTool); !ok {
+ t.Errorf("Plan-mode-unsafe builtin %q must hide itself from provider schemas while unavailable", builtin.Name())
+ }
+ }
+}
diff --git a/internal/tool/tool.go b/internal/tool/tool.go
index 90512f95d5..e219f9016a 100644
--- a/internal/tool/tool.go
+++ b/internal/tool/tool.go
@@ -33,6 +33,13 @@ type Tool interface {
ReadOnly() bool
}
+// ContextualTool can hide a registered tool from provider requests when the
+// current turn cannot execute it. Execute must still validate the context so
+// stale transcripts and provider-hallucinated calls fail closed.
+type ContextualTool interface {
+ ProviderVisible(context.Context) bool
+}
+
// Previewer is an optional capability a writer Tool may implement: given the
// same raw JSON args Execute would receive, compute the file change the call
// *would* make — without touching disk. ctx must be Execute's, so the preview
@@ -519,23 +526,41 @@ func (r *Registry) Names() []string {
// Schemas exports tool definitions in stable name order for the provider.
func (r *Registry) Schemas() []provider.ToolSchema {
- r.mu.RLock()
- defer r.mu.RUnlock()
+ return r.schemasForContext(context.Background(), false)
+}
- names := make([]string, len(r.order))
- copy(names, r.order)
- sort.Strings(names)
+// SchemasForContext exports only tools available during ctx. Tools without a
+// contextual availability contract remain visible as before.
+func (r *Registry) SchemasForContext(ctx context.Context) []provider.ToolSchema {
+ return r.schemasForContext(ctx, true)
+}
+
+func (r *Registry) schemasForContext(ctx context.Context, filterContextual bool) []provider.ToolSchema {
+ r.mu.RLock()
+ type schemaEntry struct {
+ name string
+ tool Tool
+ canonical json.RawMessage
+ }
+ entries := make([]schemaEntry, 0, len(r.order))
+ for _, name := range r.order {
+ if t := r.tools[name]; t != nil {
+ entries = append(entries, schemaEntry{name: name, tool: t, canonical: r.canon[name]})
+ }
+ }
+ r.mu.RUnlock()
+ sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name })
- out := make([]provider.ToolSchema, 0, len(names))
- for _, name := range names {
- t := r.tools[name]
- if t == nil {
+ out := make([]provider.ToolSchema, 0, len(entries))
+ for _, entry := range entries {
+ t := entry.tool
+ if contextual, ok := t.(ContextualTool); filterContextual && ok && !contextual.ProviderVisible(ctx) {
continue
}
out = append(out, provider.ToolSchema{
Name: t.Name(),
Description: t.Description(),
- Parameters: r.canon[name],
+ Parameters: entry.canonical,
})
}
return out
diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json
index 4cc8a71330..ade285aeea 100644
--- a/tools/repolint/baseline.json
+++ b/tools/repolint/baseline.json
@@ -2,19 +2,19 @@
"limits": {
"banner": 0,
"commented-code": 0,
- "complexity": 2056,
- "essay": 4028,
- "file-size": 108472,
- "function-size": 9127,
+ "complexity": 2048,
+ "essay": 4005,
+ "file-size": 107873,
+ "function-size": 9102,
"layering": 1,
"marker": 0,
"narrative": 61,
- "test-file-size": 68117
+ "test-file-size": 67810
},
"files": {
"cmd/e2ebench/main.go": {
"essay": 1,
- "function-size": 5
+ "function-size": 3
},
"cmd/e2ebench/mutation.go": {
"essay": 1
@@ -28,7 +28,7 @@
"desktop/app.go": {
"complexity": 64,
"essay": 90,
- "file-size": 11538,
+ "file-size": 11264,
"function-size": 361
},
"desktop/app_autosave_test.go": {
@@ -84,13 +84,13 @@
"essay": 2
},
"desktop/frontend/src/App.tsx": {
- "file-size": 4764
+ "file-size": 4762
},
"desktop/frontend/src/__tests__/app-chrome-tabs.test.ts": {
"test-file-size": 19
},
"desktop/frontend/src/__tests__/capabilities-panel-actions.test.ts": {
- "test-file-size": 308
+ "test-file-size": 307
},
"desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx": {
"test-file-size": 1701
@@ -114,7 +114,7 @@
"file-size": 255
},
"desktop/frontend/src/components/CapabilitiesPanel.tsx": {
- "file-size": 2633
+ "file-size": 2626
},
"desktop/frontend/src/components/Composer.tsx": {
"file-size": 3963
@@ -156,16 +156,16 @@
"file-size": 413
},
"desktop/frontend/src/lib/bridge.ts": {
- "file-size": 4453
+ "file-size": 4368
},
"desktop/frontend/src/lib/crash.ts": {
"file-size": 179
},
"desktop/frontend/src/lib/types.ts": {
- "file-size": 1369
+ "file-size": 1318
},
"desktop/frontend/src/lib/useController.ts": {
- "file-size": 3503
+ "file-size": 3499
},
"desktop/heartbeat.go": {
"essay": 18,
@@ -285,7 +285,7 @@
"desktop/tabs.go": {
"complexity": 71,
"essay": 123,
- "file-size": 8804,
+ "file-size": 8802,
"function-size": 620
},
"desktop/tabs_order_test.go": {
@@ -405,8 +405,8 @@
},
"internal/agent/agent.go": {
"complexity": 61,
- "essay": 113,
- "file-size": 2719,
+ "essay": 109,
+ "file-size": 2730,
"function-size": 124
},
"internal/agent/ask.go": {
@@ -441,7 +441,8 @@
},
"internal/agent/coordinator.go": {
"essay": 17,
- "file-size": 267
+ "file-size": 270,
+ "function-size": 3
},
"internal/agent/coordinator_test.go": {
"essay": 3,
@@ -478,7 +479,7 @@
"internal/agent/extensions_test.go": {
"essay": 4,
"narrative": 1,
- "test-file-size": 1022
+ "test-file-size": 1057
},
"internal/agent/fleet.go": {
"essay": 4,
@@ -544,10 +545,10 @@
"essay": 1
},
"internal/agent/run_loop.go": {
- "complexity": 5,
+ "complexity": 6,
"essay": 45,
- "file-size": 311,
- "function-size": 46
+ "file-size": 326,
+ "function-size": 57
},
"internal/agent/save.go": {
"complexity": 44,
@@ -602,7 +603,7 @@
},
"internal/agent/subagent_store.go": {
"essay": 5,
- "file-size": 171
+ "file-size": 179
},
"internal/agent/subagent_store_test.go": {
"test-file-size": 250
@@ -610,7 +611,7 @@
"internal/agent/task.go": {
"complexity": 25,
"essay": 56,
- "file-size": 1377,
+ "file-size": 1390,
"function-size": 114
},
"internal/agent/task_test.go": {
@@ -635,20 +636,16 @@
"internal/agent/width.go": {
"essay": 1
},
- "internal/autoresearch/store.go": {
- "essay": 3,
- "file-size": 216
- },
"internal/boot/boot.go": {
"complexity": 287,
"essay": 102,
- "file-size": 2204,
+ "file-size": 2191,
"function-size": 1818,
"narrative": 3
},
"internal/boot/boot_test.go": {
"essay": 10,
- "test-file-size": 4331
+ "test-file-size": 4338
},
"internal/boot/extension_dispatch_test.go": {
"essay": 3,
@@ -678,8 +675,7 @@
"essay": 4
},
"internal/boot/rebuild_subgraph.go": {
- "complexity": 2,
- "function-size": 17
+ "function-size": 7
},
"internal/boot/reload.go": {
"essay": 32
@@ -777,10 +773,10 @@
"essay": 15
},
"internal/cli/chat_tui.go": {
- "complexity": 302,
+ "complexity": 300,
"essay": 110,
"file-size": 4555,
- "function-size": 1200
+ "function-size": 1196
},
"internal/cli/chat_tui_paste.go": {
"essay": 9
@@ -830,7 +826,7 @@
"internal/cli/mcp.go": {
"complexity": 8,
"essay": 5,
- "file-size": 85,
+ "file-size": 66,
"function-size": 7
},
"internal/cli/mcp_manager.go": {
@@ -972,9 +968,8 @@
"test-file-size": 2194
},
"internal/config/effort.go": {
- "complexity": 10,
- "essay": 11,
- "function-size": 5
+ "complexity": 9,
+ "essay": 11
},
"internal/config/effort_test.go": {
"essay": 2
@@ -1032,22 +1027,19 @@
"internal/control/approval.go": {
"essay": 19
},
- "internal/control/autoresearch_manager.go": {
- "essay": 2
- },
"internal/control/checkpoint.go": {
"essay": 10
},
"internal/control/controller.go": {
"complexity": 11,
"essay": 170,
- "file-size": 5334,
+ "file-size": 5343,
"function-size": 77,
"narrative": 4
},
"internal/control/controller_test.go": {
"essay": 10,
- "test-file-size": 4507
+ "test-file-size": 4506
},
"internal/control/errmsg.go": {
"essay": 2
@@ -1065,15 +1057,15 @@
},
"internal/control/goal.go": {
"complexity": 7,
- "essay": 20,
- "file-size": 318,
+ "essay": 10,
+ "file-size": 327,
"function-size": 7
},
"internal/control/goal_runtime_test.go": {
"test-file-size": 14
},
"internal/control/goal_test.go": {
- "test-file-size": 607
+ "test-file-size": 243
},
"internal/control/goalusage.go": {
"essay": 2
@@ -1086,7 +1078,7 @@
},
"internal/control/input_test.go": {
"essay": 4,
- "test-file-size": 779
+ "test-file-size": 773
},
"internal/control/mcp.go": {
"essay": 7
@@ -1137,9 +1129,9 @@
"essay": 1
},
"internal/control/turn_orchestrator.go": {
- "complexity": 4,
+ "complexity": 3,
"essay": 13,
- "function-size": 78
+ "function-size": 63
},
"internal/control/turn_orchestrator_test.go": {
"essay": 1,
@@ -1379,7 +1371,7 @@
},
"internal/jobs/jobs.go": {
"essay": 42,
- "file-size": 1264,
+ "file-size": 1272,
"function-size": 12
},
"internal/jobs/jobs_extra_test.go": {
@@ -1389,7 +1381,7 @@
"essay": 4
},
"internal/jobs/jobs_test.go": {
- "test-file-size": 12
+ "test-file-size": 27
},
"internal/memory/doc.go": {
"essay": 1
@@ -1438,7 +1430,7 @@
"test-file-size": 275
},
"internal/plugin/plugin.go": {
- "essay": 30,
+ "essay": 29,
"file-size": 1315,
"function-size": 14
},
@@ -1511,10 +1503,10 @@
"essay": 8
},
"internal/provider/openai/openai.go": {
- "complexity": 64,
- "essay": 42,
- "file-size": 554,
- "function-size": 255
+ "complexity": 61,
+ "essay": 40,
+ "file-size": 531,
+ "function-size": 252
},
"internal/provider/openai/openai_test.go": {
"essay": 5,
@@ -1524,7 +1516,7 @@
"essay": 7
},
"internal/provider/provider.go": {
- "essay": 51,
+ "essay": 50,
"file-size": 353
},
"internal/provider/responses/responses.go": {
@@ -1792,6 +1784,9 @@
"internal/tool/builtin/completestep.go": {
"essay": 3
},
+ "internal/tool/builtin/completestep_test.go": {
+ "test-file-size": 8
+ },
"internal/tool/builtin/confine.go": {
"essay": 16
},
From 4636cc165e2272208d483ad0b516b3407101ae64 Mon Sep 17 00:00:00 2001
From: SivanCola <32437197+SivanCola@users.noreply.github.com>
Date: Sun, 9 Aug 2026 05:13:53 +0800
Subject: [PATCH 09/12] Fix legacy Goal recovery boundary
Problem
Legacy archive retry identity lived inside the active Goal machine, and the removed AutoResearch readiness reader still carried a second completion contract.
Root cause
Archive failures depended on a Goal-owned task token that was written back into new sidecars and could be mistaken for an active AutoResearch runtime.
Fix
Keep archive identity only in the Controller-owned read-only recovery boundary, fence ordinary Goal resume for legacy failures, omit deprecated sidecar fields, and fold finding compatibility checks into the read-only summary path.
Verification
Focused legacy restore, finding compatibility, control, agent, boot, and autoresearch tests pass; git diff --check passes.
---
internal/autoresearch/readiness.go | 72 ------------------
internal/autoresearch/store_test.go | 18 ++---
internal/autoresearch/summary.go | 20 +++++
internal/autoresearch/task.go | 7 --
internal/control/autoresearch_manager.go | 38 +++++++---
internal/control/controller.go | 11 +--
internal/control/goal.go | 31 ++++----
internal/control/goal_durable.go | 3 -
internal/control/goal_legacy.go | 33 +++------
internal/control/goal_legacy_restore_test.go | 77 ++++++++++++++------
10 files changed, 143 insertions(+), 167 deletions(-)
delete mode 100644 internal/autoresearch/readiness.go
diff --git a/internal/autoresearch/readiness.go b/internal/autoresearch/readiness.go
deleted file mode 100644
index d580fe5bab..0000000000
--- a/internal/autoresearch/readiness.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package autoresearch
-
-import "path/filepath"
-
-func (s *Store) Readiness(taskID string) (*ReadinessReport, error) {
- report := &ReadinessReport{}
- validation, err := s.ValidateTask(taskID)
- if err != nil {
- return nil, err
- }
- if !validation.Valid {
- for _, validationErr := range validation.Errors {
- report.Errors = append(report.Errors, validationErr.File+":"+validationErr.Field+": "+validationErr.Error)
- }
- return report, nil
- }
- task, err := s.LoadTask(taskID)
- if err != nil {
- return nil, err
- }
- storeRoot, taskRel, err := s.openTaskRoot(taskID)
- if err != nil {
- return nil, err
- }
- defer storeRoot.Close()
- var progress Progress
- if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil {
- return nil, err
- }
- if progress.Status == StatusBlocked {
- report.BlockedReason = progress.BlockedReason
- if report.BlockedReason == "" {
- report.BlockedReason = "task is blocked"
- }
- return report, nil
- }
- findings, err := s.Findings(taskID, 0)
- if err != nil {
- return nil, err
- }
- accepted := acceptedFindingIDs(findings)
- for _, criterion := range task.Spec.SuccessCriteria {
- if !criterion.Required {
- continue
- }
- if countAcceptedEvidence(criterion.EvidenceIDs, accepted) == 0 {
- report.MissingCriteria = append(report.MissingCriteria, criterion.ID)
- }
- }
- report.Ready = len(report.MissingCriteria) == 0 && report.BlockedReason == "" && len(report.Errors) == 0
- return report, nil
-}
-
-func acceptedFindingIDs(findings []Finding) map[string]bool {
- accepted := make(map[string]bool, len(findings))
- for _, finding := range findings {
- if finding.Accepted {
- accepted[finding.ID] = true
- }
- }
- return accepted
-}
-
-func countAcceptedEvidence(ids []string, accepted map[string]bool) int {
- count := 0
- for _, id := range ids {
- if accepted[id] {
- count++
- }
- }
- return count
-}
diff --git a/internal/autoresearch/store_test.go b/internal/autoresearch/store_test.go
index c8cde8eaa3..85e7aad63b 100644
--- a/internal/autoresearch/store_test.go
+++ b/internal/autoresearch/store_test.go
@@ -196,12 +196,12 @@ func TestFindingsPreserveVerificationAndUnknownKinds(t *testing.T) {
if err := validateFinding(Finding{ID: "", Kind: "anything", Summary: "x", CreatedAt: time.Now()}); err == nil {
t.Fatal("validateFinding accepted empty id")
}
- report, err := store.Readiness(taskID)
+ summary, err := store.Summary(taskID)
if err != nil {
- t.Fatalf("Readiness: %v", err)
+ t.Fatalf("Summary: %v", err)
}
- if !report.Ready {
- t.Fatalf("readiness = %+v, want ready after verification evidence", report)
+ if len(summary.OpenCriteria) != 0 {
+ t.Fatalf("summary = %+v, want verification evidence to satisfy the legacy criterion", summary)
}
}
@@ -218,7 +218,7 @@ func TestValidateFindingDoesNotEnumerateKind(t *testing.T) {
}
}
-func TestReadinessReportsMissingCriteria(t *testing.T) {
+func TestSummaryReportsMissingCriteria(t *testing.T) {
root := t.TempDir()
taskID := "missing-criteria"
writeArchiveFixture(t, root, taskID, "Block incomplete completion", []SuccessCriterion{
@@ -226,12 +226,12 @@ func TestReadinessReportsMissingCriteria(t *testing.T) {
{ID: "verification", Description: "Verification", Required: true},
})
store := NewStore(root)
- report, err := store.Readiness(taskID)
+ summary, err := store.Summary(taskID)
if err != nil {
- t.Fatalf("Readiness: %v", err)
+ t.Fatalf("Summary: %v", err)
}
- if report.Ready || len(report.MissingCriteria) != 2 {
- t.Fatalf("readiness = %+v, want missing both criteria", report)
+ if len(summary.OpenCriteria) != 2 {
+ t.Fatalf("summary = %+v, want missing both criteria", summary)
}
}
diff --git a/internal/autoresearch/summary.go b/internal/autoresearch/summary.go
index 8c967cf1a9..a9ba8f0d08 100644
--- a/internal/autoresearch/summary.go
+++ b/internal/autoresearch/summary.go
@@ -73,3 +73,23 @@ func nextRequiredAction(progress Progress) string {
}
return "continue with the next evidence-producing step"
}
+
+func acceptedFindingIDs(findings []Finding) map[string]bool {
+ accepted := make(map[string]bool, len(findings))
+ for _, finding := range findings {
+ if finding.Accepted {
+ accepted[finding.ID] = true
+ }
+ }
+ return accepted
+}
+
+func countAcceptedEvidence(ids []string, accepted map[string]bool) int {
+ count := 0
+ for _, id := range ids {
+ if accepted[id] {
+ count++
+ }
+ }
+ return count
+}
diff --git a/internal/autoresearch/task.go b/internal/autoresearch/task.go
index a3b1714100..4575850ea2 100644
--- a/internal/autoresearch/task.go
+++ b/internal/autoresearch/task.go
@@ -113,13 +113,6 @@ type Summary struct {
NextRequiredAction string `json:"next_required_action"`
}
-type ReadinessReport struct {
- Ready bool `json:"ready"`
- MissingCriteria []string `json:"missing_criteria"`
- BlockedReason string `json:"blocked_reason"`
- Errors []string `json:"errors"`
-}
-
type ValidationError struct {
File string `json:"file"`
Field string `json:"field"`
diff --git a/internal/control/autoresearch_manager.go b/internal/control/autoresearch_manager.go
index 93ed5fa8c2..a6c3a14123 100644
--- a/internal/control/autoresearch_manager.go
+++ b/internal/control/autoresearch_manager.go
@@ -92,6 +92,22 @@ func (c *Controller) prepareLegacyResearchTask(goal string) legacyResearchSetup
}
func (c *Controller) restorePendingLegacyGoal(legacy legacyGoalRestore) bool {
+ if legacy.taskID == "" {
+ goal, epoch, ok := c.goals.legacyArchiveBlockedState()
+ if ok {
+ setup := c.prepareLegacyResearchTask(goal)
+ if setup.explicit {
+ legacy = legacyGoalRestore{taskID: setup.taskID, epoch: epoch, explicit: true}
+ }
+ }
+ }
+ // A malformed explicit archive path has no safe task id to load. Keep the
+ // Controller-owned retry token so ResumeGoal cannot fall through to the
+ // ordinary Goal resume path and execute the raw path text as an objective.
+ if legacy.explicit && legacy.taskID == "" {
+ c.replaceLegacyRestore(legacy)
+ return true
+ }
if legacy.taskID == "" || strings.TrimSpace(c.goals.goalText()) != "" {
c.replaceLegacyRestore(legacyGoalRestore{})
return false
@@ -106,7 +122,7 @@ func (c *Controller) restorePendingLegacyGoal(legacy legacyGoalRestore) bool {
}
goal, err := c.legacyResearchArchive.loadGoalText(legacy.taskID)
if err != nil {
- if epoch, ok := c.goals.blockLegacyRestore(legacy.epoch, legacy.taskID, err.Error()); ok {
+ if epoch, ok := c.goals.blockLegacyRestore(legacy.epoch, err.Error()); ok {
_, _ = c.persistGoalStateAtEpoch(epoch, restoreTodos)
c.advanceLegacyRestoreEpoch(legacy.taskID, legacy.epoch, epoch)
c.notice("legacy research archive resume failed: " + err.Error())
@@ -120,7 +136,7 @@ func (c *Controller) restorePendingLegacyGoal(legacy legacyGoalRestore) bool {
_, persistErr := c.persistGoalStateAtEpoch(epoch, restoreTodos)
if persistErr != nil {
reason := "persist migrated legacy Goal: " + persistErr.Error()
- if blockedEpoch, blocked := c.goals.failLegacyRestorePersistence(epoch, legacy.taskID, reason); blocked {
+ if blockedEpoch, blocked := c.goals.failLegacyRestorePersistence(epoch, reason); blocked {
c.replaceLegacyRestore(legacyGoalRestore{taskID: legacy.taskID, todos: restoreTodos, epoch: blockedEpoch})
c.notice("legacy research archive resume failed: " + reason)
} else {
@@ -137,13 +153,17 @@ func (c *Controller) restorePendingLegacyGoal(legacy legacyGoalRestore) bool {
}
func (c *Controller) retryBlockedLegacyGoal() (handled, resumed bool) {
- goal, taskID, epoch, ok := c.goals.legacyArchiveRetryToken()
- if !ok {
- if _, _, blocked := c.goals.legacyArchiveBlockedState(); blocked {
- return true, false
- }
+ goal, epoch, blocked := c.goals.legacyArchiveBlockedState()
+ if !blocked {
return false, false
}
+ legacy, hasLegacy := c.legacyRestoreSnapshot()
+ if !hasLegacy || legacy.epoch != epoch || legacy.taskID == "" {
+ // A blocked sidecar without a Controller-owned archive identity is a
+ // fail-closed migration boundary after restart. Never resume raw text.
+ return true, false
+ }
+ taskID := legacy.taskID
setup := c.prepareLegacyResearchTask(goal)
resolvedGoal, reason := setup.goal, setup.blockReason
if !setup.explicit {
@@ -159,7 +179,7 @@ func (c *Controller) retryBlockedLegacyGoal() (handled, resumed bool) {
if reason == "" {
reason = "legacy research archive could not be recovered"
}
- if nextEpoch, applied := c.goals.blockLegacyRestore(epoch, taskID, reason); applied {
+ if nextEpoch, applied := c.goals.blockLegacyRestore(epoch, reason); applied {
_, _ = c.persistGoalStateAtEpoch(nextEpoch, c.goalTodos())
c.replaceLegacyRestore(legacyGoalRestore{taskID: taskID, epoch: nextEpoch})
}
@@ -175,7 +195,7 @@ func (c *Controller) retryBlockedLegacyGoal() (handled, resumed bool) {
persisted, persistErr := c.persistGoalStateAtEpoch(resumedEpoch, todos)
if persistErr != nil {
reason := "persist migrated legacy Goal: " + persistErr.Error()
- if blockedEpoch, blocked := c.goals.failLegacyRestorePersistence(resumedEpoch, taskID, reason); blocked {
+ if blockedEpoch, blocked := c.goals.failLegacyRestorePersistence(resumedEpoch, reason); blocked {
c.replaceLegacyRestore(legacyGoalRestore{taskID: taskID, todos: todos, epoch: blockedEpoch})
c.notice("legacy research archive resume failed: " + reason)
} else {
diff --git a/internal/control/controller.go b/internal/control/controller.go
index f54508d2aa..c164b98dec 100644
--- a/internal/control/controller.go
+++ b/internal/control/controller.go
@@ -2684,8 +2684,8 @@ func (c *Controller) SetGoalDurable(goal string, _ ...string) error {
var data []byte
var persist bool
if setup.blockReason != "" {
- path, data, persist = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.legacyTaskID, setup.blockReason, c.goalTodos())
- c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken()})
+ path, data, persist = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.blockReason, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken(), explicit: setup.explicit})
} else {
path, data, persist = c.goals.set(resolved, setup.budgetClass, c.goalTodos())
c.replaceLegacyRestore(legacyGoalRestore{})
@@ -2720,8 +2720,8 @@ func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResea
var data []byte
var ok bool
if setup.blockReason != "" {
- path, data, ok = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.legacyTaskID, setup.blockReason, c.goalTodos())
- c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken()})
+ path, data, ok = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.blockReason, c.goalTodos())
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken(), explicit: setup.explicit})
c.notice("legacy research archive resume failed: " + setup.blockReason)
} else {
path, data, ok = c.goals.set(resolved, setup.budgetClass, c.goalTodos())
@@ -2736,6 +2736,7 @@ type goalSetSetup struct {
notice string
blockReason string
legacyTaskID string
+ explicit bool
}
func (c *Controller) resolveGoalText(goal string, researchMode GoalResearchMode) (string, goalSetSetup) {
@@ -2744,7 +2745,7 @@ func (c *Controller) resolveGoalText(goal string, researchMode GoalResearchMode)
if !legacy.explicit {
return goal, setup
}
- setup.notice, setup.blockReason, setup.legacyTaskID = legacy.notice, legacy.blockReason, legacy.taskID
+ setup.notice, setup.blockReason, setup.legacyTaskID, setup.explicit = legacy.notice, legacy.blockReason, legacy.taskID, legacy.explicit
if legacy.blockReason != "" {
return goal, setup
}
diff --git a/internal/control/goal.go b/internal/control/goal.go
index b5d4bdaa65..3de8749bf7 100644
--- a/internal/control/goal.go
+++ b/internal/control/goal.go
@@ -100,7 +100,6 @@ type goalMachine struct {
lastEvaluatorReason string
stopCause string
budgetExtensions int // turn extensions from resume (compat field name)
- pendingLegacyTaskID string
// statePath is the persisted goal-state sidecar; empty disables persistence.
statePath string
@@ -282,7 +281,7 @@ func (g *goalMachine) set(goal, preferredBudgetClass string, todos []evidence.To
}
g.mu.Lock()
defer g.mu.Unlock()
- if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.budgetClass == preferredBudgetClass && g.pendingLegacyTaskID == "" {
+ if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.budgetClass == preferredBudgetClass {
return "", nil, false
}
g.installGoalLocked(goal, preferredBudgetClass)
@@ -292,7 +291,7 @@ func (g *goalMachine) set(goal, preferredBudgetClass string, todos []evidence.To
// setLegacyArchiveBlocked atomically installs and blocks an explicit legacy
// archive goal. A concurrent Goal replacement cannot be blocked between two
// separate FSM mutations.
-func (g *goalMachine) setLegacyArchiveBlocked(goal, preferredBudgetClass, taskID, reason string, todos []evidence.TodoItem) (string, []byte, bool) {
+func (g *goalMachine) setLegacyArchiveBlocked(goal, preferredBudgetClass, reason string, todos []evidence.TodoItem) (string, []byte, bool) {
goal = strings.TrimSpace(goal)
if goal != "" && preferredBudgetClass == "" {
preferredBudgetClass = taskintent.ClassifyGoalBudget(goal)
@@ -300,7 +299,6 @@ func (g *goalMachine) setLegacyArchiveBlocked(goal, preferredBudgetClass, taskID
g.mu.Lock()
defer g.mu.Unlock()
g.installGoalLocked(goal, preferredBudgetClass)
- g.pendingLegacyTaskID = strings.TrimSpace(taskID)
if goal != "" {
g.status = GoalStatusBlocked
}
@@ -316,7 +314,6 @@ func (g *goalMachine) installGoalLocked(goal, preferredBudgetClass string) {
g.lastContinuationReason, g.lastEvaluatorReason = "", ""
g.stopCause = ""
g.budgetExtensions = 0
- g.pendingLegacyTaskID = ""
if goal == "" {
g.goal, g.status = "", GoalStatusStopped
g.budgetClass = ""
@@ -377,6 +374,11 @@ func (g *goalMachine) pauseFor(stopCause, reason string, todos []evidence.TodoIt
func (g *goalMachine) resume(todos []evidence.TodoItem) (path string, data []byte, persist, resumed, extended bool) {
g.mu.Lock()
defer g.mu.Unlock()
+ if g.stopCause == stopCauseLegacyArchive {
+ // A legacy archive block is recoverable only through the read-only
+ // archive boundary; never reinterpret it as an ordinary Goal resume.
+ return "", nil, false, false, false
+ }
if strings.TrimSpace(g.goal) == "" || g.status == GoalStatusComplete {
return "", nil, false, false, false
}
@@ -645,14 +647,11 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string,
StopCause: g.stopCause,
BudgetExtensions: g.budgetExtensions,
}
- if g.pendingLegacyTaskID != "" {
- state.ResearchMode = GoalResearchOn
- state.AutoResearchTaskID = g.pendingLegacyTaskID
- } else {
- // GoalResearchOff is a downgrade fence: old readers must not infer or
- // inject the removed AutoResearch runtime. budgetClass is authoritative.
- state.ResearchMode = GoalResearchOff
- }
+ // GoalResearchOff is a downgrade fence: old readers must not infer or inject
+ // the removed AutoResearch runtime. Legacy task identity is decode-only and
+ // remains in the Controller-owned recovery boundary; it is never written into
+ // a new sidecar.
+ state.ResearchMode = GoalResearchOff
b, err := json.Marshal(state)
if err != nil {
slog.Warn("controller: marshal goal state", "err", err)
@@ -785,11 +784,9 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
taskID: strings.TrimSpace(state.AutoResearchTaskID),
todos: append([]evidence.TodoItem(nil), state.Todos...),
}
- g.pendingLegacyTaskID = legacy.taskID
- if g.pendingLegacyTaskID != "" && g.goal != "" {
+ if legacy.taskID != "" && g.goal != "" {
// Sidecars that already carry the Goal objective do not depend on the
// historical archive. Complete the migration immediately.
- g.pendingLegacyTaskID = ""
migrated = true
}
g.scopeID = strings.TrimSpace(state.ScopeID)
@@ -859,7 +856,7 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
}
g.continuationEpoch++
legacy.epoch = g.continuationEpoch
- pendingLegacyGoal := g.pendingLegacyTaskID != "" && g.goal == ""
+ pendingLegacyGoal := legacy.taskID != "" && g.goal == ""
if migrated && !pendingLegacyGoal {
// Migration rewrites only the removed budget state. Preserve the todo
// snapshot carried by the authoritative sidecar instead of clearing it.
diff --git a/internal/control/goal_durable.go b/internal/control/goal_durable.go
index d128123d4c..7708f1c1aa 100644
--- a/internal/control/goal_durable.go
+++ b/internal/control/goal_durable.go
@@ -22,7 +22,6 @@ type goalMachineSnapshot struct {
lastEvaluatorReason string
stopCause string
budgetExtensions int
- pendingLegacyTaskID string
}
func (g *goalMachine) capture() goalMachineSnapshot {
@@ -39,7 +38,6 @@ func (g *goalMachine) capture() goalMachineSnapshot {
lastContinuationReason: g.lastContinuationReason,
lastEvaluatorReason: g.lastEvaluatorReason,
stopCause: g.stopCause, budgetExtensions: g.budgetExtensions,
- pendingLegacyTaskID: g.pendingLegacyTaskID,
}
}
@@ -57,7 +55,6 @@ func (g *goalMachine) restore(snapshot goalMachineSnapshot) {
g.lastEvaluatorReason = snapshot.lastEvaluatorReason
g.stopCause = snapshot.stopCause
g.budgetExtensions = snapshot.budgetExtensions
- g.pendingLegacyTaskID = snapshot.pendingLegacyTaskID
g.continuationEpoch++
g.mu.Unlock()
}
diff --git a/internal/control/goal_legacy.go b/internal/control/goal_legacy.go
index 1659a7ef2d..03b211281c 100644
--- a/internal/control/goal_legacy.go
+++ b/internal/control/goal_legacy.go
@@ -7,9 +7,10 @@ import (
)
type legacyGoalRestore struct {
- taskID string
- todos []evidence.TodoItem
- epoch uint64
+ taskID string
+ todos []evidence.TodoItem
+ epoch uint64
+ explicit bool
}
func normalizeBudgetClass(goal, class string, legacyMode GoalResearchMode) string {
@@ -34,8 +35,9 @@ func goalStateNeedsMigration(state goalState, normalizedBudgetClass string) bool
}
// blockLegacyRestore fails closed only while the decoded sidecar still owns the
-// active Goal epoch. The task id remains durable so a later resume can retry.
-func (g *goalMachine) blockLegacyRestore(expectedEpoch uint64, taskID, reason string) (uint64, bool) {
+// active Goal epoch. The archive identity is held by Controller's legacy-only
+// recovery boundary, never by the Goal FSM.
+func (g *goalMachine) blockLegacyRestore(expectedEpoch uint64, reason string) (uint64, bool) {
g.mu.Lock()
defer g.mu.Unlock()
if g.continuationEpoch != expectedEpoch {
@@ -44,16 +46,15 @@ func (g *goalMachine) blockLegacyRestore(expectedEpoch uint64, taskID, reason st
g.status = GoalStatusBlocked
g.stopCause = stopCauseLegacyArchive
g.block = clipGoalReason(reason)
- g.pendingLegacyTaskID = strings.TrimSpace(taskID)
g.continuationEpoch++
return g.continuationEpoch, true
}
// failLegacyRestorePersistence keeps a recovered archive retryable when the
// sidecar replacement fails. The recovered Goal text may remain in memory, but
-// the Goal stays fail-closed and the legacy task id is retained until a later
-// resume commits the migration durably.
-func (g *goalMachine) failLegacyRestorePersistence(expectedEpoch uint64, taskID, reason string) (uint64, bool) {
+// the Goal stays fail-closed while Controller retains the legacy identity until
+// a later resume commits the migration durably.
+func (g *goalMachine) failLegacyRestorePersistence(expectedEpoch uint64, reason string) (uint64, bool) {
g.mu.Lock()
defer g.mu.Unlock()
if g.continuationEpoch != expectedEpoch {
@@ -62,20 +63,10 @@ func (g *goalMachine) failLegacyRestorePersistence(expectedEpoch uint64, taskID,
g.status = GoalStatusBlocked
g.stopCause = stopCauseLegacyArchive
g.block = clipGoalReason(reason)
- g.pendingLegacyTaskID = strings.TrimSpace(taskID)
g.continuationEpoch++
return g.continuationEpoch, true
}
-func (g *goalMachine) legacyArchiveRetryToken() (goal, taskID string, epoch uint64, ok bool) {
- g.mu.Lock()
- defer g.mu.Unlock()
- if g.status != GoalStatusBlocked || g.stopCause != stopCauseLegacyArchive || g.pendingLegacyTaskID == "" {
- return "", "", 0, false
- }
- return g.goal, g.pendingLegacyTaskID, g.continuationEpoch, true
-}
-
func (g *goalMachine) legacyArchiveBlockedState() (goal string, epoch uint64, ok bool) {
g.mu.Lock()
defer g.mu.Unlock()
@@ -95,7 +86,7 @@ func (c *Controller) legacyRestoreSnapshot() (legacyGoalRestore, bool) {
c.legacyRestoreMu.Lock()
defer c.legacyRestoreMu.Unlock()
legacy := c.legacyRestore
- return legacy, strings.TrimSpace(legacy.taskID) != ""
+ return legacy, legacy.explicit || strings.TrimSpace(legacy.taskID) != ""
}
func (c *Controller) advanceLegacyRestoreEpoch(taskID string, from, to uint64) {
@@ -126,7 +117,6 @@ func (g *goalMachine) fillGoalTextIfEmpty(expectedEpoch uint64, goal string) (ui
return 0, false
}
g.goal = goal
- g.pendingLegacyTaskID = ""
if g.status == "" || g.stopCause == stopCauseLegacyArchive {
g.status = GoalStatusRunning
}
@@ -164,7 +154,6 @@ func (g *goalMachine) resumeLegacyArchive(expectedEpoch uint64, goal string) (ui
g.goal = goal
g.status = GoalStatusRunning
g.stopCause, g.block = "", ""
- g.pendingLegacyTaskID = ""
g.budgetClass = budgetClassResearch
if g.turnsLimit < budgetQuota(g.budgetClass) {
g.turnsLimit = budgetQuota(g.budgetClass)
diff --git a/internal/control/goal_legacy_restore_test.go b/internal/control/goal_legacy_restore_test.go
index b69c284811..fc305167dc 100644
--- a/internal/control/goal_legacy_restore_test.go
+++ b/internal/control/goal_legacy_restore_test.go
@@ -138,7 +138,7 @@ func TestGoalSetIdempotencyUsesEffectiveBudgetClass(t *testing.T) {
}
}
-func TestLegacySidecarArchiveFailureIsBlockedAndRetryable(t *testing.T) {
+func TestLegacySidecarArchiveFailureIsBlockedWithoutRewritingTaskID(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
@@ -172,6 +172,7 @@ func TestLegacySidecarArchiveFailureIsBlockedAndRetryable(t *testing.T) {
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec})
c.Resume(sess, sessionPath)
+ defer c.Close()
if got := c.GoalStatus(); got != GoalStatusBlocked {
t.Fatalf("failed legacy restore status = %q, want blocked", got)
}
@@ -183,7 +184,7 @@ func TestLegacySidecarArchiveFailureIsBlockedAndRetryable(t *testing.T) {
if err := json.Unmarshal(failedRaw, &failed); err != nil {
t.Fatal(err)
}
- if failed.Status != GoalStatusBlocked || failed.ResearchMode != GoalResearchOn || failed.AutoResearchTaskID != taskID || failed.StopCause != stopCauseLegacyArchive || failed.Block == "" {
+ if failed.Status != GoalStatusBlocked || failed.ResearchMode != GoalResearchOff || failed.AutoResearchTaskID != "" || failed.StopCause != stopCauseLegacyArchive || failed.Block == "" {
t.Fatalf("failed restore state = %+v, want retryable blocked legacy migration", failed)
}
if failed.ScopeID != scopeID || failed.DeliveryCheckpoint != wantCheckpoint || len(failed.Todos) != 1 || failed.Todos[0] != wantTodo {
@@ -198,33 +199,29 @@ func TestLegacySidecarArchiveFailureIsBlockedAndRetryable(t *testing.T) {
if runtime := c.GoalRuntime(); runtime.TurnsUsed != 3 || runtime.TurnsLimit != 40 || runtime.TokensUsed != 1234 || runtime.NoProgressTurns != 2 {
t.Fatalf("failed restore lost in-memory runtime state: %+v", runtime)
}
- c.Close()
-
taskRoot := writeLegacyGoalArchive(t, root, taskID, "recover after archive repair")
archiveBefore, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json"))
if err != nil {
t.Fatal(err)
}
- sess2 := agent.NewSession("sys")
- exec2 := agent.New(nil, nil, sess2, agent.Options{}, event.Discard)
- c2 := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec2})
- c2.Resume(sess2, sessionPath)
- defer c2.Close()
- if got := c2.Goal(); got != "recover after archive repair" {
+ if !c.ResumeGoal() {
+ t.Fatal("repaired archive did not resume through the in-memory legacy token")
+ }
+ if got := c.Goal(); got != "recover after archive repair" {
t.Fatalf("retried Goal() = %q", got)
}
- if got := c2.GoalStatus(); got != GoalStatusRunning {
+ if got := c.GoalStatus(); got != GoalStatusRunning {
t.Fatalf("retried status = %q, want running", got)
}
- runtime := c2.GoalRuntime()
+ runtime := c.GoalRuntime()
if runtime.TurnsUsed != 3 || runtime.TurnsLimit != 40 || runtime.TokensUsed != 1234 || runtime.NoProgressTurns != 2 || runtime.BudgetExtensions != 1 {
t.Fatalf("retried runtime = %+v, want preserved legacy consumption", runtime)
}
- if got := exec2.CanonicalTodoState(); len(got) != 1 || got[0] != wantTodo {
+ if got := exec.CanonicalTodoState(); len(got) != 1 || got[0] != wantTodo {
t.Fatalf("retried todos = %+v, want %+v", got, wantTodo)
}
- if got := c2.goals.deliveryState(); got != wantCheckpoint {
+ if got := c.goals.deliveryState(); got != wantCheckpoint {
t.Fatalf("retried delivery checkpoint = %+v, want %+v", got, wantCheckpoint)
}
retriedRaw, err := os.ReadFile(goalStatePath(sessionPath))
@@ -305,7 +302,7 @@ func TestLegacySidecarInvalidArchivesRemainRetryableAndReadOnly(t *testing.T) {
if err := json.Unmarshal(persistedRaw, &persisted); err != nil {
t.Fatal(err)
}
- if persisted.AutoResearchTaskID != taskID || persisted.ResearchMode != GoalResearchOn || persisted.StopCause != stopCauseLegacyArchive {
+ if persisted.AutoResearchTaskID != "" || persisted.ResearchMode != GoalResearchOff || persisted.StopCause != stopCauseLegacyArchive {
t.Fatalf("retry state = %+v", persisted)
}
archiveAfter, err := os.ReadFile(target)
@@ -382,14 +379,20 @@ func TestLegacyArchiveMigrationWriteFailureRemainsBlockedAndRetryable(t *testing
}
c.goals.setStatePath(filepath.Join(blockedParent, "goal.json"))
rawGoal := "resume .reasonix/autoresearch/" + taskID + "/"
- c.goals.setLegacyArchiveBlocked(rawGoal, budgetClassResearch, taskID, "retry migration", nil)
+ _, _, _ = c.goals.setLegacyArchiveBlocked(rawGoal, budgetClassResearch, "retry migration", nil)
+ _, epoch, ok := c.goals.legacyArchiveBlockedState()
+ if !ok {
+ t.Fatal("legacy archive block state unavailable")
+ }
+ c.replaceLegacyRestore(legacyGoalRestore{taskID: taskID, epoch: epoch, explicit: true})
if c.ResumeGoal() {
t.Fatal("migration reported success after its sidecar write failed")
}
- goal, retainedTaskID, _, ok := c.goals.legacyArchiveRetryToken()
- if !ok || retainedTaskID != taskID || goal != "recover after sidecar write repair" {
- t.Fatalf("failed write lost retry state: goal=%q task=%q ok=%v", goal, retainedTaskID, ok)
+ goal, retryEpoch, blocked := c.goals.legacyArchiveBlockedState()
+ legacy, hasLegacy := c.legacyRestoreSnapshot()
+ if !blocked || !hasLegacy || legacy.taskID != taskID || retryEpoch != legacy.epoch || goal != "recover after sidecar write repair" {
+ t.Fatalf("failed write lost retry state: goal=%q legacy=%+v blocked=%v", goal, legacy, blocked)
}
if c.GoalStatus() != GoalStatusBlocked {
t.Fatalf("status = %q, want fail-closed blocked", c.GoalStatus())
@@ -415,10 +418,10 @@ func TestLegacyArchiveMigrationWriteFailureRemainsBlockedAndRetryable(t *testing
func TestStaleLegacyArchiveRetryCannotReplaceNewGoal(t *testing.T) {
var g goalMachine
- g.setLegacyArchiveBlocked("resume .reasonix/autoresearch/old/", budgetClassResearch, "old", "missing", nil)
- _, _, epoch, ok := g.legacyArchiveRetryToken()
+ g.setLegacyArchiveBlocked("resume .reasonix/autoresearch/old/", budgetClassResearch, "missing", nil)
+ _, epoch, ok := g.legacyArchiveBlockedState()
if !ok {
- t.Fatal("legacy retry token unavailable")
+ t.Fatal("legacy archive block state unavailable")
}
g.set("new goal", budgetClassWrite, nil)
if _, resumed := g.resumeLegacyArchive(epoch, "stale archive goal"); resumed {
@@ -435,7 +438,7 @@ func TestStaleInitialLegacyFailureCannotBlockNewGoal(t *testing.T) {
epoch := g.continuationToken()
g.set("new goal", budgetClassWrite, nil)
- if _, blocked := g.blockLegacyRestore(epoch, "old-task", "archive disappeared"); blocked {
+ if _, blocked := g.blockLegacyRestore(epoch, "archive disappeared"); blocked {
t.Fatal("stale archive failure blocked a newer Goal")
}
if got := g.goalText(); got != "new goal" || g.statusForDisplay() != GoalStatusRunning {
@@ -578,6 +581,34 @@ func TestMalformedLegacyArchivePathCannotResumeAsGoalText(t *testing.T) {
}
}
+func TestMalformedExplicitLegacyGoalStaysBlockedAfterRestart(t *testing.T) {
+ root := t.TempDir()
+ sessionPath := filepath.Join(root, "sessions", "s.jsonl")
+ rawGoal := "resume .reasonix/autoresearch/bad-task/../../escape"
+
+ exec1 := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
+ c1 := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: exec1})
+ c1.Resume(agent.NewSession("sys"), sessionPath)
+ c1.SetGoal(rawGoal)
+ if got := c1.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("initial status = %q, want blocked", got)
+ }
+ c1.Close()
+
+ c2 := New(Options{WorkspaceRoot: root, SessionDir: root})
+ c2.Resume(agent.NewSession("sys"), sessionPath)
+ defer c2.Close()
+ if got := c2.GoalStatus(); got != GoalStatusBlocked {
+ t.Fatalf("restart status = %q, want blocked", got)
+ }
+ if c2.ResumeGoal() {
+ t.Fatal("malformed explicit archive resumed after restart")
+ }
+ if got := c2.Goal(); got != rawGoal {
+ t.Fatalf("restart retry changed Goal = %q, want %q", got, rawGoal)
+ }
+}
+
func TestMissingLegacyGoalCommandDoesNotStartProviderTurn(t *testing.T) {
runner := &gatedTurnRunner{started: make(chan struct{}), release: make(chan struct{})}
c := New(Options{WorkspaceRoot: t.TempDir(), Runner: runner})
From e08ffbf4a237c14b914cbaab02253c073b66f59a Mon Sep 17 00:00:00 2001
From: SivanCola <32437197+SivanCola@users.noreply.github.com>
Date: Sun, 9 Aug 2026 05:32:30 +0800
Subject: [PATCH 10/12] refactor: isolate contextual workflow surfaces
Problem: The Goal/runtime fixes added lines and complexity to several files already at their repolint debt ceilings, causing the repository gate to fail after merging the latest baseline.
Root cause: Contextual tool visibility, legacy durability helpers, and their tests were implemented inline in large owner files instead of dedicated modules.
Fix: Extract workflow context, planner registry, subagent identity, Goal durability, Jobs context, CLI Goal handling, and focused tests into scoped files. Keep provider behavior unchanged and add the new schema-bearing files to cache-impact coverage.
Verification: go test ./internal/agent ./internal/boot ./internal/control ./internal/jobs ./internal/tool/builtin -count=1; go run ./tools/repolint; git diff --check
---
internal/agent/agent.go | 12 ---
internal/agent/coordinator.go | 3 -
internal/agent/extensions_test.go | 35 -------
internal/agent/planner_registry.go | 48 ++++++++++
internal/agent/run_loop.go | 46 +--------
internal/agent/subagent_identity.go | 26 +++++
internal/agent/subagent_store.go | 17 ----
internal/agent/task.go | 55 -----------
internal/agent/workflow_context.go | 74 +++++++++++++++
internal/agent/workflow_context_test.go | 44 +++++++++
internal/boot/boot_test.go | 87 -----------------
internal/boot/tool_contract_surface_test.go | 95 +++++++++++++++++++
internal/cli/chat_tui.go | 11 +--
internal/cli/chat_tui_goal.go | 22 ++++-
internal/control/goal.go | 37 --------
internal/control/goal_durable.go | 40 +++++++-
internal/jobs/context.go | 35 +++++++
internal/jobs/context_test.go | 23 +++++
internal/jobs/jobs.go | 40 --------
internal/jobs/jobs_test.go | 15 ---
internal/tool/builtin/completestep_test.go | 14 ---
.../builtin/completestep_visibility_test.go | 21 ++++
scripts/check-cache-impact.sh | 2 +
23 files changed, 433 insertions(+), 369 deletions(-)
create mode 100644 internal/agent/planner_registry.go
create mode 100644 internal/agent/subagent_identity.go
create mode 100644 internal/agent/workflow_context.go
create mode 100644 internal/agent/workflow_context_test.go
create mode 100644 internal/boot/tool_contract_surface_test.go
create mode 100644 internal/jobs/context.go
create mode 100644 internal/jobs/context_test.go
create mode 100644 internal/tool/builtin/completestep_visibility_test.go
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index f50e9eff65..742dc7111e 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -139,18 +139,6 @@ func PlanModeFromContext(ctx context.Context) bool {
return ok && cc.planMode
}
-func (a *Agent) withAgentContext(ctx context.Context) context.Context {
- if a == nil {
- return ctx
- }
- if a.jobs != nil {
- ctx = jobs.WithManager(ctx, a.jobs)
- } else {
- ctx = jobs.WithoutManager(ctx)
- }
- return planmode.WithActive(ctx, a.planMode.Load())
-}
-
// WithParentSession stamps the active parent session ID onto a turn context so
// persisted sub-agents can record and enforce their owning conversation.
func WithParentSession(ctx context.Context, parentSession string) context.Context {
diff --git a/internal/agent/coordinator.go b/internal/agent/coordinator.go
index 939874840a..f751633c86 100644
--- a/internal/agent/coordinator.go
+++ b/internal/agent/coordinator.go
@@ -361,9 +361,6 @@ func (c *Coordinator) Run(ctx context.Context, input string) error {
return c.executor.Run(ctx, input)
}
c.sink.Emit(event.Event{Kind: event.Phase, Text: c.planner.Name() + " · planning", Detail: routeDetail, Source: event.UsageSourcePlanner})
- // The planner researches and proposes work but does not own the root Goal
- // turn's disposition. Hide the recorder only for planning; the executor
- // still receives the original context and can report after doing the work.
plannerCtx := tool.WithoutGoalTurnRecorder(ctx)
if decision.MaxResearchRounds > 0 {
plannerCtx = withRunStepLimit(plannerCtx, decision.MaxResearchRounds, "planner research rounds")
diff --git a/internal/agent/extensions_test.go b/internal/agent/extensions_test.go
index 8f935cc51c..43cc36b4f3 100644
--- a/internal/agent/extensions_test.go
+++ b/internal/agent/extensions_test.go
@@ -272,41 +272,6 @@ func TestAgentBeforeStartReplace(t *testing.T) {
}
}
-func TestAgentBeforeStartToolCountUsesContextualSchemas(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
-
- run := func(ctx context.Context) dispatch.AgentStartPayload {
- t.Helper()
- client := &fakeDispatchClient{}
- d := newExtDispatcher(client, true, nil, extension.PointAgentBeforeStart)
- mp := &mockProvider{name: "p", chunks: []provider.Chunk{
- {Type: provider.ChunkText, Text: "hi"}, {Type: provider.ChunkDone},
- }}
- a := New(mp, reg, NewSession("sys"), Options{Extensions: d}, event.Discard)
- if err := a.Run(ctx, "hello"); err != nil {
- t.Fatalf("Run: %v", err)
- }
- var payload dispatch.AgentStartPayload
- if !client.interceptPayloadFor(protocol.EventAgentBeforeStart, &payload) {
- t.Fatal("agent.before_start did not fire")
- }
- return payload
- }
-
- if got := run(context.Background()).ToolCount; got != 0 {
- t.Fatalf("ordinary ToolCount = %d, want update_goal hidden", got)
- }
- ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
- if got := run(ctx).ToolCount; got != 1 {
- t.Fatalf("Goal ToolCount = %d, want update_goal visible", got)
- }
-}
-
func TestAgentBeforeStartFailurePolicy(t *testing.T) {
boom := errors.New("sidecar timeout")
t.Run("required fails the run", func(t *testing.T) {
diff --git a/internal/agent/planner_registry.go b/internal/agent/planner_registry.go
new file mode 100644
index 0000000000..4e550e9be8
--- /dev/null
+++ b/internal/agent/planner_registry.go
@@ -0,0 +1,48 @@
+package agent
+
+import (
+ "strings"
+
+ "reasonix/internal/tool"
+)
+
+var plannerNonResearchTools = []string{
+ "ask",
+ "bash_output",
+ "complete_step",
+ "slash_command",
+ "todo_write",
+ "update_goal",
+ "wait",
+}
+
+// PlannerToolRegistry returns read-only research tools plus an isolated
+// use_capability proxy. Workflow and direct MCP schemas stay hidden.
+func PlannerToolRegistry(parent *tool.Registry) *tool.Registry {
+ exclude := append(SubagentMetaTools(), plannerNonResearchTools...)
+ base := FilterReadOnlyRegistry(parent, exclude...)
+ sub := tool.NewRegistry()
+ if base != nil {
+ for _, name := range base.Names() {
+ if name == "use_capability" || strings.HasPrefix(name, tool.MCPNamePrefix) {
+ continue
+ }
+ if tl, ok := base.Get(name); ok {
+ if classifier, ok := tl.(tool.PlanModeClassifier); ok && !classifier.PlanModeSafe() {
+ continue
+ }
+ sub.Add(tl)
+ }
+ }
+ }
+ if parent != nil {
+ if tl, ok := parent.Get("use_capability"); ok {
+ if uc, ok := tl.(*UseCapabilityTool); ok {
+ sub.Add(uc.CloneForAgent(nil, nil))
+ } else {
+ sub.Add(tl)
+ }
+ }
+ }
+ return sub
+}
diff --git a/internal/agent/run_loop.go b/internal/agent/run_loop.go
index 5c92617e81..d1e38c87eb 100644
--- a/internal/agent/run_loop.go
+++ b/internal/agent/run_loop.go
@@ -13,7 +13,6 @@ import (
"reasonix/internal/jobs"
"reasonix/internal/provider"
"reasonix/internal/taskintent"
- "reasonix/internal/tool"
)
// runLoopState holds per-Run loop counters and flags. It is package-private and
@@ -957,18 +956,8 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
state.emptyFinalBlocks = 0
state.usedAnyTool = true
unavailableContextTools, contextualOnly := a.unavailableContextualToolCalls(ctx, calls)
-
- if len(unavailableContextTools) > 0 && state.contextToolRepairs > 0 {
- msg := fmt.Sprintf("blocked: context-unavailable tools were called again after the repair instruction: %s", strings.Join(unavailableContextTools, ", "))
- for _, call := range calls {
- a.session.Add(provider.Message{
- Role: provider.RoleTool,
- Content: msg,
- ToolCallID: call.ID,
- Name: call.Name,
- })
- }
- return false, fmt.Errorf("model repeatedly called context-unavailable tools without a visible answer: %s", strings.Join(unavailableContextTools, ", "))
+ if err := a.rejectRepeatedContextToolCalls(state, calls, unavailableContextTools); err != nil {
+ return false, err
}
// Grace round guard: if we already gave the model one extra response
// and it still wants to call tools, stop here.
@@ -1024,17 +1013,8 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
a.recordInterruptedDisplay("", "", nil, true, state.workDurationMs())
return false, ctx.Err()
}
- if len(unavailableContextTools) > 0 {
- if hasVisibleFinalAnswer(text) {
- if contextualOnly {
- // Keep the assistant tool call and host error paired in the transcript,
- // but accept the co-streamed answer when every call was unavailable.
- return a.handleFinalResponse(ctx, state, text, reasoning, usage)
- }
- }
- state.contextToolRepairs++
- 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 handled, cont, err := a.repairContextToolCalls(ctx, state, text, reasoning, usage, unavailableContextTools, contextualOnly); handled {
+ return cont, err
}
if !a.planMode.Load() {
nextProgress, nextTracking := a.canonicalTodoProgress()
@@ -1106,21 +1086,3 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
}
return true, nil
}
-
-func (a *Agent) unavailableContextualToolCalls(ctx context.Context, calls []provider.ToolCall) ([]string, bool) {
- if len(calls) == 0 {
- return nil, false
- }
- names := make([]string, 0, len(calls))
- for _, call := range calls {
- t, ok := a.tools.Get(call.Name)
- if !ok {
- continue
- }
- contextual, ok := t.(tool.ContextualTool)
- if ok && !contextual.ProviderVisible(ctx) {
- names = append(names, call.Name)
- }
- }
- return names, len(names) == len(calls)
-}
diff --git a/internal/agent/subagent_identity.go b/internal/agent/subagent_identity.go
new file mode 100644
index 0000000000..2e6a86b3da
--- /dev/null
+++ b/internal/agent/subagent_identity.go
@@ -0,0 +1,26 @@
+package agent
+
+import (
+ "encoding/json"
+ "sort"
+
+ "reasonix/internal/provider"
+ "reasonix/internal/tool"
+)
+
+func toolIdentity(reg *tool.Registry, schemas []provider.ToolSchema) ([]string, string) {
+ if reg == nil {
+ return nil, bytesHash(nil)
+ }
+ if schemas == nil {
+ schemas = reg.Schemas()
+ }
+ names := make([]string, 0, len(schemas))
+ for _, schema := range schemas {
+ names = append(names, schema.Name)
+ }
+ sort.Strings(names)
+ schemas = normalizeToolSchemas(schemas)
+ data, _ := json.Marshal(schemas)
+ return names, bytesHash(data)
+}
diff --git a/internal/agent/subagent_store.go b/internal/agent/subagent_store.go
index 4fa19a2fba..69e2c4659d 100644
--- a/internal/agent/subagent_store.go
+++ b/internal/agent/subagent_store.go
@@ -944,23 +944,6 @@ func validSubagentRef(ref string) bool {
return true
}
-func toolIdentity(reg *tool.Registry, schemas []provider.ToolSchema) ([]string, string) {
- if reg == nil {
- return nil, bytesHash(nil)
- }
- if schemas == nil {
- schemas = reg.Schemas()
- }
- names := make([]string, 0, len(schemas))
- for _, schema := range schemas {
- names = append(names, schema.Name)
- }
- sort.Strings(names)
- schemas = normalizeToolSchemas(schemas)
- data, _ := json.Marshal(schemas)
- return names, bytesHash(data)
-}
-
func bytesHash(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
diff --git a/internal/agent/task.go b/internal/agent/task.go
index 35ee63c746..6296004dbe 100644
--- a/internal/agent/task.go
+++ b/internal/agent/task.go
@@ -19,7 +19,6 @@ import (
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/jobs"
- "reasonix/internal/memory"
"reasonix/internal/permission"
"reasonix/internal/planmode"
"reasonix/internal/provider"
@@ -1503,54 +1502,6 @@ func allowlistRequestsUnrestrictedProxy(names []string) bool {
return false
}
-var plannerNonResearchTools = []string{
- "ask",
- "bash_output",
- "complete_step",
- "slash_command",
- "todo_write",
- "update_goal",
- "wait",
-}
-
-// PlannerToolRegistry returns the tool set exposed to the two-model planner:
-// built-in read-only research tools plus the stable use_capability proxy. Direct
-// mcp__* schemas are excluded so MCP connect/disconnect/tool-list churn never
-// changes the Planner provider-visible tool prefix. Workflow/meta tools that are
-// technically read-only but can prompt the user, update visible task state, wait
-// on jobs, or expand commands are also excluded.
-func PlannerToolRegistry(parent *tool.Registry) *tool.Registry {
- exclude := append(SubagentMetaTools(), plannerNonResearchTools...)
- base := FilterReadOnlyRegistry(parent, exclude...)
- sub := tool.NewRegistry()
- if base != nil {
- for _, name := range base.Names() {
- // Never copy the parent proxy or direct MCP: Delivery would share
- // Executor ledger/audit; MCP schemas are proxy-only for the planner.
- if name == "use_capability" || strings.HasPrefix(name, tool.MCPNamePrefix) {
- continue
- }
- if tl, ok := base.Get(name); ok {
- if classifier, ok := tl.(tool.PlanModeClassifier); ok && !classifier.PlanModeSafe() {
- continue
- }
- sub.Add(tl)
- }
- }
- }
- // Always install an isolated frontend (independent ledger/audit; shared Host).
- if parent != nil {
- if tl, ok := parent.Get("use_capability"); ok {
- if uc, ok := tl.(*UseCapabilityTool); ok {
- sub.Add(uc.CloneForAgent(nil, nil))
- } else {
- sub.Add(tl)
- }
- }
- }
- return sub
-}
-
// ReadOnlySubagentToolRegistry returns the tool set exposed to read-only
// sub-agents: read-only research tools plus a bash wrapper that enforces the
// permission-layer read-only command policy at execution time. Workflow/meta tools are
@@ -1943,12 +1894,6 @@ func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *to
return "", fmt.Errorf("sub-agent finished without producing a final answer")
}
-func subagentProviderContext(ctx context.Context) context.Context {
- ctx = tool.WithoutGoalTurnRecorder(ctx)
- ctx = jobs.WithoutManager(ctx)
- return memory.WithoutQueue(ctx)
-}
-
// readOnlyAgentConstruction is the single pairing every strictly read-only
// loop shares: the permanent ReadOnlyExecution flag plus the final registry
// filter. Batch children (RunReadOnlySubAgentWithSession) and legacy call sites
diff --git a/internal/agent/workflow_context.go b/internal/agent/workflow_context.go
new file mode 100644
index 0000000000..295f8d561b
--- /dev/null
+++ b/internal/agent/workflow_context.go
@@ -0,0 +1,74 @@
+package agent
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "reasonix/internal/jobs"
+ "reasonix/internal/memory"
+ "reasonix/internal/planmode"
+ "reasonix/internal/provider"
+ "reasonix/internal/tool"
+)
+
+func (a *Agent) withAgentContext(ctx context.Context) context.Context {
+ if a == nil {
+ return ctx
+ }
+ if a.jobs != nil {
+ ctx = jobs.WithManager(ctx, a.jobs)
+ } else {
+ ctx = jobs.WithoutManager(ctx)
+ }
+ return planmode.WithActive(ctx, a.planMode.Load())
+}
+
+func subagentProviderContext(ctx context.Context) context.Context {
+ ctx = tool.WithoutGoalTurnRecorder(ctx)
+ ctx = jobs.WithoutManager(ctx)
+ return memory.WithoutQueue(ctx)
+}
+
+func (a *Agent) unavailableContextualToolCalls(ctx context.Context, calls []provider.ToolCall) ([]string, bool) {
+ if len(calls) == 0 {
+ return nil, false
+ }
+ names := make([]string, 0, len(calls))
+ for _, call := range calls {
+ t, ok := a.tools.Get(call.Name)
+ if !ok {
+ continue
+ }
+ contextual, ok := t.(tool.ContextualTool)
+ if ok && !contextual.ProviderVisible(ctx) {
+ names = append(names, call.Name)
+ }
+ }
+ return names, len(names) == len(calls)
+}
+
+func (a *Agent) rejectRepeatedContextToolCalls(state *runLoopState, calls []provider.ToolCall, unavailable []string) error {
+ if len(unavailable) == 0 || state.contextToolRepairs == 0 {
+ return nil
+ }
+ msg := fmt.Sprintf("blocked: context-unavailable tools were called again after the repair instruction: %s", strings.Join(unavailable, ", "))
+ for _, call := range calls {
+ a.session.Add(provider.Message{Role: provider.RoleTool, Content: msg, ToolCallID: call.ID, Name: call.Name})
+ }
+ return fmt.Errorf("model repeatedly called context-unavailable tools without a visible answer: %s", strings.Join(unavailable, ", "))
+}
+
+func (a *Agent) repairContextToolCalls(ctx context.Context, state *runLoopState, text, reasoning string, usage *provider.Usage, unavailable []string, contextualOnly bool) (bool, bool, error) {
+ if len(unavailable) == 0 {
+ return false, false, nil
+ }
+ if contextualOnly && hasVisibleFinalAnswer(text) {
+ cont, err := a.handleFinalResponse(ctx, state, text, reasoning, usage)
+ return true, cont, err
+ }
+ state.contextToolRepairs++
+ 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(unavailable, ", "))
+ a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge)})
+ return false, false, nil
+}
diff --git a/internal/agent/workflow_context_test.go b/internal/agent/workflow_context_test.go
new file mode 100644
index 0000000000..491d962f4e
--- /dev/null
+++ b/internal/agent/workflow_context_test.go
@@ -0,0 +1,44 @@
+package agent
+
+import (
+ "context"
+ "testing"
+
+ "reasonix/internal/event"
+ "reasonix/internal/extension"
+ "reasonix/internal/extension/dispatch"
+ "reasonix/internal/extension/protocol"
+ "reasonix/internal/provider"
+ "reasonix/internal/tool"
+)
+
+func TestAgentBeforeStartToolCountUsesContextualSchemas(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ run := func(ctx context.Context) dispatch.AgentStartPayload {
+ t.Helper()
+ client := &fakeDispatchClient{}
+ d := newExtDispatcher(client, true, nil, extension.PointAgentBeforeStart)
+ mp := &mockProvider{name: "p", chunks: []provider.Chunk{{Type: provider.ChunkText, Text: "hi"}, {Type: provider.ChunkDone}}}
+ a := New(mp, reg, NewSession("sys"), Options{Extensions: d}, event.Discard)
+ if err := a.Run(ctx, "hello"); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ var payload dispatch.AgentStartPayload
+ if !client.interceptPayloadFor(protocol.EventAgentBeforeStart, &payload) {
+ t.Fatal("agent.before_start did not fire")
+ }
+ return payload
+ }
+ if got := run(context.Background()).ToolCount; got != 0 {
+ t.Fatalf("ordinary ToolCount = %d, want update_goal hidden", got)
+ }
+ ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
+ if got := run(ctx).ToolCount; got != 1 {
+ t.Fatalf("Goal ToolCount = %d, want update_goal visible", got)
+ }
+}
diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go
index 14c3a95eba..228a099f85 100644
--- a/internal/boot/boot_test.go
+++ b/internal/boot/boot_test.go
@@ -2049,93 +2049,6 @@ model = "x"
}
}
-func TestBootToolContractCoversProviderVisibleSurface(t *testing.T) {
- for _, tc := range []struct {
- name string
- tokenMode string
- }{
- {name: "default", tokenMode: ""},
- {name: "economy", tokenMode: TokenModeEconomy},
- } {
- t.Run(tc.name, func(t *testing.T) {
- isolateConfigHome(t)
- dir := robustTempDir(t)
- t.Chdir(dir)
- writeFile(t, dir, "reasonix.toml", `
-default_model = "test-model"
-
-[agent]
-system_prompt = "BASE"
-
-[[providers]]
-name = "test-model"
-kind = "boot-token-profile-test"
-model = "x"
-`)
-
- req, entries := captureTokenProfileSurface(t, tc.tokenMode)
- wantNames := defaultFullBootToolNames()
- if tc.tokenMode == TokenModeEconomy {
- wantNames = economyBootToolNames()
- }
- if got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantNames) {
- t.Fatalf("%s provider-visible tool surface changed\ngot %v\nwant %v", tc.name, got, wantNames)
- }
- entryByName := make(map[string]tool.ContractEntry, len(entries))
- for _, entry := range entries {
- entryByName[entry.Name] = entry
- }
- if _, ok := entryByName["update_goal"]; !ok {
- t.Fatalf("static contract must retain contextual update_goal: %v", contractEntryNames(entries))
- }
- if len(entries) != len(req.Tools)+1 {
- t.Fatalf("contract entries = %d, provider tools = %d; want only contextual update_goal hidden\ncontract=%v\nprovider=%v", len(entries), len(req.Tools), contractEntryNames(entries), toolSchemaNames(req.Tools))
- }
- for i, s := range req.Tools {
- e, ok := entryByName[s.Name]
- if !ok {
- t.Fatalf("provider tool %q missing from static contract", s.Name)
- }
- if e.Name != s.Name {
- t.Fatalf("tool[%d] name = %q, want %q\ncontract=%v\nprovider=%v", i, e.Name, s.Name, contractEntryNames(entries), toolSchemaNames(req.Tools))
- }
- if e.Description != strings.TrimSpace(s.Description) {
- t.Fatalf("%s description drift\ncontract=%q\nprovider=%q", e.Name, e.Description, s.Description)
- }
- if !json.Valid(e.Schema) {
- t.Fatalf("%s contract schema is invalid JSON: %s", e.Name, e.Schema)
- }
- if got := string(provider.CanonicalizeSchema(e.Schema)); got != string(e.Schema) {
- t.Fatalf("%s contract schema is not canonical", e.Name)
- }
- if string(e.Schema) != string(s.Parameters) {
- t.Fatalf("%s schema drift\ncontract=%s\nprovider=%s", e.Name, e.Schema, s.Parameters)
- }
- }
- readOnly := map[string]bool{}
- for _, e := range entries {
- readOnly[e.Name] = e.ReadOnly
- }
- for name, want := range map[string]bool{
- "bash": false,
- "read_file": true,
- "connect_tool_source": tc.tokenMode == TokenModeEconomy,
- } {
- got, ok := readOnly[name]
- if !ok {
- if name == "connect_tool_source" && tc.tokenMode != TokenModeEconomy {
- continue
- }
- t.Fatalf("contract missing %s; tools=%v", name, contractEntryNames(entries))
- }
- if got != want {
- t.Fatalf("%s ReadOnly = %v, want %v", name, got, want)
- }
- }
- })
- }
-}
-
func TestToolContractDocCoversDefaultBootSurfaces(t *testing.T) {
pkgDir, err := os.Getwd()
if err != nil {
diff --git a/internal/boot/tool_contract_surface_test.go b/internal/boot/tool_contract_surface_test.go
new file mode 100644
index 0000000000..53f3f2cdfd
--- /dev/null
+++ b/internal/boot/tool_contract_surface_test.go
@@ -0,0 +1,95 @@
+package boot
+
+import (
+ "encoding/json"
+ "reflect"
+ "strings"
+ "testing"
+
+ "reasonix/internal/provider"
+ "reasonix/internal/tool"
+)
+
+func TestBootToolContractCoversProviderVisibleSurface(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ tokenMode string
+ }{
+ {name: "default", tokenMode: ""},
+ {name: "economy", tokenMode: TokenModeEconomy},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ isolateConfigHome(t)
+ dir := robustTempDir(t)
+ t.Chdir(dir)
+ writeFile(t, dir, "reasonix.toml", `
+default_model = "test-model"
+
+[agent]
+system_prompt = "BASE"
+
+[[providers]]
+name = "test-model"
+kind = "boot-token-profile-test"
+model = "x"
+`)
+
+ req, entries := captureTokenProfileSurface(t, tc.tokenMode)
+ wantNames := defaultFullBootToolNames()
+ if tc.tokenMode == TokenModeEconomy {
+ wantNames = economyBootToolNames()
+ }
+ if got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantNames) {
+ t.Fatalf("%s provider-visible tool surface changed\ngot %v\nwant %v", tc.name, got, wantNames)
+ }
+ entryByName := make(map[string]tool.ContractEntry, len(entries))
+ for _, entry := range entries {
+ entryByName[entry.Name] = entry
+ }
+ if _, ok := entryByName["update_goal"]; !ok {
+ t.Fatalf("static contract must retain contextual update_goal: %v", contractEntryNames(entries))
+ }
+ if len(entries) != len(req.Tools)+1 {
+ t.Fatalf("contract entries = %d, provider tools = %d; want only contextual update_goal hidden\ncontract=%v\nprovider=%v", len(entries), len(req.Tools), contractEntryNames(entries), toolSchemaNames(req.Tools))
+ }
+ for _, s := range req.Tools {
+ e, ok := entryByName[s.Name]
+ if !ok {
+ t.Fatalf("provider tool %q missing from static contract", s.Name)
+ }
+ if e.Description != strings.TrimSpace(s.Description) {
+ t.Fatalf("%s description drift\ncontract=%q\nprovider=%q", e.Name, e.Description, s.Description)
+ }
+ if !json.Valid(e.Schema) {
+ t.Fatalf("%s contract schema is invalid JSON: %s", e.Name, e.Schema)
+ }
+ if got := string(provider.CanonicalizeSchema(e.Schema)); got != string(e.Schema) {
+ t.Fatalf("%s contract schema is not canonical", e.Name)
+ }
+ if string(e.Schema) != string(s.Parameters) {
+ t.Fatalf("%s schema drift\ncontract=%s\nprovider=%s", e.Name, e.Schema, s.Parameters)
+ }
+ }
+ readOnly := map[string]bool{}
+ for _, e := range entries {
+ readOnly[e.Name] = e.ReadOnly
+ }
+ for name, want := range map[string]bool{
+ "bash": false,
+ "read_file": true,
+ "connect_tool_source": tc.tokenMode == TokenModeEconomy,
+ } {
+ got, ok := readOnly[name]
+ if !ok {
+ if name == "connect_tool_source" && tc.tokenMode != TokenModeEconomy {
+ continue
+ }
+ t.Fatalf("contract missing %s; tools=%v", name, contractEntryNames(entries))
+ }
+ if got != want {
+ t.Fatalf("%s ReadOnly = %v, want %v", name, got, want)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/cli/chat_tui.go b/internal/cli/chat_tui.go
index c124c1d883..9a118d74dc 100644
--- a/internal/cli/chat_tui.go
+++ b/internal/cli/chat_tui.go
@@ -4836,16 +4836,7 @@ func (m *chatTUI) runGoalSubcommand(input string) tea.Cmd {
}
switch m.noticeDeprecatedGoalBudget(cmd); cmd.Action {
case control.GoalCommandSet:
- m.planMode = false
- m.ctrl.SetPlanMode(false)
- m.ctrl.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode)
- m.ctrl.GoalStrict(cmd.Strict)
- if m.ctrl.GoalStatus() != control.GoalStatusRunning {
- m.echoLocalCommand(input)
- return nil
- }
- m.notice(fmt.Sprintf(i18n.M.GoalSetFmt, control.ShortGoalForNotice(m.ctrl.Goal())))
- return m.startTurn("Start pursuing the active goal now.", input, input)
+ return m.setGoalCommand(cmd, input)
case control.GoalCommandClear:
m.echoLocalCommand(input)
m.ctrl.ClearGoal()
diff --git a/internal/cli/chat_tui_goal.go b/internal/cli/chat_tui_goal.go
index 4c59083074..b6081cc380 100644
--- a/internal/cli/chat_tui_goal.go
+++ b/internal/cli/chat_tui_goal.go
@@ -1,9 +1,29 @@
package cli
-import "reasonix/internal/control"
+import (
+ "fmt"
+
+ tea "charm.land/bubbletea/v2"
+
+ "reasonix/internal/control"
+ "reasonix/internal/i18n"
+)
func (m *chatTUI) noticeDeprecatedGoalBudget(cmd control.GoalCommand) {
if cmd.DeprecatedBudgetFlag {
m.notice(control.GoalBudgetFlagDeprecatedNotice)
}
}
+
+func (m *chatTUI) setGoalCommand(cmd control.GoalCommand, input string) tea.Cmd {
+ m.planMode = false
+ m.ctrl.SetPlanMode(false)
+ m.ctrl.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode)
+ m.ctrl.GoalStrict(cmd.Strict)
+ if m.ctrl.GoalStatus() != control.GoalStatusRunning {
+ m.echoLocalCommand(input)
+ return nil
+ }
+ m.notice(fmt.Sprintf(i18n.M.GoalSetFmt, control.ShortGoalForNotice(m.ctrl.Goal())))
+ return m.startTurn("Start pursuing the active goal now.", input, input)
+}
diff --git a/internal/control/goal.go b/internal/control/goal.go
index 3de8749bf7..c2b3853e59 100644
--- a/internal/control/goal.go
+++ b/internal/control/goal.go
@@ -6,14 +6,12 @@ import (
"fmt"
"log/slog"
"os"
- "path/filepath"
"strings"
"sync"
"time"
"reasonix/internal/agent"
"reasonix/internal/evidence"
- "reasonix/internal/fileutil"
fileencoding "reasonix/internal/fileutil/encoding"
"reasonix/internal/goaleval"
"reasonix/internal/store"
@@ -660,41 +658,6 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string,
return g.statePath, b, true
}
-// writeStateErr persists pre-marshaled goal-state bytes to disk, OFF mu and
-// serialized by writeMu so concurrent saves don't interleave or land out of
-// order. Atomic replacement keeps the prior state intact when a write fails.
-func (g *goalMachine) writeStateErr(path string, data []byte) error {
- if path == "" || data == nil {
- return nil
- }
- g.writeMu.Lock()
- defer g.writeMu.Unlock()
- return writeGoalStateData(path, data)
-}
-
-func (g *goalMachine) writeStateAtEpoch(epoch uint64, todos []evidence.TodoItem) (bool, error) {
- g.writeMu.Lock()
- defer g.writeMu.Unlock()
- g.mu.Lock()
- if g.continuationEpoch != epoch {
- g.mu.Unlock()
- return false, nil
- }
- path, data, ok := g.buildStateLocked(todos)
- g.mu.Unlock()
- if !ok {
- return true, nil
- }
- return true, writeGoalStateData(path, data)
-}
-
-func writeGoalStateData(path string, data []byte) error {
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return err
- }
- return fileutil.AtomicWriteFile(path, data, 0o644)
-}
-
// writeState preserves the existing best-effort behavior for background Goal
// progress. Callers that need transactional persistence use writeStateErr.
func (g *goalMachine) writeState(path string, data []byte) {
diff --git a/internal/control/goal_durable.go b/internal/control/goal_durable.go
index 7708f1c1aa..48fac7a62f 100644
--- a/internal/control/goal_durable.go
+++ b/internal/control/goal_durable.go
@@ -1,6 +1,12 @@
package control
-import "reasonix/internal/evidence"
+import (
+ "os"
+ "path/filepath"
+
+ "reasonix/internal/evidence"
+ "reasonix/internal/fileutil"
+)
// goalMachineSnapshot is an in-memory rollback point for durable Goal updates.
// Persistence paths and mutexes are deliberately excluded.
@@ -58,3 +64,35 @@ func (g *goalMachine) restore(snapshot goalMachineSnapshot) {
g.continuationEpoch++
g.mu.Unlock()
}
+
+func (g *goalMachine) writeStateErr(path string, data []byte) error {
+ if path == "" || data == nil {
+ return nil
+ }
+ g.writeMu.Lock()
+ defer g.writeMu.Unlock()
+ return writeGoalStateData(path, data)
+}
+
+func (g *goalMachine) writeStateAtEpoch(epoch uint64, todos []evidence.TodoItem) (bool, error) {
+ g.writeMu.Lock()
+ defer g.writeMu.Unlock()
+ g.mu.Lock()
+ if g.continuationEpoch != epoch {
+ g.mu.Unlock()
+ return false, nil
+ }
+ path, data, ok := g.buildStateLocked(todos)
+ g.mu.Unlock()
+ if !ok {
+ return true, nil
+ }
+ return true, writeGoalStateData(path, data)
+}
+
+func writeGoalStateData(path string, data []byte) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ return fileutil.AtomicWriteFile(path, data, 0o644)
+}
diff --git a/internal/jobs/context.go b/internal/jobs/context.go
new file mode 100644
index 0000000000..599ae0ffb0
--- /dev/null
+++ b/internal/jobs/context.go
@@ -0,0 +1,35 @@
+package jobs
+
+import (
+ "context"
+ "strings"
+)
+
+type ctxKey struct{}
+type sessionCtxKey struct{}
+type jobCtxKey struct{}
+type noManager struct{}
+
+// WithManager stamps ctx with the job manager used by background tools.
+func WithManager(ctx context.Context, m *Manager) context.Context {
+ return context.WithValue(ctx, ctxKey{}, m)
+}
+
+// WithoutManager shadows an ancestor manager without discarding other values.
+func WithoutManager(ctx context.Context) context.Context {
+ return context.WithValue(ctx, ctxKey{}, noManager{})
+}
+
+func FromContext(ctx context.Context) (*Manager, bool) {
+ m, ok := ctx.Value(ctxKey{}).(*Manager)
+ return m, ok && m != nil
+}
+
+func WithSession(ctx context.Context, parentSession string) context.Context {
+ return context.WithValue(ctx, sessionCtxKey{}, strings.TrimSpace(parentSession))
+}
+
+func SessionFromContext(ctx context.Context) string {
+ session, _ := ctx.Value(sessionCtxKey{}).(string)
+ return strings.TrimSpace(session)
+}
diff --git a/internal/jobs/context_test.go b/internal/jobs/context_test.go
new file mode 100644
index 0000000000..26783e5dd2
--- /dev/null
+++ b/internal/jobs/context_test.go
@@ -0,0 +1,23 @@
+package jobs
+
+import (
+ "context"
+ "testing"
+
+ "reasonix/internal/event"
+)
+
+type preservedContextKey struct{}
+
+func TestWithoutManagerShadowsOnlyManager(t *testing.T) {
+ manager := NewManager(event.Discard)
+ defer manager.Close()
+ parent := context.WithValue(WithManager(context.Background(), manager), preservedContextKey{}, "preserved")
+ child := WithoutManager(parent)
+ if _, ok := FromContext(child); ok {
+ t.Fatal("child context inherited a disabled parent job manager")
+ }
+ if got := child.Value(preservedContextKey{}); got != "preserved" {
+ t.Fatalf("unrelated context value = %v, want preserved", got)
+ }
+}
diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go
index 2b17635c05..482ec76981 100644
--- a/internal/jobs/jobs.go
+++ b/internal/jobs/jobs.go
@@ -1906,46 +1906,6 @@ func jobKey(parentSession, id string) string {
return strings.TrimSpace(parentSession) + "\x00" + strings.TrimSpace(id)
}
-// call-context injection (mirrors agent.CallContext)
-
-type ctxKey struct{}
-type sessionCtxKey struct{}
-type jobCtxKey struct{}
-type noManager struct{}
-
-// WithManager stamps ctx with the job manager so tools can reach it via
-// FromContext. The agent sets this on every tool call's context.
-func WithManager(ctx context.Context, m *Manager) context.Context {
- return context.WithValue(ctx, ctxKey{}, m)
-}
-
-// WithoutManager shadows an ancestor manager while preserving the rest of the
-// context chain. Agents without Jobs must not accidentally operate a parent's
-// background jobs through inherited call context.
-func WithoutManager(ctx context.Context) context.Context {
- return context.WithValue(ctx, ctxKey{}, noManager{})
-}
-
-// FromContext returns the job manager set by the agent, if any. ok is false for a
-// plain context (headless tests, calls outside the run loop).
-func FromContext(ctx context.Context) (*Manager, bool) {
- m, ok := ctx.Value(ctxKey{}).(*Manager)
- return m, ok && m != nil
-}
-
-// WithSession stamps ctx with the active parent session ID for session-scoped job
-// operations.
-func WithSession(ctx context.Context, parentSession string) context.Context {
- return context.WithValue(ctx, sessionCtxKey{}, strings.TrimSpace(parentSession))
-}
-
-// SessionFromContext returns the active parent session ID for job ownership and
-// filtering. Empty means no session scope is available.
-func SessionFromContext(ctx context.Context) string {
- session, _ := ctx.Value(sessionCtxKey{}).(string)
- return strings.TrimSpace(session)
-}
-
// PublishEvidence attaches a background agent's host-observed receipts to its
// job. The receipts stay independent of the parent turn ledger until the
// parent collects the terminal result with wait or bash_output.
diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go
index 292f537aec..fc1d8c15ba 100644
--- a/internal/jobs/jobs_test.go
+++ b/internal/jobs/jobs_test.go
@@ -41,8 +41,6 @@ type blockingFinishedSink struct {
once sync.Once
}
-type preservedContextKey struct{}
-
func (s *blockingFinishedSink) Emit(ev event.Event) {
if strings.Contains(ev.Text, "background bash finished") {
s.once.Do(func() { close(s.entered) })
@@ -81,19 +79,6 @@ func TestStartForSessionStampsJobContext(t *testing.T) {
}
}
-func TestWithoutManagerShadowsOnlyManager(t *testing.T) {
- manager := NewManager(event.Discard)
- defer manager.Close()
- parent := context.WithValue(WithManager(context.Background(), manager), preservedContextKey{}, "preserved")
- child := WithoutManager(parent)
- if _, ok := FromContext(child); ok {
- t.Fatal("child context inherited a disabled parent job manager")
- }
- if got := child.Value(preservedContextKey{}); got != "preserved" {
- t.Fatalf("unrelated context value = %v, want preserved", got)
- }
-}
-
func TestJobStartObserverSeesLifetimeUntilTerminal(t *testing.T) {
observed := make(chan (<-chan struct{}), 1)
release := make(chan struct{})
diff --git a/internal/tool/builtin/completestep_test.go b/internal/tool/builtin/completestep_test.go
index d81497d573..1b2861e30c 100644
--- a/internal/tool/builtin/completestep_test.go
+++ b/internal/tool/builtin/completestep_test.go
@@ -8,9 +8,7 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/instruction"
- "reasonix/internal/planmode"
"reasonix/internal/provider"
- "reasonix/internal/tool"
)
func TestTodoInventoryListsTurnTodos(t *testing.T) {
@@ -490,18 +488,6 @@ func TestCompleteStepReadOnlyForPermissionLayer(t *testing.T) {
}
}
-func TestCompleteStepSchemaOnlyVisibleAfterPlanApproval(t *testing.T) {
- reg := tool.NewRegistry()
- reg.Add(completeStep{})
- if got := reg.SchemasForContext(planmode.WithActive(context.Background(), true)); len(got) != 0 {
- t.Fatalf("Plan-mode schemas = %+v, want complete_step hidden", got)
- }
- got := reg.SchemasForContext(planmode.WithActive(context.Background(), false))
- if len(got) != 1 || got[0].Name != "complete_step" {
- t.Fatalf("execution schemas = %+v, want complete_step", got)
- }
-}
-
// Replays of real complete_step rejections captured from local sessions (2026-06-02) and issue #2917.
func TestCompleteStepMatchesParaphrasedCommands(t *testing.T) {
cases := []struct {
diff --git a/internal/tool/builtin/completestep_visibility_test.go b/internal/tool/builtin/completestep_visibility_test.go
new file mode 100644
index 0000000000..f3d1bc51cb
--- /dev/null
+++ b/internal/tool/builtin/completestep_visibility_test.go
@@ -0,0 +1,21 @@
+package builtin
+
+import (
+ "context"
+ "testing"
+
+ "reasonix/internal/planmode"
+ "reasonix/internal/tool"
+)
+
+func TestCompleteStepSchemaOnlyVisibleAfterPlanApproval(t *testing.T) {
+ reg := tool.NewRegistry()
+ reg.Add(completeStep{})
+ if got := reg.SchemasForContext(planmode.WithActive(context.Background(), true)); len(got) != 0 {
+ t.Fatalf("Plan-mode schemas = %+v, want complete_step hidden", got)
+ }
+ got := reg.SchemasForContext(planmode.WithActive(context.Background(), false))
+ if len(got) != 1 || got[0].Name != "complete_step" {
+ t.Fatalf("execution schemas = %+v, want complete_step", got)
+ }
+}
diff --git a/scripts/check-cache-impact.sh b/scripts/check-cache-impact.sh
index 99408c972b..5a6daa89c7 100755
--- a/scripts/check-cache-impact.sh
+++ b/scripts/check-cache-impact.sh
@@ -66,9 +66,11 @@ for file in "${changed_files[@]:-}"; do
internal/agent/compact*|\
internal/agent/goal_display.go|\
internal/agent/parallel_tasks.go|\
+ internal/agent/planner_registry.go|\
internal/agent/prune*|\
internal/agent/subagent_registry*|\
internal/agent/task.go|\
+ internal/agent/workflow_context.go|\
internal/boot/*|\
internal/command/slashtool.go|\
internal/config/config.go|\
From f2929dc4cdc9ed5f70263aad86a7789165f3b218 Mon Sep 17 00:00:00 2001
From: SivanCola <32437197+SivanCola@users.noreply.github.com>
Date: Sun, 9 Aug 2026 05:49:54 +0800
Subject: [PATCH 11/12] fix: preserve the static provider tool contract
Problem: The PR made provider-visible schemas conditional on Goal, Plan, and Jobs context, removing update_goal from ordinary and economy requests and violating the required byte-stable tool contract.
Root cause: A broad contextual-tool mechanism was added while hardening Goal recorder isolation, coupling an execution boundary to provider schema selection.
Fix: Restore static Schemas() requests and the main-v2 tool order, remove contextual schema APIs and phase-specific visibility, and keep only execution-time Goal recorder isolation for planners and child agents. Correct the changelog and cache-impact coverage.
Verification: go test ./internal/agent ./internal/boot ./internal/control ./internal/tool ./internal/tool/builtin -count=1; go run ./tools/repolint; git diff --exit-code origin/main-v2 -- internal/agent/extensions.go internal/agent/run_loop.go internal/agent/sampling_request.go internal/tool/tool.go internal/tool/builtin/bgjobs.go internal/tool/builtin/completestep.go internal/tool/builtin/updategoal.go internal/jobs/jobs.go internal/memory/queue.go; git diff --check
---
CHANGELOG.md | 11 +-
internal/agent/delivery_hardening_test.go | 6 +-
internal/agent/extensions.go | 3 +-
.../agent/goal_recorder_isolation_test.go | 104 ++++++
internal/agent/goal_schema_isolation_test.go | 350 ------------------
internal/agent/planmode_test.go | 111 +-----
internal/agent/planner_registry.go | 6 +-
internal/agent/run_loop.go | 41 +-
internal/agent/sampling_request.go | 3 +-
.../agent/subagent_context_isolation_test.go | 74 ----
internal/agent/subagent_identity.go | 13 +-
internal/agent/subagent_store.go | 4 +-
internal/agent/task.go | 7 +-
internal/agent/workflow_context.go | 74 ----
internal/agent/workflow_context_test.go | 44 ---
internal/boot/boot_test.go | 3 +
internal/boot/tool_contract_surface_test.go | 22 +-
internal/control/goal_legacy_restore_test.go | 2 +-
internal/jobs/context.go | 35 --
internal/jobs/context_test.go | 23 --
internal/jobs/jobs.go | 32 ++
internal/memory/queue.go | 8 -
internal/memory/queue_test.go | 28 --
internal/tool/builtin/bgjobs.go | 15 -
internal/tool/builtin/bgjobs_test.go | 26 --
internal/tool/builtin/completestep.go | 8 -
.../builtin/completestep_visibility_test.go | 21 --
internal/tool/builtin/updategoal.go | 10 +-
internal/tool/builtin/updategoal_test.go | 14 -
internal/tool/contract_lock_test.go | 58 ---
internal/tool/contract_test.go | 12 -
internal/tool/tool.go | 45 +--
scripts/check-cache-impact.sh | 2 +-
33 files changed, 214 insertions(+), 1001 deletions(-)
create mode 100644 internal/agent/goal_recorder_isolation_test.go
delete mode 100644 internal/agent/goal_schema_isolation_test.go
delete mode 100644 internal/agent/subagent_context_isolation_test.go
delete mode 100644 internal/agent/workflow_context.go
delete mode 100644 internal/agent/workflow_context_test.go
delete mode 100644 internal/jobs/context.go
delete mode 100644 internal/jobs/context_test.go
delete mode 100644 internal/memory/queue_test.go
delete mode 100644 internal/tool/builtin/completestep_visibility_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75e6b2089d..0bbb0a1df6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,13 +9,10 @@ branch.
### Fixed
- Goal is now the sole long-task runtime. Historical AutoResearch sidecars
- migrate transactionally into research-budget Goals, retain their archive id
- for retry when recovery fails, and write an explicit legacy-reader fence so
- downgrading cannot reactivate the removed AutoResearch runtime.
-- Workflow-only tools are exposed to models only while their required Goal,
- Plan, or background-job context is active. Mixed valid/unavailable tool
- batches receive one bounded repair, while sub-agents no longer inherit parent
- Goal reports, background jobs, or immediate memory-queue injection.
+ migrate transactionally into research-budget Goals. Invalid archives block
+ fail closed and remain read-only; successful Goal-only sidecars omit the old
+ task id and write an explicit downgrade fence so previous readers cannot
+ reactivate the removed runtime.
- **Issue #7575:** Linux Bash under bubblewrap no longer mounts a fresh empty
`--tmpfs /tmp` on every call. Consecutive commands in the same logical session
diff --git a/internal/agent/delivery_hardening_test.go b/internal/agent/delivery_hardening_test.go
index 707bc5c6b9..3dc243e66b 100644
--- a/internal/agent/delivery_hardening_test.go
+++ b/internal/agent/delivery_hardening_test.go
@@ -198,7 +198,7 @@ func TestDeliveryDurableMemoryRequiresRememberWithoutCodeCeremony(t *testing.T)
}
}
-func TestNonGoalHallucinatedUpdateGoalWithVisibleTextDoesNotSpendRepairRound(t *testing.T) {
+func TestNonGoalUpdateGoalWithVisibleTextDoesNotSpendRepairRound(t *testing.T) {
goalTool, ok := tool.LookupBuiltin("update_goal")
if !ok {
t.Fatal("update_goal builtin not registered")
@@ -211,7 +211,7 @@ func TestNonGoalHallucinatedUpdateGoalWithVisibleTextDoesNotSpendRepairRound(t *
}}
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
if err := a.Run(context.Background(), "answer normally"); err != nil {
- t.Fatalf("non-Goal hallucinated update_goal with text: %v", err)
+ t.Fatalf("non-Goal update_goal with text: %v", err)
}
if prov.call != 1 {
t.Fatalf("provider calls = %d, want no repair round", prov.call)
@@ -238,7 +238,7 @@ func TestNonGoalToolOnlyUpdateGoalGetsAtMostOneRepairRound(t *testing.T) {
}}
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
err := a.Run(context.Background(), "answer normally")
- if err == nil || !strings.Contains(err.Error(), "repeatedly called context-unavailable tools") {
+ if err == nil || !strings.Contains(err.Error(), "repeatedly called update_goal outside Goal mode") {
t.Fatalf("repeated tool-only misuse error = %v", err)
}
if prov.call != 2 {
diff --git a/internal/agent/extensions.go b/internal/agent/extensions.go
index 16eae72788..20a8131f62 100644
--- a/internal/agent/extensions.go
+++ b/internal/agent/extensions.go
@@ -111,10 +111,9 @@ func (a *Agent) interceptAgentStart(ctx context.Context) error {
if d == nil {
return nil
}
- providerCtx := a.withAgentContext(ctx)
payload := dispatch.AgentStartPayload{
Model: a.prov.Name(),
- ToolCount: len(a.tools.SchemasForContext(providerCtx)),
+ ToolCount: len(a.tools.Schemas()),
SessionID: ParentSession(ctx),
}
result, err := d.Intercept(ctx, extension.PointAgentBeforeStart, &payload)
diff --git a/internal/agent/goal_recorder_isolation_test.go b/internal/agent/goal_recorder_isolation_test.go
new file mode 100644
index 0000000000..80f395930a
--- /dev/null
+++ b/internal/agent/goal_recorder_isolation_test.go
@@ -0,0 +1,104 @@
+package agent
+
+import (
+ "context"
+ "slices"
+ "strings"
+ "testing"
+
+ "reasonix/internal/event"
+ "reasonix/internal/provider"
+ "reasonix/internal/tool"
+)
+
+type childIsolationGoalRecorder struct {
+ reports []tool.GoalReport
+}
+
+func (r *childIsolationGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
+ r.reports = append(r.reports, report)
+ return "recorded " + report.Status, nil
+}
+
+func TestSubAgentDoesNotInheritParentGoalRecorder(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ prov := &scriptedProvider{name: "goal-child", turns: [][]provider.Chunk{
+ {toolCallChunk("goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "Child result."}, {Type: provider.ChunkDone}},
+ }}
+ recorder := &childIsolationGoalRecorder{}
+ ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
+ sess := NewSession("child system")
+ answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the task", Options{}, event.Discard)
+ if err != nil {
+ t.Fatalf("Goal child: %v", err)
+ }
+ if answer != "Child result." {
+ t.Fatalf("Goal child answer = %q", answer)
+ }
+ for i, req := range prov.requests {
+ if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
+ t.Fatalf("child request %d changed the static tool surface: %v", i+1, toolSchemaNames(req.Tools))
+ }
+ }
+ if len(recorder.reports) != 0 {
+ t.Fatalf("child wrote reports into parent Goal recorder: %+v", recorder.reports)
+ }
+ if got := lastToolResult(sess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
+ t.Fatalf("child update_goal result = %q", got)
+ }
+}
+
+type coordinatorGoalRecorder struct {
+ reports []tool.GoalReport
+}
+
+func (r *coordinatorGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
+ r.reports = append(r.reports, report)
+ return "recorded " + report.Status, nil
+}
+
+func TestCoordinatorPlannerCannotReportExecutorGoalDisposition(t *testing.T) {
+ goalTool, ok := tool.LookupBuiltin("update_goal")
+ if !ok {
+ t.Fatal("update_goal builtin not registered")
+ }
+ reg := tool.NewRegistry()
+ reg.Add(goalTool)
+ planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
+ {toolCallChunk("planner-goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
+ {{Type: provider.ChunkText, Text: "1. inspect the implementation\n2. apply and verify the fix"}, {Type: provider.ChunkDone}},
+ }}
+ exec := &mockProvider{name: "executor", chunks: []provider.Chunk{{Type: provider.ChunkText, Text: "Implemented and verified."}, {Type: provider.ChunkDone}}}
+ plannerSess := NewSession("planner-sys")
+ executor := New(exec, reg, NewSession("exec-sys"), Options{}, event.Discard)
+ customPlannerReg := tool.NewRegistry()
+ customPlannerReg.Add(goalTool)
+ coord := NewCoordinator(planner, plannerSess, nil, customPlannerReg, Options{}, executor, 0, event.Discard, nil)
+ recorder := &coordinatorGoalRecorder{}
+ ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
+ if err := coord.Run(ctx, "fix the goal bug"); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ for i, req := range planner.requests {
+ if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
+ t.Fatalf("planner request %d changed the static tool surface: %v", i+1, toolSchemaNames(req.Tools))
+ }
+ }
+ if got := lastToolResult(plannerSess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
+ t.Fatalf("planner update_goal result = %q", got)
+ }
+ for i, req := range exec.requests {
+ if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
+ t.Fatalf("executor request %d lost update_goal: %v", i+1, toolSchemaNames(req.Tools))
+ }
+ }
+ if len(recorder.reports) != 0 {
+ t.Fatalf("planner wrote reports into executor Goal recorder: %+v", recorder.reports)
+ }
+}
diff --git a/internal/agent/goal_schema_isolation_test.go b/internal/agent/goal_schema_isolation_test.go
deleted file mode 100644
index f4b9cd2127..0000000000
--- a/internal/agent/goal_schema_isolation_test.go
+++ /dev/null
@@ -1,350 +0,0 @@
-package agent
-
-import (
- "context"
- "encoding/json"
- "slices"
- "strings"
- "sync/atomic"
- "testing"
-
- "reasonix/internal/event"
- "reasonix/internal/provider"
- "reasonix/internal/tool"
-)
-
-type requestGoalRecorder struct{}
-
-func (requestGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
- return "recorded " + report.Status, nil
-}
-
-type childIsolationGoalRecorder struct {
- reports []tool.GoalReport
-}
-
-func (r *childIsolationGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
- r.reports = append(r.reports, report)
- return "recorded " + report.Status, nil
-}
-
-type plannerPhaseOnlyTool struct{}
-
-func (plannerPhaseOnlyTool) Name() string { return "planner_phase_only" }
-func (plannerPhaseOnlyTool) Description() string { return "planner phase-only test tool" }
-func (plannerPhaseOnlyTool) Schema() json.RawMessage {
- return json.RawMessage(`{"type":"object"}`)
-}
-func (plannerPhaseOnlyTool) Execute(context.Context, json.RawMessage) (string, error) {
- return "phase-only", nil
-}
-func (plannerPhaseOnlyTool) ReadOnly() bool { return true }
-func (plannerPhaseOnlyTool) PlanModeSafe() bool { return false }
-
-func TestPlannerToolRegistryExcludesNonContextualPlanUnsafeTools(t *testing.T) {
- parent := tool.NewRegistry()
- parent.Add(plannerPhaseOnlyTool{})
- if _, ok := PlannerToolRegistry(parent).Get("planner_phase_only"); ok {
- t.Fatal("two-model Planner exposed a PlanModeSafe=false custom tool")
- }
-}
-
-func TestGoalContextChangesOnlyUpdateGoalVisibility(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- ordinary := &scriptedProvider{name: "ordinary", turns: [][]provider.Chunk{
- {{Type: provider.ChunkText, Text: "ordinary"}, {Type: provider.ChunkDone}},
- }}
- ordinaryAgent := New(ordinary, reg, NewSession("sys"), Options{}, event.Discard)
- if err := ordinaryAgent.Run(context.Background(), "answer normally"); err != nil {
- t.Fatalf("ordinary Run: %v", err)
- }
- goal := &scriptedProvider{name: "goal", turns: [][]provider.Chunk{
- {{Type: provider.ChunkText, Text: "goal"}, {Type: provider.ChunkDone}},
- }}
- goalAgent := New(goal, reg, NewSession("sys"), Options{}, event.Discard)
- ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
- if err := goalAgent.Run(ctx, "continue goal"); err != nil {
- t.Fatalf("Goal Run: %v", err)
- }
- ordinarySchemas, err := json.Marshal(ordinary.requests[0].Tools)
- if err != nil {
- t.Fatal(err)
- }
- goalSchemas, err := json.Marshal(goal.requests[0].Tools)
- if err != nil {
- t.Fatal(err)
- }
- if string(ordinarySchemas) == string(goalSchemas) {
- t.Fatalf("Goal context did not expose update_goal:\nordinary=%s\ngoal=%s", ordinarySchemas, goalSchemas)
- }
- if slices.Contains(toolSchemaNames(ordinary.requests[0].Tools), "update_goal") {
- t.Fatalf("ordinary request exposed update_goal: %s", ordinarySchemas)
- }
- if !slices.Contains(toolSchemaNames(goal.requests[0].Tools), "update_goal") {
- t.Fatalf("Goal request hid update_goal: %s", goalSchemas)
- }
-}
-
-func TestContextualToolSchemasStayStableWithinEachGoalPhase(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- reg.Add(fakeTool{name: "read_file", readOnly: true})
-
- marshal := func(ctx context.Context) string {
- t.Helper()
- raw, err := json.Marshal(reg.SchemasForContext(ctx))
- if err != nil {
- t.Fatal(err)
- }
- return string(raw)
- }
- ordinaryCtx := context.Background()
- goalCtx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
- ordinary := marshal(ordinaryCtx)
- goal := marshal(goalCtx)
- if ordinary != marshal(ordinaryCtx) {
- t.Fatal("ordinary-phase schema bytes changed between identical requests")
- }
- if goal != marshal(goalCtx) {
- t.Fatal("Goal-phase schema bytes changed between identical requests")
- }
- if ordinary == goal {
- t.Fatal("Goal phase transition did not produce the expected one-time schema difference")
- }
-}
-
-func TestGoalRequestExposesUpdateGoal(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
- {{Type: provider.ChunkText, Text: "Goal work continues."}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
- if err := a.Run(ctx, "continue goal"); err != nil {
- t.Fatalf("Goal answer: %v", err)
- }
- if len(prov.requests) != 1 {
- t.Fatalf("provider requests = %d, want 1", len(prov.requests))
- }
- if !slices.Contains(toolSchemaNames(prov.requests[0].Tools), "update_goal") {
- t.Fatal("Goal provider request did not expose update_goal")
- }
-}
-
-func TestMixedContextUnavailableBatchExecutesValidToolsAndRepairsOnce(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- var validCalls int32
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &validCalls})
- prov := &scriptedProvider{name: "mixed", turns: [][]provider.Chunk{
- {
- toolCallChunk("goal", "update_goal", `{"status":"complete"}`),
- toolCallChunk("read", "read_file", `{}`),
- {Type: provider.ChunkDone},
- },
- {{Type: provider.ChunkText, Text: "Visible answer after collecting the valid result."}, {Type: provider.ChunkDone}},
- }}
- sess := NewSession("sys")
- a := New(prov, reg, sess, Options{}, event.Discard)
-
- if err := a.Run(context.Background(), "inspect and answer"); err != nil {
- t.Fatalf("Run: %v", err)
- }
- if got := atomic.LoadInt32(&validCalls); got != 1 {
- t.Fatalf("valid tool calls = %d, want 1", got)
- }
- if len(prov.requests) != 2 {
- t.Fatalf("provider requests = %d, want one repair", len(prov.requests))
- }
- if got := lastUser(prov.requests[1]); !strings.Contains(got, "update_goal") || !strings.Contains(got, "visible answer text") {
- t.Fatalf("repair instruction = %q", got)
- }
- if slices.Contains(toolSchemaNames(prov.requests[1].Tools), "update_goal") || !slices.Contains(toolSchemaNames(prov.requests[1].Tools), "read_file") {
- t.Fatalf("repair schemas = %v", toolSchemaNames(prov.requests[1].Tools))
- }
- if got := toolResultByID(sess, "goal"); !strings.Contains(got, "only available while an active goal turn") {
- t.Fatalf("unavailable result = %q", got)
- }
- if got := toolResultByID(sess, "read"); got != "read_file done" {
- t.Fatalf("valid result = %q", got)
- }
-}
-
-func TestRepeatedMixedContextUnavailableBatchStopsBeforeReexecution(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- var validCalls int32
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &validCalls})
- firstMixed := []provider.Chunk{
- toolCallChunk("goal", "update_goal", `{"status":"complete"}`),
- toolCallChunk("read", "read_file", `{}`),
- {Type: provider.ChunkDone},
- }
- secondMixed := []provider.Chunk{
- toolCallChunk("goal-2", "update_goal", `{"status":"complete"}`),
- toolCallChunk("read-2", "read_file", `{}`),
- {Type: provider.ChunkDone},
- }
- prov := &scriptedProvider{name: "repeated-mixed", turns: [][]provider.Chunk{firstMixed, secondMixed}}
- sess := NewSession("sys")
- a := New(prov, reg, sess, Options{MaxSteps: 1}, event.Discard)
-
- err := a.Run(context.Background(), "inspect and answer")
- if err == nil || !strings.Contains(err.Error(), "repeatedly called context-unavailable tools") {
- t.Fatalf("Run error = %v, want repeated contextual misuse", err)
- }
- if got := atomic.LoadInt32(&validCalls); got != 1 {
- t.Fatalf("valid tool calls = %d, want second mixed batch blocked before execution", got)
- }
- if got := toolResultByID(sess, "read-2"); !strings.Contains(got, "called again after the repair instruction") {
- t.Fatalf("second batch pairing result = %q", got)
- }
-}
-
-func TestSubAgentDoesNotInheritParentGoalRecorder(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- prov := &scriptedProvider{name: "goal-child", turns: [][]provider.Chunk{
- {toolCallChunk("goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
- {{Type: provider.ChunkText, Text: "Child result."}, {Type: provider.ChunkDone}},
- }}
- recorder := &childIsolationGoalRecorder{}
- ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
- sess := NewSession("child system")
-
- answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the task", Options{}, event.Discard)
- if err != nil {
- t.Fatalf("Goal child: %v", err)
- }
- if answer != "Child result." {
- t.Fatalf("Goal child answer = %q", answer)
- }
- if len(prov.requests) != 2 {
- t.Fatalf("provider requests = %d, want hallucinated call plus repair", len(prov.requests))
- }
- for i, req := range prov.requests {
- if slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
- t.Fatalf("child provider request %d exposed update_goal: %v", i+1, toolSchemaNames(req.Tools))
- }
- }
- if len(recorder.reports) != 0 {
- t.Fatalf("child wrote reports into parent Goal recorder: %+v", recorder.reports)
- }
- if got := lastToolResult(sess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
- t.Fatalf("child update_goal result = %q", got)
- }
-}
-
-type coordinatorGoalRecorder struct {
- reports []tool.GoalReport
-}
-
-func (r *coordinatorGoalRecorder) RecordGoalReport(report tool.GoalReport) (string, error) {
- r.reports = append(r.reports, report)
- return "recorded " + report.Status, nil
-}
-
-func TestCoordinatorPlannerCannotReportExecutorGoalDisposition(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
- {toolCallChunk("planner-goal", "update_goal", `{"status":"complete"}`), {Type: provider.ChunkDone}},
- {{Type: provider.ChunkText, Text: "1. inspect the implementation\n2. apply and verify the fix"}, {Type: provider.ChunkDone}},
- }}
- exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
- {Type: provider.ChunkText, Text: "Implemented and verified."},
- {Type: provider.ChunkDone},
- }}
- plannerSess := NewSession("planner-sys")
- executor := New(exec, reg, NewSession("exec-sys"), Options{}, event.Discard)
- customPlannerReg := tool.NewRegistry()
- customPlannerReg.Add(goalTool)
- coord := NewCoordinator(planner, plannerSess, nil, customPlannerReg, Options{}, executor, 0, event.Discard, nil)
- recorder := &coordinatorGoalRecorder{}
- ctx := tool.WithGoalTurnRecorder(context.Background(), recorder)
-
- if err := coord.Run(ctx, "fix the goal bug"); err != nil {
- t.Fatalf("Run: %v", err)
- }
- if len(planner.requests) != 2 {
- t.Fatalf("planner requests = %d, want hallucinated call plus repair", len(planner.requests))
- }
- for i, req := range planner.requests {
- if slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
- t.Fatalf("planner request %d exposed update_goal: %v", i+1, toolSchemaNames(req.Tools))
- }
- }
- if got := lastToolResult(plannerSess, "update_goal"); !strings.Contains(got, "only available while an active goal turn") {
- t.Fatalf("planner update_goal result = %q", got)
- }
- if len(exec.requests) == 0 {
- t.Fatal("executor made no requests")
- }
- for i, req := range exec.requests {
- if !slices.Contains(toolSchemaNames(req.Tools), "update_goal") {
- t.Fatalf("executor request %d lost update_goal after planner isolation: %v", i+1, toolSchemaNames(req.Tools))
- }
- }
- if len(recorder.reports) != 0 {
- t.Fatalf("planner wrote reports into executor Goal recorder: %+v", recorder.reports)
- }
-}
-
-func TestSubagentIdentityUsesEffectiveChildToolSchemas(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- reg.Add(fakeTool{name: "read_file", readOnly: true})
- store := NewSubagentStore(t.TempDir())
- task := &TaskTool{transcripts: store, sysPrompt: "child system", workspaceRoot: t.TempDir()}
- ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
- run, err := task.prepareTranscriptRunWithPrompt(ctx, reg, "model", "medium", "parent-session", "call-1", "", "", "child system", "task", "inspect")
- if err != nil {
- t.Fatalf("prepareTranscriptRunWithPrompt: %v", err)
- }
- defer run.Release()
- if slices.Contains(run.Meta.ToolScope, "update_goal") || !slices.Contains(run.Meta.ToolScope, "read_file") {
- t.Fatalf("subagent tool scope = %v, want only child-visible tools", run.Meta.ToolScope)
- }
- _, wantHash := toolIdentity(reg, reg.SchemasForContext(subagentProviderContext(ctx)))
- if run.Meta.ToolSchemaHash != wantHash {
- t.Fatalf("subagent schema hash = %q, want %q", run.Meta.ToolSchemaHash, wantHash)
- }
- _, staticHash := toolIdentity(reg, reg.Schemas())
- if run.Meta.ToolSchemaHash == staticHash {
- t.Fatal("subagent identity used static schemas and included parent-only update_goal")
- }
-}
diff --git a/internal/agent/planmode_test.go b/internal/agent/planmode_test.go
index b135c03cb7..ed1a53e168 100644
--- a/internal/agent/planmode_test.go
+++ b/internal/agent/planmode_test.go
@@ -3,7 +3,6 @@ package agent
import (
"context"
"encoding/json"
- "slices"
"strings"
"testing"
@@ -268,10 +267,11 @@ func TestPlanModeCanReplacePriorExecutionTodoState(t *testing.T) {
}
}
-// TestPlanModePreservesSystemAndOrdinaryTools is the cache-stability test for
-// non-contextual tools. Phase-only tools are the intentional exception and are
-// covered by TestPlanModeRequestHidesCompleteStepUntilExecution.
-func TestPlanModePreservesSystemAndOrdinaryTools(t *testing.T) {
+// TestPlanModeDoesNotMutateSystemOrTools is the cache-stability test. Toggling
+// plan mode between two stream calls must not change the system prompt or the
+// tool list seen by the provider — those are the cache-key prefix, and any
+// change there forces an expensive cache miss.
+func TestPlanModeDoesNotMutateSystemOrTools(t *testing.T) {
prov := &mockProvider{name: "p", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "ok"},
{Type: provider.ChunkDone},
@@ -303,107 +303,6 @@ func TestPlanModePreservesSystemAndOrdinaryTools(t *testing.T) {
}
}
-func TestPlanModeRequestHidesCompleteStepUntilExecution(t *testing.T) {
- prov := &mockProvider{name: "p", chunks: []provider.Chunk{
- {Type: provider.ChunkText, Text: "ok"},
- {Type: provider.ChunkDone},
- }}
- reg := tool.NewRegistry()
- reg.Add(fakeTool{name: "read_file", readOnly: true})
- reg.Add(mustBuiltinTool(t, "complete_step"))
- a := New(prov, reg, NewSession("STABLE-SYS"), Options{}, event.Discard)
-
- if err := a.Run(context.Background(), "execution"); err != nil {
- t.Fatalf("execution Run: %v", err)
- }
- if !slices.Contains(toolSchemaNames(prov.lastReq.Tools), "complete_step") {
- t.Fatalf("execution request missing complete_step: %v", toolSchemaNames(prov.lastReq.Tools))
- }
-
- prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "plan"}, {Type: provider.ChunkDone}}
- a.SetPlanMode(true)
- if err := a.Run(context.Background(), "plan first"); err != nil {
- t.Fatalf("Plan Run: %v", err)
- }
- planTools := toolSchemaNames(prov.lastReq.Tools)
- if slices.Contains(planTools, "complete_step") {
- t.Fatalf("Plan request exposed complete_step: %v", planTools)
- }
- if !slices.Contains(planTools, "read_file") {
- t.Fatalf("Plan request lost ordinary tool: %v", planTools)
- }
- stablePlanTools := serializeToolSchemas(t, prov.lastReq.Tools)
- prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "plan again"}, {Type: provider.ChunkDone}}
- if err := a.Run(context.Background(), "refine plan"); err != nil {
- t.Fatalf("second Plan Run: %v", err)
- }
- if got := serializeToolSchemas(t, prov.lastReq.Tools); got != stablePlanTools {
- t.Fatalf("Plan tool schemas changed within the same mode:\nfirst=%s\nsecond=%s", stablePlanTools, got)
- }
-
- prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "execute"}, {Type: provider.ChunkDone}}
- a.SetPlanMode(false)
- if err := a.Run(context.Background(), "execute approved plan"); err != nil {
- t.Fatalf("post-approval Run: %v", err)
- }
- if !slices.Contains(toolSchemaNames(prov.lastReq.Tools), "complete_step") {
- t.Fatalf("post-approval request missing complete_step: %v", toolSchemaNames(prov.lastReq.Tools))
- }
-}
-
-func TestPlanModeHallucinatedCompleteStepPreservesVisibleAnswer(t *testing.T) {
- reg := tool.NewRegistry()
- reg.Add(mustBuiltinTool(t, "complete_step"))
- prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
- {
- {Type: provider.ChunkText, Text: "Here is the plan."},
- toolCallChunk("step", "complete_step", `{}`),
- {Type: provider.ChunkDone},
- },
- {{Type: provider.ChunkText, Text: "unexpected repair"}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- a.SetPlanMode(true)
- if err := a.Run(context.Background(), "plan the change"); err != nil {
- t.Fatalf("Plan Run: %v", err)
- }
- if prov.call != 1 {
- t.Fatalf("provider calls = %d, want no repair round", prov.call)
- }
- if got := lastAssistantContent(a.Session()); got != "Here is the plan." {
- t.Fatalf("last assistant text = %q", got)
- }
- if got := lastToolResult(a.Session(), "complete_step"); !strings.Contains(got, "only available after plan approval") {
- t.Fatalf("complete_step result = %q", got)
- }
-}
-
-func TestPlanModeToolOnlyCompleteStepNudgesVisibleAnswer(t *testing.T) {
- reg := tool.NewRegistry()
- reg.Add(mustBuiltinTool(t, "complete_step"))
- prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
- {toolCallChunk("step", "complete_step", `{}`), {Type: provider.ChunkDone}},
- {{Type: provider.ChunkText, Text: "Here is the recovered plan."}, {Type: provider.ChunkDone}},
- }}
- a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
- a.SetPlanMode(true)
- if err := a.Run(context.Background(), "plan the change"); err != nil {
- t.Fatalf("Plan repair: %v", err)
- }
- if len(prov.requests) != 2 {
- t.Fatalf("provider requests = %d, want repair round", len(prov.requests))
- }
- if got := lastUser(prov.requests[1]); !strings.Contains(got, "complete_step") || !strings.Contains(got, "visible answer text") {
- t.Fatalf("repair instruction = %q", got)
- }
- if slices.Contains(toolSchemaNames(prov.requests[1].Tools), "complete_step") {
- t.Fatalf("repair request re-exposed complete_step: %v", toolSchemaNames(prov.requests[1].Tools))
- }
- if got := lastAssistantContent(a.Session()); got != "Here is the recovered plan." {
- t.Fatalf("last assistant text = %q", got)
- }
-}
-
func serializeToolSchemas(t *testing.T, schemas []provider.ToolSchema) string {
t.Helper()
b, err := json.Marshal(schemas)
diff --git a/internal/agent/planner_registry.go b/internal/agent/planner_registry.go
index 4e550e9be8..a1ca1cfd52 100644
--- a/internal/agent/planner_registry.go
+++ b/internal/agent/planner_registry.go
@@ -12,12 +12,11 @@ var plannerNonResearchTools = []string{
"complete_step",
"slash_command",
"todo_write",
- "update_goal",
"wait",
}
// PlannerToolRegistry returns read-only research tools plus an isolated
-// use_capability proxy. Workflow and direct MCP schemas stay hidden.
+// use_capability proxy. Direct MCP schemas and selected workflow tools stay hidden.
func PlannerToolRegistry(parent *tool.Registry) *tool.Registry {
exclude := append(SubagentMetaTools(), plannerNonResearchTools...)
base := FilterReadOnlyRegistry(parent, exclude...)
@@ -28,9 +27,6 @@ func PlannerToolRegistry(parent *tool.Registry) *tool.Registry {
continue
}
if tl, ok := base.Get(name); ok {
- if classifier, ok := tl.(tool.PlanModeClassifier); ok && !classifier.PlanModeSafe() {
- continue
- }
sub.Add(tl)
}
}
diff --git a/internal/agent/run_loop.go b/internal/agent/run_loop.go
index d1e38c87eb..a7cde3b8c8 100644
--- a/internal/agent/run_loop.go
+++ b/internal/agent/run_loop.go
@@ -13,6 +13,7 @@ import (
"reasonix/internal/jobs"
"reasonix/internal/provider"
"reasonix/internal/taskintent"
+ "reasonix/internal/tool"
)
// runLoopState holds per-Run loop counters and flags. It is package-private and
@@ -26,7 +27,7 @@ type runLoopState struct {
emptyFinalBlocks int
handoffNudges int
usedAnyTool bool
- contextToolRepairs int
+ goalToolRepairs int
graceRound bool
recoveryGraceRound bool
@@ -326,7 +327,6 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
// runToolLoop owns the main tool-round budget and dispatches each streamed
// 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++ {
// Consume a queued steer and persist it to the session so it
// survives tab switches and history replay. The model sees it as
@@ -336,7 +336,7 @@ func (a *Agent) runToolLoop(ctx context.Context, state *runLoopState) error {
a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(midTurnSteerMessage(text))})
a.sink.Emit(event.Event{Kind: event.Steer, Text: text})
}
- schemas := a.tools.SchemasForContext(ctx)
+ schemas := a.tools.Schemas()
prefixShape := a.capturePrefixShape(schemas)
prevPrefixShape := a.lastPrefixShape
if !a.haveLastPrefixShape {
@@ -955,10 +955,8 @@ func (a *Agent) handleFinalResponse(ctx context.Context, state *runLoopState, te
func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step int, text, reasoning string, calls []provider.ToolCall, usage *provider.Usage) (cont bool, err error) {
state.emptyFinalBlocks = 0
state.usedAnyTool = true
- unavailableContextTools, contextualOnly := a.unavailableContextualToolCalls(ctx, calls)
- if err := a.rejectRepeatedContextToolCalls(state, calls, unavailableContextTools); err != nil {
- return false, err
- }
+ outOfContextGoalOnly := toolCallsAreOutOfContextGoalReports(ctx, calls)
+
// Grace round guard: if we already gave the model one extra response
// and it still wants to call tools, stop here.
if state.graceRound {
@@ -988,6 +986,7 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
StopReason: reason,
}
}
+
receiptMark := 0
if a.evidence != nil {
receiptMark = a.evidence.Len()
@@ -1013,8 +1012,17 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
a.recordInterruptedDisplay("", "", nil, true, state.workDurationMs())
return false, ctx.Err()
}
- if handled, cont, err := a.repairContextToolCalls(ctx, state, text, reasoning, usage, unavailableContextTools, contextualOnly); handled {
- return cont, err
+ if outOfContextGoalOnly {
+ if hasVisibleFinalAnswer(text) {
+ // Keep the assistant tool call and host error paired in the transcript,
+ // but accept the co-streamed answer instead of spending another model
+ // request repairing harmless Goal bookkeeping outside Goal mode.
+ return a.handleFinalResponse(ctx, state, text, reasoning, usage)
+ }
+ state.goalToolRepairs++
+ if state.goalToolRepairs > 1 {
+ return false, fmt.Errorf("model repeatedly called update_goal outside Goal mode without a visible answer")
+ }
}
if !a.planMode.Load() {
nextProgress, nextTracking := a.canonicalTodoProgress()
@@ -1086,3 +1094,18 @@ func (a *Agent) handleToolRound(ctx context.Context, state *runLoopState, step i
}
return true, nil
}
+
+func toolCallsAreOutOfContextGoalReports(ctx context.Context, calls []provider.ToolCall) bool {
+ if len(calls) == 0 {
+ return false
+ }
+ if _, ok := tool.GoalTurnRecorderFromContext(ctx); ok {
+ return false
+ }
+ for _, call := range calls {
+ if call.Name != "update_goal" {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/agent/sampling_request.go b/internal/agent/sampling_request.go
index 336560ac2d..b22998c7e6 100644
--- a/internal/agent/sampling_request.go
+++ b/internal/agent/sampling_request.go
@@ -16,7 +16,6 @@ type samplingRequest struct {
// prepareSamplingRequest freezes one model-round request (preflight + interceptors).
func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, error) {
- ctx = a.withAgentContext(ctx)
// CreatedAt is durable UI metadata, not model input. Strip it from the
// transport copy so wall-clock differences never invalidate the provider's
// prompt-cache prefix (and custom providers cannot accidentally send it).
@@ -37,7 +36,7 @@ func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, er
}
req := provider.Request{
Messages: requestMessages,
- Tools: a.tools.SchemasForContext(ctx),
+ Tools: a.tools.Schemas(),
MaxTokens: a.maxOutputTokens,
Temperature: provider.OptionalTemperature(a.temperature),
ResponseFormat: responseFormatFromRequest(ctx),
diff --git a/internal/agent/subagent_context_isolation_test.go b/internal/agent/subagent_context_isolation_test.go
deleted file mode 100644
index 624e3811cf..0000000000
--- a/internal/agent/subagent_context_isolation_test.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package agent
-
-import (
- "context"
- "encoding/json"
- "slices"
- "testing"
-
- "reasonix/internal/event"
- "reasonix/internal/jobs"
- "reasonix/internal/memory"
- "reasonix/internal/provider"
- "reasonix/internal/tool"
-)
-
-type recordingMemoryQueue struct {
- notes []string
-}
-
-func (q *recordingMemoryQueue) QueueMemory(note string) {
- q.notes = append(q.notes, note)
-}
-
-type memoryQueueProbeTool struct{}
-
-func (memoryQueueProbeTool) Name() string { return "memory_queue_probe" }
-func (memoryQueueProbeTool) Description() string { return "probe child memory context" }
-func (memoryQueueProbeTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
-func (memoryQueueProbeTool) ReadOnly() bool { return true }
-func (memoryQueueProbeTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
- if q, ok := memory.QueueFromContext(ctx); ok {
- q.QueueMemory("child injected into parent")
- return "queue present", nil
- }
- return "queue absent", nil
-}
-
-func TestSubAgentMasksParentJobsAndMemoryContexts(t *testing.T) {
- reg := tool.NewRegistry()
- reg.Add(memoryQueueProbeTool{})
- waitTool, ok := tool.LookupBuiltin("wait")
- if !ok {
- t.Fatal("wait builtin not registered")
- }
- reg.Add(waitTool)
- prov := &scriptedProvider{name: "child-context", turns: [][]provider.Chunk{
- {toolCallChunk("probe", "memory_queue_probe", `{}`), {Type: provider.ChunkDone}},
- {{Type: provider.ChunkText, Text: "Child result."}, {Type: provider.ChunkDone}},
- }}
- parentQueue := &recordingMemoryQueue{}
- manager := jobs.NewManager(event.Discard)
- defer manager.Close()
- ctx := memory.WithQueue(jobs.WithManager(context.Background(), manager), parentQueue)
- sess := NewSession("child system")
-
- answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the task", Options{}, event.Discard)
- if err != nil {
- t.Fatalf("RunSubAgentWithSession: %v", err)
- }
- if answer != "Child result." {
- t.Fatalf("answer = %q", answer)
- }
- if len(parentQueue.notes) != 0 {
- t.Fatalf("child injected memory notes into parent queue: %v", parentQueue.notes)
- }
- if got := toolResultByID(sess, "probe"); got != "queue absent" {
- t.Fatalf("memory queue probe result = %q", got)
- }
- for i, req := range prov.requests {
- if slices.Contains(toolSchemaNames(req.Tools), "wait") {
- t.Fatalf("child request %d inherited parent Jobs manager: %v", i+1, toolSchemaNames(req.Tools))
- }
- }
-}
diff --git a/internal/agent/subagent_identity.go b/internal/agent/subagent_identity.go
index 2e6a86b3da..94850892e5 100644
--- a/internal/agent/subagent_identity.go
+++ b/internal/agent/subagent_identity.go
@@ -4,23 +4,16 @@ import (
"encoding/json"
"sort"
- "reasonix/internal/provider"
"reasonix/internal/tool"
)
-func toolIdentity(reg *tool.Registry, schemas []provider.ToolSchema) ([]string, string) {
+func toolIdentity(reg *tool.Registry) ([]string, string) {
if reg == nil {
return nil, bytesHash(nil)
}
- if schemas == nil {
- schemas = reg.Schemas()
- }
- names := make([]string, 0, len(schemas))
- for _, schema := range schemas {
- names = append(names, schema.Name)
- }
+ names := reg.Names()
sort.Strings(names)
- schemas = normalizeToolSchemas(schemas)
+ schemas := normalizeToolSchemas(reg.Schemas())
data, _ := json.Marshal(schemas)
return names, bytesHash(data)
}
diff --git a/internal/agent/subagent_store.go b/internal/agent/subagent_store.go
index 69e2c4659d..833f532187 100644
--- a/internal/agent/subagent_store.go
+++ b/internal/agent/subagent_store.go
@@ -16,7 +16,6 @@ import (
"reasonix/internal/fileutil"
fileencoding "reasonix/internal/fileutil/encoding"
- "reasonix/internal/provider"
"reasonix/internal/store"
"reasonix/internal/tool"
)
@@ -78,7 +77,6 @@ type SubagentSpec struct {
ParentToolCallID string
SystemPrompt string
Registry *tool.Registry
- ToolSchemas []provider.ToolSchema
Model string
Effort string
}
@@ -744,7 +742,7 @@ func (s *SubagentStore) LoadMeta(ref string) (SubagentMeta, error) {
}
func metaFromSpec(ref string, status SubagentStatus, created, updated time.Time, spec SubagentSpec) SubagentMeta {
- scope, schemaHash := toolIdentity(spec.Registry, spec.ToolSchemas)
+ scope, schemaHash := toolIdentity(spec.Registry)
return SubagentMeta{
Ref: ref,
CreatedAt: created,
diff --git a/internal/agent/task.go b/internal/agent/task.go
index 6296004dbe..ab0ee93eee 100644
--- a/internal/agent/task.go
+++ b/internal/agent/task.go
@@ -873,7 +873,7 @@ func (t *TaskTool) RunProfileSpec(ctx context.Context, spec ProfileExecSpec) (re
modelRef, effortRef := spec.Model, spec.Effort
usageModelRef := t.usageModelRef(modelRef, effortRef)
parentID, _, _, _ := CallContext(ctx)
- run, err := t.prepareTranscriptRunWithPrompt(ctx, subReg, modelRef, effortRef, ParentSession(ctx), parentID, spec.ContinueFrom, spec.ForkFrom, spec.SystemPrompt, spec.Kind, spec.Name)
+ run, err := t.prepareTranscriptRunWithPrompt(subReg, modelRef, effortRef, ParentSession(ctx), parentID, spec.ContinueFrom, spec.ForkFrom, spec.SystemPrompt, spec.Kind, spec.Name)
if err != nil {
return "", err
}
@@ -1055,7 +1055,7 @@ func (t *TaskTool) bashCanEnforceWriteRoots() bool {
return false
}
-func (t *TaskTool) prepareTranscriptRunWithPrompt(ctx context.Context, subReg *tool.Registry, modelRef, effortRef, parentSession, parentID, continueFrom, legacyForkFrom, systemPrompt, kind, name string) (*SubagentRun, error) {
+func (t *TaskTool) prepareTranscriptRunWithPrompt(subReg *tool.Registry, modelRef, effortRef, parentSession, parentID, continueFrom, legacyForkFrom, systemPrompt, kind, name string) (*SubagentRun, error) {
continueFrom = strings.TrimSpace(continueFrom)
legacyForkFrom = strings.TrimSpace(legacyForkFrom)
parentSession = strings.TrimSpace(parentSession)
@@ -1089,7 +1089,6 @@ func (t *TaskTool) prepareTranscriptRunWithPrompt(ctx context.Context, subReg *t
ParentToolCallID: parentID,
SystemPrompt: systemPrompt,
Registry: subReg,
- ToolSchemas: subReg.SchemasForContext(subagentProviderContext(ctx)),
Model: identityModel,
Effort: identityEffort,
}
@@ -1830,7 +1829,7 @@ func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *to
return "", fmt.Errorf("sub-agent session is nil")
}
// Isolate temporary files for this run before any tool execution.
- ctx = subagentProviderContext(ctx)
+ ctx = tool.WithoutGoalTurnRecorder(ctx)
ctx, releaseTemp := withSubagentSessionTemp(ctx)
defer releaseTemp()
if opts.SubagentDepth > 0 {
diff --git a/internal/agent/workflow_context.go b/internal/agent/workflow_context.go
deleted file mode 100644
index 295f8d561b..0000000000
--- a/internal/agent/workflow_context.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package agent
-
-import (
- "context"
- "fmt"
- "strings"
-
- "reasonix/internal/jobs"
- "reasonix/internal/memory"
- "reasonix/internal/planmode"
- "reasonix/internal/provider"
- "reasonix/internal/tool"
-)
-
-func (a *Agent) withAgentContext(ctx context.Context) context.Context {
- if a == nil {
- return ctx
- }
- if a.jobs != nil {
- ctx = jobs.WithManager(ctx, a.jobs)
- } else {
- ctx = jobs.WithoutManager(ctx)
- }
- return planmode.WithActive(ctx, a.planMode.Load())
-}
-
-func subagentProviderContext(ctx context.Context) context.Context {
- ctx = tool.WithoutGoalTurnRecorder(ctx)
- ctx = jobs.WithoutManager(ctx)
- return memory.WithoutQueue(ctx)
-}
-
-func (a *Agent) unavailableContextualToolCalls(ctx context.Context, calls []provider.ToolCall) ([]string, bool) {
- if len(calls) == 0 {
- return nil, false
- }
- names := make([]string, 0, len(calls))
- for _, call := range calls {
- t, ok := a.tools.Get(call.Name)
- if !ok {
- continue
- }
- contextual, ok := t.(tool.ContextualTool)
- if ok && !contextual.ProviderVisible(ctx) {
- names = append(names, call.Name)
- }
- }
- return names, len(names) == len(calls)
-}
-
-func (a *Agent) rejectRepeatedContextToolCalls(state *runLoopState, calls []provider.ToolCall, unavailable []string) error {
- if len(unavailable) == 0 || state.contextToolRepairs == 0 {
- return nil
- }
- msg := fmt.Sprintf("blocked: context-unavailable tools were called again after the repair instruction: %s", strings.Join(unavailable, ", "))
- for _, call := range calls {
- a.session.Add(provider.Message{Role: provider.RoleTool, Content: msg, ToolCallID: call.ID, Name: call.Name})
- }
- return fmt.Errorf("model repeatedly called context-unavailable tools without a visible answer: %s", strings.Join(unavailable, ", "))
-}
-
-func (a *Agent) repairContextToolCalls(ctx context.Context, state *runLoopState, text, reasoning string, usage *provider.Usage, unavailable []string, contextualOnly bool) (bool, bool, error) {
- if len(unavailable) == 0 {
- return false, false, nil
- }
- if contextualOnly && hasVisibleFinalAnswer(text) {
- cont, err := a.handleFinalResponse(ctx, state, text, reasoning, usage)
- return true, cont, err
- }
- state.contextToolRepairs++
- 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(unavailable, ", "))
- a.session.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(nudge)})
- return false, false, nil
-}
diff --git a/internal/agent/workflow_context_test.go b/internal/agent/workflow_context_test.go
deleted file mode 100644
index 491d962f4e..0000000000
--- a/internal/agent/workflow_context_test.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package agent
-
-import (
- "context"
- "testing"
-
- "reasonix/internal/event"
- "reasonix/internal/extension"
- "reasonix/internal/extension/dispatch"
- "reasonix/internal/extension/protocol"
- "reasonix/internal/provider"
- "reasonix/internal/tool"
-)
-
-func TestAgentBeforeStartToolCountUsesContextualSchemas(t *testing.T) {
- goalTool, ok := tool.LookupBuiltin("update_goal")
- if !ok {
- t.Fatal("update_goal builtin not registered")
- }
- reg := tool.NewRegistry()
- reg.Add(goalTool)
- run := func(ctx context.Context) dispatch.AgentStartPayload {
- t.Helper()
- client := &fakeDispatchClient{}
- d := newExtDispatcher(client, true, nil, extension.PointAgentBeforeStart)
- mp := &mockProvider{name: "p", chunks: []provider.Chunk{{Type: provider.ChunkText, Text: "hi"}, {Type: provider.ChunkDone}}}
- a := New(mp, reg, NewSession("sys"), Options{Extensions: d}, event.Discard)
- if err := a.Run(ctx, "hello"); err != nil {
- t.Fatalf("Run: %v", err)
- }
- var payload dispatch.AgentStartPayload
- if !client.interceptPayloadFor(protocol.EventAgentBeforeStart, &payload) {
- t.Fatal("agent.before_start did not fire")
- }
- return payload
- }
- if got := run(context.Background()).ToolCount; got != 0 {
- t.Fatalf("ordinary ToolCount = %d, want update_goal hidden", got)
- }
- ctx := tool.WithGoalTurnRecorder(context.Background(), requestGoalRecorder{})
- if got := run(ctx).ToolCount; got != 1 {
- t.Fatalf("Goal ToolCount = %d, want update_goal visible", got)
- }
-}
diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go
index 228a099f85..f783a4b244 100644
--- a/internal/boot/boot_test.go
+++ b/internal/boot/boot_test.go
@@ -2145,6 +2145,7 @@ func defaultFullBootToolNames() []string {
"slash_command",
"task",
"todo_write",
+ "update_goal",
"wait",
"web_fetch",
"write_file",
@@ -2160,6 +2161,7 @@ func economyBootToolNames() []string {
"edit_file",
"kill_shell",
"read_file",
+ "update_goal",
"wait",
"write_file",
}
@@ -2211,6 +2213,7 @@ command = "reasonix-missing-mockmcp"
"edit_file",
"kill_shell",
"read_file",
+ "update_goal",
"wait",
"write_file",
}
diff --git a/internal/boot/tool_contract_surface_test.go b/internal/boot/tool_contract_surface_test.go
index 53f3f2cdfd..779ae1b9a9 100644
--- a/internal/boot/tool_contract_surface_test.go
+++ b/internal/boot/tool_contract_surface_test.go
@@ -7,10 +7,9 @@ import (
"testing"
"reasonix/internal/provider"
- "reasonix/internal/tool"
)
-func TestBootToolContractCoversProviderVisibleSurface(t *testing.T) {
+func TestBootToolContractMatchesProviderVisibleSurface(t *testing.T) {
for _, tc := range []struct {
name string
tokenMode string
@@ -42,20 +41,13 @@ model = "x"
if got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantNames) {
t.Fatalf("%s provider-visible tool surface changed\ngot %v\nwant %v", tc.name, got, wantNames)
}
- entryByName := make(map[string]tool.ContractEntry, len(entries))
- for _, entry := range entries {
- entryByName[entry.Name] = entry
+ if len(entries) != len(req.Tools) {
+ t.Fatalf("contract entries = %d, provider tools = %d\ncontract=%v\nprovider=%v", len(entries), len(req.Tools), contractEntryNames(entries), toolSchemaNames(req.Tools))
}
- if _, ok := entryByName["update_goal"]; !ok {
- t.Fatalf("static contract must retain contextual update_goal: %v", contractEntryNames(entries))
- }
- if len(entries) != len(req.Tools)+1 {
- t.Fatalf("contract entries = %d, provider tools = %d; want only contextual update_goal hidden\ncontract=%v\nprovider=%v", len(entries), len(req.Tools), contractEntryNames(entries), toolSchemaNames(req.Tools))
- }
- for _, s := range req.Tools {
- e, ok := entryByName[s.Name]
- if !ok {
- t.Fatalf("provider tool %q missing from static contract", s.Name)
+ for i, e := range entries {
+ s := req.Tools[i]
+ if e.Name != s.Name {
+ t.Fatalf("tool[%d] name = %q, want %q\ncontract=%v\nprovider=%v", i, e.Name, s.Name, contractEntryNames(entries), toolSchemaNames(req.Tools))
}
if e.Description != strings.TrimSpace(s.Description) {
t.Fatalf("%s description drift\ncontract=%q\nprovider=%q", e.Name, e.Description, s.Description)
diff --git a/internal/control/goal_legacy_restore_test.go b/internal/control/goal_legacy_restore_test.go
index fc305167dc..adc3fe7ddd 100644
--- a/internal/control/goal_legacy_restore_test.go
+++ b/internal/control/goal_legacy_restore_test.go
@@ -138,7 +138,7 @@ func TestGoalSetIdempotencyUsesEffectiveBudgetClass(t *testing.T) {
}
}
-func TestLegacySidecarArchiveFailureIsBlockedWithoutRewritingTaskID(t *testing.T) {
+func TestLegacySidecarArchiveFailureBlocksWithoutPersistingTaskID(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
diff --git a/internal/jobs/context.go b/internal/jobs/context.go
deleted file mode 100644
index 599ae0ffb0..0000000000
--- a/internal/jobs/context.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package jobs
-
-import (
- "context"
- "strings"
-)
-
-type ctxKey struct{}
-type sessionCtxKey struct{}
-type jobCtxKey struct{}
-type noManager struct{}
-
-// WithManager stamps ctx with the job manager used by background tools.
-func WithManager(ctx context.Context, m *Manager) context.Context {
- return context.WithValue(ctx, ctxKey{}, m)
-}
-
-// WithoutManager shadows an ancestor manager without discarding other values.
-func WithoutManager(ctx context.Context) context.Context {
- return context.WithValue(ctx, ctxKey{}, noManager{})
-}
-
-func FromContext(ctx context.Context) (*Manager, bool) {
- m, ok := ctx.Value(ctxKey{}).(*Manager)
- return m, ok && m != nil
-}
-
-func WithSession(ctx context.Context, parentSession string) context.Context {
- return context.WithValue(ctx, sessionCtxKey{}, strings.TrimSpace(parentSession))
-}
-
-func SessionFromContext(ctx context.Context) string {
- session, _ := ctx.Value(sessionCtxKey{}).(string)
- return strings.TrimSpace(session)
-}
diff --git a/internal/jobs/context_test.go b/internal/jobs/context_test.go
deleted file mode 100644
index 26783e5dd2..0000000000
--- a/internal/jobs/context_test.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package jobs
-
-import (
- "context"
- "testing"
-
- "reasonix/internal/event"
-)
-
-type preservedContextKey struct{}
-
-func TestWithoutManagerShadowsOnlyManager(t *testing.T) {
- manager := NewManager(event.Discard)
- defer manager.Close()
- parent := context.WithValue(WithManager(context.Background(), manager), preservedContextKey{}, "preserved")
- child := WithoutManager(parent)
- if _, ok := FromContext(child); ok {
- t.Fatal("child context inherited a disabled parent job manager")
- }
- if got := child.Value(preservedContextKey{}); got != "preserved" {
- t.Fatalf("unrelated context value = %v, want preserved", got)
- }
-}
diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go
index 482ec76981..bfe75fbfd1 100644
--- a/internal/jobs/jobs.go
+++ b/internal/jobs/jobs.go
@@ -1906,6 +1906,38 @@ func jobKey(parentSession, id string) string {
return strings.TrimSpace(parentSession) + "\x00" + strings.TrimSpace(id)
}
+// call-context injection (mirrors agent.CallContext)
+
+type ctxKey struct{}
+type sessionCtxKey struct{}
+type jobCtxKey struct{}
+
+// WithManager stamps ctx with the job manager so tools can reach it via
+// FromContext. The agent sets this on every tool call's context.
+func WithManager(ctx context.Context, m *Manager) context.Context {
+ return context.WithValue(ctx, ctxKey{}, m)
+}
+
+// FromContext returns the job manager set by the agent, if any. ok is false for a
+// plain context (headless tests, calls outside the run loop).
+func FromContext(ctx context.Context) (*Manager, bool) {
+ m, ok := ctx.Value(ctxKey{}).(*Manager)
+ return m, ok && m != nil
+}
+
+// WithSession stamps ctx with the active parent session ID for session-scoped job
+// operations.
+func WithSession(ctx context.Context, parentSession string) context.Context {
+ return context.WithValue(ctx, sessionCtxKey{}, strings.TrimSpace(parentSession))
+}
+
+// SessionFromContext returns the active parent session ID for job ownership and
+// filtering. Empty means no session scope is available.
+func SessionFromContext(ctx context.Context) string {
+ session, _ := ctx.Value(sessionCtxKey{}).(string)
+ return strings.TrimSpace(session)
+}
+
// PublishEvidence attaches a background agent's host-observed receipts to its
// job. The receipts stay independent of the parent turn ledger until the
// parent collects the terminal result with wait or bash_output.
diff --git a/internal/memory/queue.go b/internal/memory/queue.go
index c31ca11dc4..36db70a881 100644
--- a/internal/memory/queue.go
+++ b/internal/memory/queue.go
@@ -17,20 +17,12 @@ type autoMemoryWriteClaimer interface {
}
type queueKey struct{}
-type noQueue struct{}
// WithQueue stamps q onto ctx for the remember/forget tools to find.
func WithQueue(ctx context.Context, q Queue) context.Context {
return context.WithValue(ctx, queueKey{}, q)
}
-// WithoutQueue shadows an ancestor queue while preserving cancellation and
-// unrelated context values. Sub-agents use it to avoid injecting memory changes
-// directly into their parent's current-session prompt tail.
-func WithoutQueue(ctx context.Context) context.Context {
- return context.WithValue(ctx, queueKey{}, noQueue{})
-}
-
// QueueFromContext returns the memory queue the agent stamped, if any.
func QueueFromContext(ctx context.Context) (Queue, bool) {
q, ok := ctx.Value(queueKey{}).(Queue)
diff --git a/internal/memory/queue_test.go b/internal/memory/queue_test.go
deleted file mode 100644
index 0683c9e8ba..0000000000
--- a/internal/memory/queue_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package memory
-
-import (
- "context"
- "testing"
-)
-
-type testQueue struct{}
-
-func (testQueue) QueueMemory(string) {}
-
-type preservedQueueContextKey struct{}
-
-func TestWithoutQueueShadowsOnlyQueue(t *testing.T) {
- parent := context.WithValue(WithQueue(context.Background(), testQueue{}), preservedQueueContextKey{}, "preserved")
- child := WithoutQueue(parent)
- if _, ok := QueueFromContext(child); ok {
- t.Fatal("child context inherited the parent memory queue")
- }
- if got := child.Value(preservedQueueContextKey{}); got != "preserved" {
- t.Fatalf("unrelated context value = %v, want preserved", got)
- }
-
- owned := WithQueue(child, testQueue{})
- if _, ok := QueueFromContext(owned); !ok {
- t.Fatal("child-owned memory queue did not override the shadow value")
- }
-}
diff --git a/internal/tool/builtin/bgjobs.go b/internal/tool/builtin/bgjobs.go
index 1f1d9edda3..226bd75e2c 100644
--- a/internal/tool/builtin/bgjobs.go
+++ b/internal/tool/builtin/bgjobs.go
@@ -42,11 +42,6 @@ func (bashOutput) Schema() json.RawMessage {
func (bashOutput) ReadOnly() bool { return true }
-func (bashOutput) ProviderVisible(ctx context.Context) bool {
- _, ok := jobs.FromContext(ctx)
- return ok
-}
-
func (bashOutput) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
JobID string `json:"job_id"`
@@ -114,11 +109,6 @@ func (killShell) Schema() json.RawMessage {
func (killShell) ReadOnly() bool { return false }
-func (killShell) ProviderVisible(ctx context.Context) bool {
- _, ok := jobs.FromContext(ctx)
- return ok
-}
-
func (killShell) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
JobID string `json:"job_id"`
@@ -155,11 +145,6 @@ func (waitJob) Schema() json.RawMessage {
func (waitJob) ReadOnly() bool { return true }
-func (waitJob) ProviderVisible(ctx context.Context) bool {
- _, ok := jobs.FromContext(ctx)
- return ok
-}
-
func (waitJob) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
JobIDs []string `json:"job_ids"`
diff --git a/internal/tool/builtin/bgjobs_test.go b/internal/tool/builtin/bgjobs_test.go
index bdeff1c629..48f3031620 100644
--- a/internal/tool/builtin/bgjobs_test.go
+++ b/internal/tool/builtin/bgjobs_test.go
@@ -12,32 +12,6 @@ import (
"reasonix/internal/planmode"
)
-func TestBackgroundJobToolsVisibleOnlyWithManager(t *testing.T) {
- plain := context.Background()
- for name, visible := range map[string]func(context.Context) bool{
- "bash_output": bashOutput{}.ProviderVisible,
- "kill_shell": killShell{}.ProviderVisible,
- "wait": waitJob{}.ProviderVisible,
- } {
- if visible(plain) {
- t.Fatalf("%s visible without a job manager", name)
- }
- }
-
- manager := jobs.NewManager(event.Discard)
- defer manager.Close()
- ctx := jobs.WithManager(plain, manager)
- for name, visible := range map[string]func(context.Context) bool{
- "bash_output": bashOutput{}.ProviderVisible,
- "kill_shell": killShell{}.ProviderVisible,
- "wait": waitJob{}.ProviderVisible,
- } {
- if !visible(ctx) {
- t.Fatalf("%s hidden despite an active job manager", name)
- }
- }
-}
-
// End-to-end through the actual tools: a background bash job runs under a manager
// injected on the context, the wait tool collects its output, and bash_output
// reads it — the same path the agent drives.
diff --git a/internal/tool/builtin/completestep.go b/internal/tool/builtin/completestep.go
index a4b2355aa2..c9704e0867 100644
--- a/internal/tool/builtin/completestep.go
+++ b/internal/tool/builtin/completestep.go
@@ -9,7 +9,6 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/instruction"
- "reasonix/internal/planmode"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
@@ -81,13 +80,6 @@ func (completeStep) Schema() json.RawMessage {
// effect), so it never needs approval and stays available alongside todo_write.
func (completeStep) ReadOnly() bool { return true }
-// ProviderVisible hides execution-only sign-off from planning requests. The
-// execution gate remains authoritative for stale transcripts and hallucinated
-// calls that still reach the host.
-func (completeStep) ProviderVisible(ctx context.Context) bool {
- return !planmode.Active(ctx)
-}
-
// PlanModeSafe reports false: although complete_step is read-only, it signs off a
// completed execution step, which is meaningful only after plan approval — not
// during planning. This explicit phase opt-out is the Plan gate's enforced
diff --git a/internal/tool/builtin/completestep_visibility_test.go b/internal/tool/builtin/completestep_visibility_test.go
deleted file mode 100644
index f3d1bc51cb..0000000000
--- a/internal/tool/builtin/completestep_visibility_test.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package builtin
-
-import (
- "context"
- "testing"
-
- "reasonix/internal/planmode"
- "reasonix/internal/tool"
-)
-
-func TestCompleteStepSchemaOnlyVisibleAfterPlanApproval(t *testing.T) {
- reg := tool.NewRegistry()
- reg.Add(completeStep{})
- if got := reg.SchemasForContext(planmode.WithActive(context.Background(), true)); len(got) != 0 {
- t.Fatalf("Plan-mode schemas = %+v, want complete_step hidden", got)
- }
- got := reg.SchemasForContext(planmode.WithActive(context.Background(), false))
- if len(got) != 1 || got[0].Name != "complete_step" {
- t.Fatalf("execution schemas = %+v, want complete_step", got)
- }
-}
diff --git a/internal/tool/builtin/updategoal.go b/internal/tool/builtin/updategoal.go
index 16a62a78ce..d38635bfb8 100644
--- a/internal/tool/builtin/updategoal.go
+++ b/internal/tool/builtin/updategoal.go
@@ -43,14 +43,8 @@ func (updateGoal) Schema() json.RawMessage {
// tool permissions or bypass sandbox policy.
func (updateGoal) ReadOnly() bool { return true }
-func (updateGoal) ProviderVisible(ctx context.Context) bool {
- _, ok := tool.GoalTurnRecorderFromContext(ctx)
- return ok
-}
-
-// PlanModeSafe reports true: the tool is read-only host bookkeeping. It is
-// provider-visible only during an active goal turn, and Execute also fails
-// closed if a stale or hallucinated call reaches an ordinary turn.
+// PlanModeSafe reports true: the tool is read-only host bookkeeping, and
+// outside an active goal turn its Execute fails closed anyway.
func (updateGoal) PlanModeSafe() bool { return true }
func (updateGoal) Execute(ctx context.Context, args json.RawMessage) (string, error) {
diff --git a/internal/tool/builtin/updategoal_test.go b/internal/tool/builtin/updategoal_test.go
index 428c9b416d..ee514f6ebe 100644
--- a/internal/tool/builtin/updategoal_test.go
+++ b/internal/tool/builtin/updategoal_test.go
@@ -75,20 +75,6 @@ func TestUpdateGoalFailsClosedOutsideActiveGoalTurn(t *testing.T) {
}
}
-func TestUpdateGoalSchemaOnlyVisibleDuringActiveGoalTurn(t *testing.T) {
- reg := tool.NewRegistry()
- reg.Add(updateGoal{})
- if got := reg.SchemasForContext(context.Background()); len(got) != 0 {
- t.Fatalf("ordinary turn schemas = %+v, want update_goal hidden", got)
- }
-
- _, _, ctx := goalTool(t)
- got := reg.SchemasForContext(ctx)
- if len(got) != 1 || got[0].Name != "update_goal" {
- t.Fatalf("goal turn schemas = %+v, want update_goal", got)
- }
-}
-
func TestUpdateGoalRecordsReport(t *testing.T) {
toolFn, rec, ctx := goalTool(t)
_, err := toolFn.Execute(ctx, json.RawMessage(`{"status":"continue","reason":"fixing the parser","next_action":"run tests"}`))
diff --git a/internal/tool/contract_lock_test.go b/internal/tool/contract_lock_test.go
index 99ed961905..de78eea88f 100644
--- a/internal/tool/contract_lock_test.go
+++ b/internal/tool/contract_lock_test.go
@@ -5,8 +5,6 @@ import (
"encoding/json"
"testing"
"time"
-
- "reasonix/internal/provider"
)
// blockingReadOnlyTool lets a test park ContractEntries inside the per-tool
@@ -17,27 +15,6 @@ type blockingReadOnlyTool struct {
release <-chan struct{}
}
-type blockingContextualTool struct {
- name string
- entered chan<- struct{}
- release <-chan struct{}
-}
-
-func (t *blockingContextualTool) Name() string { return t.name }
-func (t *blockingContextualTool) Description() string { return "blocking contextual test tool" }
-func (t *blockingContextualTool) Schema() json.RawMessage {
- return json.RawMessage(`{"type":"object","properties":{}}`)
-}
-func (t *blockingContextualTool) Execute(context.Context, json.RawMessage) (string, error) {
- return "ok", nil
-}
-func (t *blockingContextualTool) ReadOnly() bool { return true }
-func (t *blockingContextualTool) ProviderVisible(context.Context) bool {
- close(t.entered)
- <-t.release
- return true
-}
-
func (t *blockingReadOnlyTool) Name() string { return t.name }
func (t *blockingReadOnlyTool) Description() string { return "blocking test tool" }
func (t *blockingReadOnlyTool) Schema() json.RawMessage {
@@ -95,38 +72,3 @@ func TestContractEntriesDoesNotHoldRegistryLockAcrossToolCallbacks(t *testing.T)
t.Fatalf("ContractEntries returned %+v, want one read-only blocking_tool", entries)
}
}
-
-func TestSchemasForContextDoesNotHoldRegistryLockAcrossAvailability(t *testing.T) {
- reg := NewRegistry()
- entered := make(chan struct{})
- release := make(chan struct{})
- reg.Add(&blockingContextualTool{name: "contextual", entered: entered, release: release})
-
- schemasCh := make(chan []provider.ToolSchema, 1)
- go func() {
- schemasCh <- reg.SchemasForContext(context.Background())
- }()
-
- select {
- case <-entered:
- case <-time.After(5 * time.Second):
- t.Fatal("SchemasForContext never reached the availability callback")
- }
-
- addDone := make(chan struct{})
- go func() {
- reg.Add(stubTool{name: "writer_tool"})
- close(addDone)
- }()
- select {
- case <-addDone:
- case <-time.After(5 * time.Second):
- t.Fatal("registry writer blocked while SchemasForContext checked availability")
- }
-
- close(release)
- schemas := <-schemasCh
- if len(schemas) != 1 || schemas[0].Name != "contextual" {
- t.Fatalf("SchemasForContext returned %+v, want contextual snapshot", schemas)
- }
-}
diff --git a/internal/tool/contract_test.go b/internal/tool/contract_test.go
index 2bd495fbb8..5feae3626c 100644
--- a/internal/tool/contract_test.go
+++ b/internal/tool/contract_test.go
@@ -86,15 +86,3 @@ func TestEveryBuiltinDeclaresSnipStance(t *testing.T) {
}
}
}
-
-func TestPlanModeUnsafeBuiltinsDeclareContextualVisibility(t *testing.T) {
- for _, builtin := range tool.Builtins() {
- classifier, ok := builtin.(tool.PlanModeClassifier)
- if !ok || classifier.PlanModeSafe() {
- continue
- }
- if _, ok := builtin.(tool.ContextualTool); !ok {
- t.Errorf("Plan-mode-unsafe builtin %q must hide itself from provider schemas while unavailable", builtin.Name())
- }
- }
-}
diff --git a/internal/tool/tool.go b/internal/tool/tool.go
index e219f9016a..90512f95d5 100644
--- a/internal/tool/tool.go
+++ b/internal/tool/tool.go
@@ -33,13 +33,6 @@ type Tool interface {
ReadOnly() bool
}
-// ContextualTool can hide a registered tool from provider requests when the
-// current turn cannot execute it. Execute must still validate the context so
-// stale transcripts and provider-hallucinated calls fail closed.
-type ContextualTool interface {
- ProviderVisible(context.Context) bool
-}
-
// Previewer is an optional capability a writer Tool may implement: given the
// same raw JSON args Execute would receive, compute the file change the call
// *would* make — without touching disk. ctx must be Execute's, so the preview
@@ -526,41 +519,23 @@ func (r *Registry) Names() []string {
// Schemas exports tool definitions in stable name order for the provider.
func (r *Registry) Schemas() []provider.ToolSchema {
- return r.schemasForContext(context.Background(), false)
-}
-
-// SchemasForContext exports only tools available during ctx. Tools without a
-// contextual availability contract remain visible as before.
-func (r *Registry) SchemasForContext(ctx context.Context) []provider.ToolSchema {
- return r.schemasForContext(ctx, true)
-}
-
-func (r *Registry) schemasForContext(ctx context.Context, filterContextual bool) []provider.ToolSchema {
r.mu.RLock()
- type schemaEntry struct {
- name string
- tool Tool
- canonical json.RawMessage
- }
- entries := make([]schemaEntry, 0, len(r.order))
- for _, name := range r.order {
- if t := r.tools[name]; t != nil {
- entries = append(entries, schemaEntry{name: name, tool: t, canonical: r.canon[name]})
- }
- }
- r.mu.RUnlock()
- sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name })
+ defer r.mu.RUnlock()
+
+ names := make([]string, len(r.order))
+ copy(names, r.order)
+ sort.Strings(names)
- out := make([]provider.ToolSchema, 0, len(entries))
- for _, entry := range entries {
- t := entry.tool
- if contextual, ok := t.(ContextualTool); filterContextual && ok && !contextual.ProviderVisible(ctx) {
+ out := make([]provider.ToolSchema, 0, len(names))
+ for _, name := range names {
+ t := r.tools[name]
+ if t == nil {
continue
}
out = append(out, provider.ToolSchema{
Name: t.Name(),
Description: t.Description(),
- Parameters: entry.canonical,
+ Parameters: r.canon[name],
})
}
return out
diff --git a/scripts/check-cache-impact.sh b/scripts/check-cache-impact.sh
index 5a6daa89c7..90ec4115b0 100755
--- a/scripts/check-cache-impact.sh
+++ b/scripts/check-cache-impact.sh
@@ -69,8 +69,8 @@ for file in "${changed_files[@]:-}"; do
internal/agent/planner_registry.go|\
internal/agent/prune*|\
internal/agent/subagent_registry*|\
+ internal/agent/subagent_identity.go|\
internal/agent/task.go|\
- internal/agent/workflow_context.go|\
internal/boot/*|\
internal/command/slashtool.go|\
internal/config/config.go|\
From ccca8fc4002009b586318350e2d0a0a63269b9a7 Mon Sep 17 00:00:00 2001
From: SivanCola <32437197+SivanCola@users.noreply.github.com>
Date: Sun, 9 Aug 2026 06:13:38 +0800
Subject: [PATCH 12/12] fix: complete the Goal-only compatibility boundary
Problem
The Goal-only migration still exposed a stale Desktop AutoResearch mock, retained a removed variadic setup shape, and reopened a legacy archive after it had already been validated.
Root cause
Compatibility cleanup stopped short of the final source-level boundary, and the archive goal loader performed redundant validation after LoadTask had bound and verified the archive snapshot.
Fix
Remove the stale Desktop mock and variadic argument, document the evidence sanitizer as display-only, and use the single validated LoadTask result for legacy goal recovery.
Verification
- go test ./internal/control ./internal/autoresearch ./internal/agent ./internal/taskintent ./internal/boot ./internal/tool -count=1
- go test -race ./internal/control ./internal/agent ./internal/jobs ./internal/tool ./internal/tool/builtin ./internal/memory ./internal/autoresearch -count=1
- go test ./... -count=1
- cd desktop && go test ./... -count=1
- cd desktop/frontend && pnpm typecheck && pnpm test:all && pnpm build
- go vet ./...
- golangci-lint run --timeout=5m
- scripts/cache-guard.sh
- go run ./tools/repolint
- git diff --check
---
desktop/topic_activation_test.go | 4 ----
internal/agent/goal_display.go | 6 +++---
internal/control/autoresearch_manager.go | 6 ------
internal/control/controller.go | 5 ++---
4 files changed, 5 insertions(+), 16 deletions(-)
diff --git a/desktop/topic_activation_test.go b/desktop/topic_activation_test.go
index d96d0ad773..b0d6f0332d 100644
--- a/desktop/topic_activation_test.go
+++ b/desktop/topic_activation_test.go
@@ -10,7 +10,6 @@ import (
"time"
"reasonix/internal/agent"
- "reasonix/internal/autoresearch"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/evidence"
@@ -334,9 +333,6 @@ func (c *activationStubController) Turn() int { return 0 }
func (c *activationStubController) GoalRuntime() control.GoalRuntimeView {
return control.GoalRuntimeView{}
}
-func (c *activationStubController) AutoResearchSummary() (*autoresearch.Summary, bool) {
- return nil, false
-}
func (c *activationStubController) Todos() []evidence.TodoItem { return nil }
func (c *activationStubController) SnapshotForShutdown() error { return nil }
diff --git a/internal/agent/goal_display.go b/internal/agent/goal_display.go
index 4705c21b61..00f05de95b 100644
--- a/internal/agent/goal_display.go
+++ b/internal/agent/goal_display.go
@@ -42,9 +42,9 @@ const (
autoResearchEvidenceClose = "