Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
102 changes: 102 additions & 0 deletions internal/agent/contextual_tool_test.go
Original file line number Diff line number Diff line change
@@ -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 changes: 39 additions & 0 deletions internal/agent/execute_one.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion internal/agent/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 40 additions & 16 deletions internal/agent/run_loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type runLoopState struct {
emptyFinalBlocks int
handoffNudges int
usedAnyTool bool
goalToolRepairs int
contextToolRepairs int
graceRound bool
recoveryGraceRound bool

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
10 changes: 7 additions & 3 deletions internal/agent/subagent_identity.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 3 additions & 1 deletion internal/agent/subagent_store.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package agent

import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
Expand Down Expand Up @@ -77,6 +78,7 @@ type SubagentSpec struct {
ParentToolCallID string
SystemPrompt string
Registry *tool.Registry
ToolContext context.Context
Model string
Effort string
}
Expand Down Expand Up @@ -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,
Expand Down
Loading