fix(goal): harden contextual tool and migration boundaries

Problem:
Context-dependent tools could be called outside their owning Goal, Plan, Jobs, or child-agent context, and failed legacy AutoResearch migration lost its retry identity after restart.

Root cause:
Availability was enforced by scattered execution checks while provider-visible registration, host metadata, mixed batches, and inherited child context used different state. The Goal sidecar writer also fenced every new write without preserving a pending legacy task id.

Fix:
Add a shared ContextualTool execution contract with one-repair run-loop handling, isolate child Goal/Jobs/memory state, project contextual schemas only for host metadata, and retain pending legacy task ids until a Goal-only sidecar is durably committed.

Verification:
go test ./... -count=1
cd desktop && go test ./... -count=1
go test -race ./internal/control ./internal/agent ./internal/tool ./internal/tool/builtin ./internal/jobs ./internal/memory -count=1
go vet ./...
golangci-lint run --timeout=5m
scripts/cache-guard.sh
git diff --check
This commit is contained in:
SivanCola committed 2026-08-09 09:10:28 +08:00
1 parent 2c0303c067
commit a6238139c4
23 files changed
+520 -38

No files matched your search

+10 -2
View File
@@ -10,10 +10,18 @@ branch.
- Goal is now the sole long-task runtime. Historical AutoResearch sidecars
migrate transactionally into research-budget Goals. Invalid archives block
fail closed and remain read-only; successful Goal-only sidecars omit the old
task id and write an explicit downgrade fence so previous readers cannot
fail closed and remain read-only, retaining the task id and compatibility mode
for a restart or `/goal resume` retry; successful Goal-only sidecars omit the
old task id and write an explicit downgrade fence so previous readers cannot
reactivate the removed runtime.
- Context-dependent workflow tools now share one host-side execution boundary.
Goal, Plan sign-off, and background-job calls cannot reach permissions,
hooks, leases, or Execute outside their owning context; mixed batches execute
valid calls once and stop safely after one repair. Child agents also isolate
inherited Goal, Jobs, and live memory queues, while persisted tool identity
records the effective child schema projection.
- **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
+25
View File
@@ -139,6 +139,27 @@ func PlanModeFromContext(ctx context.Context) bool {
return ok && cc.planMode
}
// withAgentContext establishes the agent-owned workflow capabilities for a
// model round and for tool availability checks. Missing capabilities shadow
// inherited values so child agents cannot reach parent Goal, Jobs, or memory
// state accidentally.
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)
}
if a.memQueue != nil {
ctx = memory.WithQueue(ctx, a.memQueue)
} else {
ctx = memory.WithoutQueue(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 {
@@ -1076,6 +1097,9 @@ type Options struct {
// Jobs is the session's background-job manager (nil disables background tools).
Jobs *jobs.Manager
// MemoryQueue optionally gives a child agent an explicitly owned live-memory
// queue. When nil, child construction shadows inherited queues.
MemoryQueue memory.Queue
// WriteScheduler is the session-scoped subagent concurrency/write-claim
// controller. When set on the parent executor, write-capable tools reserve
@@ -1246,6 +1270,7 @@ func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Op
configWriteApprover: configWriteApprover,
hooks: hooks,
jobs: opts.Jobs,
memQueue: opts.MemoryQueue,
writeScheduler: opts.WriteScheduler,
writeWorkspaceRoot: strings.TrimSpace(opts.WriteWorkspaceRoot),
workspaceLease: opts.WorkspaceLease,
+102
View File
@@ -0,0 +1,102 @@
package agent
import (
"context"
"encoding/json"
"strings"
"sync/atomic"
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
type unavailableTool struct {
fakeTool
calls *int32
}
func (u unavailableTool) ProviderVisible(context.Context) bool { return false }
func (u unavailableTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
if u.calls != nil {
atomic.AddInt32(u.calls, 1)
}
return u.fakeTool.Execute(ctx, args)
}
func TestContextualToolHostGateRunsBeforePermissionAndExecute(t *testing.T) {
var executions int32
reg := tool.NewRegistry()
reg.Add(unavailableTool{fakeTool: fakeTool{name: "phase_tool", readOnly: false}, calls: &executions})
gate := &recordingPermissionGate{allow: true}
a := New(nil, reg, NewSession("sys"), Options{Gate: gate}, event.Discard)
out := a.executeOne(context.Background(), provider.ToolCall{ID: "phase", Name: "phase_tool", Arguments: `{}`})
if !out.blocked || !strings.Contains(out.output, "unavailable") {
t.Fatalf("contextual tool outcome = %+v", out)
}
if executions != 0 || len(gate.calls) != 0 {
t.Fatalf("unavailable tool crossed host gate: executions=%d permission=%+v", executions, gate.calls)
}
}
func TestMixedContextualBatchExecutesAvailableCallsOnce(t *testing.T) {
var executions int32
reg := tool.NewRegistry()
reg.Add(unavailableTool{fakeTool: fakeTool{name: "phase_tool", readOnly: true}, calls: &executions})
reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &executions})
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("phase", "phase_tool", `{}`), toolCallChunk("read", "read_file", `{}`), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "answer after the available read"}, {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
if err := a.Run(context.Background(), "inspect the file"); err != nil {
t.Fatalf("mixed contextual batch failed: %v", err)
}
if got := atomic.LoadInt32(&executions); got != 1 {
t.Fatalf("available tool executions = %d, want exactly one", got)
}
if got := lastToolResult(a.Session(), "phase_tool"); !strings.Contains(got, "unavailable") {
t.Fatalf("contextual result = %q", got)
}
}
func TestRepeatedMixedContextualBatchStopsAllCalls(t *testing.T) {
var executions int32
reg := tool.NewRegistry()
reg.Add(unavailableTool{fakeTool: fakeTool{name: "phase_tool", readOnly: true}, calls: &executions})
reg.Add(fakeTool{name: "read_file", readOnly: true, calls: &executions})
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("phase-1", "phase_tool", `{}`), toolCallChunk("read-1", "read_file", `{}`), {Type: provider.ChunkDone}},
{toolCallChunk("phase-2", "phase_tool", `{}`), toolCallChunk("read-2", "read_file", `{}`), {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
err := a.Run(context.Background(), "inspect the file")
if err == nil || !strings.Contains(err.Error(), "context-unavailable tools") {
t.Fatalf("repeated contextual batch error = %v", err)
}
if got := atomic.LoadInt32(&executions); got != 1 {
t.Fatalf("available tool was re-executed after repair: %d", got)
}
if got := lastToolResult(a.Session(), "read_file"); !strings.Contains(got, "called again") {
t.Fatalf("second legal call was not paired with stop result: %q", got)
}
}
func TestRepeatedPureContextualCallWithVisibleAnswerFinishes(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(unavailableTool{fakeTool: fakeTool{name: "phase_tool", readOnly: true}})
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{toolCallChunk("phase-1", "phase_tool", `{}`), {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "visible answer"}, toolCallChunk("phase-2", "phase_tool", `{}`), {Type: provider.ChunkDone}},
}}
a := New(prov, reg, NewSession("sys"), Options{}, event.Discard)
if err := a.Run(context.Background(), "answer normally"); err != nil {
t.Fatalf("visible answer after repeated contextual call failed: %v", err)
}
if got := lastAssistantContent(a.Session()); got != "visible answer" {
t.Fatalf("final answer = %q", got)
}
}
+39
View File
@@ -63,6 +63,7 @@ type toolCallPlan struct {
// — the caller emits ToolDispatch/ToolResult — so it is safe to invoke from
// parallel goroutines. Stages: parse → policy → prepare → finish.
func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) (out toolOutcome) {
ctx = a.withAgentContext(ctx)
plan := &toolCallPlan{call: call}
defer func() {
if plan.mutationObserved && !plan.mutationAfterDone {
@@ -170,6 +171,9 @@ func (a *Agent) resolveToolPolicy(ctx context.Context, plan *toolCallPlan) (tool
if blocked, early := a.applyPlanModeAndProxy(ctx, plan); early {
return blocked, true
}
if blocked, early := a.applyContextualToolGate(ctx, plan); early {
return blocked, true
}
if blocked, early := a.applyDeliveryPolicyGates(plan); early {
return blocked, true
}
@@ -186,6 +190,38 @@ func (a *Agent) resolveToolPolicy(ctx context.Context, plan *toolCallPlan) (tool
return toolOutcome{}, false
}
func (a *Agent) applyContextualToolGate(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
if plan == nil || plan.tool == nil {
return toolOutcome{}, false
}
if outcome, blocked := contextualToolGateOutcome(ctx, plan.tool, plan.canonicalName); blocked {
return outcome, true
}
if plan.execTool != nil {
if outcome, blocked := contextualToolGateOutcome(ctx, plan.execTool, plan.permName); blocked {
return outcome, true
}
}
return toolOutcome{}, false
}
func contextualToolGateOutcome(ctx context.Context, target tool.Tool, name string) (toolOutcome, bool) {
contextual, ok := target.(tool.ContextualTool)
if !ok || contextual.ProviderVisible(ctx) {
return toolOutcome{}, false
}
msg := fmt.Sprintf("blocked: tool %q is unavailable in the current workflow context", name)
switch name {
case "update_goal":
msg = "update_goal is only available while an active goal turn is running — no goal state was changed"
case "complete_step":
msg = "blocked: complete_step is only available after plan approval. While planning, keep task state with todo_write and present the plan for user approval."
case "bash_output", "wait", "kill_shell":
msg = "background jobs are not available in this context"
}
return toolOutcome{output: msg, blocked: true, errMsg: firstLine(msg)}, true
}
// applyMutationDependencyBarrier blocks later mutations and verifications in the
// same provider batch after an earlier modification failed. Host-proven
// read-only diagnosis (resolved ReadOnly with no verification classification)
@@ -272,6 +308,9 @@ func (a *Agent) applyPlanModeAndProxy(ctx context.Context, plan *toolCallPlan) (
if rc.Target != nil {
plan.execTool = rc.Target
}
if outcome, blocked := contextualToolGateOutcome(ctx, plan.execTool, plan.permName); blocked {
return outcome, true
}
plan.readOnly = rc.ReadOnly
if outcome, blocked := a.readOnlyExecutionBlock(t, &rc); blocked {
return outcome, true
+2 -1
View File
@@ -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)
+40 -16
View File
@@ -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
@@ -955,7 +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 := 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})
}
if hasVisibleFinalAnswer(text) {
return a.handleFinalResponse(ctx, state, text, reasoning, usage)
}
if len(unavailableContextTools) == 1 && unavailableContextTools[0] == "update_goal" {
return false, fmt.Errorf("model repeatedly called update_goal outside Goal mode without a visible answer")
}
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.
@@ -1012,17 +1026,15 @@ 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 in the transcript,
// but accept the co-streamed answer instead of spending another model
// request repairing harmless Goal bookkeeping outside Goal mode.
// but accept a co-streamed answer without another repair request.
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++
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 +1107,29 @@ 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 {
if len(calls) == 0 {
return false
return nil
}
if _, ok := tool.GoalTurnRecorderFromContext(ctx); ok {
return false
if a == nil || a.tools == nil {
return nil
}
names := make([]string, 0, len(calls))
seen := make(map[string]struct{}, len(calls))
for _, call := range calls {
if call.Name != "update_goal" {
return false
t, canonical, ambiguous := a.tools.ResolveCall(call.Name)
if t == nil || len(ambiguous) > 0 {
continue
}
contextual, ok := t.(tool.ContextualTool)
if !ok || contextual.ProviderVisible(ctx) {
continue
}
if _, ok := seen[canonical]; ok {
continue
}
seen[canonical] = struct{}{}
names = append(names, canonical)
}
return true
return names
}
+7 -3
View File
@@ -1,19 +1,23 @@
package agent
import (
"context"
"encoding/json"
"sort"
"reasonix/internal/tool"
)
func toolIdentity(reg *tool.Registry) ([]string, string) {
func toolIdentity(reg *tool.Registry, ctx context.Context) ([]string, string) {
if reg == nil {
return nil, bytesHash(nil)
}
names := reg.Names()
schemas := normalizeToolSchemas(reg.SchemasForContext(ctx))
names := make([]string, 0, len(schemas))
for _, schema := range schemas {
names = append(names, schema.Name)
}
sort.Strings(names)
schemas := normalizeToolSchemas(reg.Schemas())
data, _ := json.Marshal(schemas)
return names, bytesHash(data)
}
+3 -1
View File
@@ -1,6 +1,7 @@
package agent
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
@@ -77,6 +78,7 @@ type SubagentSpec struct {
ParentToolCallID string
SystemPrompt string
Registry *tool.Registry
ToolContext context.Context
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.ToolContext)
return SubagentMeta{
Ref: ref,
CreatedAt: created,
+19 -2
View File
@@ -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,
ToolContext: childToolIdentityContext(ctx),
Model: identityModel,
Effort: identityEffort,
}
@@ -1101,6 +1103,13 @@ func (t *TaskTool) prepareTranscriptRunWithPrompt(subReg *tool.Registry, modelRe
return t.transcripts.PrepareFresh(spec)
}
func childToolIdentityContext(ctx context.Context) context.Context {
ctx = tool.WithoutGoalTurnRecorder(ctx)
ctx = memory.WithoutQueue(ctx)
ctx = jobs.WithoutManager(ctx)
return planmode.WithActive(ctx, PlanModeFromContext(ctx))
}
func (t *TaskTool) effectiveIdentity(modelRef, effort string) (string, string) {
if t.identityProfile != nil {
model, eff := t.identityProfile(modelRef, effort)
@@ -1830,6 +1839,14 @@ func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *to
}
// Isolate temporary files for this run before any tool execution.
ctx = tool.WithoutGoalTurnRecorder(ctx)
if opts.MemoryQueue != nil {
ctx = memory.WithQueue(ctx, opts.MemoryQueue)
} else {
ctx = memory.WithoutQueue(ctx)
}
if opts.Jobs == nil {
ctx = jobs.WithoutManager(ctx)
}
ctx, releaseTemp := withSubagentSessionTemp(ctx)
defer releaseTemp()
if opts.SubagentDepth > 0 {
+2
View File
@@ -137,6 +137,7 @@ func (c *Controller) restorePendingLegacyGoal(legacy legacyGoalRestore) bool {
c.clearLegacyRestore(legacy.taskID, legacy.epoch)
}
} else {
c.goals.clearLegacyTaskID(epoch)
c.clearLegacyRestore(legacy.taskID, legacy.epoch)
}
} else {
@@ -200,6 +201,7 @@ func (c *Controller) retryBlockedLegacyGoal() (handled, resumed bool) {
if !persisted {
return true, false
}
c.goals.clearLegacyTaskID(resumedEpoch)
c.replaceLegacyRestore(legacyGoalRestore{})
if setup.notice != "" {
c.notice(setup.notice)
+2 -2
View File
@@ -2659,7 +2659,7 @@ func (c *Controller) SetGoalDurable(goal string) error {
var data []byte
var persist bool
if setup.blockReason != "" {
path, data, persist = c.goals.setLegacyArchiveBlocked(resolved, setup.budgetClass, setup.blockReason, c.goalTodos())
path, data, persist = c.goals.setLegacyArchiveBlockedWithTaskID(resolved, setup.budgetClass, setup.blockReason, setup.legacyTaskID, 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())
@@ -2695,7 +2695,7 @@ 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.blockReason, c.goalTodos())
path, data, ok = c.goals.setLegacyArchiveBlockedWithTaskID(resolved, setup.budgetClass, setup.blockReason, setup.legacyTaskID, 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 {
+33 -7
View File
@@ -98,6 +98,11 @@ type goalMachine struct {
lastEvaluatorReason string
stopCause string
budgetExtensions int // turn extensions from resume (compat field name)
// legacyTaskID is retained only while a historical AutoResearch archive is
// awaiting migration. It is serialized on fail-closed blocked sidecars so a
// restart can retry the migration without treating the raw archive path as a
// new Goal.
legacyTaskID string
// statePath is the persisted goal-state sidecar; empty disables persistence.
statePath string
@@ -290,7 +295,12 @@ func (g *goalMachine) set(goal, preferredBudgetClass string, todos []evidence.To
// 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) {
return g.setLegacyArchiveBlockedWithTaskID(goal, preferredBudgetClass, reason, "", todos)
}
func (g *goalMachine) setLegacyArchiveBlockedWithTaskID(goal, preferredBudgetClass, reason, taskID string, todos []evidence.TodoItem) (string, []byte, bool) {
goal = strings.TrimSpace(goal)
taskID = strings.TrimSpace(taskID)
if goal != "" && preferredBudgetClass == "" {
preferredBudgetClass = taskintent.ClassifyGoalBudget(goal)
}
@@ -302,6 +312,7 @@ func (g *goalMachine) setLegacyArchiveBlocked(goal, preferredBudgetClass, reason
}
g.stopCause = stopCauseLegacyArchive
g.block = clipGoalReason(reason)
g.legacyTaskID = taskID
return g.buildStateLocked(todos)
}
@@ -326,6 +337,8 @@ func (g *goalMachine) installGoalLocked(goal, preferredBudgetClass string) {
g.tokensLimit = 0 // no token hard limit
g.noProgressLimit = defaultNoProgressLimit
}
// Installing a normal Goal always abandons any pending legacy migration.
g.legacyTaskID = ""
}
func (g *goalMachine) setStrict(strict bool, todos []evidence.TodoItem) (string, []byte, bool) {
@@ -645,11 +658,15 @@ func (g *goalMachine) buildStateLocked(todos []evidence.TodoItem) (path string,
StopCause: g.stopCause,
BudgetExtensions: g.budgetExtensions,
}
// 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
// GoalResearchOff is a downgrade fence for ordinary Goal sidecars. A
// fail-closed legacy migration keeps its task identity and compatibility mode
// until the archive has been validated and the Goal-only state is committed.
if g.legacyTaskID != "" && g.status == GoalStatusBlocked && g.stopCause == stopCauseLegacyArchive {
state.AutoResearchTaskID = g.legacyTaskID
state.ResearchMode = GoalResearchOn
} else {
state.ResearchMode = GoalResearchOff
}
b, err := json.Marshal(state)
if err != nil {
slog.Warn("controller: marshal goal state", "err", err)
@@ -741,12 +758,21 @@ func (g *goalMachine) restoreFromState(sessionPath string) (path string, data []
if g.status == "" {
g.status = GoalStatusStopped
}
// Legacy task identity is decode-only compatibility data. It is returned to
// the Controller's migration boundary and never enters active Goal memory.
// Legacy task identity is migration-only compatibility data. It is returned to
// the Controller's archive boundary and retained in the machine only while a
// fail-closed migration remains pending.
legacy = legacyGoalRestore{
taskID: strings.TrimSpace(state.AutoResearchTaskID),
todos: append([]evidence.TodoItem(nil), state.Todos...),
}
// A task id is pending only when the sidecar has no Goal text. A legacy
// sidecar that already contains an objective can be migrated directly and
// must serialize as ordinary Goal state on the first write.
if g.goal == "" {
g.legacyTaskID = legacy.taskID
} else {
g.legacyTaskID = ""
}
if legacy.taskID != "" && g.goal != "" {
// Sidecars that already carry the Goal objective do not depend on the
// historical archive. Complete the migration immediately.
+13
View File
@@ -67,6 +67,19 @@ func (g *goalMachine) failLegacyRestorePersistence(expectedEpoch uint64, reason
return g.continuationEpoch, true
}
// clearLegacyTaskID completes the sidecar migration after the Goal-only state
// has been durably written. The epoch check prevents a late completion from
// clearing the identity of a newer migration.
func (g *goalMachine) clearLegacyTaskID(expectedEpoch uint64) bool {
g.mu.Lock()
defer g.mu.Unlock()
if g.continuationEpoch != expectedEpoch {
return false
}
g.legacyTaskID = ""
return true
}
func (g *goalMachine) legacyArchiveBlockedState() (goal string, epoch uint64, ok bool) {
g.mu.Lock()
defer g.mu.Unlock()
+51 -4
View File
@@ -138,7 +138,7 @@ func TestGoalSetIdempotencyUsesEffectiveBudgetClass(t *testing.T) {
}
}
func TestLegacySidecarArchiveFailureBlocksWithoutPersistingTaskID(t *testing.T) {
func TestLegacySidecarArchiveFailureBlocksWithRetryableTaskID(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
@@ -184,7 +184,7 @@ func TestLegacySidecarArchiveFailureBlocksWithoutPersistingTaskID(t *testing.T)
if err := json.Unmarshal(failedRaw, &failed); err != nil {
t.Fatal(err)
}
if failed.Status != GoalStatusBlocked || failed.ResearchMode != GoalResearchOff || failed.AutoResearchTaskID != "" || failed.StopCause != stopCauseLegacyArchive || failed.Block == "" {
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 {
@@ -244,6 +244,53 @@ func TestLegacySidecarArchiveFailureBlocksWithoutPersistingTaskID(t *testing.T)
}
}
func TestLegacySidecarPendingTaskRetriesAfterRestart(t *testing.T) {
root := t.TempDir()
sessionPath := filepath.Join(root, "sessions", "restart.jsonl")
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
t.Fatal(err)
}
const taskID = "restartable-legacy"
raw, err := json.Marshal(goalState{
Status: GoalStatusRunning, ResearchMode: GoalResearchOn,
AutoResearchTaskID: taskID, BudgetClass: budgetClassResearch, TurnsUsed: 4, TurnsLimit: 40,
})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(goalStatePath(sessionPath), raw, 0o644); err != nil {
t.Fatal(err)
}
firstSession := agent.NewSession("sys")
first := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: agent.New(nil, nil, firstSession, agent.Options{}, event.Discard)})
first.Resume(firstSession, sessionPath)
if first.GoalStatus() != GoalStatusBlocked {
t.Fatalf("first restore status = %q, want blocked", first.GoalStatus())
}
first.Close()
writeLegacyGoalArchive(t, root, taskID, "recover after process restart")
secondSession := agent.NewSession("sys")
second := New(Options{WorkspaceRoot: root, SessionDir: root, Executor: agent.New(nil, nil, secondSession, agent.Options{}, event.Discard)})
defer second.Close()
second.Resume(secondSession, sessionPath)
if second.GoalStatus() != GoalStatusRunning || second.Goal() != "recover after process restart" {
t.Fatalf("restart migration = goal:%q status:%q", second.Goal(), second.GoalStatus())
}
persisted, err := os.ReadFile(goalStatePath(sessionPath))
if err != nil {
t.Fatal(err)
}
var state goalState
if err := json.Unmarshal(persisted, &state); err != nil {
t.Fatal(err)
}
if state.AutoResearchTaskID != "" || state.ResearchMode != GoalResearchOff || state.BudgetClass != budgetClassResearch {
t.Fatalf("restart migration left compatibility fields: %+v", state)
}
}
func TestLegacySidecarInvalidArchivesRemainRetryableAndReadOnly(t *testing.T) {
tests := []struct {
name string
@@ -302,7 +349,7 @@ func TestLegacySidecarInvalidArchivesRemainRetryableAndReadOnly(t *testing.T) {
if err := json.Unmarshal(persistedRaw, &persisted); err != nil {
t.Fatal(err)
}
if persisted.AutoResearchTaskID != "" || persisted.ResearchMode != GoalResearchOff || persisted.StopCause != stopCauseLegacyArchive {
if persisted.AutoResearchTaskID != taskID || persisted.ResearchMode != GoalResearchOn || persisted.StopCause != stopCauseLegacyArchive {
t.Fatalf("retry state = %+v", persisted)
}
archiveAfter, err := os.ReadFile(target)
@@ -543,7 +590,7 @@ func TestExplicitLegacyGoalRetryNeverRunsArchivePathAsGoal(t *testing.T) {
if err := json.Unmarshal(persistedRaw, &blocked); err != nil {
t.Fatal(err)
}
if blocked.Status != GoalStatusBlocked || blocked.StopCause != stopCauseLegacyArchive {
if blocked.Status != GoalStatusBlocked || blocked.StopCause != stopCauseLegacyArchive || blocked.AutoResearchTaskID != taskID || blocked.ResearchMode != GoalResearchOn {
t.Fatalf("blocked sidecar = %+v", blocked)
}
if c.ResumeGoal() {
+17
View File
@@ -0,0 +1,17 @@
package jobs
import (
"context"
"testing"
)
func TestWithoutManagerShadowsAncestorManager(t *testing.T) {
parent := WithManager(context.Background(), &Manager{})
child := WithoutManager(parent)
if _, ok := FromContext(child); ok {
t.Fatal("child context inherited the parent Jobs manager")
}
if _, ok := FromContext(parent); !ok {
t.Fatal("parent Jobs manager was lost")
}
}
+7
View File
@@ -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,12 @@ func WithManager(ctx context.Context, m *Manager) context.Context {
return context.WithValue(ctx, ctxKey{}, m)
}
// WithoutManager shadows an ancestor manager. Child agents without an owned
// Jobs manager must not operate the parent's background jobs by inheritance.
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) {
+7
View File
@@ -17,12 +17,19 @@ 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. Child agents may persist memory but
// must not inject turn-tail notes into the parent's live conversation.
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)
+21
View File
@@ -0,0 +1,21 @@
package memory
import (
"context"
"testing"
)
type queueContextProbe struct{}
func (queueContextProbe) QueueMemory(string) {}
func TestWithoutQueueShadowsAncestorQueue(t *testing.T) {
parent := WithQueue(context.Background(), queueContextProbe{})
child := WithoutQueue(parent)
if _, ok := QueueFromContext(child); ok {
t.Fatal("child context inherited the parent memory queue")
}
if _, ok := QueueFromContext(parent); !ok {
t.Fatal("parent memory queue was lost")
}
}
+15
View File
@@ -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"`
+7
View File
@@ -9,6 +9,7 @@ import (
"reasonix/internal/evidence"
"reasonix/internal/instruction"
"reasonix/internal/planmode"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
@@ -80,6 +81,12 @@ func (completeStep) Schema() json.RawMessage {
// effect), so it never needs approval and stays available alongside todo_write.
func (completeStep) ReadOnly() bool { return true }
// complete_step signs off execution work and is unavailable during planning.
// The host Plan gate remains authoritative for stale or hallucinated calls.
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
@@ -0,0 +1,48 @@
package builtin
import (
"context"
"testing"
"reasonix/internal/jobs"
"reasonix/internal/planmode"
"reasonix/internal/tool"
)
type visibilityRecorder struct{}
func (visibilityRecorder) RecordGoalReport(tool.GoalReport) (string, error) { return "", nil }
func TestContextualBuiltinVisibilityFollowsOwningContext(t *testing.T) {
goal, _ := tool.LookupBuiltin("update_goal")
step, _ := tool.LookupBuiltin("complete_step")
jobNames := []string{"bash_output", "wait", "kill_shell"}
if goal.(tool.ContextualTool).ProviderVisible(context.Background()) {
t.Fatal("update_goal visible without a Goal recorder")
}
if !goal.(tool.ContextualTool).ProviderVisible(tool.WithGoalTurnRecorder(context.Background(), visibilityRecorder{})) {
t.Fatal("update_goal hidden during an active Goal turn")
}
if !step.(tool.ContextualTool).ProviderVisible(context.Background()) {
t.Fatal("complete_step hidden outside Plan mode")
}
if step.(tool.ContextualTool).ProviderVisible(planmode.WithActive(context.Background(), true)) {
t.Fatal("complete_step visible during Plan mode")
}
for _, name := range jobNames {
t.Run(name, func(t *testing.T) {
candidate, ok := tool.LookupBuiltin(name)
if !ok {
t.Fatal("missing builtin")
}
contextual := candidate.(tool.ContextualTool)
if contextual.ProviderVisible(context.Background()) {
t.Fatal("job tool visible without a manager")
}
if !contextual.ProviderVisible(jobs.WithManager(context.Background(), &jobs.Manager{})) {
t.Fatal("job tool hidden with an owned manager")
}
})
}
}
+5
View File
@@ -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, and
// outside an active goal turn its Execute fails closed anyway.
func (updateGoal) PlanModeSafe() bool { return true }
+45
View File
@@ -33,6 +33,14 @@ type Tool interface {
ReadOnly() bool
}
// ContextualTool is an execution-time availability contract for tools whose
// ownership depends on the active workflow context. Provider schemas remain
// static for cache stability; the host must still consult this contract before
// permissions, hooks, leases, or Execute so stale transcripts 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
@@ -540,3 +548,40 @@ func (r *Registry) Schemas() []provider.ToolSchema {
}
return out
}
// SchemasForContext returns the contextual projection for host metadata and
// diagnostics. Provider requests intentionally use Schemas so phase changes do
// not churn the cache-stable tool contract.
func (r *Registry) SchemasForContext(ctx context.Context) []provider.ToolSchema {
if ctx == nil {
ctx = context.Background()
}
r.mu.RLock()
names := append([]string(nil), r.order...)
entries := make(map[string]struct {
t Tool
data json.RawMessage
}, len(names))
for _, name := range names {
if t := r.tools[name]; t != nil {
entries[name] = struct {
t Tool
data json.RawMessage
}{t: t, data: r.canon[name]}
}
}
r.mu.RUnlock()
sort.Strings(names)
out := make([]provider.ToolSchema, 0, len(names))
for _, name := range names {
entry, ok := entries[name]
if !ok || entry.t == nil {
continue
}
if contextual, ok := entry.t.(ContextualTool); ok && !contextual.ProviderVisible(ctx) {
continue
}
out = append(out, provider.ToolSchema{Name: entry.t.Name(), Description: entry.t.Description(), Parameters: entry.data})
}
return out
}