Files
SivanCola 72b725862f fix(agent): preserve workspace invalidation for hook effects
Problem: A hook can change files before refusing a tool or outside a reader's
declared paths without notifying an opted-in workspace sink. Nested writers
also omit publication until an outer writer happens to complete.

Root cause: Invalidation is gated by concrete tool execution and classification,
and publication belongs to the batch caller rather than the invocation owner.

Fix: Track possible tool-hook effects separately from Executed, publish through
the shared invocation exit after cleanup, and share result metadata projection
between batch and nested calls. Unknown hook scope invalidates content, tree and
Git metadata without claiming a confirmed write or widening authorization.

Verification: Full root 238-package inventory passed; complete agent/event race,
eight boot direct/capability hook cases under race, original-owner compiling
negative controls, and provisional-lease composition passed. Formatting, vet,
full lint and make check passed without changing the baseline. Local evidence
is macOS only; optional sink wiring and evidence accounting remain separate.
2026-10-25 07:29:49 +08:00

59 lines
2.0 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"sync/atomic"
"time"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
)
// nestedInvoker lets a running tool make calls that pass every stage a
// model-issued call does. Each shows as its own card under an id derived from
// the caller's; its result goes to the calling tool, not the conversation.
type nestedInvoker struct {
a *Agent
turn *turnRuntime
parent *toolCallPlan
seq atomic.Int32
}
func (n *nestedInvoker) Invoke(ctx context.Context, name string, args json.RawMessage) (string, error) {
if len(args) == 0 {
args = json.RawMessage(`{}`)
}
call := provider.ToolCall{ID: tool.NestedCallID(n.parent.call.ID, int(n.seq.Add(1))), Name: name, Arguments: string(args)}
call = n.a.prepareCallArguments(call)
n.a.emitFullToolDispatch(ctx, call, false)
started := time.Now()
out := n.a.executeOne(tool.MarkNested(ctx), n.turn, call)
elapsed := time.Since(started).Milliseconds()
resultEvent := event.Tool{
ID: call.ID, Name: name, Args: call.ExecutionArguments(), ArgumentNormalization: call.ArgumentNormalization, Output: out.output, Err: out.errMsg,
RefusalCode: out.refusalCode, Bound: out.bound, DurationMs: elapsed,
WorkspaceLease: out.workspaceLease,
StartedAt: started.UnixMilli(), EndedAt: started.UnixMilli() + elapsed, Issuer: event.IssuedByModel,
Executed: out.executed,
}
attachWorkspaceMutation(&resultEvent, out.workspaceMutation)
n.a.svc.sink.Emit(event.Event{Kind: event.ToolResult, Tool: resultEvent})
// The bound fits a result to the model's context; the calling tool reads
// the result itself, and a window or spill pointer in its place is not it.
result := out.output
if out.bound.Kind != event.BoundWhole {
result = out.unbounded
}
if out.errMsg != "" || out.blocked || out.refusalCode != "" {
reason := out.errMsg
if reason == "" {
reason = firstLine(out.output)
}
return result, fmt.Errorf("%w: %s: %s", tool.ErrNestedCallFailed, name, reason)
}
return result, nil
}