Files
pulse/internal/ai/tools/tools_propose.go
T
rcourtman 9bc51a8a35 Build the narrow proposal channel: patrol_propose_action and its capture sink
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.
2026-07-10 16:47:23 +01:00

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
}