mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
461 lines
18 KiB
Go
461 lines
18 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type stubMetadataUpdater struct{}
|
|
|
|
func (s *stubMetadataUpdater) SetResourceURL(resourceType, resourceID, url string) error {
|
|
return nil
|
|
}
|
|
|
|
type stubAgentProfileManager struct{}
|
|
|
|
func (s *stubAgentProfileManager) ApplyAgentScope(ctx context.Context, agentID, agentLabel string, settings map[string]interface{}) (string, string, bool, error) {
|
|
return "profile-1", "default", false, nil
|
|
}
|
|
|
|
func (s *stubAgentProfileManager) AssignProfile(ctx context.Context, agentID, profileID string) (string, error) {
|
|
return "default", nil
|
|
}
|
|
|
|
func (s *stubAgentProfileManager) GetAgentScope(ctx context.Context, agentID string) (*AgentScope, error) {
|
|
return &AgentScope{AgentID: agentID}, nil
|
|
}
|
|
|
|
func TestPulseToolExecutor_Setters(t *testing.T) {
|
|
exec := NewPulseToolExecutor(ExecutorConfig{})
|
|
|
|
exec.SetContext("vm", "101", true)
|
|
assert.Equal(t, "vm", exec.targetType)
|
|
assert.Equal(t, "101", exec.targetID)
|
|
assert.True(t, exec.isAutonomous)
|
|
|
|
exec.SetAutonomousMode(false)
|
|
assert.Equal(t, "vm", exec.targetType)
|
|
assert.Equal(t, "101", exec.targetID)
|
|
assert.False(t, exec.isAutonomous)
|
|
|
|
exec.SetControlLevel(ControlLevelControlled)
|
|
assert.Equal(t, ControlLevelControlled, exec.controlLevel)
|
|
|
|
exec.SetProtectedGuests([]string{"100", "101"})
|
|
assert.Equal(t, []string{"100", "101"}, exec.protectedGuests)
|
|
|
|
metadataUpdater := &stubMetadataUpdater{}
|
|
exec.SetMetadataUpdater(metadataUpdater)
|
|
assert.Equal(t, metadataUpdater, exec.metadataUpdater)
|
|
|
|
findingsManager := &stubFindingsManager{}
|
|
exec.SetFindingsManager(findingsManager)
|
|
assert.Equal(t, findingsManager, exec.findingsManager)
|
|
|
|
metricsHistory := &mockMetricsHistoryProvider{}
|
|
exec.SetMetricsHistory(metricsHistory)
|
|
assert.Equal(t, metricsHistory, exec.metricsHistory)
|
|
|
|
baselineProvider := &stubBaselineProvider{}
|
|
exec.SetBaselineProvider(baselineProvider)
|
|
assert.Equal(t, baselineProvider, exec.baselineProvider)
|
|
|
|
patternProvider := &stubPatternProvider{}
|
|
exec.SetPatternProvider(patternProvider)
|
|
assert.Equal(t, patternProvider, exec.patternProvider)
|
|
|
|
alertProvider := &mockAlertProvider{}
|
|
exec.SetAlertProvider(alertProvider)
|
|
assert.Equal(t, alertProvider, exec.alertProvider)
|
|
|
|
findingsProvider := &mockFindingsProvider{}
|
|
exec.SetFindingsProvider(findingsProvider)
|
|
assert.Equal(t, findingsProvider, exec.findingsProvider)
|
|
|
|
backupProvider := &stubBackupProvider{}
|
|
exec.SetBackupProvider(backupProvider)
|
|
assert.Equal(t, backupProvider, exec.backupProvider)
|
|
|
|
diskHealthProvider := &mockDiskHealthProvider{}
|
|
exec.SetDiskHealthProvider(diskHealthProvider)
|
|
assert.Equal(t, diskHealthProvider, exec.diskHealthProvider)
|
|
|
|
agentProfileManager := &stubAgentProfileManager{}
|
|
exec.SetAgentProfileManager(agentProfileManager)
|
|
assert.Equal(t, agentProfileManager, exec.agentProfileManager)
|
|
|
|
updatesProvider := &mockUpdatesProvider{}
|
|
exec.SetUpdatesProvider(updatesProvider)
|
|
assert.Equal(t, updatesProvider, exec.updatesProvider)
|
|
|
|
actionAuditStore := unifiedresources.NewMemoryStore()
|
|
exec.SetActionAuditStore(actionAuditStore)
|
|
assert.Equal(t, actionAuditStore, exec.actionAuditStore)
|
|
}
|
|
|
|
func TestPulseToolExecutor_ListTools(t *testing.T) {
|
|
exec := NewPulseToolExecutor(ExecutorConfig{})
|
|
tools := exec.ListTools()
|
|
// pulse_query requires state provider, so it should not be available without one
|
|
assert.False(t, containsTool(tools, "pulse_query"))
|
|
|
|
execWithState := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{}})
|
|
stateTools := execWithState.ListTools()
|
|
// With state provider, pulse_query should be available
|
|
assert.True(t, containsTool(stateTools, "pulse_query"))
|
|
assert.False(t, containsTool(stateTools, "pulse_kubernetes"))
|
|
|
|
adapter := unifiedresources.NewMonitorAdapter(nil)
|
|
adapter.PopulateFromSnapshot(models.StateSnapshot{
|
|
Nodes: []models.Node{{ID: "node-1", Name: "pve1", Status: "online"}},
|
|
KubernetesClusters: []models.KubernetesCluster{{ID: "cluster-1", Name: "cluster-1"}},
|
|
})
|
|
execWithUnifiedReadState := NewPulseToolExecutor(ExecutorConfig{UnifiedResourceProvider: adapter})
|
|
unifiedTools := execWithUnifiedReadState.ListTools()
|
|
assert.True(t, containsTool(unifiedTools, "pulse_query"))
|
|
assert.True(t, containsTool(unifiedTools, "pulse_pmg"))
|
|
assert.True(t, containsTool(unifiedTools, "pulse_kubernetes"))
|
|
}
|
|
|
|
func TestPulseToolExecutor_IsToolAvailable(t *testing.T) {
|
|
exec := NewPulseToolExecutor(ExecutorConfig{})
|
|
// pulse_metrics requires metrics provider or state provider
|
|
assert.False(t, exec.isToolAvailable("pulse_metrics"))
|
|
// pulse_query requires state provider
|
|
assert.False(t, exec.isToolAvailable("pulse_query"))
|
|
|
|
// Create new executor with state provider and metrics history
|
|
execWithProviders := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: &mockStateProvider{},
|
|
MetricsHistory: &mockMetricsHistoryProvider{},
|
|
})
|
|
// Now pulse_metrics should be available with metrics history
|
|
assert.True(t, execWithProviders.isToolAvailable("pulse_metrics"))
|
|
// And pulse_query should be available with state provider
|
|
assert.True(t, execWithProviders.isToolAvailable("pulse_query"))
|
|
assert.True(t, execWithProviders.isToolAvailable("pulse_pmg"))
|
|
assert.False(t, execWithProviders.isToolAvailable("pulse_kubernetes"))
|
|
|
|
adapter := unifiedresources.NewMonitorAdapter(nil)
|
|
adapter.PopulateFromSnapshot(models.StateSnapshot{
|
|
PMGInstances: []models.PMGInstance{{ID: "pmg-1", Name: "pmg-1"}},
|
|
KubernetesClusters: []models.KubernetesCluster{{ID: "cluster-1", Name: "cluster-1"}},
|
|
})
|
|
execWithUnifiedReadState := NewPulseToolExecutor(ExecutorConfig{
|
|
UnifiedResourceProvider: adapter,
|
|
})
|
|
assert.True(t, execWithUnifiedReadState.isToolAvailable("pulse_pmg"))
|
|
assert.True(t, execWithUnifiedReadState.isToolAvailable("pulse_kubernetes"))
|
|
assert.False(t, execWithUnifiedReadState.isToolAvailable("pulse_read"))
|
|
|
|
execWithNativeRead := NewPulseToolExecutor(ExecutorConfig{
|
|
UnifiedResourceProvider: adapter,
|
|
ReadState: unifiedresources.NewRegistry(nil),
|
|
AppContainerReadProvider: &stubAppContainerReadProvider{},
|
|
})
|
|
assert.True(t, execWithNativeRead.isToolAvailable("pulse_read"))
|
|
}
|
|
|
|
func TestPulseToolExecutor_GetReadStatePrefersUnifiedResourceProvider(t *testing.T) {
|
|
adapter := unifiedresources.NewMonitorAdapter(nil)
|
|
adapter.PopulateFromSnapshot(models.StateSnapshot{
|
|
VMs: []models.VM{{ID: "vm-unified", Name: "vm-unified", Status: "running", Node: "pve1", Instance: "cluster-a"}},
|
|
})
|
|
|
|
stateProvider := &mockStateProvider{}
|
|
stateProvider.On("ReadSnapshot").Return(models.StateSnapshot{
|
|
VMs: []models.VM{{ID: "vm-snapshot", Name: "vm-snapshot", Status: "running", Node: "pve2", Instance: "cluster-b"}},
|
|
})
|
|
|
|
exec := NewPulseToolExecutor(ExecutorConfig{
|
|
StateProvider: stateProvider,
|
|
UnifiedResourceProvider: adapter,
|
|
})
|
|
|
|
readState := exec.getReadState()
|
|
require.NotNil(t, readState)
|
|
require.Len(t, readState.VMs(), 1)
|
|
assert.Equal(t, "vm-unified", readState.VMs()[0].Name())
|
|
stateProvider.AssertNotCalled(t, "ReadSnapshot")
|
|
}
|
|
|
|
func TestToolRegistry_ListTools(t *testing.T) {
|
|
registry := NewToolRegistry()
|
|
registry.RegisterExtension(RegisteredTool{
|
|
Invocation: StaticInvocation(agentcapabilities.ToolCallKindRead, agentcapabilities.MutationNone),
|
|
Definition: Tool{Name: "read"},
|
|
})
|
|
registry.RegisterExtension(RegisteredTool{
|
|
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationInfrastructure),
|
|
Definition: Tool{Name: "control"},
|
|
RequireControl: true,
|
|
Governance: ToolGovernance{
|
|
ActionMode: ToolActionWrite,
|
|
ApprovalPolicy: ToolApprovalActionPlan,
|
|
ApprovalSummary: "hidden in read-only mode; approval required in controlled mode",
|
|
},
|
|
})
|
|
|
|
readOnly := registry.ListTools(InvocationPolicy{ControlLevel: ControlLevelReadOnly})
|
|
require.Len(t, readOnly, 1)
|
|
assert.Equal(t, "read", readOnly[0].Name)
|
|
|
|
unknown := registry.ListTools(InvocationPolicy{ControlLevel: ControlLevel("bad")})
|
|
require.Len(t, unknown, 1)
|
|
assert.Equal(t, "read", unknown[0].Name)
|
|
|
|
full := registry.ListTools(InvocationPolicy{ControlLevel: ControlLevelControlled})
|
|
require.Len(t, full, 2)
|
|
assert.Equal(t, "read", full[0].Name)
|
|
assert.Equal(t, "control", full[1].Name)
|
|
|
|
governance := registry.ListToolGovernance(InvocationPolicy{ControlLevel: ControlLevelControlled})
|
|
require.Len(t, governance, 2)
|
|
assert.Equal(t, ToolActionRead, governance[0].ActionMode)
|
|
assert.Equal(t, ToolActionWrite, governance[1].ActionMode)
|
|
assert.Equal(t, ToolApprovalActionPlan, governance[1].ApprovalPolicy)
|
|
assert.Equal(t, "hidden in read-only mode; approval required in controlled mode", governance[1].ApprovalSummary)
|
|
}
|
|
|
|
func TestToolRegistry_ListToolsReturnsIndependentDefinitions(t *testing.T) {
|
|
registry := NewToolRegistry()
|
|
required := []string{"mode"}
|
|
enum := []string{"summary", "detail"}
|
|
properties := map[string]PropertySchema{
|
|
"mode": {Type: "string", Enum: enum},
|
|
}
|
|
registry.RegisterExtension(RegisteredTool{
|
|
Invocation: StaticInvocation(agentcapabilities.ToolCallKindRead, agentcapabilities.MutationNone),
|
|
Definition: Tool{
|
|
Name: "read",
|
|
InputSchema: InputSchema{
|
|
Properties: properties,
|
|
Required: required,
|
|
},
|
|
},
|
|
})
|
|
|
|
required[0] = "source-mutated"
|
|
enum[0] = "source-mutated"
|
|
sourceMode := properties["mode"]
|
|
sourceMode.Enum[1] = "source-mutated"
|
|
properties["mode"] = sourceMode
|
|
|
|
first := registry.ListTools(InvocationPolicy{ControlLevel: ControlLevelReadOnly})
|
|
require.Len(t, first, 1)
|
|
assert.Equal(t, "object", first[0].InputSchema.Type)
|
|
assert.Equal(t, "mode", first[0].InputSchema.Required[0])
|
|
assert.Equal(t, "summary", first[0].InputSchema.Properties["mode"].Enum[0])
|
|
assert.Equal(t, "detail", first[0].InputSchema.Properties["mode"].Enum[1])
|
|
|
|
first[0].InputSchema.Required[0] = "listed-mutated"
|
|
listedMode := first[0].InputSchema.Properties["mode"]
|
|
listedMode.Enum[0] = "listed-mutated"
|
|
first[0].InputSchema.Properties["mode"] = listedMode
|
|
|
|
second := registry.ListTools(InvocationPolicy{ControlLevel: ControlLevelReadOnly})
|
|
require.Len(t, second, 1)
|
|
assert.Equal(t, "mode", second[0].InputSchema.Required[0])
|
|
assert.Equal(t, "summary", second[0].InputSchema.Properties["mode"].Enum[0])
|
|
}
|
|
|
|
func TestControlLevelUsesSharedAgentCapabilityVocabulary(t *testing.T) {
|
|
assert.Equal(t, agentcapabilities.ControlLevelReadOnly, ControlLevelReadOnly)
|
|
assert.Equal(t, agentcapabilities.ControlLevelControlled, ControlLevelControlled)
|
|
assert.Equal(t, agentcapabilities.ControlLevelAutonomous, ControlLevelAutonomous)
|
|
assert.False(t, agentcapabilities.ControlLevelAllowsControlTools(ControlLevelReadOnly))
|
|
assert.True(t, agentcapabilities.ControlLevelAllowsControlTools(ControlLevelControlled))
|
|
}
|
|
|
|
func TestToolActionModeUsesSharedAgentCapabilityVocabulary(t *testing.T) {
|
|
assert.Equal(t, agentcapabilities.ActionModeRead, ToolActionRead)
|
|
assert.Equal(t, agentcapabilities.ActionModeMixed, ToolActionMixed)
|
|
assert.Equal(t, agentcapabilities.ActionModeWrite, ToolActionWrite)
|
|
}
|
|
|
|
func TestToolApprovalPolicyUsesSharedAgentCapabilityVocabulary(t *testing.T) {
|
|
assert.Equal(t, agentcapabilities.ApprovalPolicyScopeOnly, ToolApprovalScopeOnly)
|
|
assert.Equal(t, agentcapabilities.ApprovalPolicyActionPlan, ToolApprovalActionPlan)
|
|
}
|
|
|
|
func TestToolGovernanceUsesSharedAgentCapabilityShape(t *testing.T) {
|
|
var shared agentcapabilities.ToolGovernance = ToolGovernance{
|
|
ActionMode: ToolActionMixed,
|
|
ApprovalPolicy: ToolApprovalScopeOnly,
|
|
ApprovalSummary: "read/list actions are safe",
|
|
Summary: "Reads and refreshes state.",
|
|
}
|
|
assert.Equal(t, agentcapabilities.ActionModeMixed, shared.ActionMode)
|
|
assert.Equal(t, agentcapabilities.ApprovalPolicyScopeOnly, shared.ApprovalPolicy)
|
|
|
|
var descriptor agentcapabilities.ToolGovernanceDescriptor = ToolGovernanceDescriptor{
|
|
Name: "pulse_discovery",
|
|
ActionMode: ToolActionMixed,
|
|
ApprovalPolicy: ToolApprovalScopeOnly,
|
|
ApprovalSummary: "no approval required",
|
|
}
|
|
assert.Equal(t, "pulse_discovery", descriptor.Name)
|
|
assert.Equal(t, agentcapabilities.ActionModeMixed, descriptor.ActionMode)
|
|
}
|
|
|
|
func TestToolRegistry_ExecuteControlToolReadOnlyUsesAssistantAndPatrolGuidance(t *testing.T) {
|
|
registry := NewToolRegistry()
|
|
registry.RegisterExtension(RegisteredTool{
|
|
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationInfrastructure),
|
|
Definition: Tool{Name: "control"},
|
|
RequireControl: true,
|
|
Handler: func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
|
t.Fatal("control handler should not run in read-only mode")
|
|
return NewTextResult("unexpected"), nil
|
|
},
|
|
})
|
|
|
|
exec := NewPulseToolExecutor(ExecutorConfig{})
|
|
exec.SetControlLevel(ControlLevelReadOnly)
|
|
|
|
result, err := registry.Execute(context.Background(), exec, "control", nil)
|
|
require.NoError(t, err)
|
|
require.Len(t, result.Content, 1)
|
|
text := result.Content[0].Text
|
|
assert.Equal(t, agentcapabilities.ControlToolsDisabledMessage, text)
|
|
assert.Contains(t, text, "Pulse Intelligence settings")
|
|
assert.Contains(t, text, "Pulse Assistant Permissions > Control mode")
|
|
assert.NotContains(t, text, "Settings > Pulse Assistant")
|
|
}
|
|
|
|
func TestToolRegistryExecuteNormalizesSharedToolCallParams(t *testing.T) {
|
|
registry := NewToolRegistry()
|
|
var gotArgs map[string]interface{}
|
|
var gotArgsLenBeforeMutation int
|
|
registry.RegisterExtension(RegisteredTool{
|
|
Invocation: StaticInvocation(agentcapabilities.ToolCallKindRead, agentcapabilities.MutationNone),
|
|
Definition: Tool{Name: "test_tool", InputSchema: InputSchema{Properties: map[string]PropertySchema{
|
|
"resource_id": {Type: "string"},
|
|
"body": {Type: "object"},
|
|
}}},
|
|
Handler: func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
|
gotArgs = args
|
|
gotArgsLenBeforeMutation = len(args)
|
|
args["resource_id"] = "vm/101"
|
|
args["body"].(map[string]interface{})["note"] = "changed"
|
|
return NewTextResult("ok"), nil
|
|
},
|
|
})
|
|
exec := NewPulseToolExecutor(ExecutorConfig{})
|
|
|
|
sourceArgs := map[string]interface{}{
|
|
"resource_id": "vm/100",
|
|
"body": map[string]interface{}{"note": "maintenance"},
|
|
}
|
|
result, err := registry.Execute(context.Background(), exec, " test_tool ", sourceArgs)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "ok", agentcapabilities.ToolResultText(result))
|
|
assert.Equal(t, "vm/100", sourceArgs["resource_id"], "registry execution must not expose the caller's argument map to handlers")
|
|
assert.Equal(t, "maintenance", sourceArgs["body"].(map[string]interface{})["note"], "registry execution must not expose nested caller arguments to handlers")
|
|
require.NotNil(t, gotArgs)
|
|
assert.Equal(t, "vm/101", gotArgs["resource_id"])
|
|
assert.Equal(t, "changed", gotArgs["body"].(map[string]interface{})["note"])
|
|
|
|
gotArgs = nil
|
|
result, err = registry.Execute(context.Background(), exec, " ", nil)
|
|
require.NoError(t, err)
|
|
interpreted := agentcapabilities.InterpretToolResult(result)
|
|
assert.True(t, interpreted.IsError)
|
|
assert.Contains(t, interpreted.Text, "invalid tools/call params: tool name is required")
|
|
|
|
registry.RegisterExtension(RegisteredTool{
|
|
Invocation: StaticInvocation(agentcapabilities.ToolCallKindRead, agentcapabilities.MutationNone),
|
|
Definition: Tool{Name: "empty_args"},
|
|
Handler: func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
|
gotArgs = args
|
|
gotArgsLenBeforeMutation = len(args)
|
|
return NewTextResult("ok"), nil
|
|
},
|
|
})
|
|
result, err = registry.Execute(context.Background(), exec, "empty_args", nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "ok", agentcapabilities.ToolResultText(result))
|
|
require.NotNil(t, gotArgs)
|
|
assert.Equal(t, 0, gotArgsLenBeforeMutation)
|
|
}
|
|
|
|
func TestToolRegistryExecuteNormalizesSharedToolResult(t *testing.T) {
|
|
registry := NewToolRegistry()
|
|
handlerContent := []Content{{Type: "text", Text: "ok"}}
|
|
registry.RegisterExtension(RegisteredTool{
|
|
Invocation: StaticInvocation(agentcapabilities.ToolCallKindRead, agentcapabilities.MutationNone),
|
|
Definition: Tool{Name: "read"},
|
|
Handler: func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
|
return CallToolResult{Content: handlerContent}, nil
|
|
},
|
|
})
|
|
registry.RegisterExtension(RegisteredTool{
|
|
Invocation: StaticInvocation(agentcapabilities.ToolCallKindRead, agentcapabilities.MutationNone),
|
|
Definition: Tool{Name: "empty"},
|
|
Handler: func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
|
return CallToolResult{}, nil
|
|
},
|
|
})
|
|
|
|
exec := NewPulseToolExecutor(ExecutorConfig{})
|
|
result, err := registry.Execute(context.Background(), exec, "read", nil)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "ok", agentcapabilities.ToolResultText(result))
|
|
result.Content[0].Text = "changed"
|
|
assert.Equal(t, "ok", handlerContent[0].Text)
|
|
|
|
empty, err := registry.Execute(context.Background(), exec, "empty", nil)
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, empty.Content)
|
|
assert.Empty(t, empty.Content)
|
|
}
|
|
|
|
func TestToolRegistryExecuteUsesSharedRegistryExecutionResults(t *testing.T) {
|
|
src, err := os.ReadFile("registry.go")
|
|
require.NoError(t, err)
|
|
text := string(src)
|
|
|
|
for _, fragment := range []string{
|
|
"agentcapabilities.PrepareToolRegistryExecution(name, args)",
|
|
"agentcapabilities.NewUnknownToolResult(name)",
|
|
"agentcapabilities.NewControlToolsDisabledToolResult()",
|
|
} {
|
|
assert.Contains(t, text, fragment)
|
|
}
|
|
for _, fragment := range []string{
|
|
"PrepareMCPToolRegistryExecution",
|
|
"NewUnknownMCPToolResult",
|
|
"NewControlToolsDisabledMCPToolResult",
|
|
} {
|
|
if strings.Contains(text, fragment) {
|
|
t.Fatalf("native registry execution must use neutral shared helpers; found %s", fragment)
|
|
}
|
|
}
|
|
for _, fragment := range []string{
|
|
"invalid tools/call params:",
|
|
"unknown tool: %s",
|
|
"agentcapabilities.ControlToolsDisabledMessage",
|
|
} {
|
|
if strings.Contains(text, fragment) {
|
|
t.Fatalf("registry execution must use shared registry result helpers; found %s", fragment)
|
|
}
|
|
}
|
|
}
|
|
|
|
func containsTool(tools []Tool, name string) bool {
|
|
for _, tool := range tools {
|
|
if tool.Name == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|