From 6f94970a6401faf33331626dad782ffc6340f3ad Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:29:04 +0800 Subject: [PATCH 01/12] feat(goal): make Goal the sole long-task runtime Problem: AutoResearch still had active write paths, protocol injection, and desktop surfaces after Goal already owned long-task lifecycle, which left dual completion gates and upgrade ambiguity. Root cause: The previous host-managed AutoResearch runtime remained wired into store writes, Compose, turn orchestration, evaluator evidence, and Wails/TS APIs instead of being reduced to read-only archive recovery. Fix: - Keep Goal as the only state machine for budgets, pause/resume, receipts, update_goal, Delivery readiness, and completion. - Shrink internal/autoresearch to a fail-closed read-only archive reader. - Restore explicit legacy task paths and old sidecars as ordinary research Goals without mutating archive contents or persisting autoResearchTaskID. - Remove AutoResearch runtime/evidence protocol and desktop API surfaces. - Treat Finding.kind as an opaque string; stop enum rejection on write. - Centralize simple/write/research budget selection in taskintent. - Update docs/site and cache-impact coverage for Goal prompt construction. Verification: - go test ./internal/control ./internal/autoresearch ./internal/taskintent ./internal/goaleval ./internal/agent - go test -race ./internal/control ./internal/agent - cd desktop && go test ./... - cd desktop/frontend && pnpm typecheck && pnpm test:all && pnpm build - go run ./tools/repolint - scripts/cache-guard.sh - scripts/check-cache-impact.sh Cache-impact: low - ordinary Goal prefixes stay stable; research Goals only drop the old AutoResearch protocol block; provider-visible tool schema and default tool order remain byte-stable. --- desktop/app.go | 243 +----- desktop/bound_array_contract_test.go | 2 - desktop/frontend/src/App.tsx | 10 +- .../src/__tests__/stream-delta-batch.test.ts | 2 +- desktop/frontend/src/lib/bridge.ts | 86 +-- desktop/frontend/src/lib/types.ts | 51 -- desktop/frontend/src/lib/useController.ts | 4 - desktop/frontend/src/locales/en.ts | 2 - desktop/frontend/src/locales/zh-TW.ts | 2 - desktop/frontend/src/locales/zh.ts | 2 - desktop/goal_delivery_yolo_test.go | 6 +- desktop/tab_profile_test.go | 171 ----- desktop/tabs.go | 70 +- docs/COLLABORATION_MODES.zh-CN.md | 8 +- docs/GOAL_ENFORCEMENT.zh-CN.md | 6 +- docs/GUIDE.md | 59 +- docs/GUIDE.zh-CN.md | 39 +- docs/SPEC.md | 17 +- docs/SPEC.zh-CN.md | 2 +- ...06-30-autoresearch-runtime-verification.md | 3 + ...-29-autoresearch-runtime-implementation.md | 3 + .../2026-06-29-autoresearch-runtime-design.md | 6 +- internal/autoresearch/bounded_store_test.go | 267 ++----- internal/autoresearch/fixture_test.go | 143 ++++ internal/autoresearch/store.go | 654 +--------------- internal/autoresearch/store_test.go | 703 ++++-------------- internal/autoresearch/task.go | 46 +- internal/control/autoresearch_manager.go | 422 ++--------- internal/control/controller.go | 110 +-- internal/control/controller_test.go | 21 +- internal/control/goal.go | 178 ++--- internal/control/goal_runtime_test.go | 10 +- internal/control/goal_test.go | 585 +++------------ internal/control/input.go | 180 +---- internal/control/input_test.go | 36 +- internal/control/port.go | 5 - internal/control/slash.go | 2 - internal/control/slash_test.go | 4 +- internal/control/turn_orchestrator.go | 54 -- internal/goaleval/evaluator.go | 31 +- internal/taskintent/boundary_test.go | 9 +- internal/taskintent/doc.go | 12 +- internal/taskintent/goal_budget_test.go | 15 + internal/taskintent/goal_research_budget.go | 89 +++ scripts/check-cache-impact.sh | 6 + site/src/pages/docs.astro | 2 +- 46 files changed, 1009 insertions(+), 3369 deletions(-) create mode 100644 internal/autoresearch/fixture_test.go create mode 100644 internal/taskintent/goal_research_budget.go diff --git a/desktop/app.go b/desktop/app.go index 779861c2e1..da06f6c6ae 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -33,7 +33,6 @@ import ( "github.com/wailsapp/wails/v2/pkg/runtime" "reasonix/internal/agent" - "reasonix/internal/autoresearch" "reasonix/internal/billing" "reasonix/internal/boot" "reasonix/internal/botruntime" @@ -6821,26 +6820,25 @@ func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView { // Meta describes the session for the frontend's header and status line. type Meta struct { - Label string `json:"label"` - Ready bool `json:"ready"` - Runtime SessionRuntimeView `json:"runtime"` - StartupErr string `json:"startupErr,omitempty"` - EventChannel string `json:"eventChannel"` - Cwd string `json:"cwd"` - WorkspaceRoot string `json:"workspaceRoot,omitempty"` - WorkspaceName string `json:"workspaceName,omitempty"` - WorkspacePath string `json:"workspacePath,omitempty"` - GitBranch string `json:"gitBranch,omitempty"` - ImageInputEnabled bool `json:"imageInputEnabled"` - AutoApproveTools bool `json:"autoApproveTools"` - Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval - CollaborationMode string `json:"collaborationMode"` - ToolApprovalMode string `json:"toolApprovalMode"` - TokenMode string `json:"tokenMode"` - Goal string `json:"goal,omitempty"` - GoalStatus string `json:"goalStatus,omitempty"` - GoalRuntime *GoalRuntimeView `json:"goalRuntime,omitempty"` - AutoResearch *AutoResearchCompactView `json:"autoResearch,omitempty"` + Label string `json:"label"` + Ready bool `json:"ready"` + Runtime SessionRuntimeView `json:"runtime"` + StartupErr string `json:"startupErr,omitempty"` + EventChannel string `json:"eventChannel"` + Cwd string `json:"cwd"` + WorkspaceRoot string `json:"workspaceRoot,omitempty"` + WorkspaceName string `json:"workspaceName,omitempty"` + WorkspacePath string `json:"workspacePath,omitempty"` + GitBranch string `json:"gitBranch,omitempty"` + ImageInputEnabled bool `json:"imageInputEnabled"` + AutoApproveTools bool `json:"autoApproveTools"` + Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval + CollaborationMode string `json:"collaborationMode"` + ToolApprovalMode string `json:"toolApprovalMode"` + TokenMode string `json:"tokenMode"` + Goal string `json:"goal,omitempty"` + GoalStatus string `json:"goalStatus,omitempty"` + GoalRuntime *GoalRuntimeView `json:"goalRuntime,omitempty"` // A nil pointer means the controller cannot provide an authoritative snapshot; // a non-nil pointer preserves an empty list as an explicit panel clear. CanonicalTodos *[]evidence.TodoItem `json:"canonicalTodos,omitempty"` @@ -6877,60 +6875,6 @@ func goalRuntimeViewFromController(ctrl control.SessionAPI) *GoalRuntimeView { } } -type AutoResearchCompactView struct { - TaskID string `json:"taskId"` - Status string `json:"status"` - Iteration int `json:"iteration"` - PivotRequired bool `json:"pivotRequired"` - StaleCount int `json:"staleCount"` -} - -type AutoResearchCriterionView struct { - ID string `json:"id"` - Description string `json:"description"` - Required bool `json:"required"` - EvidenceCount int `json:"evidenceCount"` - Status string `json:"status"` -} - -type AutoResearchStatusView struct { - TaskID string `json:"taskId"` - Goal string `json:"goal"` - Status string `json:"status"` - Iteration int `json:"iteration"` - CurrentDirection string `json:"currentDirection"` - StaleCount int `json:"staleCount"` - PivotCount int `json:"pivotCount"` - PivotRequired bool `json:"pivotRequired"` - LastHeartbeatAt string `json:"lastHeartbeatAt"` - FindingCount int `json:"findingCount"` - OpenCriteria []AutoResearchCriterionView `json:"openCriteria"` - Blocker string `json:"blocker"` - TaskPath string `json:"taskPath"` - NextRequiredAction string `json:"nextRequiredAction"` -} - -type AutoResearchFindingView struct { - ID string `json:"id"` - Kind string `json:"kind"` - Summary string `json:"summary"` - Source string `json:"source"` - Command string `json:"command,omitempty"` - Paths []string `json:"paths,omitempty"` - Accepted bool `json:"accepted"` - CreatedAt string `json:"createdAt"` -} - -type AutoResearchEvidenceView struct { - ID string `json:"id"` - Kind string `json:"kind"` - Summary string `json:"summary"` - Source string `json:"source"` - Command string `json:"command,omitempty"` - Paths []string `json:"paths,omitempty"` - Accepted bool `json:"accepted"` -} - // Meta reports the model label, readiness, any startup error, the working // directory (for the status line), and the runtime event channel the frontend // subscribes to. @@ -7000,29 +6944,10 @@ func (a *App) MetaForTab(tabID string) Meta { Goal: goal, GoalStatus: goalStatus, GoalRuntime: goalRuntimeViewFromController(snap.ctrl), - AutoResearch: compactAutoResearchFromController(snap.ctrl), CanonicalTodos: ctrlTodos(snap.ctrl), } } -func compactAutoResearchFromController(ctrl control.SessionAPI) *AutoResearchCompactView { - if ctrl == nil { - return nil - } - - summary, ok := ctrl.AutoResearchSummary() - if !ok || summary == nil || summary.TaskID == "" { - return nil - } - return &AutoResearchCompactView{ - TaskID: summary.TaskID, - Status: summary.Status, - Iteration: summary.Iteration, - PivotRequired: summary.PivotRequired, - StaleCount: summary.StaleCount, - } -} - // ctrlTodos returns the canonical task list from a session controller, or nil // if the controller is not yet bound. Used by MetaForTab so the frontend // task panel has access to the authoritative server-side todo state. @@ -7037,136 +6962,6 @@ func ctrlTodos(ctrl control.SessionAPI) *[]evidence.TodoItem { return &todos } -func compactAutoResearch(tab *WorkspaceTab) *AutoResearchCompactView { - if tab == nil || tab.Ctrl == nil { - return nil - } - summary, ok := tab.Ctrl.AutoResearchSummary() - if !ok || summary == nil || summary.TaskID == "" { - return nil - } - return &AutoResearchCompactView{ - TaskID: summary.TaskID, - Status: summary.Status, - Iteration: summary.Iteration, - PivotRequired: summary.PivotRequired, - StaleCount: summary.StaleCount, - } -} - -func autoResearchStatusView(summary *autoresearch.Summary) AutoResearchStatusView { - if summary == nil { - return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}} - } - open := make([]AutoResearchCriterionView, 0, len(summary.OpenCriteria)) - for _, criterion := range summary.OpenCriteria { - open = append(open, AutoResearchCriterionView{ - ID: criterion.ID, - Description: criterion.Description, - Required: criterion.Required, - EvidenceCount: criterion.EvidenceCount, - Status: criterion.Status, - }) - } - return AutoResearchStatusView{ - TaskID: summary.TaskID, - Goal: summary.Goal, - Status: summary.Status, - Iteration: summary.Iteration, - CurrentDirection: summary.CurrentDirection, - StaleCount: summary.StaleCount, - PivotCount: summary.PivotCount, - PivotRequired: summary.PivotRequired, - LastHeartbeatAt: summary.LastHeartbeatAt.Format(time.RFC3339), - FindingCount: summary.FindingCount, - OpenCriteria: open, - Blocker: summary.Blocker, - TaskPath: summary.TaskPath, - NextRequiredAction: summary.NextRequiredAction, - } -} - -func (a *App) AutoResearchCurrent() AutoResearchStatusView { - return a.AutoResearchStatus("") -} - -func (a *App) AutoResearchStatus(tabID string) AutoResearchStatusView { - ctrl := a.ctrlByTabID(tabID) - if ctrl == nil { - return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}} - } - summary, ok := ctrl.AutoResearchSummary() - if !ok { - return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}} - } - return autoResearchStatusView(summary) -} - -func (a *App) AutoResearchList(tabID string) []AutoResearchStatusView { - ctrl := a.ctrlByTabID(tabID) - if ctrl == nil { - return []AutoResearchStatusView{} - } - summaries, ok := ctrl.AutoResearchList() - if !ok { - return []AutoResearchStatusView{} - } - out := make([]AutoResearchStatusView, 0, len(summaries)) - for i := range summaries { - out = append(out, autoResearchStatusView(&summaries[i])) - } - return out -} - -func (a *App) AutoResearchFindings(tabID string, limit int) []AutoResearchFindingView { - ctrl := a.ctrlByTabID(tabID) - if ctrl == nil { - return []AutoResearchFindingView{} - } - findings, ok := ctrl.AutoResearchFindings(limit) - if !ok { - return []AutoResearchFindingView{} - } - out := make([]AutoResearchFindingView, 0, len(findings)) - for _, finding := range findings { - out = append(out, AutoResearchFindingView{ - ID: finding.ID, - Kind: finding.Kind, - Summary: finding.Summary, - Source: finding.Source, - Command: finding.Command, - Paths: append([]string(nil), finding.Paths...), - Accepted: finding.Accepted, - CreatedAt: finding.CreatedAt.Format(time.RFC3339), - }) - } - return out -} - -func (a *App) AutoResearchOpenTask(tabID string) error { - status := a.AutoResearchStatus(tabID) - if strings.TrimSpace(status.TaskPath) == "" { - return os.ErrInvalid - } - return a.RevealPath(status.TaskPath) -} - -func (a *App) AutoResearchRecordEvidence(tabID, criterionID string, input AutoResearchEvidenceView) error { - ctrl := a.ctrlByTabID(tabID) - if ctrl == nil { - return os.ErrInvalid - } - return ctrl.RecordAutoResearchEvidence(criterionID, control.AutoResearchEvidenceInput{ - ID: input.ID, - Kind: input.Kind, - Summary: input.Summary, - Source: input.Source, - Command: input.Command, - Paths: append([]string(nil), input.Paths...), - Accepted: input.Accepted, - }) -} - func (a *App) SetGoal(goal string) error { return a.SetGoalForTab("", goal) } diff --git a/desktop/bound_array_contract_test.go b/desktop/bound_array_contract_test.go index c479b3d20d..3d45308244 100644 --- a/desktop/bound_array_contract_test.go +++ b/desktop/bound_array_contract_test.go @@ -29,8 +29,6 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) { {"ListTabs", app.ListTabs()}, {"ListProjectTree", app.ListProjectTree()}, {"AvailableSubagentTools", app.AvailableSubagentTools()}, - {"AutoResearchList", app.AutoResearchList("missing")}, - {"AutoResearchFindings", app.AutoResearchFindings("missing", 10)}, {"MCPServers", app.MCPServers()}, {"Plugins", app.Plugins()}, {"HeartbeatListTasks", app.HeartbeatListTasks()}, diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 088edcc4e1..39ae5a6050 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -257,8 +257,6 @@ function noticePreviewItems(): Item[] { notice(8, "info", "Context was compacted without a generated summary.", "compaction completed after upstream summary generation returned empty content; retained transcript checkpoint"), notice(9, "info", "Goal is not ready to complete yet; continuing the remaining work.", "goal completion check found pending validation: desktop/frontend typecheck"), notice(13, "info", "Goal still has unfinished task state; continuing the remaining work.", "active goal has open task state: implement preview, verify browser, report result"), - notice(14, "warn", "AutoResearch status update failed.", "autoresearch task completion update failed: write .reasonix/autoresearch/task-42/state/task_spec.json: permission denied"), - notice(15, "warn", "AutoResearch task marked blocked.", "autoresearch task blocked: task-42\nreason: missing accepted verification evidence after three turns"), notice(16, "warn", "background export failed: needs attention", "background export failed: session archive upload returned 503 after 3 retries"), notice(17, "warn", "Job artifact migration failed.", "artifact migration failed for job job_123: checksum mismatch while moving output.zip"), notice(18, "warn", "Background job teardown timed out.", "job job_123 did not stop within 10s; process is still marked running by the supervisor"), @@ -304,7 +302,7 @@ const CHAT_MIN_WIDTH = 400; const CHAT_COMFORT_MIN_WIDTH = 560; const WORKSPACE_RESIZER_WIDTH = 8; -function stripGoalResearchFlags(arg: string): string { +function stripLegacyGoalBudgetFlags(arg: string): string { const parts = arg.trim().split(/\s+/).filter(Boolean); while (parts.length > 0) { const flag = parts[0].toLowerCase(); @@ -314,7 +312,7 @@ function stripGoalResearchFlags(arg: string): string { return parts.join(" "); } -function hasGoalResearchFlag(arg: string): boolean { +function hasLegacyGoalBudgetFlag(arg: string): boolean { const first = arg.trim().split(/\s+/, 1)[0]?.toLowerCase(); return first === "--research" || first === "--auto-research" || first === "--deep" || first === "--simple" || first === "--no-research"; } @@ -2385,9 +2383,9 @@ export default function App() { const goalCommand = /^\/goal(?:\s+(.*))?$/.exec(trimmed); if (goalCommand) { const arg = (goalCommand[1] ?? "").trim(); - const displayGoal = stripGoalResearchFlags(arg); + const displayGoal = stripLegacyGoalBudgetFlags(arg); if (displayGoal && !["status", "clear", "off", "stop", "done"].includes(displayGoal.toLowerCase())) { - if (hasGoalResearchFlag(arg)) { + if (hasLegacyGoalBudgetFlag(arg)) { userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, false); patchActiveComposerProfile({ collaborationMode: "goal", diff --git a/desktop/frontend/src/__tests__/stream-delta-batch.test.ts b/desktop/frontend/src/__tests__/stream-delta-batch.test.ts index 2787e65d8b..8ad314115e 100644 --- a/desktop/frontend/src/__tests__/stream-delta-batch.test.ts +++ b/desktop/frontend/src/__tests__/stream-delta-batch.test.ts @@ -87,7 +87,7 @@ const reasoning = (tabId: string, t: string): StreamDeltaEntry => ({ tabId, e: { const after = reducer(discarding, { type: "stream_batch", segments: [{ kind: "text", delta: "x" }] } as never); eq(after, discarding, "discardTurn swallows a stream_batch like per-delta events"); - let retrying = { ...initialState, running: true, turnActive: true, retry: { attempt: 1, max: 3, observedAt: 1 } }; + let retrying: typeof initialState = { ...initialState, running: true, turnActive: true, retry: { attempt: 1, max: 3, observedAt: 1 } }; retrying = reducer(retrying, { type: "stream_batch", segments: [{ kind: "text", delta: "x" }] } as never); eq(retrying.retry, undefined, "stream_batch clears the retry indicator like per-delta events"); } diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index 765582f2ca..28592bc50d 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -19,9 +19,6 @@ import { modeHasAutoApproveTools, modeWithAutoApproveTools, modeWithPlan, normal import { decisionSurfaceMockFromInput, isLongDecisionOptionsMockInput } from "./decisionSurfaceMock"; import type { - AutoResearchFindingView, - AutoResearchEvidenceView, - AutoResearchStatusView, RemoteHostView, RemoteHostInput, RemoteConnectionStatus, @@ -118,7 +115,7 @@ import type { const GLOBAL_PROJECT_ORDER_KEY = "__global__"; -function stripGoalResearchFlags(arg: string): string { +function stripLegacyGoalBudgetFlags(arg: string): string { const parts = arg.trim().split(/\s+/).filter(Boolean); while (parts.length > 0) { const flag = parts[0].toLowerCase(); @@ -307,12 +304,6 @@ export interface AppBindings { ToolResultForTab(tabID: string, toolID: string): Promise<{ args: string; output: string; execution?: import("./types").WireShellExecution } | null>; Meta(): Promise; MetaForTab(tabID: string): Promise; - AutoResearchCurrent(): Promise; - AutoResearchStatus(tabID: string): Promise; - AutoResearchList(tabID: string): Promise; - AutoResearchFindings(tabID: string, limit: number): Promise; - AutoResearchOpenTask(tabID: string): Promise; - AutoResearchRecordEvidence(tabID: string, criterionID: string, input: AutoResearchEvidenceView): Promise; Commands(): Promise; Capabilities(): Promise; MCPServers(): Promise; @@ -2212,7 +2203,7 @@ function makeMockApp(): AppBindings { const decisionSurfaceMock = decisionSurfaceMockFromInput(trimmedInput); const goalMatch = /^\/goal(?:\s+([\s\S]*))?$/.exec(input.trim()); if (goalMatch) { - const arg = stripGoalResearchFlags((goalMatch[1] ?? "").trim()); + const arg = stripLegacyGoalBudgetFlags((goalMatch[1] ?? "").trim()); const lowered = arg.toLowerCase(); const active = mockTabs.find((tab) => tab.active); if (!arg || lowered === "status") { @@ -3151,7 +3142,6 @@ function makeMockApp(): AppBindings { tokenMode: normalizeTokenMode(active?.tokenMode), goal: active?.goal ?? "", goalStatus: active?.goalStatus ?? (active?.goal ? "running" : "stopped"), - autoResearch: active?.goal ? { taskId: "mock-autoresearch", status: "running", iteration: 4, pivotRequired: false, staleCount: 0 } : undefined, }; }, async MetaForTab(tabID) { @@ -3177,80 +3167,8 @@ function makeMockApp(): AppBindings { tokenMode: normalizeTokenMode(tab?.tokenMode), goal: tab?.goal ?? "", goalStatus: tab?.goalStatus ?? (tab?.goal ? "running" : "stopped"), - autoResearch: tab?.goal ? { taskId: "mock-autoresearch", status: "running", iteration: 4, pivotRequired: false, staleCount: 0 } : undefined, }; }, - async AutoResearchCurrent() { - return { - taskId: "mock-autoresearch", - goal: "Mock long-running research", - status: "running", - iteration: 4, - currentDirection: "Inspect status chip", - staleCount: 0, - pivotCount: 0, - pivotRequired: false, - lastHeartbeatAt: "2026-06-29T00:00:00Z", - findingCount: 1, - openCriteria: [], - blocker: "", - taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch", - nextRequiredAction: "continue with the next evidence-producing step", - }; - }, - async AutoResearchStatus(_tabID) { - return { - taskId: "mock-autoresearch", - goal: "Mock long-running research", - status: "running", - iteration: 4, - currentDirection: "Inspect status chip", - staleCount: 0, - pivotCount: 0, - pivotRequired: false, - lastHeartbeatAt: "2026-06-29T00:00:00Z", - findingCount: 1, - openCriteria: [], - blocker: "", - taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch", - nextRequiredAction: "continue with the next evidence-producing step", - }; - }, - async AutoResearchList(_tabID) { - return [{ - taskId: "mock-autoresearch", - goal: "Mock long-running research", - status: "running", - iteration: 4, - currentDirection: "Inspect status chip", - staleCount: 0, - pivotCount: 0, - pivotRequired: false, - lastHeartbeatAt: "2026-06-29T00:00:00Z", - findingCount: 1, - openCriteria: [], - blocker: "", - taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch", - nextRequiredAction: "continue with the next evidence-producing step", - }]; - }, - async AutoResearchFindings(_tabID, limit) { - return [{ - id: "f1", - kind: "test", - summary: "Mock accepted finding", - source: "command", - command: "go test ./...", - accepted: true, - createdAt: "2026-06-29T00:00:00Z", - }].slice(0, Math.max(0, limit || 1)); - }, - async AutoResearchOpenTask(_tabID) { - console.info("mock AutoResearchOpenTask"); - }, - async AutoResearchRecordEvidence(_tabID, _criterionID, _input) { - console.info("mock AutoResearchRecordEvidence"); - }, async Commands() { const commands: CommandInfo[] = [ { name: "new", description: "start new session; save transcript", kind: "builtin" as const, group: "actions" }, diff --git a/desktop/frontend/src/lib/types.ts b/desktop/frontend/src/lib/types.ts index e3d75c4df3..ae82c79b73 100644 --- a/desktop/frontend/src/lib/types.ts +++ b/desktop/frontend/src/lib/types.ts @@ -367,7 +367,6 @@ export interface TabMeta { tokenMode?: TokenMode; goal?: string; goalStatus?: GoalStatus; - autoResearch?: AutoResearchCompactView; recovered?: boolean; recoveryReason?: string; recoveryDigest?: string; @@ -718,7 +717,6 @@ export interface Meta { goal?: string; goalStatus?: GoalStatus; goalRuntime?: GoalRuntime; - autoResearch?: AutoResearchCompactView; canonicalTodos?: Todo[]; } @@ -743,55 +741,6 @@ export interface GoalRuntime { budgetExtensions: number; } -export interface AutoResearchCompactView { - taskId: string; - status: "running" | "blocked" | "complete" | "stopped" | "invalid"; - iteration: number; - pivotRequired: boolean; - staleCount: number; -} - -export interface AutoResearchCriterionView { - id: string; - description: string; - required: boolean; - evidenceCount: number; - status: string; -} - -export interface AutoResearchStatusView extends AutoResearchCompactView { - goal: string; - currentDirection: string; - pivotCount: number; - lastHeartbeatAt: string; - findingCount: number; - openCriteria: AutoResearchCriterionView[]; - blocker: string; - taskPath: string; - nextRequiredAction: string; -} - -export interface AutoResearchFindingView { - id: string; - kind: string; - summary: string; - source: string; - command?: string; - paths?: string[]; - accepted: boolean; - createdAt: string; -} - -export interface AutoResearchEvidenceView { - id: string; - kind: string; - summary: string; - source: string; - command?: string; - paths?: string[]; - accepted: boolean; -} - export function normalizeCollaborationMode(mode?: string, goal?: string, legacyMode?: Mode): CollaborationMode { if (mode === "plan" || mode === "goal" || mode === "normal") return mode; if (legacyMode && modeHasPlan(legacyMode)) return "plan"; diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 7cc081500c..bf3e6adadc 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -2177,10 +2177,6 @@ function backendNoticeKey(msg: string): DictKey | "" { return "notice.goalNotReady"; case "Goal still has unfinished task state; continuing the remaining work.": return "notice.goalUnfinished"; - case "AutoResearch status update failed.": - return "notice.autoresearchStatusFailed"; - case "AutoResearch task marked blocked.": - return "notice.autoresearchBlocked"; case "Job artifact migration failed.": return "notice.jobArtifactMigrationFailed"; case "Background job teardown timed out.": diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index dd4464cdf4..64a81f6f41 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -2836,8 +2836,6 @@ export const en = { "notice.compactionNoSummary": "Context was compacted without a generated summary.", "notice.goalNotReady": "Goal is not ready to complete yet; continuing the remaining work.", "notice.goalUnfinished": "Goal still has unfinished task state; continuing the remaining work.", - "notice.autoresearchStatusFailed": "AutoResearch status update failed.", - "notice.autoresearchBlocked": "AutoResearch task marked blocked.", "notice.backgroundJobFailed": "Background {kind} needs attention.", "notice.jobArtifactMigrationFailed": "Job artifact migration failed.", "notice.jobTeardownTimeout": "Background job teardown timed out.", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index 661fdaf362..d831497f6c 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -1960,8 +1960,6 @@ export const zhTW: Record = { "notice.compactionNoSummary": "上下文已壓縮,但未產生摘要。", "notice.goalNotReady": "目標暫未達到完成條件,繼續處理剩餘工作。", "notice.goalUnfinished": "目標還有未完成的任務狀態,繼續處理。", - "notice.autoresearchStatusFailed": "AutoResearch 狀態更新失敗。", - "notice.autoresearchBlocked": "AutoResearch 任務已標記為阻塞。", "notice.backgroundJobFailed": "背景 {kind} 需要處理。", "notice.jobArtifactMigrationFailed": "任務產物遷移失敗。", "notice.jobTeardownTimeout": "背景任務清理逾時。", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 3a60a09269..36f493c30f 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -2839,8 +2839,6 @@ export const zh: Record = { "notice.compactionNoSummary": "上下文已压缩,但未生成摘要。", "notice.goalNotReady": "目标暂未达到完成条件,继续处理剩余工作。", "notice.goalUnfinished": "目标还有未完成的任务状态,继续处理。", - "notice.autoresearchStatusFailed": "AutoResearch 状态更新失败。", - "notice.autoresearchBlocked": "AutoResearch 任务已标记为阻塞。", "notice.backgroundJobFailed": "后台 {kind} 需要处理。", "notice.jobArtifactMigrationFailed": "任务产物迁移失败。", "notice.jobTeardownTimeout": "后台任务清理超时。", diff --git a/desktop/goal_delivery_yolo_test.go b/desktop/goal_delivery_yolo_test.go index 537c8a0cdd..2ce256d974 100644 --- a/desktop/goal_delivery_yolo_test.go +++ b/desktop/goal_delivery_yolo_test.go @@ -227,7 +227,7 @@ func TestPlanWinsRunningGoalConflictDuringDeliveryRebuild(t *testing.T) { } } -func TestRunningGoalDeliveryYoloRebuildKeepsScopeAndAutoResearch(t *testing.T) { +func TestRunningGoalDeliveryYoloRebuildKeepsUnifiedGoalScope(t *testing.T) { app, tab, oldCtrl, path := newGoalDeliveryYoloTestApp(t, control.GoalStatusRunning) if err := app.SetTokenModeForTab(tab.ID, boot.TokenModeDelivery); err != nil { t.Fatalf("SetTokenModeForTab: %v", err) @@ -251,7 +251,7 @@ func TestRunningGoalDeliveryYoloRebuildKeepsScopeAndAutoResearch(t *testing.T) { if err := json.Unmarshal(data, &persisted); err != nil { t.Fatal(err) } - if persisted.ScopeID != "goal-test-scope" || persisted.AutoResearchTaskID != "research-task-1" { + if persisted.ScopeID != "goal-test-scope" || persisted.AutoResearchTaskID != "" { t.Fatalf("restored Goal identity = %+v", persisted) } if persisted.DeliveryCheckpoint.ScopeID != persisted.ScopeID || !persisted.DeliveryCheckpoint.PendingMutation { @@ -336,7 +336,7 @@ func TestGoalDeliveryYoloSurvivesEveryControllerRebuildPath(t *testing.T) { if err := json.Unmarshal(data, &persisted); err != nil { t.Fatalf("decode Goal sidecar: %v", err) } - if persisted.ScopeID != "goal-test-scope" || persisted.AutoResearchTaskID != "research-task-1" { + if persisted.ScopeID != "goal-test-scope" || persisted.AutoResearchTaskID != "" { t.Fatalf("restored Goal identity = %+v", persisted) } if persisted.DeliveryCheckpoint.ScopeID != persisted.ScopeID || !persisted.DeliveryCheckpoint.PendingMutation { diff --git a/desktop/tab_profile_test.go b/desktop/tab_profile_test.go index 2c2b9abe70..ada38eb429 100644 --- a/desktop/tab_profile_test.go +++ b/desktop/tab_profile_test.go @@ -490,177 +490,6 @@ func TestMetaReportsGoalStatus(t *testing.T) { } } -func TestAutoResearchStatusSurfaceForActiveTab(t *testing.T) { - isolateDesktopUserDirs(t) - - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - app := NewApp() - tab := testTab("a", root) - tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root}) - app.tabs = map[string]*WorkspaceTab{tab.ID: tab} - app.tabOrder = []string{tab.ID} - app.activeTabID = tab.ID - defer tab.Ctrl.Close() - - tab.Ctrl.SetGoalWithResearchMode("identify the root cause", control.GoalResearchOn) - - meta := app.MetaForTab(tab.ID) - if meta.AutoResearch == nil || meta.AutoResearch.TaskID == "" { - t.Fatalf("MetaForTab AutoResearch = %+v, want compact active task summary", meta.AutoResearch) - } - if meta.AutoResearch.Status != control.GoalStatusRunning || meta.AutoResearch.Iteration != 0 { - t.Fatalf("compact AutoResearch summary = %+v", meta.AutoResearch) - } - - current := app.AutoResearchCurrent() - if current.TaskID != meta.AutoResearch.TaskID || current.Goal != "identify the root cause" { - t.Fatalf("AutoResearchCurrent = %+v, want task %q", current, meta.AutoResearch.TaskID) - } - if current.TaskPath == "" || current.Status != control.GoalStatusRunning { - t.Fatalf("AutoResearchCurrent missing status/path: %+v", current) - } - heartbeatPath := filepath.Join(root, ".reasonix", "autoresearch", current.TaskID, "logs", "heartbeat.jsonl") - if err := os.WriteFile(heartbeatPath, []byte(`{"status":"turn_done","iteration":1,"created_at":"2026-06-30T00:00:00Z"}`+"\n"), 0o644); err != nil { - t.Fatalf("write heartbeat: %v", err) - } - current = app.AutoResearchCurrent() - if current.LastHeartbeatAt != "2026-06-30T00:00:00Z" || current.NextRequiredAction == "" { - t.Fatalf("AutoResearchCurrent missing runtime fields: %+v", current) - } - - tabs := app.ListTabs() - if len(tabs) != 1 || tabs[0].AutoResearch == nil || tabs[0].AutoResearch.TaskID != current.TaskID { - t.Fatalf("ListTabs AutoResearch = %+v, want compact summary for task %q", tabs, current.TaskID) - } -} - -func TestAutoResearchFindingsAreLoadedOnDemand(t *testing.T) { - isolateDesktopUserDirs(t) - - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - app := NewApp() - tab := testTab("a", root) - tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root}) - app.tabs = map[string]*WorkspaceTab{tab.ID: tab} - app.tabOrder = []string{tab.ID} - app.activeTabID = tab.ID - defer tab.Ctrl.Close() - - tab.Ctrl.SetGoalWithResearchMode("collect accepted findings", control.GoalResearchOn) - current := app.AutoResearchCurrent() - if current.TaskID == "" { - t.Fatal("expected active AutoResearch task") - } - findingsPath := filepath.Join(root, ".reasonix", "autoresearch", current.TaskID, "state", "findings.jsonl") - if err := os.WriteFile(findingsPath, []byte( - `{"id":"f1","kind":"test","summary":"old","source":"command","command":"go test ./...","accepted":true,"created_at":"2026-06-29T10:00:00Z"}`+"\n"+ - `{"id":"f2","kind":"review","summary":"new","source":"manual","accepted":true,"created_at":"2026-06-29T11:00:00Z"}`+"\n", - ), 0o644); err != nil { - t.Fatalf("write findings: %v", err) - } - - findings := app.AutoResearchFindings(tab.ID, 1) - if len(findings) != 1 || findings[0].ID != "f2" || findings[0].Summary != "new" { - t.Fatalf("AutoResearchFindings = %+v, want newest capped finding", findings) - } - if meta := app.MetaForTab(tab.ID); meta.AutoResearch == nil || meta.AutoResearch.TaskID != current.TaskID { - t.Fatalf("MetaForTab compact summary lost task id: %+v", meta.AutoResearch) - } -} - -func TestAutoResearchListReturnsWorkspaceTasks(t *testing.T) { - isolateDesktopUserDirs(t) - - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - app := NewApp() - tab := testTab("a", root) - tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root}) - app.tabs = map[string]*WorkspaceTab{tab.ID: tab} - app.tabOrder = []string{tab.ID} - app.activeTabID = tab.ID - defer tab.Ctrl.Close() - - tab.Ctrl.SetGoalWithResearchMode("list active research task", control.GoalResearchOn) - list := app.AutoResearchList(tab.ID) - if len(list) != 1 || list[0].TaskID == "" || list[0].Goal != "list active research task" { - t.Fatalf("AutoResearchList = %+v, want active workspace task", list) - } -} - -func TestAutoResearchOpenTaskRevealsTaskDirectory(t *testing.T) { - isolateDesktopUserDirs(t) - - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - app := NewApp() - tab := testTab("a", root) - tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root}) - app.tabs = map[string]*WorkspaceTab{tab.ID: tab} - app.tabOrder = []string{tab.ID} - app.activeTabID = tab.ID - defer tab.Ctrl.Close() - - tab.Ctrl.SetGoalWithResearchMode("open task folder", control.GoalResearchOn) - current := app.AutoResearchCurrent() - var revealed string - oldReveal := revealPath - revealPath = func(path string) error { - revealed = path - return nil - } - defer func() { revealPath = oldReveal }() - - if err := app.AutoResearchOpenTask(tab.ID); err != nil { - t.Fatalf("AutoResearchOpenTask: %v", err) - } - if revealed != current.TaskPath { - t.Fatalf("revealed path = %q, want task path %q", revealed, current.TaskPath) - } -} - -func TestAutoResearchRecordEvidenceThroughDesktopAPI(t *testing.T) { - isolateDesktopUserDirs(t) - - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - app := NewApp() - tab := testTab("a", root) - tab.Ctrl = control.New(control.Options{Label: tab.ID, WorkspaceRoot: root}) - app.tabs = map[string]*WorkspaceTab{tab.ID: tab} - app.tabOrder = []string{tab.ID} - app.activeTabID = tab.ID - defer tab.Ctrl.Close() - - tab.Ctrl.SetGoalWithResearchMode("record evidence through desktop", control.GoalResearchOn) - err := app.AutoResearchRecordEvidence(tab.ID, "objective_evidence", AutoResearchEvidenceView{ - ID: "f1", - Kind: "test", - Summary: "desktop evidence recorded", - Source: "manual", - Accepted: true, - }) - if err != nil { - t.Fatalf("AutoResearchRecordEvidence: %v", err) - } - findings := app.AutoResearchFindings(tab.ID, 10) - if len(findings) != 1 || findings[0].ID != "f1" { - t.Fatalf("findings = %+v, want f1", findings) - } -} - func TestMetaReportsStoredCollaborationModeWhileControllerRebuilds(t *testing.T) { isolateDesktopUserDirs(t) diff --git a/desktop/tabs.go b/desktop/tabs.go index 7271780db7..13de4d8af0 100644 --- a/desktop/tabs.go +++ b/desktop/tabs.go @@ -2174,41 +2174,40 @@ type wireEventTab struct { // TabMeta is the frontend-facing shape of one tab. type TabMeta struct { - ID string `json:"id"` - Scope string `json:"scope"` - WorkspaceRoot string `json:"workspaceRoot"` - WorkspaceName string `json:"workspaceName"` - WorkspacePath string `json:"workspacePath,omitempty"` - GitBranch string `json:"gitBranch,omitempty"` - IsolatedWorktree bool `json:"isolatedWorktree,omitempty"` - TopicID string `json:"topicId"` - TopicTitle string `json:"topicTitle"` - SessionPath string `json:"sessionPath,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - ProjectColor string `json:"projectColor,omitempty"` - Label string `json:"label"` - Ready bool `json:"ready"` - Runtime SessionRuntimeView `json:"runtime"` - Running bool `json:"running"` - PendingPrompt bool `json:"pendingPrompt,omitempty"` - RemoteControlled bool `json:"remoteControlled,omitempty"` - BackgroundJobs int `json:"backgroundJobs,omitempty"` - CancelRequested bool `json:"cancelRequested,omitempty"` - Cancellable bool `json:"cancellable"` - Mode string `json:"mode"` - CollaborationMode string `json:"collaborationMode"` - ToolApprovalMode string `json:"toolApprovalMode"` - TokenMode string `json:"tokenMode"` - Goal string `json:"goal,omitempty"` - GoalStatus string `json:"goalStatus,omitempty"` - AutoResearch *AutoResearchCompactView `json:"autoResearch,omitempty"` - Recovered bool `json:"recovered,omitempty"` - RecoveryReason string `json:"recoveryReason,omitempty"` - RecoveryDigest string `json:"recoveryDigest,omitempty"` - RecoveryParentID string `json:"recoveryParentId,omitempty"` - StartupErr string `json:"startupErr,omitempty"` - Active bool `json:"active"` - Cwd string `json:"cwd"` + ID string `json:"id"` + Scope string `json:"scope"` + WorkspaceRoot string `json:"workspaceRoot"` + WorkspaceName string `json:"workspaceName"` + WorkspacePath string `json:"workspacePath,omitempty"` + GitBranch string `json:"gitBranch,omitempty"` + IsolatedWorktree bool `json:"isolatedWorktree,omitempty"` + TopicID string `json:"topicId"` + TopicTitle string `json:"topicTitle"` + SessionPath string `json:"sessionPath,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + ProjectColor string `json:"projectColor,omitempty"` + Label string `json:"label"` + Ready bool `json:"ready"` + Runtime SessionRuntimeView `json:"runtime"` + Running bool `json:"running"` + PendingPrompt bool `json:"pendingPrompt,omitempty"` + RemoteControlled bool `json:"remoteControlled,omitempty"` + BackgroundJobs int `json:"backgroundJobs,omitempty"` + CancelRequested bool `json:"cancelRequested,omitempty"` + Cancellable bool `json:"cancellable"` + Mode string `json:"mode"` + CollaborationMode string `json:"collaborationMode"` + ToolApprovalMode string `json:"toolApprovalMode"` + TokenMode string `json:"tokenMode"` + Goal string `json:"goal,omitempty"` + GoalStatus string `json:"goalStatus,omitempty"` + Recovered bool `json:"recovered,omitempty"` + RecoveryReason string `json:"recoveryReason,omitempty"` + RecoveryDigest string `json:"recoveryDigest,omitempty"` + RecoveryParentID string `json:"recoveryParentId,omitempty"` + StartupErr string `json:"startupErr,omitempty"` + Active bool `json:"active"` + Cwd string `json:"cwd"` } func enrichTabMeta(meta TabMeta) TabMeta { @@ -2248,7 +2247,6 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta { TokenMode: currentTabTokenMode(tab), Goal: currentTabGoal(tab), GoalStatus: currentTabGoalStatus(tab), - AutoResearch: compactAutoResearch(tab), StartupErr: tab.StartupErr, Active: active, Cwd: tab.WorkspaceRoot, diff --git a/docs/COLLABORATION_MODES.zh-CN.md b/docs/COLLABORATION_MODES.zh-CN.md index 1a59d315b8..54a2c1673e 100644 --- a/docs/COLLABORATION_MODES.zh-CN.md +++ b/docs/COLLABORATION_MODES.zh-CN.md @@ -50,8 +50,7 @@ Reasonix 桌面端输入框左下角的菜单包含两条互相独立的轴: - 任务需要探索、实现、验证多个阶段。 - 你希望减少中途反复下指令,让 Reasonix 在目标范围内持续推进。 - 目标明显是长周期研究、排障或优化,例如“持续排查直到根因明确”“彻底实现并验证” - “不要原地打转”。这类目标会自动进入 AutoResearch 策略,把状态写到 - `.reasonix/autoresearch//`。 + “不要原地打转”。这类目标会自动使用 40 轮研究预算,但仍由同一个 Goal 状态机推进。 ### 推荐目标写法 @@ -65,10 +64,9 @@ Goal 模式会把这些部分当作任务边界;除非下一步涉及不可逆 - 目标要写得具体。推荐包含范围、成功标准和限制,例如“只改桌面端输入栏,补前端测试,不改后端协议”。 - 目标模式不是跳过审批。遇到高风险操作、权限限制、阻塞或需要产品判断时,仍可能停下来询问。 - 如果目标过大或边界不清,Reasonix 可能需要更多探索轮次,也会消耗更多 token。 -- AutoResearch 是 Goal 的自动持久化策略,不是独立的后台 daemon,也不是 Settings 里的全局 skill。 - 可以用 `/goal --research <目标>` 强制启用,或用 `/goal --simple <目标>` 强制保持轻量 Goal。 - 普通聊天不会因为目标文本看起来复杂或长周期而自动切换模式。只有明确选择“目标”或使用 - `/goal` 后,Reasonix 才会进入 Goal,并在 Goal 内判断是否采用 AutoResearch。 + `/goal` 后,Reasonix 才会进入 Goal,并自动选择简单、写入或研究轮次预算。 +- 旧 `.reasonix/autoresearch/...` 目录不会被新版本创建或改写;显式引用旧任务路径时只会读取并恢复成普通 Goal。 - 目标模式和计划模式是同一协作轴。切到计划模式时,会退出目标草稿/目标显示状态;运行模式不会因此改变。 ## 运行模式 diff --git a/docs/GOAL_ENFORCEMENT.zh-CN.md b/docs/GOAL_ENFORCEMENT.zh-CN.md index 6ffe4327c6..fe0646550c 100644 --- a/docs/GOAL_ENFORCEMENT.zh-CN.md +++ b/docs/GOAL_ENFORCEMENT.zh-CN.md @@ -9,7 +9,7 @@ Reasonix 的 Goal 模式(`/goal`)将目标推进(Goal)、验收(Delive | 结构化完成协议 | `update_goal` 工具 | 每轮目标 turn 结束时模型通过工具报告 continue/complete/blocked(含 reason 与 next_action),取代旧的 `[goal:*]` footer 文本标记 | | 完成校验 | 默认 | `complete` 声明必须通过 Delivery readiness(todos、验证、review、签收、能力门禁)才会真正完成;不满足时用缺失项开启下一轮 | | 独立评审 | 无报告时 | 模型未调用 `update_goal` 时,宿主调用一次独立 bounded evaluator 判定;评审不可用/出错/不确定时安全暂停,绝不默认继续 | -| 执行预算 | 默认 | **轮次与无进展熔断**:简单 10 轮、写入型 20 轮、AutoResearch 40 轮;连续 4 轮无宿主可验证进展则暂停。累计 token 只做观测展示,**没有 token 硬上限**,也没有 provider 请求前预算准入 | +| 执行预算 | 默认 | **轮次与无进展熔断**:简单 10 轮、写入型 20 轮、研究型 40 轮;连续 4 轮无宿主可验证进展则暂停。累计 token 只做观测展示,**没有 token 硬上限**,也没有 provider 请求前预算准入 | | 暂停/恢复 | `/goal pause` / `/goal resume` | 暂停保留 Goal、todo、Delivery checkpoint 与运行历史;轮次型暂停恢复时追加一档同类别**轮数**(`budget_extensions` 统计轮次追加次数) | | 立即阻塞 | `blocked` 报告 | 单个 blocked 报告立即结束目标,不再重复三轮确认 | | 并行调度 | `parallel_tasks` 工具 | 并发派发多个子 agent,各自独立显示结果 | @@ -36,7 +36,7 @@ Reasonix 的 Goal 模式(`/goal`)将目标推进(Goal)、验收(Delive - **写入型(write,20 轮)**:含明确修改动词(修复/实现/更新…),或 Goal 中**不带问句/解释意图/只读诊断/否定修改约束**的故障陈述(如「数据模型管理器又出现历史 BUG 了」「应用打开设置时崩溃」)。 - **简单型(simple,10 轮)**:咨询、解释、「为什么…」、只分析/诊断/复现定位且不要修复等。 -- **研究型(research,40 轮)**:AutoResearch 目标。 +- **研究型(research,40 轮)**:带有明显长周期信号或多个独立阶段的目标。 普通 Delivery 的只读/咨询分类不变;上述「裸故障默认 write」只作用于 Goal 轮数类别。 @@ -140,7 +140,7 @@ Delivery 不再自行注入隐藏模型消息做 3/6 次 readiness 重试:普 ### 进展签名 -只有宿主可验证信息才能重置停滞计数:todo 状态变化、新的有效 mutation/verification/review/signoff receipt、Delivery checkpoint 变化、新接受的 AutoResearch evidence、终态 `update_goal` 报告。任意工具调用、重复读取、仅改变措辞的回答或重复 continue 理由都不能伪造进展。 +只有宿主可验证信息才能重置停滞计数:todo 状态变化、新的有效 mutation/verification/review/signoff receipt、Delivery checkpoint 变化、终态 `update_goal` 报告。任意工具调用、重复读取、仅改变措辞的回答或重复 continue 理由都不能伪造进展。 ### Todo 状态流 diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 9b3b9e51d3..c2e7391d28 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -619,7 +619,7 @@ Mode and display shortcuts: | `/theme [auto|light|dark|style]` | Shows or switches the CLI theme | Bare `/theme` lists background modes and named accent palettes. The choice is saved to the user config; `REASONIX_THEME` and `REASONIX_THEME_STYLE` can override it for one run. | | `Ctrl+O` | Toggles verbose reasoning display | Also available through `/verbose`. | | `Ctrl+B` | Expands or collapses long shell output | Long shell-output hint lines can also be clicked in the transcript; text selection is handled in-app while the full-screen TUI has mouse reporting enabled. | -| `/goal `, `/goal --research `, `/goal --simple `, `/goal status`, `/goal clear` | Starts, checks, or clears Goal | Goal is not in any keyboard cycle; clearly long-horizon goals automatically enable AutoResearch after Goal is explicitly started. | +| `/goal `, `/goal status`, `/goal pause`, `/goal resume`, `/goal clear` | Starts, checks, pauses, resumes, or clears Goal | Goal automatically selects a simple, write, or research turn budget. | | `/migrate`, `/migrate --from ` | Retries legacy migration or imports sessions from a chosen v0.x source | Use `--from` for custom Windows v0.52 install/data directories; it imports sessions only. See [Configuration paths](./CONFIG_PATHS.md). | Picker and approval shortcuts: @@ -1069,19 +1069,15 @@ permission, or tool behavior must declare whether embedded documentation was updated. When no documentation change is needed, the declaration must explain why the existing version-matched guidance remains correct. -## Goal and AutoResearch +## Goal -Goal is the unified runtime for long-running objectives. Ordinary `/goal` -objectives stay lightweight: Reasonix keeps working until the goal is complete, -blocked, paused, or cleared. When a goal is clearly long-horizon, Goal -automatically enables the AutoResearch strategy instead of requiring a separate -`/auto-research` skill; `auto-research` is not listed as a standalone built-in -skill in Settings -> Skills or the slash menu. Ordinary chat never changes the -collaboration mode implicitly; choose Goal in the composer or use `/goal` to -start a long-running objective. +Goal is the unified runtime for long-running objectives. Reasonix keeps working +until the goal is complete, blocked, paused, or cleared. Ordinary chat never +changes collaboration mode implicitly; choose Goal in the composer or use +`/goal` to start a long-running objective. Goal runs under a per-class **turn** budget: simple goals get 10 turns, write -goals 20 turns, and AutoResearch goals 40 turns; four consecutive turns without +goals 20 turns, and research goals 40 turns; four consecutive turns without host-verifiable progress pause the goal. Cumulative token usage is still tracked and shown for diagnostics, but there is **no token hard limit** and no pre-provider request admission. In Goal mode, a bare bug/crash/exception @@ -1103,38 +1099,15 @@ for autonomous work. It keeps going with sensible defaults unless the next step requires an irreversible or externally visible operation, a scope change, or information only the user can provide. -AutoResearch is enabled for goals with strong signals such as "keep -researching", "long-running", "thoroughly", "debug until the root cause is -clear", "do not spin", "run experiments", "verify repeatedly", or "turn this -into a complete plan". It can also trigger when the objective combines multiple -phases such as research/diagnosis, implementation/fixing, verification/testing, -optimization/documentation/release, or when the user names an existing -`.reasonix/autoresearch//` directory. Advanced users can force it with -`/goal --research ` or force lightweight Goal with -`/goal --simple `. Outside an explicitly started Goal, those signals -remain ordinary chat text and do not create durable AutoResearch state. - -Once AutoResearch is active, the agent treats the goal as a stateful research -loop instead of a chat-only continuation. It creates or reuses a project-local -`.reasonix/autoresearch//` directory. For new tasks, the default id -shape is `YYYYMMDD-HHMMSS-slug`, such as `20260618-224530-cache-audit`; Reasonix -checks the project directory first and appends `-2`, `-3`, and so on only if -that id already exists. The task state includes `task_spec.md`, `progress.json`, -`findings.jsonl`, `directions_tried.json`, and `iteration_log.jsonl`, records -each iteration's direction, evidence, verification result, and blocker, and uses -`stale_count` to detect repeated weak progress. Repeated stalls force a -structural pivot, such as changing evidence source, entrypoint, test oracle, -decomposition, benchmark, or worker strategy, rather than retrying the same -tactic. - -Workers and subagents may explore independently, but the orchestrator owns the -canonical state files. Completion requires a requirement-by-requirement evidence -audit against `task_spec.md`; a passing narrow check is not treated as proof of a -broad requirement. Dynamic run state stays in `.reasonix/autoresearch/...`, not -in `REASONIX.md`, `AGENTS.md`, project memory, tool schemas, or the cache-stable -system prompt. Public publishing, destructive operations, credentials, payments, -and external notifications still follow the normal approval, privacy, and cache -gates. +Research budgets are selected automatically for goals with strong long-horizon +signals or several distinct phases. There is no separate research mode or +runtime to configure. Goal state stays in the normal session sidecar, progress +comes only from host receipts, canonical todos, `complete_step`, review and the +Delivery checkpoint, and completion is decided by Delivery readiness plus the +bounded Goal evaluator. Legacy `.reasonix/autoresearch//` archives are +read-only: an explicit old path can be recovered as an ordinary Goal, but new +runs never create or update those directories. Deprecated budget flags are +accepted for compatibility but are hidden from help and completion. ## @ references diff --git a/docs/GUIDE.zh-CN.md b/docs/GUIDE.zh-CN.md index 5a077f3443..b13561b15f 100644 --- a/docs/GUIDE.zh-CN.md +++ b/docs/GUIDE.zh-CN.md @@ -503,7 +503,7 @@ CLI/TUI 文本输入可通过 `[ui].cursor_shape` 设置光标形状,支持 `u | `/theme [auto|light|dark|style]` | 查看或切换 CLI 主题 | 不带参数会列出背景模式和命名配色。选择会保存到用户配置;单次运行可用 `REASONIX_THEME` 和 `REASONIX_THEME_STYLE` 覆盖。 | | `Ctrl+O` | 切换详细 reasoning 显示 | 也可通过 `/verbose` 使用。 | | `Ctrl+B` | 展开或收起较长 shell 输出 | 较长 shell 输出的提示行也可点击;全屏 TUI 开启鼠标接管时,文本选区由应用内处理。 | -| `/goal <目标>`、`/goal --research <目标>`、`/goal --simple <目标>`、`/goal status`、`/goal clear` | 启动、查看或清除 Goal | Goal 不进入任何快捷键循环;显式启动 Goal 后,明显长周期目标会自动启用 AutoResearch。 | +| `/goal <目标>`、`/goal status`、`/goal pause`、`/goal resume`、`/goal clear` | 启动、查看、暂停、恢复或清除 Goal | Goal 自动选择简单、写入或研究轮次预算。 | | `/migrate`、`/migrate --from <旧目录>` | 重试旧数据迁移,或从指定 v0.x 来源导入 sessions | Windows v0.52 自定义安装/数据目录用 `--from`;该形式只导入 sessions。详见[配置路径](./CONFIG_PATHS.zh-CN.md)。 | 选择器与审批: @@ -845,15 +845,12 @@ Skill 别名会继续拥有 `/docs`;发生冲突时,CLI 与桌面端通常 如果 Pull Request 修改了用户可见的 CLI、桌面端、配置、Provider、权限或工具行为,必须声明 是否已同步更新内置文档;如果无需更新,则必须说明现有的版本匹配说明为何仍然正确。 -## Goal 与 AutoResearch +## Goal -Goal 是长期目标的统一运行机制。普通 `/goal` 继续走轻量 Goal:Reasonix 会持续推进,直到 -完成、阻塞、暂停或被清除。对于明显长周期的目标,Goal 会自动进入 AutoResearch 策略,而不是 -要求用户单独运行 `/auto-research` skill;`auto-research` 也不会作为独立 builtin skill 出现在 -Settings -> Skills 或斜杠菜单里。普通聊天不会隐式改变协作模式;需要长目标时,请在输入框中 -明确选择 Goal,或使用 `/goal` 启动。 +Goal 是长期目标的统一运行机制。Reasonix 会持续推进,直到完成、阻塞、暂停或被清除。 +普通聊天不会隐式改变协作模式;需要长目标时,请在输入框中明确选择 Goal,或使用 `/goal` 启动。 -Goal 按类别运行在**轮次**预算内:简单目标 10 轮,写入型 20 轮,AutoResearch 40 轮; +Goal 按类别运行在**轮次**预算内:简单目标 10 轮,写入型 20 轮,研究型 40 轮; 连续 4 轮没有宿主可验证进展会暂停。累计 token 仍会统计并展示(便于诊断),但**没有 token 硬上限**,也不会在 provider 请求前做 token 准入拦截。Goal 中只陈述 BUG/崩溃/异常 且未要求分析或禁止修改时,默认按写入型轮数类别。暂停会保留 Goal、todo、Delivery @@ -867,27 +864,11 @@ continue/complete/blocked;没有报告时由独立的有界 evaluator 判定 Output format、Constraints 和 Pause policy。Goal 模式会把这些部分当作自主执行的边界; 除非下一步需要不可逆或对外可见操作、任务范围变化,或必须由用户提供信息,否则会继续采用合理默认值推进,并在最后汇报假设与结果。 -AutoResearch 会在这些目标里自动启用:包含“持续”“长期”“彻底”“直到根因明确”“多轮排查” -“不要原地打转”“完整方案”“跑实验”“反复验证”“系统性研究”等强信号;或者目标同时包含 -研究/排查、实现/修复、验证/测试、优化/文档/发布等多个阶段;或者用户明确给出 -`.reasonix/autoresearch//` 任务目录。高级用户可以用 -`/goal --research <目标>` 强制启用,也可以用 `/goal --simple <目标>` 强制保持轻量 Goal。 -未显式启动 Goal 时,这些信号只作为普通聊天文本处理,不会创建持久化 AutoResearch 任务。 - -进入 AutoResearch 后,agent 会把目标当成有状态的研究循环,而不是只靠聊天上下文续写。 -它会创建或复用项目级 `.reasonix/autoresearch//` 目录。新任务默认使用 -`YYYYMMDD-HHMMSS-slug` 作为 id,例如 `20260618-224530-cache-audit`;创建前会先检查 -当前项目目录,只有同名已存在时才追加 `-2`、`-3` 等后缀。任务状态包括 -`task_spec.md`、`progress.json`、`findings.jsonl`、`directions_tried.json` 和 -`iteration_log.jsonl`,记录每轮方向、证据、验证结果和卡住原因,并用 `stale_count` 判断 -是否在低质量重复。连续停滞时,它会要求结构性 pivot,例如换证据源、入口、测试 oracle、 -拆解方式、benchmark 或 worker 策略,而不是继续重复同一种尝试。 - -worker/subagent 可以独立探索,但 canonical state 由 orchestrator 负责写入。完成前必须 -对照 `task_spec.md` 的 success criteria 做逐项证据审计;窄范围检查通过不能证明宽范围需求 -完成。动态运行态只写进 `.reasonix/autoresearch/...`,不写入 `REASONIX.md`、`AGENTS.md`、 -project memory、tool schema 或 cache-stable system prompt。公开发布、破坏性操作、凭证、 -付款和外部通知仍然遵守正常的 approval、privacy 与 cache gate。 +带有明显长周期信号或多个独立阶段的目标会自动获得研究型预算,不需要配置单独的研究模式或 +运行时。Goal 状态只保存在普通会话 sidecar;进展只来自宿主工具 receipt、canonical todo、 +`complete_step`、review 与 Delivery checkpoint,最终由 Delivery readiness 和有界 Goal +evaluator 判定。旧 `.reasonix/autoresearch//` 目录保持只读:显式引用旧路径时可恢复为 +普通 Goal,但新版本不会创建或改写这些目录。旧预算 flags 仅为兼容继续接受,不再出现在帮助和补全中。 ## @ 引用 diff --git a/docs/SPEC.md b/docs/SPEC.md index 65b378dd53..89b96adc78 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -477,16 +477,13 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec assumption. Completion requires the concrete request, output format, constraints, and relevant verification expectations to be satisfied or explicitly reported as unverified. - Goals that look like long-horizon research, debugging, optimization, or - implementation work automatically add an AutoResearch protocol to the same - transient active-goal user block. AutoResearch is a Goal strategy, not a - standalone global skill: it writes project-local state under - `.reasonix/autoresearch/YYYYMMDD-HHMMSS-slug/` and keeps dynamic run state out - of `REASONIX.md`, `AGENTS.md`, project memory, tool schemas, and the - cache-stable system prompt. `/goal --research ` forces that - strategy; `/goal --simple ` forces lightweight Goal. Outside goal - mode, ordinary prompts never change collaboration mode or create durable - AutoResearch state; the user must choose Goal or use `/goal` explicitly. + Goal automatically selects a simple (10), write (20), or research (40) turn + budget from the objective. All classes use the same Goal FSM, host receipts, + Delivery readiness, and bounded evaluator; there is no second research + protocol or writable sidecar runtime. Legacy `.reasonix/autoresearch/...` + archives remain read-only and explicit old paths recover as ordinary Goals. + Outside goal mode, ordinary prompts never change collaboration mode; the user + must choose Goal or use `/goal` explicitly. `/goal clear` removes the active goal. Switching into plan/normal mode clears the active goal in the desktop UI so the collaboration mode remains one of the three choices, while the underlying tool approval posture is preserved. diff --git a/docs/SPEC.zh-CN.md b/docs/SPEC.zh-CN.md index dcbda61865..eb4dfdd1d9 100644 --- a/docs/SPEC.zh-CN.md +++ b/docs/SPEC.zh-CN.md @@ -187,7 +187,7 @@ func (p Policy) Decide(toolName string, readOnly bool, args json.RawMessage) Dec - 安装 MCP server 即授权其全部工具,不再有 server、raw tool、writer 或 destructive 的第二套审批策略;项目 `reasonix.toml` 与 `.mcp.json` 声明同样默认可信,不需要额外启动确认,显式全局 `deny` 仍然优先。全局安装写入用户 `config.toml`,项目声明保留在原项目文件;同名时项目覆盖全局,项目内部 `reasonix.toml` 高于 `.mcp.json`。编辑写回当前生效来源,删除高优先级声明后露出下一层。`readOnlyHint` 与 `destructiveHint` 仅用于调度、Plan/严格只读边界及缓存到实时安全分类复核,不会新增逐调用审批。严格只读子智能体 registry 仍仅暴露已授权且 `readOnlyHint: true`、无 `destructiveHint` 的 MCP;双模型 Planner 通过固定 `use_capability` 代理(从不暴露直接 `mcp__*` schema)调用已授权、非 destructive 的 MCP,不再要求 `readOnlyHint`,destructive 工具留给 Executor。Balanced 双模型的 Executor 使用独立 frontend 复用同一稳定代理,因此 Planner 发现的 capability ID 可在 handoff 后直接执行,同时保持两侧 ledger/audit 隔离。分发前代理会再次复核当前 controller 的 enable、授权和完整运行时连接身份;共享 Host 中仅 server 同名不构成复用权限。 - Plan 是协作流程,不等于全工具只读。普通 built-in 与 Bash 仍走 Ask/Auto/YOLO 和 Sandbox;独立双模型 Planner 允许已授权、非 destructive 的 MCP(即使没有 `readOnlyHint`),但在规划阶段持续阻止 destructive 与未授权目标;没有独立 Planner 的单模型 Plan 仍阻止 MCP writer/destructive。 - Plan 只能由用户显式选择进入,与当前工具审批姿态相互独立;普通聊天不会自动切换到 Plan。Auto/YOLO 不会回答 `ask`,也不会替用户批准 `exit_plan_mode`,获批计划的短期自动执行窗口也不会自动批准后续计划或嵌套/间接 Bash。 -- 桌面端协作模式分为 `normal`、`plan` 和 `goal`。Goal 会持续推进目标,直到完成、同一阻塞状态重复三次、用户停止或达到安全续跑边界。只有用户在输入框中选择 Goal 或运行 `/goal` 显式启动后,长周期研究、调试、优化或实现目标才可启用 AutoResearch;普通聊天不会隐式切换协作模式,也不会创建持久化 AutoResearch 状态。动态状态保存在 `.reasonix/autoresearch/.../`。 +- 桌面端协作模式分为 `normal`、`plan` 和 `goal`。Goal 会持续推进目标,直到完成、阻塞、用户停止或达到轮次/无进展安全边界,并按目标自动选择简单(10)、写入(20)或研究(40)轮预算。三类预算共用同一个 Goal FSM、宿主 receipt、Delivery readiness 和有界 evaluator,不再存在第二套研究协议或可写 sidecar。普通聊天不会隐式切换协作模式;旧 `.reasonix/autoresearch/.../` 目录只读,显式旧路径可恢复为普通 Goal。 ### 3.8 Slash command diff --git a/docs/superpowers/audits/2026-06-30-autoresearch-runtime-verification.md b/docs/superpowers/audits/2026-06-30-autoresearch-runtime-verification.md index 699ae102ba..0bf311c58b 100644 --- a/docs/superpowers/audits/2026-06-30-autoresearch-runtime-verification.md +++ b/docs/superpowers/audits/2026-06-30-autoresearch-runtime-verification.md @@ -1,5 +1,8 @@ # AutoResearch Runtime Verification Matrix +> Superseded historical audit. The verified runtime described below is no +> longer active; Goal now owns budgeting, progress and completion directly. + Date: 2026-06-30 Scope: host-managed AutoResearch runtime, controller integration, desktop API, diff --git a/docs/superpowers/plans/2026-06-29-autoresearch-runtime-implementation.md b/docs/superpowers/plans/2026-06-29-autoresearch-runtime-implementation.md index bc493e7f91..cd40266224 100644 --- a/docs/superpowers/plans/2026-06-29-autoresearch-runtime-implementation.md +++ b/docs/superpowers/plans/2026-06-29-autoresearch-runtime-implementation.md @@ -1,5 +1,8 @@ # AutoResearch Runtime Implementation Plan +> Superseded historical plan. The standalone runtime and desktop surfaces were +> later removed in favor of the unified Goal runtime. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Implement the host-managed AutoResearch runtime from the design spec, including durable state, controller integration, desktop API, and frontend visibility. diff --git a/docs/superpowers/specs/2026-06-29-autoresearch-runtime-design.md b/docs/superpowers/specs/2026-06-29-autoresearch-runtime-design.md index 1426456b74..518aecd5f3 100644 --- a/docs/superpowers/specs/2026-06-29-autoresearch-runtime-design.md +++ b/docs/superpowers/specs/2026-06-29-autoresearch-runtime-design.md @@ -1,5 +1,9 @@ # AutoResearch Runtime Design +> Superseded: the active AutoResearch runtime was removed when Goal became the +> sole runtime. This document is retained as historical design context only; +> new Goal runs do not create or mutate `.reasonix/autoresearch/` state. + ## Context Reasonix already has Goal mode and AutoResearch instructions. When a goal looks @@ -318,7 +322,7 @@ Success criteria list: Findings list: - newest accepted findings first -- kind badge: command, file, test, benchmark, manual, review +- kind badge: command, file, test, benchmark, manual, review, verification - summary - source command/path if present - created time diff --git a/internal/autoresearch/bounded_store_test.go b/internal/autoresearch/bounded_store_test.go index 03f4a95cc0..ba06dfc83f 100644 --- a/internal/autoresearch/bounded_store_test.go +++ b/internal/autoresearch/bounded_store_test.go @@ -1,239 +1,100 @@ package autoresearch import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "strings" "testing" "time" ) -// TestDirectionFingerprintDistinguishesLongPrefixes: two directions sharing a -// >56-char slug prefix used to collapse to one fingerprint, wrongly counting -// the second as a repeat and inflating StaleCount toward a forced pivot. -func TestDirectionFingerprintDistinguishesLongPrefixes(t *testing.T) { +// TestFindingsTailEquivalence: newest-N reads match a full scan followed by a +// reverse/limit without writing through a production API. +func TestFindingsTailEquivalence(t *testing.T) { root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Long prefix fingerprints", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - prefix := "Benchmark the desktop frontend markdown rendering pipeline for " - - progress, err := store.RecordDirection(task.ID, Direction{ - Summary: prefix + "large tables", - AcceptedEvidenceIDs: []string{"f1"}, - Now: time.Date(2026, 7, 7, 10, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordDirection first: %v", err) - } - if progress.StaleCount != 0 { - t.Fatalf("first direction stale = %d, want 0", progress.StaleCount) - } - - progress, err = store.RecordDirection(task.ID, Direction{ - Summary: prefix + "code blocks", - AcceptedEvidenceIDs: []string{"f2"}, - Now: time.Date(2026, 7, 7, 10, 1, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordDirection second: %v", err) - } - // A genuinely different direction with accepted evidence must not be - // counted as a repeat. - if progress.StaleCount != 0 { - t.Fatalf("distinct long-prefix direction treated as repeat: stale = %d, want 0", progress.StaleCount) - } - - // An exact repeat must still be detected. - progress, err = store.RecordDirection(task.ID, Direction{ - Summary: prefix + "code blocks", - AcceptedEvidenceIDs: []string{"f3"}, - Now: time.Date(2026, 7, 7, 10, 2, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordDirection third: %v", err) - } - if progress.StaleCount != 1 { - t.Fatalf("exact repeat not detected: stale = %d, want 1", progress.StaleCount) + taskID := "findings-tail" + taskRoot := writeArchiveFixture(t, root, taskID, "Findings tail equivalence", nil) + for i := 1; i <= 40; i++ { + appendFindingLine(t, taskRoot, Finding{ + ID: fmt.Sprintf("f%d", i), + Kind: "manual", + Summary: fmt.Sprintf("finding %d", i), + Source: FindingSourceManual, + Accepted: true, + CreatedAt: time.Date(2026, 6, 30, 10, 0, i, 0, time.UTC), + }) } -} - -// TestDirectionFingerprintDistinguishesCJK: directions differing only in CJK -// text slugify to the same string ("task") and used to collide. -func TestDirectionFingerprintDistinguishesCJK(t *testing.T) { - a := directionFingerprint("评测甲方案的渲染性能") - b := directionFingerprint("评测乙方案的渲染性能") - if a == b { - t.Fatalf("CJK-only-diff directions share a fingerprint: %q", a) - } - if directionFingerprint("评测甲方案的渲染性能") != a { - t.Fatal("fingerprint is not deterministic") - } -} - -// TestDirectionFingerprintBackwardCompatShortASCII: short ASCII summaries keep -// the bare slug so fingerprints recorded by older versions still match. -func TestDirectionFingerprintBackwardCompatShortASCII(t *testing.T) { - got := directionFingerprint("Profile markdown rendering") - want := slugify("Profile markdown rendering") - if got != want { - t.Fatalf("short ASCII fingerprint = %q, want legacy slug %q", got, want) - } -} - -// TestRecordDirectionMigratesLegacyFingerprint: a directions_tried.json entry -// written by an older version (bare truncated slug) must still match its own -// summary on repeat, not be double-counted as a new direction. -func TestRecordDirectionMigratesLegacyFingerprint(t *testing.T) { - root := t.TempDir() store := NewStore(root) - task, err := store.CreateTask("Legacy fingerprint migration", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - summary := "Benchmark the desktop frontend markdown rendering pipeline for large tables" - - // Simulate a legacy entry: fingerprint from the old bare slugify. - dirPath := filepath.Join(task.Root, "state", "directions_tried.json") - legacy := []DirectionTried{{ - Fingerprint: slugify(summary), - Summary: summary, - FirstSeenIteration: 1, - LastSeenIteration: 1, - Count: 1, - }} - data, err := json.Marshal(legacy) + all, err := store.Findings(taskID, 0) if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(dirPath, data, 0o644); err != nil { - t.Fatal(err) + t.Fatalf("Findings full: %v", err) } - - progress, err := store.RecordDirection(task.ID, Direction{ - Summary: summary, - Now: time.Date(2026, 7, 7, 11, 0, 0, 0, time.UTC), - }) + tail, err := store.Findings(taskID, 5) if err != nil { - t.Fatalf("RecordDirection: %v", err) - } - // Repeat of the legacy direction: stale should increment (repeat + no - // accepted evidence), and the entry should be migrated, not duplicated. - if progress.StaleCount != 1 { - t.Fatalf("legacy repeat not detected: stale = %d, want 1", progress.StaleCount) - } - raw, err := os.ReadFile(dirPath) - if err != nil { - t.Fatal(err) - } - var directions []DirectionTried - if err := json.Unmarshal(raw, &directions); err != nil { - t.Fatal(err) + t.Fatalf("Findings tail: %v", err) } - if len(directions) != 1 { - t.Fatalf("directions = %+v, want single migrated entry", directions) + if len(tail) != 5 { + t.Fatalf("tail len = %d", len(tail)) } - if directions[0].Count != 2 { - t.Fatalf("migrated count = %d, want 2", directions[0].Count) - } - if directions[0].Fingerprint != directionFingerprint(summary) { - t.Fatalf("entry not migrated to new fingerprint: %q", directions[0].Fingerprint) + // Newest-first order: first of tail is the latest full entry. + if tail[0].ID != all[0].ID || tail[4].ID != all[4].ID { + t.Fatalf("tail=%+v all[:5]=%+v", tail, all[:5]) } } -// TestTailJSONLLinesMatchesFullScan: the tail reader must return exactly the -// same entries as a full scan for every limit, including limits larger than -// the file and files bigger than one read chunk. -func TestTailJSONLLinesMatchesFullScan(t *testing.T) { +func TestHeartbeatTailEquivalence(t *testing.T) { root := t.TempDir() + taskID := "heartbeat-tail" + taskRoot := writeArchiveFixture(t, root, taskID, "Tail read equivalence", nil) + for i := 1; i <= 30; i++ { + appendHeartbeatLine(t, taskRoot, Heartbeat{ + Status: HeartbeatTurnDone, + Iteration: i, + CreatedAt: time.Date(2026, 6, 30, 10, 0, i, 0, time.UTC), + }) + } store := NewStore(root) - task, err := store.CreateTask("Tail read equivalence", CreateOptions{}) + all, err := store.Heartbeats(taskID, 0) if err != nil { - t.Fatalf("CreateTask: %v", err) - } - // Write enough heartbeats that the log exceeds one 64KiB chunk. - long := strings.Repeat("x", 700) - for i := range 150 { - if err := store.AppendHeartbeat(task.ID, Heartbeat{ - Status: HeartbeatTurnDone, - Iteration: i + 1, - Message: fmt.Sprintf("turn-%03d %s", i, long), - CreatedAt: time.Date(2026, 7, 7, 12, 0, i%60, 0, time.UTC), - }); err != nil { - t.Fatalf("AppendHeartbeat %d: %v", i, err) - } + t.Fatalf("Heartbeats full: %v", err) } - all, err := store.Heartbeats(task.ID, 0) + tail, err := store.Heartbeats(taskID, 3) if err != nil { - t.Fatalf("Heartbeats(0): %v", err) + t.Fatalf("Heartbeats tail: %v", err) } - if len(all) != 150 { - t.Fatalf("full scan = %d heartbeats, want 150", len(all)) + if len(tail) != 3 { + t.Fatalf("tail len = %d", len(tail)) } - for _, limit := range []int{1, 3, 149, 150, 500} { - got, err := store.Heartbeats(task.ID, limit) - if err != nil { - t.Fatalf("Heartbeats(%d): %v", limit, err) - } - want := all - if limit < len(all) { - want = all[len(all)-limit:] - } - if len(got) != len(want) { - t.Fatalf("Heartbeats(%d) = %d entries, want %d", limit, len(got), len(want)) + want := all[len(all)-3:] + for i := range want { + if tail[i].Iteration != want[i].Iteration { + t.Fatalf("tail[%d]=%+v want %+v", i, tail[i], want[i]) } - for i := range got { - if got[i].Iteration != want[i].Iteration { - t.Fatalf("Heartbeats(%d)[%d].Iteration = %d, want %d", limit, i, got[i].Iteration, want[i].Iteration) - } - } - } - // LastHeartbeat must be the newest entry. - last, ok, err := store.LastHeartbeat(task.ID) - if err != nil || !ok { - t.Fatalf("LastHeartbeat: ok=%v err=%v", ok, err) - } - if last.Iteration != 150 { - t.Fatalf("LastHeartbeat iteration = %d, want 150", last.Iteration) } } -// TestFindingsBoundedTailMatchesFullScan mirrors the heartbeat check for the -// findings log, which desktop views read with a limit. -func TestFindingsBoundedTailMatchesFullScan(t *testing.T) { +func TestDirectionsFileIsReadableThroughSummary(t *testing.T) { root := t.TempDir() + taskID := "directions-readable" + taskRoot := writeArchiveFixture(t, root, taskID, "Legacy fingerprint migration", nil) + summary := "Benchmark the desktop frontend markdown rendering pipeline for large tables" + writeDirections(t, taskRoot, []DirectionTried{{ + Fingerprint: "legacy-slug", + Summary: summary, + FirstSeenIteration: 1, + LastSeenIteration: 1, + Count: 1, + }}) + writeProgress(t, taskRoot, Progress{ + Status: StatusRunning, + Iteration: 1, + CurrentDirection: summary, + UpdatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC), + }) store := NewStore(root) - task, err := store.CreateTask("Findings tail equivalence", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - for i := range 25 { - if err := store.AppendFinding(task.ID, Finding{ - ID: fmt.Sprintf("f%03d", i), - Kind: FindingKindManual, - Summary: fmt.Sprintf("finding %03d", i), - Accepted: i%2 == 0, - CreatedAt: time.Date(2026, 7, 7, 13, 0, i%60, 0, time.UTC), - }); err != nil { - t.Fatalf("AppendFinding %d: %v", i, err) - } - } - all, err := store.Findings(task.ID, 0) - if err != nil { - t.Fatalf("Findings(0): %v", err) - } - if len(all) != 25 || all[0].ID != "f024" { - t.Fatalf("full scan = %d findings first %q, want 25 / f024 (newest first)", len(all), all[0].ID) - } - got, err := store.Findings(task.ID, 5) + got, err := store.Summary(taskID) if err != nil { - t.Fatalf("Findings(5): %v", err) + t.Fatalf("Summary: %v", err) } - if len(got) != 5 || got[0].ID != "f024" || got[4].ID != "f020" { - t.Fatalf("Findings(5) = %+v, want newest five f024..f020", got) + if got.CurrentDirection != summary || got.Iteration != 1 { + t.Fatalf("summary = %+v", got) } } diff --git a/internal/autoresearch/fixture_test.go b/internal/autoresearch/fixture_test.go new file mode 100644 index 0000000000..9c65e1134e --- /dev/null +++ b/internal/autoresearch/fixture_test.go @@ -0,0 +1,143 @@ +package autoresearch + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// writeArchiveFixture materializes a historical task directory for read-only +// tests. Production code never creates archives. +func writeArchiveFixture(t *testing.T, workspaceRoot, taskID, goal string, criteria []SuccessCriterion) string { + t.Helper() + if resolved, err := filepath.EvalSymlinks(workspaceRoot); err == nil { + workspaceRoot = resolved + } + root := filepath.Join(workspaceRoot, ".reasonix", "autoresearch", taskID) + state := filepath.Join(root, "state") + logs := filepath.Join(root, "logs") + if err := os.MkdirAll(state, 0o755); err != nil { + t.Fatalf("mkdir state: %v", err) + } + if err := os.MkdirAll(logs, 0o755); err != nil { + t.Fatalf("mkdir logs: %v", err) + } + if criteria == nil { + criteria = []SuccessCriterion{} + } + spec := TaskSpec{ + TaskID: taskID, + Goal: goal, + AllowedOperations: AllowedOperations{ + Write: true, + }, + SuccessCriteria: criteria, + } + writeJSON(t, filepath.Join(state, "task_spec.json"), spec) + writeJSON(t, filepath.Join(state, "progress.json"), Progress{ + Status: StatusRunning, + UpdatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC), + }) + if err := os.WriteFile(filepath.Join(state, "directions_tried.json"), []byte("[]\n"), 0o644); err != nil { + t.Fatalf("write directions: %v", err) + } + if err := os.WriteFile(filepath.Join(state, "findings.jsonl"), nil, 0o644); err != nil { + t.Fatalf("write findings: %v", err) + } + if err := os.WriteFile(filepath.Join(state, "iteration_log.jsonl"), nil, 0o644); err != nil { + t.Fatalf("write iteration log: %v", err) + } + if err := os.WriteFile(filepath.Join(logs, "heartbeat.jsonl"), nil, 0o644); err != nil { + t.Fatalf("write heartbeat: %v", err) + } + return root +} + +func writeJSON(t *testing.T, path string, v any) { + t.Helper() + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatalf("marshal %s: %v", path, err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func appendFindingLine(t *testing.T, taskRoot string, f Finding) { + t.Helper() + data, err := json.Marshal(f) + if err != nil { + t.Fatalf("marshal finding: %v", err) + } + path := filepath.Join(taskRoot, "state", "findings.jsonl") + fhandle, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + t.Fatalf("open findings: %v", err) + } + defer fhandle.Close() + if _, err := fhandle.Write(append(data, '\n')); err != nil { + t.Fatalf("append finding: %v", err) + } +} + +func writeProgress(t *testing.T, taskRoot string, progress Progress) { + t.Helper() + writeJSON(t, filepath.Join(taskRoot, "state", "progress.json"), progress) +} + +func writeDirections(t *testing.T, taskRoot string, directions []DirectionTried) { + t.Helper() + 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) + if err != nil { + t.Fatalf("marshal heartbeat: %v", err) + } + path := filepath.Join(taskRoot, "logs", "heartbeat.jsonl") + fhandle, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + t.Fatalf("open heartbeat: %v", err) + } + defer fhandle.Close() + if _, err := fhandle.Write(append(data, '\n')); err != nil { + t.Fatalf("append heartbeat: %v", err) + } +} + +func hashTree(t *testing.T, root string) map[string]string { + t.Helper() + out := map[string]string{} + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + out[rel] = string(data) + return nil + }) + if err != nil { + t.Fatalf("hash tree: %v", err) + } + return out +} diff --git a/internal/autoresearch/store.go b/internal/autoresearch/store.go index 6016ce998c..5b76d32be0 100644 --- a/internal/autoresearch/store.go +++ b/internal/autoresearch/store.go @@ -1,41 +1,29 @@ +// Package autoresearch is a read-only compatibility reader for historical +// `.reasonix/autoresearch//` archives. New Goal runs never create or +// mutate these directories. package autoresearch import ( "bufio" - "crypto/rand" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" - "hash/fnv" - "io/fs" "os" "path/filepath" "regexp" - "slices" "sort" "strings" - "sync" - "time" - "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._-]*)/?`) -var safeCreateToken = regexp.MustCompile(`^[a-f0-9]{32}$`) - -// createTokenFile is written immediately after an atomic task-directory -// reservation so rollback can prove ownership before RemoveAll. -const createTokenFile = ".create_token" +// Store is a fail-closed reader over a workspace's legacy AutoResearch root. type Store struct { workspaceRoot string root string - mu sync.Mutex - taskLocks map[string]*sync.Mutex } func NewStore(workspaceRoot string) *Store { @@ -45,186 +33,12 @@ func NewStore(workspaceRoot string) *Store { return &Store{ workspaceRoot: workspaceRoot, root: filepath.Join(workspaceRoot, ".reasonix", "autoresearch"), - taskLocks: map[string]*sync.Mutex{}, - } -} - -func (s *Store) lockTask(taskID string) func() { - s.mu.Lock() - lock := s.taskLocks[taskID] - if lock == nil { - lock = &sync.Mutex{} - s.taskLocks[taskID] = lock - } - s.mu.Unlock() - lock.Lock() - return lock.Unlock -} - -func (s *Store) CreateTask(goal string, opts CreateOptions) (*Task, error) { - goal = strings.TrimSpace(goal) - if goal == "" { - return nil, errors.New("autoresearch: goal is required") - } - now := time.Now().UTC() - if opts.Now != nil { - now = opts.Now().UTC() - } - id, createToken, err := s.reserveTaskID(now, goal, opts.CreateToken) - if err != nil { - return nil, err - } - storeRoot, err := os.OpenRoot(s.root) - if err != nil { - _ = s.RemoveTask(id, createToken) - return nil, fmt.Errorf("autoresearch: open root dir: %w", err) - } - defer storeRoot.Close() - taskRel, err := s.taskRel(id) - if err != nil { - _ = s.RemoveTask(id, createToken) - return nil, err - } - - cleanup := func() { - _ = s.RemoveTask(id, createToken) - } - - if err := storeRoot.MkdirAll(filepath.Join(taskRel, "state"), 0o755); err != nil { - cleanup() - return nil, fmt.Errorf("autoresearch: create state dir: %w", err) - } - if err := storeRoot.MkdirAll(filepath.Join(taskRel, "logs"), 0o755); err != nil { - cleanup() - return nil, fmt.Errorf("autoresearch: create logs dir: %w", err) - } - - spec := TaskSpec{ - TaskID: id, - Goal: goal, - Scope: append([]string(nil), opts.Scope...), - NonGoals: append([]string(nil), opts.NonGoals...), - AllowedOperations: opts.AllowedOperations, - SuccessCriteria: cloneCriteria(opts.SuccessCriteria), - } - progress := Progress{ - Status: StatusRunning, - UpdatedAt: now, - } - - if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "task_spec.json"), spec); err != nil { - cleanup() - return nil, err - } - if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil { - cleanup() - return nil, err - } - for _, path := range []string{ - filepath.Join(taskRel, "state", "directions_tried.json"), - filepath.Join(taskRel, "state", "findings.jsonl"), - filepath.Join(taskRel, "state", "iteration_log.jsonl"), - filepath.Join(taskRel, "logs", "heartbeat.jsonl"), - } { - if err := storeRoot.WriteFile(path, nil, 0o644); err != nil { - cleanup() - return nil, fmt.Errorf("autoresearch: initialize %s: %w", path, err) - } } - return &Task{ID: id, Root: s.taskRoot(id), Spec: spec, CreateToken: createToken}, nil } -// RemoveTask deletes a task directory within the store only when createToken -// matches the ownership token written during reservation. It is intended for -// rolling back a task this process created as part of a larger transaction. -func (s *Store) RemoveTask(taskID, createToken string) error { - if err := validateTaskID(taskID); err != nil { - return err - } - createToken = strings.TrimSpace(createToken) - if createToken == "" { - return errors.New("autoresearch: create token is required to remove a task") - } - unlock := s.lockTask(taskID) - defer unlock() - storeRoot, err := os.OpenRoot(s.root) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("autoresearch: open root dir: %w", err) - } - defer storeRoot.Close() - taskRel, err := s.taskRel(taskID) - if err != nil { - return err - } - tokenPath := filepath.Join(taskRel, createTokenFile) - stored, err := storeRoot.ReadFile(tokenPath) - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("autoresearch: refuse to remove task %s without matching create token", taskID) - } - return fmt.Errorf("autoresearch: read create token for %s: %w", taskID, err) - } - if strings.TrimSpace(string(stored)) != createToken { - return fmt.Errorf("autoresearch: refuse to remove task %s: create token mismatch", taskID) - } - if err := storeRoot.RemoveAll(taskRel); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("autoresearch: remove task %s: %w", taskID, err) - } - return nil -} - -// RemoveTaskByCreateToken removes the unique task owned by createToken. Parent -// transactions use it after a crash, when the token was durable before task -// creation but the task ID may not have been returned to the caller. -func (s *Store) RemoveTaskByCreateToken(createToken string) error { - createToken = strings.TrimSpace(createToken) - if err := validateCreateToken(createToken); err != nil { - return err - } - storeRoot, err := os.OpenRoot(s.root) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("autoresearch: open root dir: %w", err) - } - defer storeRoot.Close() - entries, err := fs.ReadDir(storeRoot.FS(), ".") - if err != nil { - return fmt.Errorf("autoresearch: list tasks for create token: %w", err) - } - matches := make([]string, 0, 1) - marker := createTokenTaskIDMarker(createToken) - for _, entry := range entries { - if !entry.IsDir() { - continue - } - taskID := entry.Name() - if validateTaskID(taskID) != nil { - continue - } - if strings.Contains(taskID, marker) { - // Transaction-owned task IDs carry a hash of the token so recovery - // still owns a directory if the process died between Mkdir and the - // create-token file write. - matches = append(matches, taskID) - } - } - if len(matches) > 1 { - return fmt.Errorf("autoresearch: create token unexpectedly owns %d tasks", len(matches)) - } - if len(matches) == 0 { - return nil - } - unlock := s.lockTask(matches[0]) - defer unlock() - if err := storeRoot.RemoveAll(matches[0]); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("autoresearch: remove transaction-owned task %s: %w", matches[0], err) - } - return nil +// Root returns the absolute archive root under the workspace. +func (s *Store) Root() string { + return s.root } func (s *Store) ListSummaries() ([]Summary, error) { @@ -284,6 +98,9 @@ func (s *Store) LoadTask(taskID string) (*Task, error) { return &Task{ID: taskID, Root: s.taskRoot(taskID), Spec: spec}, nil } +// ResumeFromGoalText loads an archive only when goal text names an explicit +// `.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 { @@ -301,72 +118,14 @@ func (s *Store) ResumeFromGoalText(goal string) (*Task, bool, error) { return task, true, nil } -func (s *Store) AppendFinding(taskID string, f Finding) error { - if err := validateTaskID(taskID); err != nil { - return err - } - unlock := s.lockTask(taskID) - defer unlock() - storeRoot, taskRel, err := s.openTaskRoot(taskID) - if err != nil { - return err - } - defer storeRoot.Close() - if err := validateFinding(f); err != nil { - return err - } - data, err := json.Marshal(f) - if err != nil { - return fmt.Errorf("autoresearch: marshal finding: %w", err) - } - return appendJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), data) -} - -func (s *Store) RecordEvidence(taskID, criterionID string, f Finding) error { - if err := validateTaskID(taskID); err != nil { - return err - } - criterionID = strings.TrimSpace(criterionID) - if criterionID == "" { - return errors.New("autoresearch: criterion id is required") - } - unlock := s.lockTask(taskID) - defer unlock() - storeRoot, taskRel, err := s.openTaskRoot(taskID) - if err != nil { - return err - } - defer storeRoot.Close() - if err := validateFinding(f); err != nil { - return err - } - specPath := filepath.Join(taskRel, "state", "task_spec.json") - var spec TaskSpec - if err := readJSONFile(storeRoot, specPath, &spec); err != nil { - return err - } - found := false - for i := range spec.SuccessCriteria { - if spec.SuccessCriteria[i].ID != criterionID { - continue - } - found = true - if !stringSliceContains(spec.SuccessCriteria[i].EvidenceIDs, f.ID) { - spec.SuccessCriteria[i].EvidenceIDs = append(spec.SuccessCriteria[i].EvidenceIDs, f.ID) - } - break - } - if !found { - return fmt.Errorf("autoresearch: criterion %q not found", criterionID) - } - if err := writeJSONFile(storeRoot, specPath, spec); err != nil { - return err - } - data, err := json.Marshal(f) - if err != nil { - return fmt.Errorf("autoresearch: marshal finding: %w", err) +// 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 } - return appendJSONL(storeRoot, filepath.Join(taskRel, "state", "findings.jsonl"), data) + return match[1], true } func (s *Store) Findings(taskID string, limit int) ([]Finding, error) { @@ -388,6 +147,7 @@ func (s *Store) Findings(taskID string, limit int) ([]Finding, error) { if err := json.Unmarshal(fileencoding.DecodeToUTF8(line), &f); err != nil { return nil, fmt.Errorf("autoresearch: parse %s: %w", path, err) } + // Kind is fully opaque: unknown historical values pass through. findings = append(findings, f) } for i, j := 0, len(findings)-1; i < j; i, j = i+1, j-1 { @@ -399,27 +159,6 @@ func (s *Store) Findings(taskID string, limit int) ([]Finding, error) { return findings, nil } -func (s *Store) AppendHeartbeat(taskID string, h Heartbeat) error { - if err := validateTaskID(taskID); err != nil { - return err - } - unlock := s.lockTask(taskID) - defer unlock() - storeRoot, taskRel, err := s.openTaskRoot(taskID) - if err != nil { - return err - } - defer storeRoot.Close() - if err := validateHeartbeat(h); err != nil { - return err - } - data, err := json.Marshal(h) - if err != nil { - return fmt.Errorf("autoresearch: marshal heartbeat: %w", err) - } - return appendJSONL(storeRoot, filepath.Join(taskRel, "logs", "heartbeat.jsonl"), data) -} - func (s *Store) Heartbeats(taskID string, limit int) ([]Heartbeat, error) { storeRoot, taskRel, err := s.openTaskRoot(taskID) if err != nil { @@ -427,9 +166,6 @@ func (s *Store) Heartbeats(taskID string, limit int) ([]Heartbeat, error) { } defer storeRoot.Close() path := filepath.Join(taskRel, "logs", "heartbeat.jsonl") - // Bounded requests only need the file tail; heartbeat logs grow one line - // per turn for the life of a task, so a full scan per read is a per-turn - // cost that keeps rising. lines, err := tailJSONLLines(storeRoot, path, limit) if err != nil { return nil, err @@ -459,112 +195,16 @@ func (s *Store) LastHeartbeat(taskID string) (Heartbeat, bool, error) { return heartbeats[0], true, nil } -func (s *Store) RecordDirection(taskID string, d Direction) (*Progress, error) { - if err := validateTaskID(taskID); err != nil { - return nil, err - } - unlock := s.lockTask(taskID) - defer unlock() +func (s *Store) Progress(taskID string) (*Progress, error) { storeRoot, taskRel, err := s.openTaskRoot(taskID) if err != nil { return nil, err } defer storeRoot.Close() - d.Summary = strings.TrimSpace(d.Summary) - if d.Summary == "" { - return nil, errors.New("autoresearch: direction summary is required") - } - now := d.Now.UTC() - if now.IsZero() { - now = time.Now().UTC() - } var progress Progress if err := readJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), &progress); err != nil { return nil, err } - progress.Iteration++ - progress.CurrentDirection = d.Summary - progress.UpdatedAt = now - - directions, err := s.loadDirections(storeRoot, taskRel) - if err != nil { - return nil, err - } - fp := directionFingerprint(d.Summary) - repeated := false - for i := range directions { - // Legacy entries carry pre-hash fingerprints; recompute from the - // stored summary so repeats recorded by older versions still match, - // then migrate the entry in place. - if directions[i].Fingerprint != fp && directionFingerprint(directions[i].Summary) != fp { - continue - } - repeated = true - directions[i].Fingerprint = fp - directions[i].Count++ - directions[i].LastSeenIteration = progress.Iteration - break - } - if !repeated { - directions = append(directions, DirectionTried{ - Fingerprint: fp, - Summary: d.Summary, - FirstSeenIteration: progress.Iteration, - LastSeenIteration: progress.Iteration, - Count: 1, - }) - } - if repeated || len(d.AcceptedEvidenceIDs) == 0 { - before := progress.StaleCount - progress.StaleCount++ - if before < 2 && progress.StaleCount >= 2 { - progress.PivotCount++ - } - } else { - progress.StaleCount = 0 - } - if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "directions_tried.json"), directions); err != nil { - return nil, err - } - if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil { - return nil, err - } - return &progress, nil -} - -func (s *Store) UpdateProgress(taskID string, patch ProgressPatch) (*Progress, error) { - if err := validateTaskID(taskID); err != nil { - return nil, err - } - unlock := s.lockTask(taskID) - defer unlock() - 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 patch.Status != nil { - progress.Status = strings.TrimSpace(*patch.Status) - } - if patch.CurrentDirection != nil { - progress.CurrentDirection = strings.TrimSpace(*patch.CurrentDirection) - } - if patch.BlockedReason != nil { - progress.BlockedReason = strings.TrimSpace(*patch.BlockedReason) - } - progress.UpdatedAt = time.Now().UTC() - report := &ValidationReport{Valid: true} - validateProgress(report, progress) - if len(report.Errors) > 0 { - return nil, fmt.Errorf("autoresearch: invalid progress patch: %v", report.Errors) - } - if err := writeJSONFile(storeRoot, filepath.Join(taskRel, "state", "progress.json"), progress); err != nil { - return nil, err - } return &progress, nil } @@ -648,78 +288,6 @@ func (s *Store) openTaskRoot(taskID string) (*os.Root, string, error) { return storeRoot, taskRel, nil } -// reserveTaskID atomically claims a task directory with non-recursive Mkdir -// and writes a create-token ownership marker. Concurrent creators sharing the -// same workspace therefore never adopt the same ID: EEXIST advances the -// candidate, and only the Mkdir winner may later roll the directory back. -func (s *Store) reserveTaskID(now time.Time, goal, requestedCreateToken string) (id, createToken string, err error) { - if err := os.MkdirAll(s.root, 0o755); err != nil { - return "", "", fmt.Errorf("autoresearch: create root dir: %w", err) - } - storeRoot, err := os.OpenRoot(s.root) - if err != nil { - return "", "", fmt.Errorf("autoresearch: open root dir: %w", err) - } - defer storeRoot.Close() - token := strings.TrimSpace(requestedCreateToken) - callerSuppliedToken := token != "" - if token == "" { - token, err = newCreateToken() - if err != nil { - return "", "", err - } - } else if err := validateCreateToken(token); err != nil { - return "", "", err - } - base := now.Format("20060102-150405") + "-" + slugify(goal) - if base == now.Format("20060102-150405")+"-" { - base += "task" - } - if callerSuppliedToken { - base += createTokenTaskIDMarker(token) - } - id = base - for i := 2; ; i++ { - taskRel, err := s.taskRel(id) - if err != nil { - return "", "", err - } - if err := storeRoot.Mkdir(taskRel, 0o755); err != nil { - if os.IsExist(err) { - id = fmt.Sprintf("%s-%d", base, i) - continue - } - return "", "", fmt.Errorf("autoresearch: reserve task id %s: %w", id, err) - } - tokenPath := filepath.Join(taskRel, createTokenFile) - if err := storeRoot.WriteFile(tokenPath, []byte(token+"\n"), 0o600); err != nil { - _ = storeRoot.RemoveAll(taskRel) - return "", "", fmt.Errorf("autoresearch: write create token for %s: %w", id, err) - } - return id, token, nil - } -} - -func newCreateToken() (string, error) { - var buf [16]byte - if _, err := rand.Read(buf[:]); err != nil { - return "", fmt.Errorf("autoresearch: generate create token: %w", err) - } - return hex.EncodeToString(buf[:]), nil -} - -func validateCreateToken(token string) error { - if !safeCreateToken.MatchString(token) { - return errors.New("autoresearch: create token must be 32 lowercase hexadecimal characters") - } - return nil -} - -func createTokenTaskIDMarker(token string) string { - sum := sha256.Sum256([]byte(token)) - return "-txn-" + hex.EncodeToString(sum[:16]) -} - func validateTaskID(id string) error { id = strings.TrimSpace(id) if id == "" { @@ -731,14 +299,19 @@ func validateTaskID(id string) error { return nil } -func writeJSONFile(root *os.Root, path string, v any) error { - data, err := json.MarshalIndent(v, "", " ") - if err != nil { - return fmt.Errorf("autoresearch: marshal %s: %w", path, err) +// validateFinding checks the base schema fields of a historical finding. +// Kind is intentionally unconstrained so unknown historical values remain +// readable. This helper exists for archive integrity checks and tests only; +// the reader never writes findings. +func validateFinding(f Finding) error { + if strings.TrimSpace(f.ID) == "" { + return errors.New("autoresearch: finding id is required") } - data = append(data, '\n') - if err := root.WriteFile(path, data, 0o644); err != nil { - return fmt.Errorf("autoresearch: write %s: %w", path, err) + if strings.TrimSpace(f.Summary) == "" { + return errors.New("autoresearch: finding summary is required") + } + if f.CreatedAt.IsZero() { + return errors.New("autoresearch: finding created_at is required") } return nil } @@ -755,21 +328,6 @@ func readJSONFile(root *os.Root, path string, out any) error { return nil } -func appendJSONL(root *os.Root, path string, data []byte) error { - if err := root.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return fmt.Errorf("autoresearch: create jsonl dir: %w", err) - } - f, err := root.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) - if err != nil { - return fmt.Errorf("autoresearch: open %s: %w", path, err) - } - defer f.Close() - if _, err := f.Write(append(data, '\n')); err != nil { - return fmt.Errorf("autoresearch: append %s: %w", path, err) - } - return nil -} - func readJSONL(root *os.Root, path string, each func([]byte) error) error { f, err := root.Open(path) if err != nil { @@ -777,6 +335,8 @@ func readJSONL(root *os.Root, path string, each func([]byte) error) error { } defer f.Close() scanner := bufio.NewScanner(f) + // Historical findings can be long; raise the scanner buffer for safety. + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { @@ -831,9 +391,6 @@ func tailJSONLLines(root *os.Root, path string, limit int) ([][]byte, error) { return nil, fmt.Errorf("autoresearch: read %s: %w", path, err) } buf = append(chunk, buf...) - // Stop once the buffered tail holds enough complete lines. Count - // newline-separated non-empty segments after the first newline (the - // first segment may be a partial line unless we reached offset 0). if countCompleteTailLines(buf, off == 0) > limit { break } @@ -869,148 +426,3 @@ func countCompleteTailLines(buf []byte, atStart bool) int { } return count } - -func (s *Store) loadDirections(root *os.Root, taskRel string) ([]DirectionTried, error) { - path := filepath.Join(taskRel, "state", "directions_tried.json") - data, err := root.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read %s: %w", path, err) - } - data = fileencoding.DecodeToUTF8(data) - if strings.TrimSpace(string(data)) == "" { - return nil, nil - } - var directions []DirectionTried - if err := json.Unmarshal(data, &directions); err != nil { - return nil, fmt.Errorf("parse %s: %w", path, err) - } - return directions, nil -} - -func validateFinding(f Finding) error { - if strings.TrimSpace(f.ID) == "" { - return errors.New("autoresearch: finding id is required") - } - switch f.Kind { - case FindingKindCommand, FindingKindFile, FindingKindTest, FindingKindBenchmark, FindingKindManual, FindingKindReview: - default: - return fmt.Errorf("autoresearch: finding kind %q is invalid", f.Kind) - } - if strings.TrimSpace(f.Summary) == "" { - return errors.New("autoresearch: finding summary is required") - } - if f.CreatedAt.IsZero() { - return errors.New("autoresearch: finding created_at is required") - } - return nil -} - -func validateHeartbeat(h Heartbeat) error { - switch h.Status { - case HeartbeatStartingTurn, HeartbeatTurnDone, HeartbeatWarning: - default: - return fmt.Errorf("autoresearch: heartbeat status %q is invalid", h.Status) - } - if h.Iteration < 0 { - return errors.New("autoresearch: heartbeat iteration must not be negative") - } - if h.CreatedAt.IsZero() { - return errors.New("autoresearch: heartbeat created_at is required") - } - return nil -} - -func stringSliceContains(values []string, want string) bool { - return slices.Contains(values, want) -} - -func cloneCriteria(in []SuccessCriterion) []SuccessCriterion { - out := make([]SuccessCriterion, len(in)) - for i, c := range in { - out[i] = c - out[i].EvidenceIDs = append([]string(nil), c.EvidenceIDs...) - } - return out -} - -func slugify(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - var b strings.Builder - lastDash := false - for _, r := range s { - switch { - case r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)): - b.WriteRune(r) - lastDash = false - default: - if !lastDash && b.Len() > 0 { - b.WriteByte('-') - lastDash = true - } - } - } - slug := strings.Trim(b.String(), "-") - const maxSlugLen = 56 - if len(slug) > maxSlugLen { - slug = strings.Trim(slug[:maxSlugLen], "-") - } - if slug == "" { - return "task" - } - return slug -} - -// directionFingerprint identifies a direction for repeat detection. slugify -// alone truncates to 56 chars and drops non-ASCII runes, so two different -// directions sharing a long ASCII prefix (or differing only in CJK text) -// collapsed to one fingerprint and wrongly inflated StaleCount/PivotCount on -// long tasks. Append a hash of the full normalized text when slugify lost -// distinguishing content (truncation or non-ASCII letters/digits); keep the -// bare slug otherwise so fingerprints recorded by older versions still match, -// and punctuation-only differences stay fuzzy-matched as before. -func directionFingerprint(summary string) string { - slug := slugify(summary) - if slug == slugifyUnbounded(summary) && !containsNonASCIIWord(summary) { - return slug - } - normalized := strings.Join(strings.Fields(strings.ToLower(summary)), " ") - h := fnv.New32a() - _, _ = h.Write([]byte(normalized)) - return fmt.Sprintf("%s-%08x", slug, h.Sum32()) -} - -// slugifyUnbounded matches slugify without the 56-char cap, used to detect -// whether truncation dropped content. -func slugifyUnbounded(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - var b strings.Builder - lastDash := false - for _, r := range s { - switch { - case r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)): - b.WriteRune(r) - lastDash = false - default: - if !lastDash && b.Len() > 0 { - b.WriteByte('-') - lastDash = true - } - } - } - slug := strings.Trim(b.String(), "-") - if slug == "" { - return "task" - } - return slug -} - -// containsNonASCIIWord reports whether s carries letters/digits that slugify -// discards entirely (e.g. CJK), meaning distinct summaries could share a slug. -func containsNonASCIIWord(s string) bool { - for _, r := range s { - if r > unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) { - return true - } - } - return false -} diff --git a/internal/autoresearch/store_test.go b/internal/autoresearch/store_test.go index d156415db2..7737558fd8 100644 --- a/internal/autoresearch/store_test.go +++ b/internal/autoresearch/store_test.go @@ -1,666 +1,245 @@ package autoresearch import ( - "encoding/json" "os" "path/filepath" - "runtime" - "slices" - "strings" - "sync" "testing" "time" ) -func TestCreateTaskCreatesHostOwnedLayoutAndInitialState(t *testing.T) { +func TestLoadTaskReadsHostOwnedLayout(t *testing.T) { root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - store := NewStore(root) - - task, err := store.CreateTask("Find the root cause of UI lag", CreateOptions{ - Now: func() time.Time { - return time.Date(2026, 6, 29, 15, 30, 0, 0, time.UTC) - }, - Scope: []string{"desktop/frontend", "desktop"}, - NonGoals: []string{"publish a release"}, - AllowedOperations: AllowedOperations{ - Write: true, - Network: false, - Publish: false, - }, - SuccessCriteria: []SuccessCriterion{ - {ID: "root_cause", Description: "A reproducible root cause is identified", Required: true}, - }, + taskID := "20260630-100000-ui-lag" + taskRoot := writeArchiveFixture(t, root, taskID, "Find the root cause of UI lag", []SuccessCriterion{ + {ID: "objective_evidence", Description: "Direct evidence", Required: true}, }) - if err != nil { - t.Fatalf("CreateTask returned error: %v", err) - } - - if task.ID != "20260629-153000-find-the-root-cause-of-ui-lag" { - t.Fatalf("task id = %q", task.ID) - } - wantRoot := filepath.Join(root, ".reasonix", "autoresearch", task.ID) - if task.Root != wantRoot { - t.Fatalf("task root = %q, want %q", task.Root, wantRoot) - } - for _, rel := range []string{ - "state/task_spec.json", - "state/progress.json", - "state/directions_tried.json", - "state/findings.jsonl", - "state/iteration_log.jsonl", - "logs/heartbeat.jsonl", - } { - if _, err := os.Stat(filepath.Join(wantRoot, rel)); err != nil { - t.Fatalf("expected %s to exist: %v", rel, err) - } - } - - var spec TaskSpec - readJSON(t, filepath.Join(wantRoot, "state/task_spec.json"), &spec) - if spec.TaskID != task.ID || spec.Goal != "Find the root cause of UI lag" { - t.Fatalf("spec identity = (%q, %q), want (%q, goal)", spec.TaskID, spec.Goal, task.ID) - } - if len(spec.SuccessCriteria) != 1 || spec.SuccessCriteria[0].ID != "root_cause" || !spec.SuccessCriteria[0].Required { - t.Fatalf("success criteria not persisted correctly: %+v", spec.SuccessCriteria) - } - if !spec.AllowedOperations.Write || spec.AllowedOperations.Network || spec.AllowedOperations.Publish { - t.Fatalf("allowed operations = %+v", spec.AllowedOperations) - } - - var progress Progress - readJSON(t, filepath.Join(wantRoot, "state/progress.json"), &progress) - if progress.Status != StatusRunning || progress.Iteration != 0 || progress.StaleCount != 0 || progress.PivotCount != 0 { - t.Fatalf("initial progress = %+v", progress) - } - if progress.UpdatedAt.IsZero() { - t.Fatalf("initial progress updated_at was zero") - } - - report, err := store.ValidateTask(task.ID) - if err != nil { - t.Fatalf("ValidateTask returned error: %v", err) - } - if !report.Valid || len(report.Errors) != 0 { - t.Fatalf("validation report = %+v, want valid", report) - } -} - -func TestCreateTaskAvoidsIDCollisions(t *testing.T) { - root := t.TempDir() store := NewStore(root) - now := func() time.Time { return time.Date(2026, 6, 29, 15, 30, 0, 0, time.UTC) } - - first, err := store.CreateTask("Investigate cache churn", CreateOptions{Now: now}) + task, err := store.LoadTask(taskID) if err != nil { - t.Fatalf("first CreateTask: %v", err) + t.Fatalf("LoadTask: %v", err) } - second, err := store.CreateTask("Investigate cache churn", CreateOptions{Now: now}) - if err != nil { - t.Fatalf("second CreateTask: %v", err) - } - - if first.ID != "20260629-153000-investigate-cache-churn" { - t.Fatalf("first id = %q", first.ID) - } - if second.ID != "20260629-153000-investigate-cache-churn-2" { - t.Fatalf("second id = %q", second.ID) - } - if first.CreateToken == "" || second.CreateToken == "" || first.CreateToken == second.CreateToken { - t.Fatalf("create tokens must be unique non-empty ownership proofs: %q vs %q", first.CreateToken, second.CreateToken) - } -} - -func TestCreateTaskReservesIDsAtomicallyAcrossStores(t *testing.T) { - root := t.TempDir() - now := func() time.Time { return time.Date(2026, 6, 29, 15, 30, 0, 0, time.UTC) } - const workers = 8 - type result struct { - task *Task - err error - } - results := make(chan result, workers) - start := make(chan struct{}) - var ready sync.WaitGroup - ready.Add(workers) - for range workers { - go func() { - store := NewStore(root) - ready.Done() - <-start - task, err := store.CreateTask("Concurrent goal reservation", CreateOptions{Now: now}) - results <- result{task: task, err: err} - }() - } - ready.Wait() - close(start) - - ids := map[string]string{} - for range workers { - res := <-results - if res.err != nil { - t.Fatalf("CreateTask worker failed: %v", res.err) - } - if res.task.CreateToken == "" { - t.Fatalf("CreateTask returned empty create token for %s", res.task.ID) - } - if prev, ok := ids[res.task.ID]; ok { - t.Fatalf("duplicate task id %q reserved by tokens %q and %q", res.task.ID, prev, res.task.CreateToken) - } - ids[res.task.ID] = res.task.CreateToken - if _, err := os.Stat(res.task.Root); err != nil { - t.Fatalf("reserved task root missing for %s: %v", res.task.ID, err) - } - } - if len(ids) != workers { - t.Fatalf("got %d unique task ids, want %d", len(ids), workers) - } -} - -func TestRemoveTaskRequiresMatchingCreateToken(t *testing.T) { - root := t.TempDir() - store := NewStore(root) - other := NewStore(root) - task, err := store.CreateTask("Owned rollback only", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - - if err := other.RemoveTask(task.ID, "not-the-owner"); err == nil { - t.Fatal("RemoveTask with wrong token succeeded") - } - if _, err := os.Stat(task.Root); err != nil { - t.Fatalf("task directory removed despite token mismatch: %v", err) - } - if err := store.RemoveTask(task.ID, ""); err == nil { - t.Fatal("RemoveTask without token succeeded") + if task.ID != taskID { + t.Fatalf("task id = %q", task.ID) } - if err := store.RemoveTask(task.ID, task.CreateToken); err != nil { - t.Fatalf("RemoveTask with owner token: %v", err) + if task.Root != taskRoot { + t.Fatalf("task root = %q, want %q", task.Root, taskRoot) } - if _, err := os.Stat(task.Root); !os.IsNotExist(err) { - t.Fatalf("task directory still present after owned remove: %v", err) + if task.Spec.Goal != "Find the root cause of UI lag" { + t.Fatalf("goal = %q", task.Spec.Goal) } -} - -func TestRemoveTaskByCallerSuppliedCreateToken(t *testing.T) { - root := t.TempDir() - store := NewStore(root) - const createToken = "0123456789abcdef0123456789abcdef" - task, err := store.CreateTask("Crash recoverable ownership", CreateOptions{CreateToken: createToken}) + report, err := store.ValidateTask(taskID) if err != nil { - t.Fatalf("CreateTask: %v", err) - } - if task.CreateToken != createToken { - t.Fatalf("CreateTask token = %q, want %q", task.CreateToken, createToken) - } - if !strings.Contains(task.ID, createTokenTaskIDMarker(createToken)) { - t.Fatalf("transaction-owned task id %q has no create-token marker", task.ID) + t.Fatalf("ValidateTask: %v", err) } - if err := store.RemoveTaskByCreateToken(createToken); err != nil { - t.Fatalf("RemoveTaskByCreateToken: %v", err) - } - if _, err := os.Stat(task.Root); !os.IsNotExist(err) { - t.Fatalf("task directory still present after token recovery: %v", err) - } - if err := store.RemoveTaskByCreateToken(createToken); err != nil { - t.Fatalf("repeated RemoveTaskByCreateToken: %v", err) + if !report.Valid { + t.Fatalf("validation errors: %+v", report.Errors) } } -func TestRemoveTaskByCreateTokenRemovesIncompleteReservation(t *testing.T) { +func TestLoadTaskRejectsSymlinkAndUnsafeIDs(t *testing.T) { root := t.TempDir() - store := NewStore(root) - const createToken = "fedcba9876543210fedcba9876543210" - taskID := "20260728-120000-incomplete" + createTokenTaskIDMarker(createToken) - taskRoot := filepath.Join(root, ".reasonix", "autoresearch", taskID) - if err := os.MkdirAll(taskRoot, 0o755); err != nil { - t.Fatal(err) - } - - if err := store.RemoveTaskByCreateToken(createToken); err != nil { - t.Fatalf("RemoveTaskByCreateToken: %v", err) - } - if _, err := os.Stat(taskRoot); !os.IsNotExist(err) { - t.Fatalf("incomplete reservation still present after recovery: %v", err) - } -} - -func TestCreateTaskRejectsInvalidCallerSuppliedCreateToken(t *testing.T) { - store := NewStore(t.TempDir()) - if _, err := store.CreateTask("Invalid ownership", CreateOptions{CreateToken: "not-a-token"}); err == nil { - t.Fatal("CreateTask accepted an invalid caller-supplied create token") - } -} - -func TestLoadTaskRejectsUnsafeOrMissingID(t *testing.T) { - root := t.TempDir() - store := NewStore(root) - - for _, id := range []string{"", "../escape", "bad/id", ".hidden"} { - if _, err := store.LoadTask(id); err == nil { - t.Fatalf("LoadTask(%q) succeeded, want error", id) - } - } - if _, err := store.LoadTask("20260629-153000-missing"); err == nil || !strings.Contains(err.Error(), "not found") { - t.Fatalf("LoadTask missing error = %v, want not found", err) - } -} - -func TestLoadTaskRejectsSymlinkTaskDirectory(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("creating directory symlinks requires elevated privileges on many Windows hosts") + if resolved, err := filepath.EvalSymlinks(root); err == nil { + root = resolved } - root := t.TempDir() - store := NewStore(root) - taskID := "20260630-120000-symlink-task" outside := t.TempDir() - if err := os.MkdirAll(filepath.Join(outside, "state"), 0o755); err != nil { - t.Fatalf("create outside state: %v", err) - } - if err := os.WriteFile(filepath.Join(outside, "state", "task_spec.json"), []byte(`{"task_id":"20260630-120000-symlink-task","goal":"escape","success_criteria":[]}`), 0o644); err != nil { - t.Fatalf("write outside spec: %v", err) - } if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch"), 0o755); err != nil { t.Fatalf("create autoresearch root: %v", err) } + taskID := "symlink-task" if err := os.Symlink(outside, filepath.Join(root, ".reasonix", "autoresearch", taskID)); err != nil { - t.Fatalf("create symlink task: %v", err) + t.Fatalf("symlink: %v", err) } - - if _, err := store.LoadTask(taskID); err == nil || !strings.Contains(err.Error(), "symlink") { - t.Fatalf("LoadTask symlink error = %v, want symlink rejection", err) - } -} - -func TestValidateTaskReportsSchemaErrors(t *testing.T) { - root := t.TempDir() store := NewStore(root) - task, err := store.CreateTask("Validate schema errors", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - - specPath := filepath.Join(task.Root, "state/task_spec.json") - var spec TaskSpec - readJSON(t, specPath, &spec) - spec.Goal = "" - writeJSON(t, specPath, spec) - - report, err := store.ValidateTask(task.ID) - if err != nil { - t.Fatalf("ValidateTask returned error: %v", err) + if _, err := store.LoadTask(taskID); err == nil { + t.Fatal("LoadTask accepted symlink task") } - if report.Valid { - t.Fatalf("ValidateTask reported valid for missing goal") + if _, err := store.LoadTask("../escape"); err == nil { + t.Fatal("LoadTask accepted path traversal id") } - if !containsValidationError(report.Errors, "task_spec.json", "goal") { - t.Fatalf("validation errors = %+v, want task_spec.json goal error", report.Errors) + if _, err := store.LoadTask("has/slash"); err == nil { + t.Fatal("LoadTask accepted slash id") } } -func TestAppendFindingRecordsAcceptedEvidenceForReadiness(t *testing.T) { +func TestFindingsPreserveVerificationAndUnknownKinds(t *testing.T) { root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Verify accepted findings", CreateOptions{ - SuccessCriteria: []SuccessCriterion{ - {ID: "verified", Description: "Verification evidence exists", Required: true, EvidenceIDs: []string{"f1"}}, - }, + taskID := "findings-kinds" + taskRoot := writeArchiveFixture(t, root, taskID, "Read evidence kinds", []SuccessCriterion{ + {ID: "verified", Description: "Verified", Required: true, EvidenceIDs: []string{"f-verify"}}, }) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - - err = store.AppendFinding(task.ID, Finding{ - ID: "f1", - Kind: FindingKindTest, - Summary: "go test ./internal/autoresearch passed", + appendFindingLine(t, taskRoot, Finding{ + ID: "f-verify", + Kind: "verification", + Summary: "targeted test passed", Source: FindingSourceCommand, Command: "go test ./internal/autoresearch", Accepted: true, - CreatedAt: time.Date(2026, 6, 29, 16, 0, 0, 0, time.UTC), + CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC), }) - if err != nil { - t.Fatalf("AppendFinding: %v", err) - } - - readiness, err := store.Readiness(task.ID) - if err != nil { - t.Fatalf("Readiness: %v", err) - } - if !readiness.Ready || len(readiness.MissingCriteria) != 0 { - t.Fatalf("readiness = %+v, want ready", readiness) - } - - findings, err := store.Findings(task.ID, 10) + appendFindingLine(t, taskRoot, Finding{ + ID: "f-future", + Kind: "future_kind", + Summary: "preserve unknown evidence", + Source: FindingSourceManual, + Accepted: true, + CreatedAt: time.Date(2026, 6, 30, 10, 1, 0, 0, time.UTC), + }) + store := NewStore(root) + findings, err := store.Findings(taskID, 0) if err != nil { t.Fatalf("Findings: %v", err) } - if len(findings) != 1 || findings[0].ID != "f1" || !findings[0].Accepted { + if len(findings) != 2 { t.Fatalf("findings = %+v", findings) } -} - -func TestRecordEvidenceLinksFindingToCriterionAndSatisfiesReadiness(t *testing.T) { - root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Record structured evidence", CreateOptions{ - SuccessCriteria: []SuccessCriterion{ - {ID: "verified", Description: "Verification evidence exists", Required: true}, - }, - }) - if err != nil { - t.Fatalf("CreateTask: %v", err) + if findings[0].Kind != "future_kind" || findings[1].Kind != "verification" { + t.Fatalf("kinds = %+v, want newest-first future then verification", findings) } - - err = store.RecordEvidence(task.ID, "verified", Finding{ - ID: "f1", - Kind: FindingKindTest, - Summary: "targeted test passed", - Source: FindingSourceCommand, - Command: "go test ./internal/autoresearch", - Accepted: true, - CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordEvidence: %v", err) + if err := validateFinding(findings[0]); err != nil { + t.Fatalf("validateFinding unknown kind: %v", err) } - - var spec TaskSpec - readJSON(t, filepath.Join(task.Root, "state/task_spec.json"), &spec) - if len(spec.SuccessCriteria) != 1 || len(spec.SuccessCriteria[0].EvidenceIDs) != 1 || spec.SuccessCriteria[0].EvidenceIDs[0] != "f1" { - t.Fatalf("criterion evidence ids = %+v, want f1", spec.SuccessCriteria) + if err := validateFinding(Finding{ID: "", Kind: "anything", Summary: "x", CreatedAt: time.Now()}); err == nil { + t.Fatal("validateFinding accepted empty id") } - readiness, err := store.Readiness(task.ID) + report, err := store.Readiness(taskID) if err != nil { t.Fatalf("Readiness: %v", err) } - if !readiness.Ready { - t.Fatalf("readiness = %+v, want ready after linked accepted evidence", readiness) + if !report.Ready { + t.Fatalf("readiness = %+v, want ready after verification evidence", report) } } -func TestRecordEvidenceRejectsUnknownCriterion(t *testing.T) { - root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Reject unknown criterion", CreateOptions{ - SuccessCriteria: []SuccessCriterion{ - {ID: "known", Description: "Known criterion", Required: true}, - }, - }) - if err != nil { - t.Fatalf("CreateTask: %v", err) +func TestValidateFindingDoesNotEnumerateKind(t *testing.T) { + now := time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC) + if err := validateFinding(Finding{ID: "f1", Kind: "totally-unknown", Summary: "ok", CreatedAt: now}); err != nil { + t.Fatalf("unknown kind rejected: %v", err) } - - err = store.RecordEvidence(task.ID, "missing", Finding{ - ID: "f1", - Kind: FindingKindTest, - Summary: "targeted test passed", - Source: FindingSourceCommand, - Accepted: true, - CreatedAt: time.Date(2026, 6, 30, 10, 0, 0, 0, time.UTC), - }) - if err == nil || !strings.Contains(err.Error(), "criterion") { - t.Fatalf("RecordEvidence unknown criterion error = %v", err) + if err := validateFinding(Finding{ID: "f1", Kind: "verification", Summary: "ok", CreatedAt: now}); err != nil { + t.Fatalf("verification rejected: %v", err) + } + if err := validateFinding(Finding{ID: "f1", Kind: "", Summary: "ok", CreatedAt: now}); err != nil { + t.Fatalf("empty kind should still pass base validation: %v", err) } } -func TestAppendFindingRejectsInvalidEntry(t *testing.T) { +func TestReadinessReportsMissingCriteria(t *testing.T) { root := t.TempDir() + taskID := "missing-criteria" + writeArchiveFixture(t, root, taskID, "Block incomplete completion", []SuccessCriterion{ + {ID: "objective_evidence", Description: "Direct evidence", Required: true}, + {ID: "verification", Description: "Verification", Required: true}, + }) store := NewStore(root) - task, err := store.CreateTask("Reject invalid findings", CreateOptions{}) + report, err := store.Readiness(taskID) if err != nil { - t.Fatalf("CreateTask: %v", err) + t.Fatalf("Readiness: %v", err) } - - err = store.AppendFinding(task.ID, Finding{ID: "", Kind: FindingKindTest, Summary: "missing id", Accepted: true, CreatedAt: time.Now()}) - if err == nil || !strings.Contains(err.Error(), "id") { - t.Fatalf("AppendFinding invalid error = %v, want id error", err) + if report.Ready || len(report.MissingCriteria) != 2 { + t.Fatalf("readiness = %+v, want missing both criteria", report) } } -func TestRecordDirectionIncrementsStaleAndRequiresPivot(t *testing.T) { +func TestResumeFromGoalTextLoadsExplicitTaskPath(t *testing.T) { root := t.TempDir() + taskID := "20260630-resume-path" + writeArchiveFixture(t, root, taskID, "Resume explicit path", nil) store := NewStore(root) - task, err := store.CreateTask("Detect repeated directions", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) + resumed, ok, err := store.ResumeFromGoalText("继续 .reasonix/autoresearch/" + taskID + "/ 这个任务") + if err != nil || !ok { + t.Fatalf("ResumeFromGoalText: ok=%v err=%v", ok, err) } - - progress, err := store.RecordDirection(task.ID, Direction{ - Summary: "Profile markdown rendering", - AcceptedEvidenceIDs: []string{"f1"}, - Now: time.Date(2026, 6, 29, 16, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordDirection first: %v", err) + if resumed.ID != taskID || resumed.Spec.Goal != "Resume explicit path" { + t.Fatalf("resumed = %+v", resumed) } - if progress.Iteration != 1 || progress.StaleCount != 0 || progress.CurrentDirection != "Profile markdown rendering" { - t.Fatalf("first progress = %+v", progress) + if _, ok, err := store.ResumeFromGoalText("ordinary goal text"); err != nil || ok { + t.Fatalf("ordinary text matched archive: ok=%v err=%v", ok, err) } - - progress, err = store.RecordDirection(task.ID, Direction{ - Summary: "Profile markdown rendering", - Now: time.Date(2026, 6, 29, 16, 1, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordDirection second: %v", err) - } - if progress.Iteration != 2 || progress.StaleCount != 1 { - t.Fatalf("second progress = %+v, want iteration 2 stale 1", progress) - } - - progress, err = store.RecordDirection(task.ID, Direction{ - Summary: "Profile markdown rendering", - Now: time.Date(2026, 6, 29, 16, 2, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("RecordDirection third: %v", err) - } - if progress.StaleCount != 2 || progress.PivotCount != 1 { - t.Fatalf("third progress = %+v, want stale 2 pivot 1", progress) - } - - summary, err := store.Summary(task.ID) - if err != nil { - t.Fatalf("Summary: %v", err) - } - if !summary.PivotRequired || summary.NextRequiredAction == "" { - t.Fatalf("summary = %+v, want pivot required with next action", summary) + 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) } } -func TestConcurrentDirectionWritesAreSerializedPerTask(t *testing.T) { +func TestListSummariesAndSummaryAreReadOnly(t *testing.T) { root := t.TempDir() + firstID := "20260630-first" + secondID := "20260630-second" + firstRoot := writeArchiveFixture(t, root, firstID, "First research task", nil) + writeArchiveFixture(t, root, secondID, "Second research task", nil) + writeProgress(t, firstRoot, Progress{ + Status: StatusRunning, + Iteration: 3, + CurrentDirection: "inspect logs", + StaleCount: 2, + PivotCount: 1, + UpdatedAt: time.Date(2026, 6, 30, 11, 0, 0, 0, time.UTC), + }) + appendHeartbeatLine(t, firstRoot, Heartbeat{ + Status: HeartbeatTurnDone, + Iteration: 3, + CreatedAt: time.Date(2026, 6, 30, 11, 0, 0, 0, time.UTC), + }) + before := hashTree(t, filepath.Join(root, ".reasonix", "autoresearch")) store := NewStore(root) - task, err := store.CreateTask("Serialize concurrent directions", CreateOptions{}) + list, err := store.ListSummaries() if err != nil { - t.Fatalf("CreateTask: %v", err) + t.Fatalf("ListSummaries: %v", err) } - - const writers = 20 - var wg sync.WaitGroup - errs := make(chan error, writers) - for i := range writers { - wg.Add(1) - go func(i int) { - defer wg.Done() - _, err := store.RecordDirection(task.ID, Direction{ - Summary: "direction", - Now: time.Date(2026, 6, 30, 8, 0, i, 0, time.UTC), - }) - errs <- err - }(i) - } - wg.Wait() - close(errs) - for err := range errs { - if err != nil { - t.Fatalf("RecordDirection concurrent error: %v", err) - } + if len(list) != 2 { + t.Fatalf("list = %+v", list) } - - summary, err := store.Summary(task.ID) + summary, err := store.Summary(firstID) if err != nil { t.Fatalf("Summary: %v", err) } - if summary.Iteration != writers || summary.StaleCount != writers { - t.Fatalf("summary = %+v, want %d serialized iterations", summary, writers) - } -} - -func TestReadinessBlocksMissingEvidenceAndBlockedStatus(t *testing.T) { - root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Block incomplete completion", CreateOptions{ - SuccessCriteria: []SuccessCriterion{ - {ID: "root_cause", Description: "Root cause identified", Required: true}, - }, - }) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - - readiness, err := store.Readiness(task.ID) - if err != nil { - t.Fatalf("Readiness missing: %v", err) - } - if readiness.Ready || !containsString(readiness.MissingCriteria, "root_cause") { - t.Fatalf("readiness missing = %+v, want root_cause missing", readiness) - } - - _, err = store.UpdateProgress(task.ID, ProgressPatch{Status: ptrString(StatusBlocked), BlockedReason: ptrString("needs user input")}) - if err != nil { - t.Fatalf("UpdateProgress: %v", err) + if summary.Iteration != 3 || !summary.PivotRequired || summary.NextRequiredAction == "" { + t.Fatalf("summary = %+v", summary) } - readiness, err = store.Readiness(task.ID) - if err != nil { - t.Fatalf("Readiness blocked: %v", err) + after := hashTree(t, filepath.Join(root, ".reasonix", "autoresearch")) + if len(before) != len(after) { + t.Fatalf("archive file count changed: before=%d after=%d", len(before), len(after)) } - if readiness.Ready || !strings.Contains(readiness.BlockedReason, "needs user input") { - t.Fatalf("blocked readiness = %+v", readiness) + for path, content := range before { + if after[path] != content { + t.Fatalf("archive mutated at %s", path) + } } } -func TestResumeFromGoalTextLoadsExplicitTaskPath(t *testing.T) { +func TestValidateTaskRejectsCorruptJSON(t *testing.T) { root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Resume explicit path", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - - resumed, ok, err := store.ResumeFromGoalText("继续 .reasonix/autoresearch/" + task.ID + "/ 这个任务") - if err != nil { - t.Fatalf("ResumeFromGoalText: %v", err) - } - if !ok || resumed.ID != task.ID { - t.Fatalf("resumed = %+v ok=%v, want %s", resumed, ok, task.ID) + taskID := "corrupt-json" + taskRoot := writeArchiveFixture(t, root, taskID, "Validate schema errors", nil) + if err := os.WriteFile(filepath.Join(taskRoot, "state", "progress.json"), []byte("{not-json"), 0o644); err != nil { + t.Fatal(err) } -} - -func TestListSummariesReturnsWorkspaceTasks(t *testing.T) { - root := t.TempDir() store := NewStore(root) - first, err := store.CreateTask("First research task", CreateOptions{ - Now: func() time.Time { return time.Date(2026, 6, 29, 10, 0, 0, 0, time.UTC) }, - }) + report, err := store.ValidateTask(taskID) if err != nil { - t.Fatalf("CreateTask first: %v", err) + t.Fatalf("ValidateTask: %v", err) } - second, err := store.CreateTask("Second research task", CreateOptions{ - Now: func() time.Time { return time.Date(2026, 6, 29, 11, 0, 0, 0, time.UTC) }, - }) - if err != nil { - t.Fatalf("CreateTask second: %v", err) - } - - summaries, err := store.ListSummaries() - if err != nil { - t.Fatalf("ListSummaries: %v", err) - } - if len(summaries) != 2 { - t.Fatalf("summaries = %+v, want two tasks", summaries) - } - if summaries[0].TaskID != second.ID || summaries[1].TaskID != first.ID { - t.Fatalf("summaries order = %+v, want newest task id first", summaries) - } - if summaries[0].Goal != "Second research task" || summaries[1].Goal != "First research task" { - t.Fatalf("summaries goals = %+v", summaries) + if report.Valid { + t.Fatal("corrupt progress reported valid") } } -func TestAppendHeartbeatRecordsDurableTurnStatus(t *testing.T) { +func TestHeartbeatsTailRead(t *testing.T) { root := t.TempDir() - store := NewStore(root) - task, err := store.CreateTask("Record heartbeats", CreateOptions{}) - if err != nil { - t.Fatalf("CreateTask: %v", err) - } - - err = store.AppendHeartbeat(task.ID, Heartbeat{ - Status: HeartbeatStartingTurn, - Iteration: 1, - Message: "starting", - CreatedAt: time.Date(2026, 6, 29, 17, 0, 0, 0, time.UTC), - }) - if err != nil { - t.Fatalf("AppendHeartbeat: %v", err) + taskID := "heartbeats" + taskRoot := writeArchiveFixture(t, root, taskID, "Record heartbeats", nil) + for i := 1; i <= 5; i++ { + appendHeartbeatLine(t, taskRoot, Heartbeat{ + Status: HeartbeatTurnDone, + Iteration: i, + CreatedAt: time.Date(2026, 6, 30, 10, i, 0, 0, time.UTC), + }) } - - heartbeats, err := store.Heartbeats(task.ID, 10) + store := NewStore(root) + heartbeats, err := store.Heartbeats(taskID, 2) if err != nil { t.Fatalf("Heartbeats: %v", err) } - if len(heartbeats) != 1 || heartbeats[0].Status != HeartbeatStartingTurn || heartbeats[0].Iteration != 1 { + if len(heartbeats) != 2 || heartbeats[0].Iteration != 4 || heartbeats[1].Iteration != 5 { t.Fatalf("heartbeats = %+v", heartbeats) } - summary, err := store.Summary(task.ID) - if err != nil { - t.Fatalf("Summary: %v", err) - } - if !summary.LastHeartbeatAt.Equal(heartbeats[0].CreatedAt) { - t.Fatalf("summary last heartbeat = %s, want %s", summary.LastHeartbeatAt, heartbeats[0].CreatedAt) - } -} - -func readJSON(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 writeJSON(t *testing.T, path string, v any) { - t.Helper() - data, err := json.MarshalIndent(v, "", " ") - if err != nil { - t.Fatalf("MarshalIndent: %v", err) - } - if err := os.WriteFile(path, data, 0o644); err != nil { - t.Fatalf("WriteFile(%s): %v", path, err) - } -} - -func containsValidationError(errors []ValidationError, file, field string) bool { - for _, err := range errors { - if err.File == file && err.Field == field { - return true - } - } - return false -} - -func containsString(values []string, want string) bool { - return slices.Contains(values, want) -} - -func ptrString(s string) *string { - return &s } diff --git a/internal/autoresearch/task.go b/internal/autoresearch/task.go index a0251c2b4c..c63a8a2fa0 100644 --- a/internal/autoresearch/task.go +++ b/internal/autoresearch/task.go @@ -10,26 +10,11 @@ const ( StatusInvalid = "invalid" ) +// Task is a read-only view of a historical AutoResearch archive. type Task struct { ID string Root string Spec TaskSpec - // CreateToken is an opaque ownership proof for the directory reserved by - // CreateTask. Rollback helpers must pass it to RemoveTask so a failed - // transaction cannot delete a task directory another creator reserved. - CreateToken string -} - -type CreateOptions struct { - Now func() time.Time - // CreateToken optionally supplies the ownership proof written during task - // reservation. Durable parent transactions persist it before calling - // CreateTask so crash recovery can find and remove an uncommitted task. - CreateToken string - Scope []string - NonGoals []string - AllowedOperations AllowedOperations - SuccessCriteria []SuccessCriterion } type AllowedOperations struct { @@ -64,13 +49,17 @@ 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" + FindingKindCommand = "command" + FindingKindFile = "file" + FindingKindTest = "test" + FindingKindBenchmark = "benchmark" + FindingKindManual = "manual" + FindingKindReview = "review" + FindingKindVerification = "verification" ) const ( @@ -79,6 +68,7 @@ const ( FindingSourceManual = "manual" ) +// Finding.Kind is an opaque string. Unknown historical values must round-trip. type Finding struct { ID string `json:"id"` Kind string `json:"kind"` @@ -103,12 +93,6 @@ type Heartbeat struct { CreatedAt time.Time `json:"created_at"` } -type Direction struct { - Summary string - AcceptedEvidenceIDs []string - Now time.Time -} - type DirectionTried struct { Fingerprint string `json:"fingerprint"` Summary string `json:"summary"` @@ -117,12 +101,6 @@ type DirectionTried struct { Count int `json:"count"` } -type ProgressPatch struct { - Status *string - CurrentDirection *string - BlockedReason *string -} - type CriterionSummary struct { ID string `json:"id"` Description string `json:"description"` diff --git a/internal/control/autoresearch_manager.go b/internal/control/autoresearch_manager.go index fd84afbc1a..ab1f33a70c 100644 --- a/internal/control/autoresearch_manager.go +++ b/internal/control/autoresearch_manager.go @@ -1,405 +1,97 @@ package control -// AutoResearch task lifecycle: creating/resuming tasks for research-mode -// goals, recording per-turn evidence and direction, and reporting readiness. -// autoResearchManager is a strict leaf over the workspace autoresearch.Store — -// it never touches Controller state; the Controller wrappers below resolve the -// active task from the goal machine and own notices. +// legacyResearchArchive is a read-only compatibility boundary for Goal +// sidecars and prompts that still reference an old .reasonix/autoresearch +// task. New Goal runs never create, update, list, or expose those archives. import ( - "encoding/json" - "errors" - "fmt" "log/slog" - "sort" "strings" - "time" - "reasonix/internal/agent" "reasonix/internal/autoresearch" ) -type autoResearchSetup struct { +type legacyResearchSetup struct { + // goal is the original objective recovered from task_spec.json when the + // user named an explicit archive path. Empty when no archive was referenced. + goal string taskID string - createToken string blockReason string notice string - created bool + explicit bool } -// autoResearchManager wraps the optional workspace autoresearch.Store. The -// zero value (no store) disables the subsystem; every method is nil-safe. -type autoResearchManager struct { +type legacyResearchArchive struct { store *autoresearch.Store } -func (m autoResearchManager) enabled() bool { - return m.store != nil -} - -// prepare resumes the task matching goal text or creates a fresh one. The -// caller has already decided AutoResearch applies and that no running goal -// owns the task. -func (m autoResearchManager) prepare(goal, createToken string) autoResearchSetup { +// 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 { - return autoResearchSetup{} - } - if task, ok, err := m.store.ResumeFromGoalText(goal); err != nil { - slog.Warn("controller: resume autoresearch task", "err", err) - if ok { - return autoResearchSetup{blockReason: err.Error()} + if _, ok := autoresearch.ExplicitTaskID(goal); ok { + return legacyResearchSetup{ + explicit: true, + blockReason: "legacy research archive is unavailable for this workspace", + } } - } else if ok { - return autoResearchSetup{taskID: task.ID, notice: "autoresearch task resumed: " + task.ID} - } - task, err := m.store.CreateTask(goal, autoresearch.CreateOptions{ - CreateToken: createToken, - AllowedOperations: autoresearch.AllowedOperations{ - Write: true, - Network: false, - Publish: false, - }, - SuccessCriteria: defaultAutoResearchSuccessCriteria(), - }) - if err != nil { - slog.Warn("controller: create autoresearch task", "err", err) - return autoResearchSetup{} - } - return autoResearchSetup{ - taskID: task.ID, - createToken: task.CreateToken, - notice: "autoresearch task created: " + task.ID, - created: true, - } -} - -func (m autoResearchManager) removeTask(taskID, createToken string) error { - if m.store == nil { - return nil - } - return m.store.RemoveTask(taskID, createToken) -} - -func (m autoResearchManager) heartbeat(taskID, status, message string) { - if m.store == nil || strings.TrimSpace(taskID) == "" { - return + return legacyResearchSetup{} } - iteration := 0 - if summary, err := m.store.Summary(taskID); err == nil { - iteration = summary.Iteration + task, ok, err := m.store.ResumeFromGoalText(goal) + if !ok { + return legacyResearchSetup{} } - if err := m.store.AppendHeartbeat(taskID, autoresearch.Heartbeat{ - Status: status, - Iteration: iteration, - Message: message, - CreatedAt: time.Now().UTC(), - }); err != nil { - slog.Warn("controller: append autoresearch heartbeat", "task_id", taskID, "status", status, "err", err) - } -} - -func (m autoResearchManager) acceptedEvidenceIDs(taskID string) map[string]bool { - if m.store == nil || strings.TrimSpace(taskID) == "" { - return nil - } - findings, err := m.store.Findings(taskID, 0) if err != nil { - slog.Warn("controller: read autoresearch findings", "task_id", taskID, "err", err) - return nil - } - accepted := make(map[string]bool, len(findings)) - for _, finding := range findings { - if finding.Accepted { - accepted[finding.ID] = true + slog.Warn("controller: resume legacy autoresearch task", "err", err) + return legacyResearchSetup{explicit: true, 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 accepted -} - -// recordTurnProgress records the turn's direction: which evidence IDs became -// accepted since acceptedBefore, summarized from the assistant's final text. -func (m autoResearchManager) recordTurnProgress(taskID string, acceptedBefore map[string]bool, assistantText string) { - if m.store == nil || strings.TrimSpace(taskID) == "" { - return - } - acceptedAfter := m.acceptedEvidenceIDs(taskID) - newAccepted := make([]string, 0) - for id := range acceptedAfter { - if acceptedBefore == nil || !acceptedBefore[id] { - newAccepted = append(newAccepted, id) - } - } - sort.Strings(newAccepted) - if _, err := m.store.RecordDirection(taskID, autoresearch.Direction{ - Summary: autoResearchDirectionSummary(assistantText), - AcceptedEvidenceIDs: newAccepted, - Now: time.Now().UTC(), - }); err != nil { - slog.Warn("controller: record autoresearch direction", "task_id", taskID, "err", err) + return legacyResearchSetup{ + goal: original, + taskID: task.ID, + notice: "legacy research archive loaded: " + task.ID, + explicit: true, } } -func (m autoResearchManager) recordEvidenceFromAssistant(taskID, text string) { - if m.store == nil || strings.TrimSpace(taskID) == "" { - return - } - for _, item := range parseAutoResearchEvidenceBlocks(text) { - if err := m.recordEvidence(taskID, item.CriterionID, AutoResearchEvidenceInput{ - ID: item.ID, - Kind: item.Kind, - Summary: item.Summary, - Source: item.Source, - Command: item.Command, - Paths: append([]string(nil), item.Paths...), - Accepted: item.Accepted, - }); err != nil { - slog.Warn("controller: record autoresearch evidence block", "task_id", taskID, "criterion_id", item.CriterionID, "err", err) - } - } -} - -func (m autoResearchManager) recordEvidence(taskID, criterionID string, input AutoResearchEvidenceInput) error { - if m.store == nil || strings.TrimSpace(taskID) == "" { - return errors.New("autoresearch: no active task") - } - id := strings.TrimSpace(input.ID) - if id == "" { - id = m.nextFindingID(taskID) - } - kind := strings.TrimSpace(input.Kind) - if kind == "" { - kind = autoresearch.FindingKindManual - } - source := strings.TrimSpace(input.Source) - if source == "" { - source = autoresearch.FindingSourceManual - } - finding := autoresearch.Finding{ - ID: id, - Kind: kind, - Summary: strings.TrimSpace(input.Summary), - Source: source, - Command: strings.TrimSpace(input.Command), - Paths: append([]string(nil), input.Paths...), - Accepted: input.Accepted, - CreatedAt: time.Now().UTC(), - } - return m.store.RecordEvidence(taskID, criterionID, finding) -} - -func (m autoResearchManager) nextFindingID(taskID string) string { - findings, err := m.store.Findings(taskID, 0) - if err != nil { - return fmt.Sprintf("f%d", time.Now().UTC().UnixNano()) - } - used := make(map[string]bool, len(findings)) - for _, finding := range findings { - used[finding.ID] = true - } - for i := 1; ; i++ { - id := fmt.Sprintf("f%d", len(findings)+i) - if !used[id] { - return id - } - } -} - -func (m autoResearchManager) readinessFailure(taskID string) string { - if m.store == nil || strings.TrimSpace(taskID) == "" { - return "" - } - report, err := m.store.Readiness(taskID) - if err != nil { - return "AutoResearch readiness check failed: " + err.Error() - } - if report.Ready { - return "" - } - var parts []string - if len(report.MissingCriteria) > 0 { - parts = append(parts, "missing criteria: "+strings.Join(report.MissingCriteria, ", ")) - } - if report.BlockedReason != "" { - parts = append(parts, "blocked: "+report.BlockedReason) - } - if len(report.Errors) > 0 { - parts = append(parts, "state errors: "+strings.Join(report.Errors, "; ")) - } - if len(parts) == 0 { - parts = append(parts, "task is not ready") - } - return "AutoResearch readiness check failed: " + strings.Join(parts, "; ") -} - -func (m autoResearchManager) summary(taskID string) (*autoresearch.Summary, error) { +// loadGoalText returns the original objective stored in a historical archive. +func (m legacyResearchArchive) loadGoalText(taskID string) (string, error) { if m.store == nil { - return nil, errors.New("autoresearch: disabled") + return "", errLegacyArchiveUnavailable } - return m.store.Summary(taskID) -} - -func (m autoResearchManager) listSummaries() ([]autoresearch.Summary, error) { - if m.store == nil { - return nil, errors.New("autoresearch: disabled") + task, err := m.store.LoadTask(taskID) + if err != nil { + return "", err } - return m.store.ListSummaries() -} - -func (m autoResearchManager) findings(taskID string, limit int) ([]autoresearch.Finding, error) { - if m.store == nil { - return nil, errors.New("autoresearch: disabled") + if report, err := m.store.ValidateTask(task.ID); err != nil { + return "", err + } else if !report.Valid { + return "", errLegacyArchiveInvalid } - return m.store.Findings(taskID, limit) -} - -func (m autoResearchManager) updateProgress(taskID string, patch autoresearch.ProgressPatch) error { - if m.store == nil { - return nil + goal := strings.TrimSpace(task.Spec.Goal) + if goal == "" { + return "", errLegacyArchiveMissingGoal } - _, err := m.store.UpdateProgress(taskID, patch) - return err + return goal, nil } -func defaultAutoResearchSuccessCriteria() []autoresearch.SuccessCriterion { - return []autoresearch.SuccessCriterion{ - { - ID: "objective_evidence", - Description: "The goal outcome is supported by direct evidence, such as inspected code, reproduced behavior, source material, or concrete findings.", - Required: true, - }, - { - ID: "verification", - Description: "The result has relevant verification evidence, such as tests, commands, benchmarks, manual checks, or a documented reason why verification is not applicable.", - Required: true, - }, - } -} - -type autoResearchEvidenceBlock struct { - CriterionID string `json:"criterion_id"` - ID string `json:"id"` - Kind string `json:"kind"` - Summary string `json:"summary"` - Source string `json:"source"` - Command string `json:"command"` - Paths []string `json:"paths"` - Accepted bool `json:"accepted"` -} - -const ( - autoResearchEvidenceOpen = "" - autoResearchEvidenceClose = "" +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") ) -func parseAutoResearchEvidenceBlocks(text string) []autoResearchEvidenceBlock { - var out []autoResearchEvidenceBlock - rest := text - for { - start := strings.Index(rest, autoResearchEvidenceOpen) - if start < 0 { - return out - } - rest = rest[start+len(autoResearchEvidenceOpen):] - end := strings.Index(rest, autoResearchEvidenceClose) - if end < 0 { - return out - } - raw := strings.TrimSpace(rest[:end]) - rest = rest[end+len(autoResearchEvidenceClose):] - if raw == "" { - continue - } - var many []autoResearchEvidenceBlock - if err := json.Unmarshal([]byte(raw), &many); err == nil { - out = append(out, many...) - continue - } - var one autoResearchEvidenceBlock - if err := json.Unmarshal([]byte(raw), &one); err == nil { - out = append(out, one) - } - } -} - -func autoResearchDirectionSummary(text string) string { - text = agent.StripAutoResearchEvidenceBlocks(text) - for line := range strings.SplitSeq(text, "\n") { - line = strings.TrimSpace(line) - lower := strings.ToLower(line) - if line == "" || strings.HasPrefix(lower, "[goal:") { - continue - } - if len(line) > 160 { - line = line[:160] - } - return line - } - return "turn completed" -} - -// Controller-side glue: resolve the active task via the goal machine, then -// delegate to the leaf manager. +type errString string -func (c *Controller) prepareAutoResearchTask(goal string, researchMode GoalResearchMode, createToken string) autoResearchSetup { - goal = strings.TrimSpace(goal) - if goal == "" || !c.autoResearch.enabled() || !shouldUseAutoResearch(goal, researchMode) { - return autoResearchSetup{} - } - currentGoal, currentStatus, _, currentTaskID := c.goals.snapshot() - if strings.TrimSpace(currentGoal) == goal && currentStatus == GoalStatusRunning && strings.TrimSpace(currentTaskID) != "" { - return autoResearchSetup{taskID: currentTaskID} - } - return c.autoResearch.prepare(goal, createToken) -} +func (e errString) Error() string { return string(e) } -func (c *Controller) autoResearchReadinessFailure() string { - return c.autoResearch.readinessFailure(c.goals.currentAutoResearchTaskID()) -} - -func (c *Controller) AutoResearchSummary() (*autoresearch.Summary, bool) { - taskID := c.goals.currentAutoResearchTaskID() - if !c.autoResearch.enabled() || strings.TrimSpace(taskID) == "" { - return nil, false - } - summary, err := c.autoResearch.summary(taskID) - if err != nil { - return &autoresearch.Summary{ - TaskID: taskID, - Status: autoresearch.StatusInvalid, - Blocker: err.Error(), - }, true - } - return summary, true -} - -func (c *Controller) AutoResearchList() ([]autoresearch.Summary, bool) { - if !c.autoResearch.enabled() { - return nil, false - } - summaries, err := c.autoResearch.listSummaries() - if err != nil { - slog.Warn("controller: list autoresearch tasks", "err", err) - return nil, true - } - return summaries, true -} - -func (c *Controller) AutoResearchFindings(limit int) ([]autoresearch.Finding, bool) { - taskID := c.goals.currentAutoResearchTaskID() - if !c.autoResearch.enabled() || strings.TrimSpace(taskID) == "" { - return nil, false - } - findings, err := c.autoResearch.findings(taskID, limit) - if err != nil { - return nil, true - } - return findings, true -} - -func (c *Controller) RecordAutoResearchEvidence(criterionID string, input AutoResearchEvidenceInput) error { - taskID := c.goals.currentAutoResearchTaskID() - if !c.autoResearch.enabled() || strings.TrimSpace(taskID) == "" { - return errors.New("autoresearch: no active task") - } - return c.autoResearch.recordEvidence(taskID, criterionID, input) +func (c *Controller) prepareLegacyResearchTask(goal string) legacyResearchSetup { + return c.legacyResearchArchive.prepare(goal) } diff --git a/internal/control/controller.go b/internal/control/controller.go index ccec1f00f2..3d9b8e65e6 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -202,10 +202,10 @@ type Controller struct { // and its persistence, behind its own mutex so a per-turn goal save never // stalls an approval or status poll on c.mu. See goal.go. goals goalMachine - // autoResearch wraps the workspace autoresearch.Store as a strict-leaf - // collaborator; goal/task resolution stays on Controller. See + // legacyResearchArchive reads explicit pre-unification task paths. It never + // creates or mutates archive state. See // autoresearch_manager.go. - autoResearch autoResearchManager + legacyResearchArchive legacyResearchArchive // workspaceRoot is the workspace root: the base for resolving @-refs and slash // path refs, the working directory for user "!" shell commands and custom @@ -323,16 +323,6 @@ type pendingAsk struct { reply chan []event.AskAnswer } -type AutoResearchEvidenceInput struct { - ID string - Kind string - Summary string - Source string - Command string - Paths []string - Accepted bool -} - type plannerSessionResetter interface { ResetPlannerSession() } @@ -613,7 +603,7 @@ func New(opts Options) *Controller { c.sessionTemp.Retain() if strings.TrimSpace(opts.WorkspaceRoot) != "" { - c.autoResearch = autoResearchManager{store: autoresearch.NewStore(opts.WorkspaceRoot)} + c.legacyResearchArchive = legacyResearchArchive{store: autoresearch.NewStore(opts.WorkspaceRoot)} } if opts.Extensions != nil { c.extensions = opts.Extensions @@ -1567,6 +1557,9 @@ func (c *Controller) applyGoalCommand(input, display string) bool { if !ok { return false } + if cmd.DeprecatedBudgetFlag { + c.notice("This /goal budget flag is deprecated; Goal now selects its budget automatically.") + } switch cmd.Action { case GoalCommandSet: c.SetPlanMode(false) @@ -2692,23 +2685,18 @@ func (c *Controller) SetGoal(goal string) { } // SetGoalDurable updates the Goal only when its sidecar can be replaced -// atomically. Remote Profile transactions persist autoResearchCreateToken -// before calling this method so crash recovery owns any newly-created task. -func (c *Controller) SetGoalDurable(goal, autoResearchCreateToken string) error { +// 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() - setup := c.prepareAutoResearchTask(goal, GoalResearchAuto, autoResearchCreateToken) - path, data, persist := c.goals.set(goal, GoalResearchAuto, setup.taskID, c.goalTodos()) + 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) - if setup.created && c.autoResearch.enabled() { - if removeErr := c.autoResearch.removeTask(setup.taskID, setup.createToken); removeErr != nil { - slog.Warn("controller: rollback autoresearch task", "task_id", setup.taskID, "err", removeErr) - } - } return err } } @@ -2716,28 +2704,49 @@ func (c *Controller) SetGoalDurable(goal, autoResearchCreateToken string) error c.notice(setup.notice) } if setup.blockReason != "" { - c.notice("autoresearch resume failed: " + setup.blockReason) + c.notice("legacy research archive resume failed: " + setup.blockReason) } return nil } func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResearchMode) { - setup := c.prepareAutoResearchTask(goal, researchMode, "") + resolved, setup := c.resolveGoalText(goal, researchMode) if setup.notice != "" { c.notice(setup.notice) } - path, data, ok := c.goals.set(goal, researchMode, setup.taskID, c.goalTodos()) + 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("autoresearch resume failed: " + setup.blockReason) + 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 or AutoResearch identity. A budget-paused Goal gets -// one extra slice of its budget class; accumulated consumption is preserved. +// delivery evidence scope. A budget-paused Goal gets one extra slice of its +// budget class; accumulated consumption is preserved. func (c *Controller) ResumeGoal() bool { path, data, persist, resumed, extended := c.goals.resume(c.goalTodos()) if !resumed { @@ -2772,11 +2781,11 @@ func (c *Controller) GoalRuntime() GoalRuntimeView { } // goalEvaluatorEvidence assembles the bounded evaluator's evidence: the goal -// contract, the current assistant final, a todo/readiness summary, the -// AutoResearch success-criteria summary, turn/budget state, and the last +// contract, the current assistant final, a todo/readiness summary, +// turn/budget state, and the last // continuation reason. Every field is treated as untrusted by the evaluator. func (c *Controller) goalEvaluatorEvidence() goaleval.GoalEvidence { - goal, _, mode, taskID := c.goals.snapshot() + goal, _, _ := c.goals.snapshot() ev := goaleval.GoalEvidence{ GoalContract: goal, LastContinuationReason: c.goals.lastContinuationReasonText(), @@ -2797,27 +2806,10 @@ func (c *Controller) goalEvaluatorEvidence() goaleval.GoalEvidence { } ev.TodoSummary = fmt.Sprintf("todos: %d total, %d incomplete; delivery readiness: %s", len(todos), incomplete, readinessText) } - if c.autoResearch.enabled() && strings.TrimSpace(taskID) != "" { - if summary, err := c.autoResearch.summary(taskID); err == nil { - ev.AutoResearchSummary = fmt.Sprintf("task %s: iteration %d, %d open success criteria, next required action: %s", - summary.TaskID, summary.Iteration, len(summary.OpenCriteria), summary.NextRequiredAction) - } - } - ev.TurnStatus = c.goals.budgetStatusText() + "; research mode: " + goalResearchModeText(mode) + ev.TurnStatus = c.goals.budgetStatusText() return ev } -func goalResearchModeText(mode GoalResearchMode) string { - switch mode { - case GoalResearchOn: - return "on" - case GoalResearchOff: - return "off" - default: - return "auto" - } -} - func (c *Controller) persistGoalDeliveryCheckpoint() { if c.executor == nil { return @@ -3508,12 +3500,20 @@ func (c *Controller) Resume(s *agent.Session, path string) { c.ResetPlannerSession() c.setActiveJobSession(path) c.rebindCheckpoints(path) - if migPath, migData, migrated := c.goals.restoreFromState(path); migrated { - // Persist legacy budget_tokens → running (and tokensLimit=0) so the - // next cold start does not re-enter the removed hard-limit pause. - // restoreFromState never issues a provider request. + migPath, migData, migrated, legacyTaskID := c.goals.restoreFromState(path) + if migrated { + // Persist omitted autoResearchTaskID / cleared token limits (no provider call). 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 8782312f15..bfc882bb24 100644 --- a/internal/control/controller_test.go +++ b/internal/control/controller_test.go @@ -570,7 +570,7 @@ func TestSetGoalDurableRestoresInMemoryStateWhenSidecarWriteFails(t *testing.T) } } -func TestSetGoalDurableRollsBackAutoResearchTaskAndNotice(t *testing.T) { +func TestSetGoalDurableNeverCreatesLegacyArchive(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "session.jsonl") sink := ¬iceSink{} @@ -595,15 +595,11 @@ func TestSetGoalDurableRollsBackAutoResearchTaskAndNotice(t *testing.T) { if err := c.SetGoalDurable(goal, ""); err == nil { t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent") } - entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch")) - if err != nil && !os.IsNotExist(err) { - t.Fatalf("read autoresearch dir: %v", err) - } - if len(entries) != 0 { - t.Fatalf("autoresearch task count after rollback = %d, want 0", len(entries)) + if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) { + t.Fatalf("durable Goal update created legacy archive: %v", err) } for _, notice := range sink.notices() { - if strings.Contains(notice, "autoresearch task created") || strings.Contains(notice, "autoresearch task resumed") { + if strings.Contains(strings.ToLower(notice), "autoresearch task created") || strings.Contains(strings.ToLower(notice), "legacy research archive loaded") { t.Fatalf("durable failure emitted success notice %q", notice) } } @@ -686,7 +682,7 @@ func TestResumeRestoresRunningAutoResearchGoalFromSidecar(t *testing.T) { if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch", taskID, "logs"), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "task_spec.json"), []byte(`{"id":"investigate-runtime-resume","goal":"investigate runtime resume","status":"running","created_at":"2026-06-30T00:00:00Z","updated_at":"2026-06-30T00:00:00Z","success_criteria":[{"id":"criterion-1","description":"resume keeps AutoResearch active","required":true}]}`), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "task_spec.json"), []byte(`{"task_id":"investigate-runtime-resume","goal":"investigate runtime resume","allowed_operations":{"write":true},"success_criteria":[{"id":"criterion-1","description":"resume keeps Goal active","required":true}]}`), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "progress.json"), []byte(`{"task_id":"investigate-runtime-resume","iteration":2,"current_direction":"verify resume","stale_count":1,"pivot_count":0,"updated_at":"2026-06-30T00:00:00Z"}`), 0o644); err != nil { @@ -714,8 +710,11 @@ func TestResumeRestoresRunningAutoResearchGoalFromSidecar(t *testing.T) { t.Fatalf("Goal() after resume = %q, want running goal from sidecar", got) } composed := c.Compose("continue") - if !strings.Contains(composed, "") || !strings.Contains(composed, "task_id: "+taskID) { - t.Fatalf("Compose after resume missing AutoResearch runtime for %q:\n%s", taskID, composed) + if strings.Contains(strings.ToLower(composed), "autoresearch") { + t.Fatalf("Compose after resume exposed removed AutoResearch protocol:\n%s", composed) + } + if got := c.GoalRuntime().TurnsLimit; got != 40 { + t.Fatalf("resumed legacy Goal budget = %d, want 40", got) } } diff --git a/internal/control/goal.go b/internal/control/goal.go index f73a580d76..bcb0c1a6a3 100644 --- a/internal/control/goal.go +++ b/internal/control/goal.go @@ -30,20 +30,16 @@ const ( defaultNoProgressLimit = 4 ) -// Budget classes select turn quotas only. They never gate permissions, writes, -// or provider request admission. Token usage is still accumulated for display. +// Budget class aliases; classification and quotas live in taskintent. const ( - budgetClassSimple = "simple" - budgetClassWrite = "write" - budgetClassResearch = "research" + budgetClassSimple = taskintent.BudgetClassSimple + budgetClassWrite = taskintent.BudgetClassWrite + budgetClassResearch = taskintent.BudgetClassResearch ) -// Stop causes distinguish a safe pause (blocked + stopCause) from a genuine -// task block (blocked with empty stopCause). Old clients see blocked either -// way and never fail open. -// -// stopCauseBudgetTokens is retained only so old sidecars that paused on the -// removed token hard-limit can be recognized and auto-resumed on load. +// Stop causes distinguish a safe pause from a genuine block. Old clients see +// 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 @@ -55,37 +51,28 @@ const ( // budgetQuota returns the default turn quota for a budget class. Token hard // limits were removed; callers no longer receive a token ceiling. func budgetQuota(class string) (turns int) { - switch class { - case budgetClassResearch: - return 40 - case budgetClassWrite: - return 20 - default: - return 10 - } + return taskintent.BudgetTurns(class) } -// budgetClassFor derives a goal's budget class: AutoResearch always means -// research; otherwise Goal-specific write classification decides whether the -// objective is a write turn budget (including bare fault statements) or simple. -// Ordinary Delivery consultation/diagnosis classification is unchanged. +// budgetClassFor derives a Goal budget. GoalResearchMode only decodes legacy +// sidecars and deprecated CLI flags. func budgetClassFor(goal string, researchMode GoalResearchMode) string { - if shouldUseAutoResearch(goal, researchMode) { + switch researchMode { + case GoalResearchOn: return budgetClassResearch + case GoalResearchOff: + if taskintent.GoalNeedsWriteBudget(goal) { + return budgetClassWrite + } + return budgetClassSimple + default: + return taskintent.ClassifyGoalBudget(goal) } - if taskintent.GoalNeedsWriteBudget(goal) { - return budgetClassWrite - } - return budgetClassSimple } -// goalMachine owns the active goal's finite-state machine and its persistence. -// It is a strict leaf: its methods take only the machine's own locks and never -// call back into the Controller, so the controller may hold c.mu while invoking -// a getter without risking lock inversion. The FSM is pure — advance() takes -// already-gathered inputs (the update_goal report, readiness, evaluator verdict, -// budget/progress state) and returns what to persist plus a notice, so no disk -// or executor work happens under mu. +// goalMachine owns the active goal FSM and its persistence. It is a strict +// leaf: methods take only machine locks and never call back into Controller. +// advance() takes already-gathered inputs so no disk/executor work holds mu. type goalMachine struct { // mu guards the FSM fields below; every critical section under it is short // and non-blocking (no disk I/O, no executor calls). @@ -93,7 +80,6 @@ type goalMachine struct { goal string status string researchMode GoalResearchMode - autoResearchTaskID string scopeID string deliveryCheckpoint evidence.DeliveryCheckpoint block string @@ -157,7 +143,6 @@ type goalMachineSnapshot struct { goal string status string researchMode GoalResearchMode - autoResearchTaskID string scopeID string deliveryCheckpoint evidence.DeliveryCheckpoint block string @@ -204,10 +189,9 @@ 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 - autoResearchTaskID string - scopeID string + goal string + researchMode GoalResearchMode + scopeID string } // goalStatePath derives a session's persisted goal-state sidecar. @@ -226,16 +210,15 @@ func (g *goalMachine) capture() goalMachineSnapshot { defer g.mu.Unlock() return goalMachineSnapshot{ goal: g.goal, status: g.status, researchMode: g.researchMode, - autoResearchTaskID: g.autoResearchTaskID, scopeID: g.scopeID, - deliveryCheckpoint: g.deliveryCheckpoint, block: g.block, - strict: g.strict, + 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.autoResearchTaskID, g.scopeID = snapshot.autoResearchTaskID, snapshot.scopeID + g.scopeID = snapshot.scopeID g.deliveryCheckpoint, g.block = snapshot.deliveryCheckpoint, snapshot.block g.strict = snapshot.strict g.continuationEpoch++ @@ -243,10 +226,10 @@ func (g *goalMachine) restore(snapshot goalMachineSnapshot) { } // snapshot returns the fields Compose injects into outgoing turns. -func (g *goalMachine) snapshot() (goal, status string, mode GoalResearchMode, autoResearchTaskID string) { +func (g *goalMachine) snapshot() (goal, status string, mode GoalResearchMode) { g.mu.Lock() defer g.mu.Unlock() - return g.goal, g.status, g.researchMode, g.autoResearchTaskID + return g.goal, g.status, g.researchMode } func (g *goalMachine) goalText() string { @@ -255,15 +238,6 @@ func (g *goalMachine) goalText() string { return g.goal } -func (g *goalMachine) currentAutoResearchTaskID() string { - g.mu.Lock() - defer g.mu.Unlock() - if strings.TrimSpace(g.goal) == "" || g.status != GoalStatusRunning { - return "" - } - return g.autoResearchTaskID -} - // continuationToken captures the Goal lifecycle that owns an outgoing turn. // The matching assistant output may advance the FSM only while this epoch is // still current. @@ -333,11 +307,11 @@ 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, autoResearchTaskID string, todos []evidence.TodoItem) (string, []byte, bool) { +func (g *goalMachine) set(goal string, mode GoalResearchMode, todos []evidence.TodoItem) (string, []byte, bool) { goal = strings.TrimSpace(goal) g.mu.Lock() defer g.mu.Unlock() - if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.researchMode == mode && g.autoResearchTaskID == autoResearchTaskID { + if goal != "" && g.goal == goal && g.status == GoalStatusRunning && g.researchMode == mode { return "", nil, false } g.continuationEpoch++ @@ -347,11 +321,11 @@ func (g *goalMachine) set(goal string, mode GoalResearchMode, autoResearchTaskID g.stopCause = "" g.budgetExtensions = 0 if goal == "" { - g.goal, g.status, g.researchMode, g.autoResearchTaskID = "", GoalStatusStopped, GoalResearchAuto, "" + g.goal, g.status, g.researchMode = "", GoalStatusStopped, GoalResearchAuto g.scopeID = "" g.deliveryCheckpoint = evidence.DeliveryCheckpoint{} } else { - g.goal, g.status, g.researchMode, g.autoResearchTaskID = goal, GoalStatusRunning, mode, autoResearchTaskID + g.goal, g.status, g.researchMode = goal, GoalStatusRunning, mode g.scopeID = newGoalScopeID() g.deliveryCheckpoint = evidence.DeliveryCheckpoint{ScopeID: g.scopeID} g.budgetClass = budgetClassFor(goal, mode) @@ -400,12 +374,9 @@ func (g *goalMachine) pauseFor(stopCause, reason string, todos []evidence.TodoIt return g.buildStateLocked(todos) } -// resume re-enters a recoverable blocked/stopped goal without resetting its -// delivery evidence scope, AutoResearch identity, or runtime history. Turn -// budget pauses (and any pause whose original turn quota is already spent) -// append one turn slice of the current budget class; no-progress counting -// resets but accumulated token usage and budget_extensions are preserved. -// Token hard limits no longer exist, so resume never extends a token ceiling. +// resume re-enters a recoverable blocked/stopped goal without resetting scope +// or runtime history. Budget pauses append one turn slice of the current class; +// token hard limits no longer exist. func (g *goalMachine) resume(todos []evidence.TodoItem) (path string, data []byte, persist, resumed, extended bool) { g.mu.Lock() defer g.mu.Unlock() @@ -486,10 +457,9 @@ func (g *goalMachine) admitContinuation(res goalAdvanceResult) (goalContinuation g.scopeID = newGoalScopeID() } return goalContinuationSnapshot{ - goal: g.goal, - researchMode: g.researchMode, - autoResearchTaskID: g.autoResearchTaskID, - scopeID: g.scopeID, + goal: g.goal, + researchMode: g.researchMode, + scopeID: g.scopeID, }, true } @@ -663,7 +633,6 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string, Goal: g.goal, Status: g.status, ResearchMode: g.researchMode, - AutoResearchTaskID: g.autoResearchTaskID, ScopeID: g.scopeID, DeliveryCheckpoint: g.deliveryCheckpoint, Turns: g.turnsUsed, @@ -757,19 +726,13 @@ func (g *goalMachine) terminalTodosFromState(sessionPath string) ([]evidence.Tod return append([]evidence.TodoItem(nil), state.Todos...), true } -// restoreFromState reloads Goal state from the persisted sidecar during resume. -// The sidecar is authoritative when present: a stale tab profile must not turn -// a blocked or stopped Goal back into a running one during a controller rebuild. -// Recoverable terminal states retain their scope for an explicit ResumeGoal. -// Old sidecars missing the budget fields get their defaults re-derived: the -// existing Turns count carries into the new turn budget, tokens start from 0, -// and the budget class is recomputed from the goal text. -// -// When a legacy budget_tokens pause is cleared, migrated is true and path/data -// carry the rewritten state for immediate atomic persistence (no provider call). -func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []byte, migrated bool) { +// restoreFromState reloads Goal state from the sidecar. The sidecar is +// 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) { if strings.TrimSpace(sessionPath) == "" { - return "", nil, false + return "", nil, false, "" } // Ensure write path is bound even when the controller rebuilds. if g.statePath == "" { @@ -780,12 +743,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, "" } 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, "" } g.mu.Lock() defer g.mu.Unlock() @@ -795,7 +758,13 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data [] g.status = GoalStatusStopped } g.researchMode = state.ResearchMode - g.autoResearchTaskID = strings.TrimSpace(state.AutoResearchTaskID) + // 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 + } g.scopeID = strings.TrimSpace(state.ScopeID) if g.goal != "" && g.scopeID == "" { g.scopeID = newGoalScopeID() @@ -824,7 +793,6 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data [] // 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 - migrated = false if g.goal != "" { g.budgetClass = state.BudgetClass if g.budgetClass == "" { @@ -864,10 +832,42 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data [] // snapshot carried by the authoritative sidecar instead of clearing it. path, data, ok := g.buildStateLocked(state.Todos) if ok { - return path, data, true + return path, data, true, legacyTaskID } } - return "", nil, false + 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) } // formatIncompleteTodos renders the reminder shown when a complete claim diff --git a/internal/control/goal_runtime_test.go b/internal/control/goal_runtime_test.go index 299e3d9ef8..5b7c9b717c 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", GoalResearchAuto, 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", GoalResearchAuto, 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", GoalResearchAuto, nil) rec.addUsage(50) if g.tokensUsed != 0 { t.Fatalf("stale usage folded into replacement goal: %d", g.tokensUsed) @@ -488,7 +488,7 @@ func TestGoalLegacyBudgetTokensSidecarAutoResumes(t *testing.T) { t.Fatal(err) } g := &goalMachine{} - migPath, migData, migrated := g.restoreFromState(path) + migPath, migData, migrated, _ := g.restoreFromState(path) if !migrated { t.Fatal("legacy budget_tokens pause must migrate") } @@ -519,7 +519,7 @@ func TestGoalLegacyBudgetTokensSidecarAutoResumes(t *testing.T) { } // Second load must stay running without re-entering the legacy pause. g2 := &goalMachine{} - if _, _, migrated2 := g2.restoreFromState(path); migrated2 { + if _, _, migrated2, _ := g2.restoreFromState(path); migrated2 { t.Fatal("normalized sidecar migrated a second time") } if g2.status != GoalStatusRunning || g2.stopCause != "" { diff --git a/internal/control/goal_test.go b/internal/control/goal_test.go index db7f38e98b..431241f3ed 100644 --- a/internal/control/goal_test.go +++ b/internal/control/goal_test.go @@ -226,530 +226,187 @@ func TestPlainInputWithStrongResearchSignalPreservesRefsWithoutStartingGoal(t *t } } -func TestPlainAutoResearchTaskPathDoesNotResumeGoal(t *testing.T) { +func TestResearchGoalUsesUnifiedGoalBudgetWithoutArchive(t *testing.T) { root := t.TempDir() - prov := &scriptedTurns{turns: [][]provider.Chunk{ - textTurn("Handled as an ordinary turn."), - }} - ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - events := make(chan event.Event, 8) - c := New(Options{ - WorkspaceRoot: root, - Runner: ag, - Executor: ag, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.TurnDone || e.Kind == event.Notice { - events <- e - } - }), - }) - defer c.Close() - c.SetGoalWithResearchMode("seed resumable task", GoalResearchOn) - taskID := c.goals.currentAutoResearchTaskID() - if taskID == "" { - t.Fatal("expected seeded AutoResearch task") - } - c.ClearGoal() - - input := "继续 .reasonix/autoresearch/" + taskID + "/ 这个任务" - c.Submit(input) - waitForTurnDone(t, events) - - if prov.call != 1 { - t.Fatalf("provider calls = %d, want 1", prov.call) - } - first := firstUserMessage(ag.Session().Messages) - if !strings.HasSuffix(first, input) { - t.Fatalf("ordinary task path should preserve the original prompt suffix: %q", first) - } - if strings.Contains(first, "") || strings.Contains(first, "AutoResearch protocol") { - t.Fatalf("ordinary task path should not enter Goal or AutoResearch:\n%s", first) - } - if got := c.Goal(); got != "" { - t.Fatalf("ordinary task path should not resume Goal, got %q", got) - } - if got := c.GoalStatus(); got != GoalStatusStopped { - t.Fatalf("GoalStatus() = %q, want stopped", got) - } -} - -func TestResearchGoalCreatesHostManagedAutoResearchTask(t *testing.T) { - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } sessionPath := filepath.Join(root, "sessions", "s.jsonl") - ag := agent.New(&scriptedTurns{}, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - c := New(Options{WorkspaceRoot: root, SessionPath: sessionPath, Runner: ag, Executor: ag}) - + 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) c.SetGoalWithResearchMode("fix the typo and add a test", GoalResearchOn) - - data, err := os.ReadFile(goalStatePath(sessionPath)) - if err != nil { - t.Fatalf("read goal state: %v", err) - } - var state goalState - if err := json.Unmarshal(data, &state); err != nil { - t.Fatalf("unmarshal goal state: %v", err) - } - if state.AutoResearchTaskID == "" { - t.Fatalf("AutoResearchTaskID was empty in persisted goal state: %+v", state) - } - for _, rel := range []string{ - "state/task_spec.json", - "state/progress.json", - "state/findings.jsonl", - "logs/heartbeat.jsonl", - } { - path := filepath.Join(root, ".reasonix", "autoresearch", state.AutoResearchTaskID, rel) - if _, err := os.Stat(path); err != nil { - t.Fatalf("expected autoresearch file %s: %v", rel, err) - } - } - var spec struct { - SuccessCriteria []struct { - ID string `json:"id"` - Required bool `json:"required"` - } `json:"success_criteria"` - } - readJSONFileForTest(t, filepath.Join(root, ".reasonix", "autoresearch", state.AutoResearchTaskID, "state", "task_spec.json"), &spec) - if len(spec.SuccessCriteria) != 2 || spec.SuccessCriteria[0].ID != "objective_evidence" || spec.SuccessCriteria[1].ID != "verification" { - t.Fatalf("default success criteria = %+v, want objective_evidence and verification", spec.SuccessCriteria) - } - for _, criterion := range spec.SuccessCriteria { - if !criterion.Required { - t.Fatalf("default criterion %+v was not required", criterion) - } - } - - composed := c.Compose("continue") - if !strings.Contains(composed, "") || !strings.Contains(composed, "task_id: "+state.AutoResearchTaskID) || !strings.Contains(composed, "objective_evidence") { - t.Fatalf("Compose missing runtime summary for task %q:\n%s", state.AutoResearchTaskID, composed) + defer c.Close() + if got := c.GoalRuntime().TurnsLimit; got != 40 { + t.Fatalf("research Goal turns limit = %d, want 40", got) } -} - -func TestResearchGoalCreatedEmitsLifecycleNotice(t *testing.T) { - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved + if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) { + t.Fatalf("research Goal created legacy archive: %v", err) } - events := make(chan event.Event, 4) - c := New(Options{ - WorkspaceRoot: root, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - events <- e - } - }), - }) - - c.SetGoalWithResearchMode("investigate lifecycle notice", GoalResearchOn) - - select { - case e := <-events: - if !strings.Contains(e.Text, "autoresearch task created") { - t.Fatalf("notice = %q, want autoresearch task created", e.Text) - } - default: - t.Fatal("expected autoresearch lifecycle notice") + if composed := c.Compose("continue"); strings.Contains(composed, "AutoResearch") || strings.Contains(composed, "autoresearch") { + t.Fatalf("Goal prompt exposes removed AutoResearch protocol:\n%s", composed) } } -func TestResearchGoalRepeatedSetReusesAutoResearchTask(t *testing.T) { +func TestLegacyGoalSidecarMigratesToResearchBudgetWithoutTaskID(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) } - events := make(chan event.Event, 8) - c := New(Options{ - WorkspaceRoot: root, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - events <- e - } - }), - }) - - goal := "请持续研究当前项目的 AutoResearch 状态栏展示链路,验证任务创建、状态刷新、右侧 Context 面板展示、状态栏 chip 展示是否一致。不要只看表面现象,需要找到根因、记录 evidence,并在完成前确认所有验证步骤通过。不要修改文件" - c.SetGoalWithResearchMode(goal, GoalResearchOn) - _, _, _, firstTaskID := c.goals.snapshot() - if firstTaskID == "" { - t.Fatal("first AutoResearch task id was empty") + if err := os.WriteFile(goalStatePath(sessionPath), []byte(`{"goal":"investigate runtime","status":"running","researchMode":1,"autoResearchTaskID":"old-task"}`), 0o644); err != nil { + t.Fatal(err) } - - c.SetGoalWithResearchMode(goal, GoalResearchOn) - c.SetGoal(goal) - _, _, _, repeatedTaskID := c.goals.snapshot() - if repeatedTaskID != firstTaskID { - t.Fatalf("repeated SetGoal created a new task: got %q, want %q", repeatedTaskID, firstTaskID) + 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.GoalRuntime().TurnsLimit; got != 40 { + t.Fatalf("migrated Goal turns limit = %d, want 40", got) } - - entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch")) + raw, err := os.ReadFile(goalStatePath(sessionPath)) if err != nil { - t.Fatalf("read autoresearch dir: %v", err) + t.Fatal(err) } - if len(entries) != 1 { - t.Fatalf("autoresearch task count = %d, want 1", len(entries)) + var state goalState + if err := json.Unmarshal(raw, &state); err != nil { + t.Fatal(err) } - - createdNotices := 0 - for { - select { - case e := <-events: - if strings.Contains(e.Text, "autoresearch task created") { - createdNotices++ - } - default: - if createdNotices != 1 { - t.Fatalf("created notices = %d, want 1", createdNotices) - } - return - } + if state.AutoResearchTaskID != "" { + t.Fatalf("migrated sidecar retained old task id: %q", state.AutoResearchTaskID) } } -func TestResearchGoalMissingExplicitTaskBlocksInsteadOfCreatingNewTask(t *testing.T) { +func TestMissingExplicitLegacyTaskBlocksWithoutCreatingArchive(t *testing.T) { root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - sessionPath := filepath.Join(root, "sessions", "s.jsonl") - var notices []string - c := New(Options{ - WorkspaceRoot: root, - SessionPath: sessionPath, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - notices = append(notices, e.Text) - } - }), - }) - + c := New(Options{WorkspaceRoot: root}) + defer c.Close() c.SetGoalWithResearchMode("resume .reasonix/autoresearch/missing-task/", GoalResearchOn) - if got := c.GoalStatus(); got != GoalStatusBlocked { - t.Fatalf("GoalStatus() = %q, want blocked for missing explicit AutoResearch task", got) - } - if got := c.goals.currentAutoResearchTaskID(); got != "" { - t.Fatalf("current AutoResearch task id = %q, want none for missing explicit task", got) - } - entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch")) - if err != nil && !os.IsNotExist(err) { - t.Fatalf("ReadDir autoresearch root: %v", err) - } - if len(entries) != 0 { - t.Fatalf("created tasks for missing explicit resume: %+v", entries) - } - if !containsNotice(notices, "autoresearch resume failed") || !containsNotice(notices, "missing-task") { - t.Fatalf("notices = %+v, want explicit resume failure", notices) - } -} - -func TestResearchGoalTurnAppendsAutoResearchHeartbeats(t *testing.T) { - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } - sessionPath := filepath.Join(root, "sessions", "s.jsonl") - prov := &scriptedTurns{turns: flattenTurns( - goalToolTurn(GoalStatusComplete, "", ""), - goalToolTurn(GoalStatusBlocked, "needs a repro trace", ""), - )} - ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - events := make(chan event.Event, 4) - c := New(Options{ - WorkspaceRoot: root, - SessionPath: sessionPath, - Runner: ag, - Executor: ag, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.TurnDone || e.Kind == event.Notice { - events <- e - } - }), - }) - - c.Submit("/goal --research fix the typo and add a test") - waitForTurnDone(t, events) - - data, err := os.ReadFile(goalStatePath(sessionPath)) - if err != nil { - t.Fatalf("read goal state: %v", err) - } - var state goalState - if err := json.Unmarshal(data, &state); err != nil { - t.Fatalf("unmarshal goal state: %v", err) - } - heartbeats, err := c.autoResearch.store.Heartbeats(state.AutoResearchTaskID, 10) - if err != nil { - t.Fatalf("Heartbeats: %v", err) + t.Fatalf("GoalStatus = %q, want blocked", got) } - if len(heartbeats) < 2 { - t.Fatalf("heartbeats = %+v, want at least starting and done", heartbeats) - } - if heartbeats[0].Status != "starting_turn" || heartbeats[len(heartbeats)-1].Status != "turn_done" { - t.Fatalf("heartbeats = %+v, want starting_turn then turn_done", heartbeats) + if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) { + t.Fatalf("missing legacy task created archive: %v", err) } } -func TestResearchGoalTurnUpdatesAutoResearchStaleProgress(t *testing.T) { +func TestAssistantEvidenceBlockIsIgnoredByUnifiedGoal(t *testing.T) { root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved - } sessionPath := filepath.Join(root, "sessions", "s.jsonl") - prov := &scriptedTurns{turns: flattenTurns( - goalToolTurn(GoalStatusRunning, "still investigating", ""), - goalToolTurn(GoalStatusBlocked, "needs a repro trace", ""), - )} + prov := &scriptedTurns{turns: flattenTurns(goalToolTurn(GoalStatusComplete, "", ""))} ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - events := make(chan event.Event, 8) - c := New(Options{ - WorkspaceRoot: root, - SessionPath: sessionPath, - Runner: ag, - Executor: ag, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.TurnDone || e.Kind == event.Notice { - events <- e - } - }), - }) - - c.Submit("/goal --research investigate stale progress") - waitForTurnDone(t, events) - - data, err := os.ReadFile(goalStatePath(sessionPath)) - if err != nil { - t.Fatalf("read goal state: %v", err) - } - var state goalState - if err := json.Unmarshal(data, &state); err != nil { - t.Fatalf("unmarshal goal state: %v", err) - } - summary, err := c.autoResearch.store.Summary(state.AutoResearchTaskID) - if err != nil { - t.Fatalf("Summary: %v", err) + c := New(Options{WorkspaceRoot: root, SessionPath: sessionPath, Runner: ag, Executor: ag}) + defer c.Close() + c.SetGoalWithResearchMode("verify the fix", GoalResearchOn) + _ = newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start") + if got := c.GoalStatus(); got != GoalStatusComplete { + t.Fatalf("GoalStatus = %q, want complete", got) } - if summary.Iteration < 2 || summary.StaleCount != summary.Iteration || !summary.PivotRequired || summary.PivotCount != 1 { - t.Fatalf("summary = %+v, want stale progress for every no-evidence turn and pivot required", summary) + if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) { + t.Fatalf("assistant evidence created archive: %v", err) } } -func TestResearchGoalCompletionIsInterceptedWhenReadinessFails(t *testing.T) { +func TestExplicitLegacyTaskPathRestoresOriginalGoal(t *testing.T) { root := t.TempDir() if resolved, err := filepath.EvalSymlinks(root); err == nil { root = resolved } - sessionPath := filepath.Join(root, "sessions", "s.jsonl") - prov := &scriptedTurns{turns: flattenTurns( - goalToolTurn(GoalStatusComplete, "", ""), - goalToolTurn(GoalStatusBlocked, "missing evidence", ""), - )} - ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - events := make(chan event.Event, 8) - var notices []string - c := New(Options{ - WorkspaceRoot: root, - SessionPath: sessionPath, - Runner: ag, - Executor: ag, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - notices = append(notices, e.Text) - } - if e.Kind == event.TurnDone || e.Kind == event.Notice { - events <- e - } - }), - }) - - c.SetGoalWithResearchMode("identify the root cause", GoalResearchOn) - - if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil { - t.Fatalf("runGoalLoopWithRawDisplay: %v", err) - } - - if got := c.GoalStatus(); got != GoalStatusBlocked { - t.Fatalf("GoalStatus() = %q, want blocked after the readiness intercept and a blocked report", got) - } - if prov.call != 4 { - t.Fatalf("provider calls = %d, want complete-intercepted + blocked turns (2 provider calls each)", prov.call) - } - if !sessionContainsUserText(ag.Session().Messages, "AutoResearch readiness check failed", "objective_evidence", "verification") { - t.Fatalf("transcript missing readiness intercept; last user:\n%s", lastUserMessage(ag.Session().Messages)) + taskID := "20260630-original-goal" + taskRoot := filepath.Join(root, ".reasonix", "autoresearch", taskID) + if err := os.MkdirAll(filepath.Join(taskRoot, "state"), 0o755); err != nil { + t.Fatal(err) } - if !containsNotice(notices, "Goal is not ready to complete yet; continuing the remaining work.") { - t.Fatalf("notices = %+v, want readiness continuation notice", notices) + if err := os.MkdirAll(filepath.Join(taskRoot, "logs"), 0o755); err != nil { + t.Fatal(err) } -} - -func TestControllerRecordsAutoResearchEvidence(t *testing.T) { - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved + spec := `{"task_id":"` + taskID + `","goal":"find the original root cause","allowed_operations":{"write":true},"success_criteria":[]}` + progress := `{"status":"running","iteration":2,"updated_at":"2026-06-30T10:00:00Z"}` + for name, body := range map[string]string{ + "state/task_spec.json": spec, + "state/progress.json": progress, + "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) + } } - c := New(Options{WorkspaceRoot: root}) - c.SetGoalWithResearchMode("verify the fix", GoalResearchOn) - taskID := c.goals.currentAutoResearchTaskID() - if taskID == "" { - t.Fatal("expected autoresearch task id") - } - - err := c.RecordAutoResearchEvidence("objective_evidence", AutoResearchEvidenceInput{ - ID: "f-objective", - Kind: "file", - Summary: "implementation inspected", - Source: "file", - Paths: []string{"internal/control/controller.go"}, - Accepted: true, - }) - if err != nil { - t.Fatalf("RecordAutoResearchEvidence objective_evidence: %v", err) - } - err = c.RecordAutoResearchEvidence("verification", AutoResearchEvidenceInput{ - ID: "f-verification", - Kind: "test", - Summary: "go test passed", - Source: "command", - Command: "go test ./internal/control", - Accepted: true, - }) + before, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json")) if err != nil { - t.Fatalf("RecordAutoResearchEvidence verification: %v", err) + t.Fatal(err) } - - report, err := c.autoResearch.store.Readiness(taskID) - if err != nil { - t.Fatalf("Readiness: %v", err) + c := New(Options{WorkspaceRoot: root}) + defer c.Close() + c.SetGoalWithResearchMode("resume .reasonix/autoresearch/"+taskID+"/", GoalResearchAuto) + if got := c.Goal(); got != "find the original root cause" { + t.Fatalf("Goal() = %q, want original archive goal", got) } - if !report.Ready { - t.Fatalf("readiness = %+v, want ready", report) + if got := c.GoalRuntime().TurnsLimit; got != 40 { + t.Fatalf("turns limit = %d, want research budget", got) } -} - -func TestAutoResearchEvidenceDoesNotChangeDefaultToolSurface(t *testing.T) { - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved + if got := c.GoalStatus(); got != GoalStatusRunning { + t.Fatalf("status = %q", got) } - reg := tool.NewRegistry() - New(Options{WorkspaceRoot: root, Registry: reg}) - if _, ok := reg.Get("autoresearch_record_evidence"); ok { - t.Fatalf("autoresearch_record_evidence should not be registered in the default provider-visible tool surface; tools=%v", reg.Names()) + after, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json")) + if err != nil { + t.Fatal(err) } - for _, schema := range reg.Schemas() { - if schema.Name == "autoresearch_record_evidence" { - t.Fatalf("autoresearch_record_evidence should not appear in provider schemas: %+v", reg.Schemas()) - } + if string(before) != string(after) { + t.Fatal("archive task_spec mutated during resume") } } -func TestResearchGoalCompletionMarksAutoResearchTaskComplete(t *testing.T) { +func TestLegacySidecarEmptyGoalFilledFromArchive(t *testing.T) { root := t.TempDir() if resolved, err := filepath.EvalSymlinks(root); err == nil { root = resolved } sessionPath := filepath.Join(root, "sessions", "s.jsonl") - prov := &scriptedTurns{turns: flattenTurns( - [][]provider.Chunk{ - {toolCallChunk("ug1", "update_goal", `{"status":"complete","reason":""}`), {Type: provider.ChunkDone}}, - textTurn(`Done. - - -{"criterion_id":"objective_evidence","id":"f-objective","kind":"file","summary":"The implementation state was inspected directly.","source":"file","paths":["internal/control/controller.go"],"accepted":true} - - -{"criterion_id":"verification","id":"f-verification","kind":"test","summary":"The focused AutoResearch tests passed.","source":"command","command":"go test ./internal/control","accepted":true} -`), - }, - )} - ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - var notices []string - c := New(Options{ - WorkspaceRoot: root, - SessionPath: sessionPath, - Runner: ag, - Executor: ag, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - notices = append(notices, e.Text) - } - }), - }) - c.SetGoalWithResearchMode("verify completion lifecycle", GoalResearchOn) - taskID := c.goals.currentAutoResearchTaskID() - if taskID == "" { - t.Fatal("expected autoresearch task id") - } - - if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil { - t.Fatalf("runGoalLoopWithRawDisplay: %v", err) - } - - summary, err := c.autoResearch.store.Summary(taskID) - if err != nil { - t.Fatalf("Summary: %v", err) - } - if summary.Status != "complete" { - t.Fatalf("AutoResearch status = %q, want complete", summary.Status) - } - if summary.StaleCount != 0 { - t.Fatalf("AutoResearch stale_count = %d, want 0 after accepted evidence", summary.StaleCount) + if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { + t.Fatal(err) } - findings, err := c.autoResearch.store.Findings(taskID, 0) - if err != nil { - t.Fatalf("Findings: %v", err) + taskID := "fill-from-archive" + taskRoot := filepath.Join(root, ".reasonix", "autoresearch", taskID) + if err := os.MkdirAll(filepath.Join(taskRoot, "state"), 0o755); err != nil { + t.Fatal(err) } - if len(findings) != 2 { - t.Fatalf("findings = %+v, want two assistant evidence records", findings) + if err := os.MkdirAll(filepath.Join(taskRoot, "logs"), 0o755); err != nil { + t.Fatal(err) } - if !containsNotice(notices, "autoresearch task completed") { - t.Fatalf("notices = %+v, want autoresearch task completed", notices) + for name, body := range map[string]string{ + "state/task_spec.json": `{"task_id":"` + taskID + `","goal":"recover me from archive","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) + } } -} - -func TestResearchGoalBlockedMarksAutoResearchTaskBlocked(t *testing.T) { - root := t.TempDir() - if resolved, err := filepath.EvalSymlinks(root); err == nil { - root = resolved + if err := os.WriteFile(goalStatePath(sessionPath), []byte(`{"status":"running","researchMode":1,"autoResearchTaskID":"`+taskID+`","turnsUsed":3,"turnsLimit":40}`), 0o644); err != nil { + t.Fatal(err) } - sessionPath := filepath.Join(root, "sessions", "s.jsonl") - prov := &scriptedTurns{turns: flattenTurns( - goalToolTurn(GoalStatusBlocked, "needs credentials", ""), - )} - ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) - var notices []string - c := New(Options{ - WorkspaceRoot: root, - SessionPath: sessionPath, - Runner: ag, - Executor: ag, - Sink: event.FuncSink(func(e event.Event) { - if e.Kind == event.Notice { - notices = append(notices, e.Text) - } - }), - }) - c.SetGoalWithResearchMode("verify blocked lifecycle", GoalResearchOn) - taskID := c.goals.currentAutoResearchTaskID() - if taskID == "" { - t.Fatal("expected autoresearch task id") + 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 != "recover me from archive" { + t.Fatalf("Goal() = %q", got) } - - if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil { - t.Fatalf("runGoalLoopWithRawDisplay: %v", err) + if got := c.GoalRuntime().TurnsUsed; got != 3 { + t.Fatalf("turns used = %d, want preserved 3", got) } - - summary, err := c.autoResearch.store.Summary(taskID) + raw, err := os.ReadFile(goalStatePath(sessionPath)) if err != nil { - t.Fatalf("Summary: %v", err) - } - if summary.Status != "blocked" || !strings.Contains(summary.Blocker, "needs credentials") { - t.Fatalf("AutoResearch summary = %+v, want blocked with reason", summary) + t.Fatal(err) } - if !containsNotice(notices, "AutoResearch task marked blocked.") { - t.Fatalf("notices = %+v, want autoresearch blocked notice", notices) + if strings.Contains(string(raw), "autoResearchTaskID") { + t.Fatalf("sidecar retained task id: %s", raw) } } @@ -1009,7 +666,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", GoalResearchAuto, nil) res := g.advance(goalAdvanceInput{ report: &goalTurnReport{status: GoalStatusComplete, reason: ""}, todos: []evidence.TodoItem{{ @@ -1034,7 +691,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", GoalResearchAuto, nil) if got, ok := g.acceptContinuation(res); ok { t.Fatalf("replacement goal accepted stale intercept %q", got) } diff --git a/internal/control/input.go b/internal/control/input.go index e37d2c612d..c1469b98ee 100644 --- a/internal/control/input.go +++ b/internal/control/input.go @@ -3,7 +3,6 @@ package control import ( "context" "fmt" - "strconv" "strings" "unicode" @@ -139,7 +138,7 @@ func (c *Controller) Compose(text string) string { } func (c *Controller) compose(text, source string, includeHookContext bool) string { - goal, goalStatus, goalResearchMode, autoResearchTaskID := c.goals.snapshot() + goal, goalStatus, goalResearchMode := c.goals.snapshot() return c.composeWithGoal( text, source, @@ -147,7 +146,6 @@ func (c *Controller) compose(text, source string, includeHookContext bool) strin goal, goalStatus, goalResearchMode, - autoResearchTaskID, ) } @@ -156,7 +154,6 @@ func (c *Controller) composeWithGoal( includeHookContext bool, goal, goalStatus string, goalResearchMode GoalResearchMode, - autoResearchTaskID string, ) string { c.mu.Lock() plan := c.planMode @@ -167,9 +164,6 @@ func (c *Controller) composeWithGoal( if strings.TrimSpace(goal) != "" && goalStatus == GoalStatusRunning { prefix := activeGoalBlock(goal, goalResearchMode) - if runtime := c.autoResearchRuntimeBlock(autoResearchTaskID); runtime != "" { - prefix += "\n\n" + runtime - } text = prefix + "\n\n" + text } if plan { @@ -291,53 +285,6 @@ func escapeHookContext(s string) string { return strings.ReplaceAll(s, "", "<\\/"+hookContextTag+">") } -func (c *Controller) autoResearchRuntimeBlock(taskID string) string { - if !c.autoResearch.enabled() || strings.TrimSpace(taskID) == "" { - return "" - } - summary, err := c.autoResearch.summary(taskID) - if err != nil { - return "\nstatus: invalid\nerror: " + strings.ReplaceAll(err.Error(), autoResearchRuntimeClose, "<\\/autoresearch-runtime>") + "\n" - } - var b strings.Builder - b.WriteString("\n") - b.WriteString("task_id: " + summary.TaskID + "\n") - b.WriteString("status: " + summary.Status + "\n") - b.WriteString("iteration: ") - b.WriteString(strconv.Itoa(summary.Iteration)) - b.WriteString("\n") - b.WriteString("current_direction: " + summary.CurrentDirection + "\n") - b.WriteString("stale_count: ") - b.WriteString(strconv.Itoa(summary.StaleCount)) - b.WriteString("\n") - b.WriteString("pivot_count: ") - b.WriteString(strconv.Itoa(summary.PivotCount)) - b.WriteString("\n") - if summary.PivotRequired { - b.WriteString("pivot_required: true\n") - } else { - b.WriteString("pivot_required: false\n") - } - b.WriteString("open_success_criteria: ") - b.WriteString(strconv.Itoa(len(summary.OpenCriteria))) - b.WriteString("\n") - for _, criterion := range summary.OpenCriteria { - b.WriteString("- ") - b.WriteString(criterion.ID) - b.WriteString(": ") - b.WriteString(strings.ReplaceAll(criterion.Description, "\n", " ")) - b.WriteString("\n") - } - if summary.Blocker != "" { - b.WriteString("blocker: " + summary.Blocker + "\n") - } - b.WriteString("next_required_action: " + summary.NextRequiredAction + "\n") - b.WriteString("") - return b.String() -} - -const autoResearchRuntimeClose = "" - func reasoningLanguageBlock(lang string) string { return agent.ReasoningLanguageBlock(lang) } @@ -352,6 +299,7 @@ func (c *Controller) ComposeSynthetic(text string) string { } func activeGoalBlock(goal string, researchMode GoalResearchMode) string { + _ = researchMode // retained for call-site stability; budget selection is host-side only goal = strings.TrimSpace(goal) goal = strings.ReplaceAll(goal, activeGoalClose, "<\\/active-goal>") var b strings.Builder @@ -360,10 +308,6 @@ func activeGoalBlock(goal string, researchMode GoalResearchMode) string { b.WriteString(goal) b.WriteString("\n\n") b.WriteString(goalTaskContractInstructions) - if shouldUseAutoResearch(goal, researchMode) { - b.WriteString("\n\n") - b.WriteString(autoResearchGoalInstructions) - } b.WriteString("\n") b.WriteString(activeGoalClose) return b.String() @@ -377,106 +321,6 @@ const goalTaskContractInstructions = `Goal mode: pursue this goal autonomously. Do not stop after describing a plan; execute the next useful step. End every goal-mode turn by calling the update_goal tool with your disposition: continue (work is ongoing — give the next concrete step in next_action), complete (only when fully done and verified), or blocked (only when the user can unblock). The host validates your claim and decides whether to continue automatically.` -const autoResearchGoalInstructions = `AutoResearch protocol: this goal looks like long-horizon research, debugging, optimization, or implementation work. Treat AutoResearch as a durable strategy for this Goal, not as a background daemon or a global skill. -- Say briefly in the first visible reply that the goal is being handled with AutoResearch and that host-owned state lives under .reasonix/autoresearch//, using the actual task_id from . -- Keep dynamic state out of REASONIX.md, AGENTS.md, project memory, system prompts, and tool schemas. Use project-local .reasonix/autoresearch/ state only. -- Use the task_id and open_success_criteria in as authoritative. The host creates task ids and owns state/task_spec.json, state/progress.json, state/findings.jsonl, state/directions_tried.json, state/iteration_log.jsonl, and logs/heartbeat.jsonl. -- Do not hand-edit the host-owned AutoResearch state files. When you have direct evidence for an open criterion, include an block in your assistant reply so the host can persist it: - -{"criterion_id":"objective_evidence","kind":"file","summary":"What was directly observed","source":"file","paths":["relative/path"],"accepted":true} - -- Before each iteration, use the runtime summary as authoritative, choose a direction that differs materially from directions already tried, execute the smallest evidence-producing chunk, verify it, and report accepted evidence with blocks. -- Increment stale_count when an iteration lacks accepted evidence or repeats a prior direction. At stale_count >= 2, make a structural pivot such as changing evidence source, entrypoint, implementation boundary, test oracle, benchmark, decomposition, environment, platform, or refutation angle. At stale_count >= 4, stop autonomous digging and ask for the smallest external input needed. -- Workers or subagents may gather evidence, but the orchestrator owns canonical state writes. Workers must not publish, push, delete, contact external systems, or write canonical state unless explicitly designated. -- Complete only after auditing every open success criterion in against direct evidence. Public publishing, destructive changes, credential use, payments, external notifications, privacy-sensitive output, and cache-sensitive changes still require the normal Reasonix gates.` - -func shouldUseAutoResearch(goal string, mode GoalResearchMode) bool { - switch mode { - case GoalResearchOn: - return true - case GoalResearchOff: - return false - } - return isAutoResearchGoal(goal) -} - -func isAutoResearchGoal(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 autoResearchStrongKeywords { - if strings.Contains(lower, kw) { - return true - } - } - return autoResearchPhaseCount(lower) >= 4 -} - -func autoResearchPhaseCount(lower string) int { - categories := 0 - for _, group := range autoResearchPhaseKeywords { - if containsAnyGoalKeyword(lower, group) { - categories++ - } - } - return categories -} - -var autoResearchStrongKeywords = []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 autoResearchPhaseKeywords = [][]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"}, -} - -func containsAnyGoalKeyword(s string, needles []string) bool { - for _, needle := range needles { - if strings.Contains(s, needle) { - return true - } - } - return false -} - // MemoryQuickAddNote parses the "# " memory shortcut. The space after // "#" is intentional: "#7", "#issue", and "#标题" are ordinary user prompts, // not memory writes. Multi-line input starting with "# " is NOT treated as a @@ -518,10 +362,11 @@ const ( ) type GoalCommand struct { - Action GoalCommandAction - Text string - Strict bool - ResearchMode GoalResearchMode + Action GoalCommandAction + Text string + Strict bool + ResearchMode GoalResearchMode + DeprecatedBudgetFlag bool } func ParseGoalCommand(input string) (GoalCommand, bool) { @@ -531,18 +376,19 @@ func ParseGoalCommand(input string) (GoalCommand, bool) { } args := strings.TrimSpace(trimmed[len("/goal"):]) strict, researchMode, actionArgs := parseLeadingGoalFlags(args) + deprecatedBudgetFlag := researchMode != GoalResearchAuto switch strings.ToLower(actionArgs) { case "", "status": - return GoalCommand{Action: GoalCommandStatus, Strict: strict, ResearchMode: researchMode}, true + return GoalCommand{Action: GoalCommandStatus, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true case "clear", "off", "stop", "done": - return GoalCommand{Action: GoalCommandClear, Strict: strict, ResearchMode: researchMode}, true + return GoalCommand{Action: GoalCommandClear, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true case "pause": - return GoalCommand{Action: GoalCommandPause, Strict: strict, ResearchMode: researchMode}, true + return GoalCommand{Action: GoalCommandPause, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true case "resume": - return GoalCommand{Action: GoalCommandResume, Strict: strict, ResearchMode: researchMode}, true + return GoalCommand{Action: GoalCommandResume, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true default: - return GoalCommand{Action: GoalCommandSet, Text: actionArgs, Strict: strict, ResearchMode: researchMode}, true + return GoalCommand{Action: GoalCommandSet, Text: actionArgs, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true } } diff --git a/internal/control/input_test.go b/internal/control/input_test.go index ed586c7190..fcea60b20d 100644 --- a/internal/control/input_test.go +++ b/internal/control/input_test.go @@ -844,30 +844,24 @@ func TestGoalAutoResearchTriggersForLongHorizonGoals(t *testing.T) { c.SetGoal("持续排查这个线上卡顿直到根因明确,并验证修复") got := c.Compose("next step?") - for _, want := range []string{ - "AutoResearch protocol", - "", - "task_id:", - "pivot_required:", - "stale_count >= 2", - "durable strategy for this Goal", - } { - if !strings.Contains(got, want) { - t.Fatalf("AutoResearch goal block missing %q:\n%s", want, got) - } + if !strings.Contains(got, "") || 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 = "
" ) -// StripAutoResearchEvidenceBlocks removes protocol -// blocks the model emits for the controller's evidence recorder. Like goal -// markers, they stay in the session history for parsing (#6665). +// StripAutoResearchEvidenceBlocks removes historical +// blocks at display boundaries only. Raw transcripts remain unchanged; current +// Goal semantics never parse these blocks or write state from them. func StripAutoResearchEvidenceBlocks(text string) string { var b strings.Builder rest := text diff --git a/internal/control/autoresearch_manager.go b/internal/control/autoresearch_manager.go index a6c3a14123..9c2ba87c5e 100644 --- a/internal/control/autoresearch_manager.go +++ b/internal/control/autoresearch_manager.go @@ -65,11 +65,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 @@ -79,7 +74,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") ) diff --git a/internal/control/controller.go b/internal/control/controller.go index 853d1b41be..93fda7dc4b 100644 --- a/internal/control/controller.go +++ b/internal/control/controller.go @@ -2674,9 +2674,8 @@ func (c *Controller) SetGoal(goal string) { } // 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 { +// atomically. +func (c *Controller) SetGoalDurable(goal string) error { snapshot := c.goals.capture() legacySnapshot, hadLegacySnapshot := c.legacyRestoreSnapshot() resolved, setup := c.resolveGoalText(goal, GoalResearchAuto)