mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
2c0303c067
commit
a6238139c4
23 files changed
+520
-38
No files matched your search
+10
-2
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user