mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
97a30b0600
Execution posture is now profile-owned through the core-only, never-serialized tools.ExecutionProfile. Both Patrol profiles are non-interactive, deny infrastructure mutations, and clear any inherited autonomous mode. Detection restricts pulse-state mutations to an explicit allowlist of the finding lifecycle tools - a blanket pulse-state allowance would also permit alert dismissal and knowledge writes - while investigation denies all pulse-state mutations, keeping it structurally read-only. InvocationPolicy gains the allowlist and Allows() is now tool-name-aware. Chat turns build ONE effective request executor (control level, autonomy, profile, resolved context) BEFORE provider projection and clone that executor per provider attempt, reversing the previous project-from-base-then-clone order so the offered schema and the runtime boundary always agree. Scheduled Patrol (ExecutePatrolStream) now runs under the detection profile instead of autonomous mode - closing its direct view of Docker/Kubernetes mutation subactions - and ListAvailableTools projects through the identical path. toolsForExecutionMode's mode booleans are replaced by the profile on the executor itself. Non-interactive profiles independently hide pulse_question from the manifest AND runtime-block it before the interactive-call-set special case: a fabricated question call returns a non-interactive error without emitting a waiting event, and sibling tool calls from the same provider turn keep processing. Approval waits never block for non-interactive profiles (they queue), and the tool-only-turn wrap-up guardrail is interactive-profile-owned instead of keyed on autonomy. The system prompt describes detection and investigation modes directly rather than claiming controlled or autonomous execution; the investigation prompt directs the model toward typed action proposals. Proofs: detection allowlist enforcement (alerts resolve and knowledge remember blocked, finding tools allowed, projection agrees), investigation structural read-only (patrol mutation tools dropped from the manifest and blocked at runtime), profile clone isolation, profile prompt modes, and question-tool hiding across profiles.
194 lines
6.7 KiB
Go
194 lines
6.7 KiB
Go
package agentcapabilities
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// ToolCallParams is the shared request/response tool-call parameter shape used
|
|
// by native Pulse Assistant execution and external-agent adapters.
|
|
type ToolCallParams struct {
|
|
Name string `json:"name"`
|
|
Arguments map[string]any `json:"arguments,omitempty"`
|
|
}
|
|
|
|
// ToolCallKind classifies a Pulse Intelligence tool call for safety gates and
|
|
// workflow transitions. Native Assistant currently uses it for FSM decisions,
|
|
// while the vocabulary lives in the shared core so external-agent adapters and
|
|
// future surfaces do not grow separate read/write heuristics.
|
|
type ToolCallKind int
|
|
|
|
const (
|
|
// ToolCallKindResolve covers discovery/query tools that establish resource
|
|
// context and also count as safe verification reads.
|
|
ToolCallKindResolve ToolCallKind = iota
|
|
|
|
// ToolCallKindRead covers read-only tools such as logs, metrics, status,
|
|
// and config inspection.
|
|
ToolCallKindRead
|
|
|
|
// ToolCallKindWrite covers tools that can change Pulse state or target-side
|
|
// infrastructure state.
|
|
ToolCallKindWrite
|
|
|
|
// ToolCallKindUserInput covers interactive tools that ask the user for
|
|
// missing information and do not advance infrastructure workflow state.
|
|
ToolCallKindUserInput
|
|
)
|
|
|
|
func (k ToolCallKind) String() string {
|
|
switch k {
|
|
case ToolCallKindResolve:
|
|
return "resolve"
|
|
case ToolCallKindRead:
|
|
return "read"
|
|
case ToolCallKindWrite:
|
|
return "write"
|
|
case ToolCallKindUserInput:
|
|
return "user_input"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// NormalizeToolCallParams returns the canonical tool-call parameter shape
|
|
// shared by native Assistant execution and external-agent adapters.
|
|
func NormalizeToolCallParams(params ToolCallParams) ToolCallParams {
|
|
params.Name = strings.TrimSpace(params.Name)
|
|
params.Arguments = CloneToolArguments(params.Arguments)
|
|
if params.Arguments == nil {
|
|
params.Arguments = map[string]any{}
|
|
}
|
|
return params
|
|
}
|
|
|
|
// ValidateToolCallParams checks the normalized tool-call contract before a
|
|
// surface attempts capability lookup or execution.
|
|
func ValidateToolCallParams(params ToolCallParams) error {
|
|
if strings.TrimSpace(params.Name) == "" {
|
|
return fmt.Errorf("tool name is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PrepareToolRegistryExecution normalizes and validates the direct in-process
|
|
// registry entrypoint used by native Assistant execution. It returns a
|
|
// ready-to-return shared error result when the tool-call params contract is
|
|
// invalid, so registry surfaces do not duplicate failure text or envelope
|
|
// construction.
|
|
func PrepareToolRegistryExecution(name string, args map[string]any) (ToolCallParams, ToolResult, bool) {
|
|
params := NormalizeToolCallParams(ToolCallParams{
|
|
Name: name,
|
|
Arguments: args,
|
|
})
|
|
if err := ValidateToolCallParams(params); err != nil {
|
|
return params, NewInvalidToolCallParamsResult(err), false
|
|
}
|
|
return params, ToolResult{}, true
|
|
}
|
|
|
|
// ClassifyToolCall classifies a provider/registry tool call for safety gates
|
|
// and workflow state transitions. Registry tools classify through their
|
|
// canonical invocation descriptors (the same table the tool registry
|
|
// enforces at execution time), so FSM classification and runtime policy
|
|
// can never disagree. The switch below covers only genuinely non-registry
|
|
// names: chat-native tools, MCP/native adapter names, and legacy assistant
|
|
// aliases. Unknown tools default to write so newly introduced tools cannot
|
|
// bypass governed-action checks accidentally.
|
|
func ClassifyToolCall(toolName string, args map[string]interface{}) ToolCallKind {
|
|
if descriptor, ok := InvocationDescriptorFor(toolName); ok {
|
|
return descriptor.Classify(args).Kind
|
|
}
|
|
|
|
action, _ := args["action"].(string)
|
|
actionLower := strings.ToLower(action)
|
|
operation, _ := args["operation"].(string)
|
|
operationLower := strings.ToLower(operation)
|
|
|
|
switch strings.TrimSpace(toolName) {
|
|
case PulseQuestionToolName:
|
|
return ToolCallKindUserInput
|
|
|
|
case LegacyAssistantFetchURLToolName:
|
|
return ToolCallKindRead
|
|
|
|
case PulseRunCommandToolName, PulseControlGuestToolName, PulseControlDockerToolName,
|
|
LegacyAssistantRunCommandToolName, LegacyAssistantSetResourceURLToolName:
|
|
return ToolCallKindWrite
|
|
|
|
case PulseSearchResourcesToolName, PulseGetResourceToolName, PulseGetTopologyToolName,
|
|
PulseListInfrastructureToolName, PulseGetConnectionHealthToolName:
|
|
return ToolCallKindResolve
|
|
|
|
case PulseGetDockerLogsToolName, PulseGetPerformanceMetricsToolName,
|
|
PulseGetTemperaturesToolName, PulseGetBaselinesToolName, PulseGetPatternsToolName:
|
|
return ToolCallKindRead
|
|
}
|
|
|
|
if toolCallActionIsWrite(actionLower) || toolCallActionIsWrite(operationLower) {
|
|
return ToolCallKindWrite
|
|
}
|
|
|
|
if toolCallActionIsRead(actionLower) || toolCallActionIsRead(operationLower) {
|
|
return ToolCallKindRead
|
|
}
|
|
|
|
return ToolCallKindWrite
|
|
}
|
|
|
|
func toolCallActionIsWrite(action string) bool {
|
|
switch action {
|
|
case "start", "stop", "restart", "delete",
|
|
"shutdown", "reboot", "write", "append",
|
|
"update", "trigger", "resolve", "dismiss",
|
|
"control":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func toolCallActionIsRead(action string) bool {
|
|
switch action {
|
|
case "get", "list", "search", "query",
|
|
"read", "logs", "status", "health",
|
|
"describe", "inspect", "show":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// NewInvalidToolCallParamsResult returns the stable shared error result for a
|
|
// registry tool-call request that fails the shared params contract.
|
|
func NewInvalidToolCallParamsResult(err error) ToolResult {
|
|
if err == nil {
|
|
err = errors.New("tool call params are invalid")
|
|
}
|
|
return NewToolErrorResult(fmt.Errorf("invalid tools/call params: %w", err))
|
|
}
|
|
|
|
// NewUnknownToolResult returns the stable shared error result for a registry
|
|
// tool-call request that names no registered tool.
|
|
func NewUnknownToolResult(name string) ToolResult {
|
|
return NewToolErrorResult(fmt.Errorf("unknown tool: %s", strings.TrimSpace(name)))
|
|
}
|
|
|
|
// NewControlToolsDisabledToolResult returns the stable shared text result used
|
|
// when a direct registry execution attempts a control tool at read-only control
|
|
// level.
|
|
func NewControlToolsDisabledToolResult() ToolResult {
|
|
return NewToolTextResult(ControlToolsDisabledMessage)
|
|
}
|
|
|
|
// NewInvocationBlockedToolResult is the stable shared result for a tool
|
|
// invocation the registry's invocation policy refuses before the handler
|
|
// runs: a mutating (or unclassifiable, therefore fail-closed) invocation
|
|
// the current execution profile does not permit.
|
|
func NewInvocationBlockedToolResult(toolName string, class InvocationClass) ToolResult {
|
|
return NewToolTextResultWithIsError(fmt.Sprintf(
|
|
"Invocation blocked: this %s call classifies as a state-mutating action (mutation target: %s), which the current session policy does not permit. Gather evidence with read-only tools and propose a typed action instead of mutating directly.",
|
|
toolName, class.Mutation), true)
|
|
}
|