mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 10:52:08 +00:00
9bc51a8a35
Commit A of the coordinated proposal slice (pulse-internal; the aicontracts interface replacement and enterprise migration follow as one window because pulse-enterprise builds against this tree via a replace directive). patrol_propose_action is a side-effect-free, mutation-none capture whose schema carries only resource_id, capability_name, params, and reason. Registry policy rejects it outside the Patrol investigation profile before the handler runs, and the same check keeps it out of every other profile's projected manifest. Correlation identity (proposal, finding, investigation, evidence) is injected from trusted orchestration context through the request-local ProposalCapture sink, which executor clones share so one run has exactly one capture. Tool calls now carry an explicit invocation envelope (tool-use ID, name, arguments) through ExecuteInvocation; the ID rides the context because per-turn tool calls execute concurrently. The sink keys on call identity plus payload fingerprint: idempotent replay re-succeeds, the same ID with a different payload latches a terminal integrity error, and a second distinct valid proposal latches terminal ambiguity - both terminal states invalidate the captured proposal, since concurrency makes first-wins nondeterministic. Proposals count only after catalog validation: advertised capability, declared/required/enum parameters, and sensitive parameters rejected before success with no value echo in any output. ExecuteInvestigationStream returns proposal cardinality as a structured result with typed errors for ambiguity, integrity violations, and the failed-attempts-only case (never collapsed into the valid zero-proposal conclusion); ListInvestigationTools projects through the identical profile path. Proposal parameter values exist only transiently for provider continuation and validation: the canonical exposure projector redacts them from the durable transcript and every tool_start/progress/end stream event, proven end-to-end with a scripted provider run that also verifies the provider continuation keeps raw values. Essential proof included: two concurrent valid proposal calls produce ErrProposalAmbiguous and a nil proposal regardless of execution order.
105 lines
4.3 KiB
Go
105 lines
4.3 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
|
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
// registerProposeTools registers the investigation-only typed action
|
|
// proposal capture. The registry policy rejects this tool outside the
|
|
// Patrol investigation profile before this handler runs; the handler
|
|
// additionally requires the request-local capture sink, which only the
|
|
// core investigation entrypoint wires.
|
|
func (e *PulseToolExecutor) registerProposeTools() {
|
|
e.registry.registerBuiltin(RegisteredTool{
|
|
Definition: Tool{
|
|
Name: agentcapabilities.PatrolProposeActionToolName,
|
|
Description: `Propose ONE typed remediation action for the finding under investigation. Side-effect-free: the proposal is validated and recorded for governed planning and approval; nothing executes now.
|
|
|
|
Reference an advertised resource capability (see the resource's capability catalog) and fill only its declared parameters. Never place secrets in params - sensitive parameters are supplied by an operator at approval time.
|
|
|
|
Submit at most one proposal per investigation. If no safe remediation exists, conclude without proposing.`,
|
|
InputSchema: InputSchema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"resource_id": {
|
|
Type: "string",
|
|
Description: "Canonical resource ID the action targets",
|
|
},
|
|
"capability_name": {
|
|
Type: "string",
|
|
Description: "Advertised capability name on that resource",
|
|
},
|
|
"params": {
|
|
Type: "object",
|
|
Description: "Values for the capability's declared parameters (non-sensitive only)",
|
|
},
|
|
"reason": {
|
|
Type: "string",
|
|
Description: "Why this action remediates the finding",
|
|
},
|
|
},
|
|
Required: []string{"resource_id", "capability_name", "reason"},
|
|
},
|
|
},
|
|
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
|
|
return exec.executeProposeAction(ctx, args)
|
|
},
|
|
Governance: ToolGovernance{
|
|
ActionMode: ToolActionRead,
|
|
ApprovalPolicy: ToolApprovalScopeOnly,
|
|
ApprovalSummary: "side-effect-free proposal capture; the proposed action itself is planned and approved on the canonical action lifecycle",
|
|
Summary: "Records one validated typed action proposal during a Patrol investigation; planning, approval, and execution stay governed.",
|
|
},
|
|
})
|
|
}
|
|
|
|
// executeProposeAction validates and captures a typed action proposal.
|
|
// Success and error outputs never echo parameter values.
|
|
func (e *PulseToolExecutor) executeProposeAction(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
|
capture := e.proposalCapture
|
|
if capture == nil {
|
|
return NewErrorResult(fmt.Errorf("action proposals are not available in this run")), nil
|
|
}
|
|
|
|
resourceID := unified.CanonicalResourceID(stringArg(args, "resource_id"))
|
|
capabilityName := strings.TrimSpace(stringArg(args, "capability_name"))
|
|
reason := strings.TrimSpace(stringArg(args, "reason"))
|
|
params, _ := args["params"].(map[string]interface{})
|
|
if params == nil {
|
|
params = map[string]interface{}{}
|
|
}
|
|
if resourceID == "" || capabilityName == "" || reason == "" {
|
|
capture.RecordFailedAttempt()
|
|
return NewErrorResult(fmt.Errorf("resource_id, capability_name, and reason are required")), nil
|
|
}
|
|
|
|
if err := validateProposalAgainstCatalog(ctx, capture.catalog, resourceID, capabilityName, params); err != nil {
|
|
capture.RecordFailedAttempt()
|
|
return NewErrorResult(err), nil
|
|
}
|
|
|
|
if err := capture.Submit(InvocationIDFromContext(ctx), resourceID, capabilityName, reason, params); err != nil {
|
|
return NewErrorResult(err), nil
|
|
}
|
|
return NewTextResult(fmt.Sprintf(
|
|
"Proposal recorded: capability %q on resource %q. It will be planned and routed for governed approval; nothing has executed. Conclude the investigation with your diagnosis.",
|
|
capabilityName, resourceID)), nil
|
|
}
|
|
|
|
func stringArg(args map[string]interface{}, key string) string {
|
|
value, _ := args[key].(string)
|
|
return value
|
|
}
|
|
|
|
// SetProposalCapture wires the request-local proposal sink. Only the core
|
|
// investigation entrypoint calls this, on the effective request executor;
|
|
// clones share the sink deliberately so one run has exactly one capture.
|
|
func (e *PulseToolExecutor) SetProposalCapture(capture *ProposalCapture) {
|
|
e.proposalCapture = capture
|
|
}
|