mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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.
59 lines
2.0 KiB
Go
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
|
|
}
|