Files
pulse/internal/ai/service_tool_loop.go
T
2026-07-12 13:29:21 +01:00

158 lines
5.1 KiB
Go

package ai
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
)
const (
// MaxServiceToolIterations is the provider-call budget for the legacy
// Service tool loop. Both streaming and non-streaming orchestration consume
// this same budget; a request never relies on a wall-clock timeout to stop a
// provider that keeps returning tool calls.
MaxServiceToolIterations = 10
ServiceToolLoopFailureCode = "TOOL_LOOP_BOUNDED_FAILURE"
)
type ServiceToolLoopFailureReason string
const (
ServiceToolLoopFailureRepeatedDenied ServiceToolLoopFailureReason = "repeated_denied_tool_call"
ServiceToolLoopFailureMaxIterations ServiceToolLoopFailureReason = "max_tool_iterations"
)
// ServiceToolLoopError is the typed terminal result for a bounded legacy
// Service model/tool loop. ProviderCalls is the exact number of provider calls
// already made; no additional provider or tool work occurs after this error.
type ServiceToolLoopError struct {
Reason ServiceToolLoopFailureReason
ProviderCalls int
MaxProviderCalls int
ToolName string
}
func (e *ServiceToolLoopError) Error() string {
if e == nil {
return "Pulse Assistant tool loop terminated"
}
switch e.Reason {
case ServiceToolLoopFailureRepeatedDenied:
return fmt.Sprintf("Pulse Assistant tool loop terminated after provider repeated denied tool %q (%d provider calls)", e.ToolName, e.ProviderCalls)
case ServiceToolLoopFailureMaxIterations:
return fmt.Sprintf("Pulse Assistant tool loop reached the maximum of %d provider calls", e.MaxProviderCalls)
default:
return fmt.Sprintf("Pulse Assistant tool loop terminated after %d provider calls", e.ProviderCalls)
}
}
// ServiceToolLoopFailureData is the stable typed stream projection of
// ServiceToolLoopError.
type ServiceToolLoopFailureData struct {
Code string `json:"code"`
Reason ServiceToolLoopFailureReason `json:"reason"`
Message string `json:"message"`
ProviderCalls int `json:"provider_calls"`
MaxProviderCalls int `json:"max_provider_calls"`
ToolName string `json:"tool_name,omitempty"`
}
func newServiceToolLoopFailureData(err *ServiceToolLoopError) ServiceToolLoopFailureData {
return ServiceToolLoopFailureData{
Code: ServiceToolLoopFailureCode,
Reason: err.Reason,
Message: err.Error(),
ProviderCalls: err.ProviderCalls,
MaxProviderCalls: err.MaxProviderCalls,
ToolName: err.ToolName,
}
}
type serviceToolLoopGuard struct {
providerCalls int
maxCalls int
deniedCalls map[string]struct{}
}
func newServiceToolLoopGuard() *serviceToolLoopGuard {
return &serviceToolLoopGuard{
maxCalls: MaxServiceToolIterations,
deniedCalls: make(map[string]struct{}),
}
}
func (g *serviceToolLoopGuard) beforeProviderCall() error {
if g.providerCalls >= g.maxCalls {
return &ServiceToolLoopError{
Reason: ServiceToolLoopFailureMaxIterations,
ProviderCalls: g.providerCalls,
MaxProviderCalls: g.maxCalls,
}
}
g.providerCalls++
return nil
}
func (g *serviceToolLoopGuard) observeDeniedToolCall(call providers.ToolCall) error {
fingerprint := serviceToolCallFingerprint(call)
if _, repeated := g.deniedCalls[fingerprint]; repeated {
return &ServiceToolLoopError{
Reason: ServiceToolLoopFailureRepeatedDenied,
ProviderCalls: g.providerCalls,
MaxProviderCalls: g.maxCalls,
ToolName: strings.TrimSpace(call.Name),
}
}
g.deniedCalls[fingerprint] = struct{}{}
return nil
}
func serviceToolCallFingerprint(call providers.ToolCall) string {
input, err := json.Marshal(call.Input)
if err != nil {
input = []byte(fmt.Sprintf("%#v", call.Input))
}
sum := sha256.Sum256(append([]byte(strings.TrimSpace(call.Name)+"\x00"), input...))
return hex.EncodeToString(sum[:])
}
func (s *Service) observeDeniedServiceToolCalls(guard *serviceToolLoopGuard, calls []providers.ToolCall) error {
for _, call := range calls {
if !s.isDeniedServiceToolCall(call) {
continue
}
if err := guard.observeDeniedToolCall(call); err != nil {
return err
}
}
return nil
}
func (s *Service) isDeniedServiceToolCall(call providers.ToolCall) bool {
class := agentcapabilities.ClassifyLegacyAssistantInvocation(call.Name)
if !s.legacyAssistantInvocationPolicy().Allows(call.Name, class) {
return true
}
if call.Name != agentcapabilities.LegacyAssistantRunCommandToolName || s.policy == nil {
return false
}
command, _ := call.Input[agentcapabilities.LegacyAssistantCommandArgumentName].(string)
return s.policy.Evaluate(command) == agentexec.PolicyBlock
}
func emitServiceToolLoopFailure(callback StreamCallback, err error) {
var loopErr *ServiceToolLoopError
if !errors.As(err, &loopErr) || callback == nil {
return
}
callback(StreamEvent{Type: "error", Data: newServiceToolLoopFailureData(loopErr)})
}