Hydrate Assistant handoff resource scope

This commit is contained in:
rcourtman 2026-05-06 17:59:55 +01:00
parent 25f9172e2e
commit aeede4fb2f
11 changed files with 327 additions and 28 deletions

View file

@ -358,8 +358,9 @@ profile and assignment columns, but embedded table framing must route through
agent auto-approval policy stay canonical in the agent/runtime owners, not
in Patrol investigation-record prompt text. Model-only Assistant handoff
context for a Patrol finding, including same-session metadata retained for
follow-up turns, is also not agent lifecycle state and must not be used as
enrollment evidence, command-websocket identity, or installer authority.
follow-up turns and any resolved-resource scope hydrated from that finding,
is also not agent lifecycle state and must not be used as enrollment
evidence, command-websocket identity, or installer authority.
The same isolation rule applies to CSRF token-store behavior in
`internal/api/csrf_store.go`: lifecycle-adjacent browser flows may rely on
the shared API/security layer to keep parallel replacement-token retries

View file

@ -178,9 +178,13 @@ runtime cost control, and shared AI transport surfaces.
preserving the user's authored prompt as the persisted conversation
message; the model-only handoff may persist as session metadata so
same-session follow-up turns keep the Patrol finding context without
mutating saved user messages. Proposed-fix command text must stay out of
both the persisted chat message and the model-only handoff context, and
command payloads remain approval-context data, not conversational copy.
mutating saved user messages. When the handoff identifies a resource, the
runtime may also seed the session's resolved-resource scope, but only through
canonical unified-resource tool registration so allowed actions, executors,
and explicit-access checks stay governed. Proposed-fix command text must
stay out of both the persisted chat message and the model-only handoff
context, and command payloads remain approval-context data, not
conversational copy.
The Assistant drawer may also render an attached context briefing for that
handoff, but the briefing is runtime context visibility only: it must not
mutate chat control settings, execute tools, or reveal raw command payloads.

View file

@ -750,9 +750,12 @@ the canonical monitored-system blocked payload.
out of the persisted prompt and inside governed approval/remediation
context; the backend may pass that summary as model-only handoff context
for the current turn and retain it as same-session model context for
follow-up turns, and frontend handoff briefings must derive from the same
shared investigation payload rather than inventing a second
finding-context transport shape.
follow-up turns. The backend may also carry structured handoff resource
references from the same finding into chat execution, but those references
must hydrate through canonical unified-resource registration before they
affect session-scoped action validation. Frontend handoff briefings must
derive from the same shared investigation payload rather than inventing a
second finding-context transport shape.
7. Keep Patrol summary payload consumers aligned on one assessment hierarchy: transport-driven Patrol summary surfaces may show supporting counts and outcomes, but the canonical assessment and verification states must remain singular and not be repeated as a second compact verdict strip
8. Keep Patrol verification and activity facts unified on one transport-backed secondary status area: when frontend consumers combine Patrol status payloads (`runtime_state`, `last_patrol_at`, `last_activity_at`, `trigger_status`) with run-history transport, the latest run result, activity mix, scoped-trigger state, and circuit-breaker context must read as one supporting explanation beneath the primary assessment instead of being re-expanded into a separate full-width status strip plus duplicate summary layers
and the main Patrol page composition boundary, so once that governed

View file

@ -413,8 +413,10 @@ bypass the API fail-closed execution gate.
only as adjacent investigation context, not as a recovery support verdict
or restore execution contract. If that guidance is passed as model-only
Assistant handoff context instead of persisted prompt text, the boundary is
unchanged: it still cannot become backup freshness, restore eligibility, or
storage-local recovery authority.
unchanged. If the same handoff seeds Assistant resolved-resource scope, that
scope remains AI/runtime action-validation context only and still cannot
become backup freshness, restore eligibility, or storage-local recovery
authority.
That same adjacent `internal/api/` boundary still carries Patrol-run
execution identity. Storage and recovery may observe shared Patrol
transport through `internal/api/chat_service_adapter.go`, but they must not

View file

@ -507,6 +507,7 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac
executor = baseExecutor.Clone()
executor.SetControlLevel(effectiveControlLevel)
}
s.hydrateHandoffResources(session.ID, req.HandoffResources, sessions, unifiedResourceProvider)
// Per-request autonomous mode override (used by investigation to avoid
// mutating shared service state from concurrent goroutines).
@ -799,6 +800,45 @@ func injectHandoffContextIntoLatestUserMessage(messages []Message, handoffContex
}
}
func (s *Service) hydrateHandoffResources(sessionID string, handoffResources []HandoffResource, sessions *SessionStore, provider tools.UnifiedResourceProvider) {
if len(handoffResources) == 0 || sessions == nil || provider == nil {
return
}
resolvedCtx := sessions.GetResolvedContext(sessionID)
seen := make(map[string]struct{}, len(handoffResources))
for _, resource := range handoffResources {
key := strings.ToLower(strings.TrimSpace(resource.Type) + "\x00" + strings.TrimSpace(resource.ID) + "\x00" + strings.TrimSpace(resource.Name))
if key == "\x00\x00" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
reg, ok := tools.CanonicalHandoffResourceRegistration(provider, resource.ID, resource.Name, resource.Type, resource.Node)
if !ok {
log.Debug().
Str("session_id", sessionID).
Str("resource_id", resource.ID).
Str("resource_name", resource.Name).
Str("resource_type", resource.Type).
Msg("[ChatService] Skipped unresolved handoff resource")
continue
}
resolvedCtx.AddResolvedResource(reg)
if resolved, ok := resolvedCtx.GetResolvedResourceByAlias(reg.Name); ok {
resolvedCtx.MarkExplicitAccess(resolved.GetResourceID())
log.Debug().
Str("session_id", sessionID).
Str("resource_id", resolved.GetResourceID()).
Str("resource_name", reg.Name).
Msg("[ChatService] Hydrated handoff resource into resolved context")
}
}
}
// PatrolRequest represents a patrol execution request within the chat service
type PatrolRequest struct {
Prompt string `json:"prompt"`

View file

@ -256,6 +256,68 @@ func TestService_ExecuteStream_ReusesModelHandoffContextAcrossFollowUps(t *testi
}
}
func TestService_ExecuteStream_HandoffResourceHydratesResolvedContext(t *testing.T) {
tmpDir := t.TempDir()
store, err := NewSessionStore(tmpDir)
if err != nil {
t.Fatalf("failed to create session store: %v", err)
}
state := models.StateSnapshot{
VMs: []models.VM{
{ID: "vm:node1:101", VMID: 101, Name: "web-server", Node: "node1", Status: "running"},
},
}
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(state)
vmResources := registry.ListByType(unifiedresources.ResourceTypeVM)
if len(vmResources) != 1 {
t.Fatalf("expected one canonical VM resource, got %d", len(vmResources))
}
vmResource := vmResources[0]
unifiedProvider := unifiedresources.NewUnifiedAIAdapter(registry)
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{UnifiedResourceProvider: unifiedProvider})
provider := &stubServiceProvider{}
loop := NewAgenticLoop(provider, executor, "system")
svc := &Service{
cfg: &config.AIConfig{ChatModel: "openai:test"},
sessions: store,
executor: executor,
agenticLoop: loop,
provider: provider,
unifiedResourceProvider: unifiedProvider,
started: true,
}
req := ExecuteRequest{
SessionID: "sess-handoff-resource",
Prompt: "What should I do next?",
HandoffResources: []HandoffResource{{
ID: vmResource.ID,
Name: "web-server",
Type: "vm",
Node: "node1",
}},
}
if err := svc.ExecuteStream(context.Background(), req, func(StreamEvent) {}); err != nil {
t.Fatalf("ExecuteStream failed: %v", err)
}
resolved := store.GetResolvedContext("sess-handoff-resource")
info, found := resolved.GetResolvedResourceByAlias("web-server")
if !found {
t.Fatalf("expected handoff resource to be registered by alias")
}
if !resolved.WasRecentlyAccessed(info.GetResourceID(), time.Minute) {
t.Fatalf("expected handoff resource to be marked as explicitly accessed")
}
if _, err := resolved.ValidateResourceForAction(info.GetResourceID(), "restart"); err != nil {
t.Fatalf("expected handoff VM to allow governed restart action: %v", err)
}
}
func latestProviderUserContent(t *testing.T, messages []providers.Message) string {
t.Helper()

View file

@ -90,16 +90,27 @@ type StructuredMention struct {
Node string `json:"node,omitempty"` // Proxmox node or parent host
}
// HandoffResource represents an explicit product-originated resource handoff.
// Unlike model-only handoff text, this is used only to seed session-scoped
// resource validation from canonical unified-resource registrations.
type HandoffResource struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
Node string `json:"node,omitempty"`
}
// ExecuteRequest represents a chat execution request
type ExecuteRequest struct {
Prompt string `json:"prompt"`
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
Mentions []StructuredMention `json:"mentions,omitempty"`
FindingID string `json:"finding_id,omitempty"` // Pre-populate finding context for "Discuss" flow
HandoffContext string `json:"handoff_context,omitempty"` // Model-only context for scoped handoffs; not persisted as user-authored text.
MaxTurns int `json:"max_turns,omitempty"` // Override max agentic turns (0 = use default)
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Per-request autonomous override (nil = use service default)
Prompt string `json:"prompt"`
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
Mentions []StructuredMention `json:"mentions,omitempty"`
FindingID string `json:"finding_id,omitempty"` // Pre-populate finding context for "Discuss" flow
HandoffContext string `json:"handoff_context,omitempty"` // Model-only context for scoped handoffs; not persisted as user-authored text.
HandoffResources []HandoffResource `json:"handoff_resources,omitempty"` // Product-originated resources to seed governed session validation.
MaxTurns int `json:"max_turns,omitempty"` // Override max agentic turns (0 = use default)
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Per-request autonomous override (nil = use service default)
}
// QuestionAnswer represents a user's answer to a question

View file

@ -2544,6 +2544,85 @@ func canonicalStorageRegistration(resource unifiedresources.Resource) (ResourceR
}, true
}
// CanonicalHandoffResourceRegistration resolves a product-originated resource
// handoff into the same governed registration shape used by explicit resource
// reads. It intentionally fails closed when the resource cannot be found in the
// unified provider, because invented registrations would bypass capability and
// executor policy.
func CanonicalHandoffResourceRegistration(provider UnifiedResourceProvider, resourceID, resourceName, resourceType, node string) (ResourceRegistration, bool) {
if provider == nil {
return ResourceRegistration{}, false
}
refs := appendUniqueStrings(nil, resourceID, resourceName)
normalizedType := strings.TrimSpace(strings.ToLower(resourceType))
switch normalizedType {
case "node":
normalizedType = "agent"
case "physical-disk":
normalizedType = "physical_disk"
}
switch normalizedType {
case "agent", "docker-host":
refs = appendUniqueStrings(refs, node)
if resource, ok := findCanonicalResourceByReference(provider.GetByType(unifiedresources.ResourceTypeAgent), refs...); ok {
return canonicalAgentRegistration(resource)
}
case "vm":
if resource, ok := findCanonicalGuestResourceByReferences(provider, "vm", refs...); ok {
return canonicalGuestRegistration("vm", resource)
}
case "system-container", "lxc":
if resource, ok := findCanonicalGuestResourceByReferences(provider, "system-container", refs...); ok {
return canonicalGuestRegistration("system-container", resource)
}
case "app-container":
if resource, ok := findCanonicalAppContainerResourceByReferences(provider, refs...); ok {
return resolvedAppContainerRegistration(resource)
}
case "storage":
if resource, ok := findCanonicalResourceByReference(canonicalStoragePoolResources(provider), refs...); ok {
return canonicalStorageRegistration(resource)
}
}
return ResourceRegistration{}, false
}
func findCanonicalResourceByReference(resources []unifiedresources.Resource, references ...string) (unifiedresources.Resource, bool) {
for _, ref := range references {
ref = strings.TrimSpace(ref)
if ref == "" {
continue
}
for _, resource := range resources {
if matchesCanonicalResourceReference(resource, ref, resourceDisplayName(resource), resource.Name) {
return resource, true
}
}
}
return unifiedresources.Resource{}, false
}
func findCanonicalGuestResourceByReferences(provider UnifiedResourceProvider, kind string, references ...string) (unifiedresources.Resource, bool) {
for _, ref := range references {
if resource, ok := findCanonicalGuestResource(provider, kind, ref); ok {
return resource, true
}
}
return unifiedresources.Resource{}, false
}
func findCanonicalAppContainerResourceByReferences(provider UnifiedResourceProvider, references ...string) (unifiedresources.Resource, bool) {
for _, ref := range references {
if resource, _, ok := findCanonicalAppContainerResource(provider, ref); ok {
return resource, true
}
}
return unifiedresources.Resource{}, false
}
func findCanonicalResourceByID(resources []unifiedresources.Resource, resourceID string) (unifiedresources.Resource, bool) {
resourceID = strings.TrimSpace(resourceID)
if resourceID == "" {

View file

@ -794,7 +794,8 @@ func buildUnifiedFindingChatContext(f *unified.UnifiedFinding) string {
appendChatContextLine(&b, "Title", f.Title)
appendChatContextLine(&b, "Severity", string(f.Severity))
appendChatContextLine(&b, "Category", string(f.Category))
appendChatContextLine(&b, "Resource", fmt.Sprintf("%s (%s)", f.ResourceName, f.ResourceType))
appendChatContextLine(&b, "Resource", formatChatResource(f.ResourceName, f.ResourceType))
appendChatContextLine(&b, "Resource ID", f.ResourceID)
appendChatContextLine(&b, "Description", f.Description)
appendChatContextLine(&b, "Recommendation", f.Recommendation)
appendChatContextLine(&b, "Evidence", f.Evidence)
@ -821,6 +822,9 @@ func appendInvestigationRecordChatContext(b *strings.Builder, rec *aicontracts.I
appendChatContextLine(b, "Status", string(rec.Status))
appendChatContextLine(b, "Outcome", string(rec.Outcome))
appendChatContextLine(b, "Confidence", string(rec.Confidence))
appendChatContextLine(b, "Subject Resource", formatChatResource(rec.Subject.ResourceName, rec.Subject.ResourceType))
appendChatContextLine(b, "Subject Resource ID", rec.Subject.ResourceID)
appendChatContextLine(b, "Subject Node", rec.Subject.Node)
appendChatContextLine(b, "Conclusion", rec.Conclusion)
appendChatContextLine(b, "Recommended Action", rec.RecommendedAction)
appendChatContextLine(b, "Trigger", rec.Trigger.Title)
@ -859,6 +863,62 @@ func appendInvestigationRecordChatContext(b *strings.Builder, rec *aicontracts.I
}
}
func buildUnifiedFindingHandoffResources(f *unified.UnifiedFinding) []chat.HandoffResource {
if f == nil {
return nil
}
resources := make([]chat.HandoffResource, 0, 2)
add := func(resource chat.HandoffResource) {
resource.ID = strings.TrimSpace(resource.ID)
resource.Name = strings.TrimSpace(resource.Name)
resource.Type = strings.TrimSpace(resource.Type)
resource.Node = strings.TrimSpace(resource.Node)
if resource.ID == "" && resource.Name == "" {
return
}
key := strings.ToLower(resource.Type + "\x00" + resource.ID + "\x00" + resource.Name + "\x00" + resource.Node)
for _, existing := range resources {
existingKey := strings.ToLower(strings.TrimSpace(existing.Type) + "\x00" + strings.TrimSpace(existing.ID) + "\x00" + strings.TrimSpace(existing.Name) + "\x00" + strings.TrimSpace(existing.Node))
if existingKey == key {
return
}
}
resources = append(resources, resource)
}
add(chat.HandoffResource{
ID: f.ResourceID,
Name: f.ResourceName,
Type: f.ResourceType,
Node: f.Node,
})
if f.InvestigationRecord != nil {
add(chat.HandoffResource{
ID: f.InvestigationRecord.Subject.ResourceID,
Name: f.InvestigationRecord.Subject.ResourceName,
Type: f.InvestigationRecord.Subject.ResourceType,
Node: f.InvestigationRecord.Subject.Node,
})
}
return resources
}
func formatChatResource(name, resourceType string) string {
name = strings.TrimSpace(name)
resourceType = strings.TrimSpace(resourceType)
switch {
case name != "" && resourceType != "":
return fmt.Sprintf("%s (%s)", name, resourceType)
case name != "":
return name
case resourceType != "":
return resourceType
default:
return ""
}
}
func appendInvestigationRecordEvidenceContext(b *strings.Builder, evidence []aicontracts.InvestigationRecordEvidence) {
if len(evidence) == 0 {
return
@ -1078,11 +1138,13 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) {
// chat service injects this into the current model turn without persisting it
// as the user's authored prompt, so conversation history stays readable.
handoffContext := ""
var handoffResources []chat.HandoffResource
if req.FindingID != "" {
store := h.GetUnifiedStoreForOrg(GetOrgID(ctx))
if store != nil {
if f := store.Get(req.FindingID); f != nil {
handoffContext = buildUnifiedFindingChatContext(f)
handoffResources = buildUnifiedFindingHandoffResources(f)
}
}
}
@ -1090,13 +1152,14 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) {
// Stream from AI chat service
serviceSentDone := false
err := svc.ExecuteStream(ctx, chat.ExecuteRequest{
Prompt: req.Prompt,
SessionID: req.SessionID,
Model: req.Model,
Mentions: chatMentions,
FindingID: req.FindingID,
HandoffContext: handoffContext,
AutonomousMode: req.AutonomousMode,
Prompt: req.Prompt,
SessionID: req.SessionID,
Model: req.Model,
Mentions: chatMentions,
FindingID: req.FindingID,
HandoffContext: handoffContext,
HandoffResources: handoffResources,
AutonomousMode: req.AutonomousMode,
}, func(event chat.StreamEvent) {
if event.Type == "done" {
serviceSentDone = true

View file

@ -627,6 +627,8 @@ func TestHandleChat_IncludesInvestigationRecordContext(t *testing.T) {
assert.Equal(t, "What happened?", reqArg.Prompt)
assert.Contains(t, reqArg.HandoffContext, "[Finding Context]")
assert.Contains(t, reqArg.HandoffContext, "[Investigation Record]")
assert.Contains(t, reqArg.HandoffContext, "Resource ID: vm-100")
assert.Contains(t, reqArg.HandoffContext, "Subject Resource ID: vm-100")
assert.Contains(t, reqArg.HandoffContext, "Conclusion: Backup job saturated CPU.")
assert.Contains(t, reqArg.HandoffContext, "Recommended Action: Approve a controlled service restart after backup completion.")
assert.Contains(t, reqArg.HandoffContext, "Evidence 1: metrics: CPU stayed above 95% for 10 minutes")
@ -634,6 +636,12 @@ func TestHandleChat_IncludesInvestigationRecordContext(t *testing.T) {
assert.Contains(t, reqArg.HandoffContext, "Proposed Fix Commands: 1 command recorded for approval context")
assert.NotContains(t, reqArg.HandoffContext, "User message: What happened?")
assert.NotContains(t, reqArg.HandoffContext, "systemctl restart workload.service")
assert.Equal(t, []chat.HandoffResource{{
ID: "vm-100",
Name: "web-server",
Type: "vm",
Node: "pve-1",
}}, reqArg.HandoffResources)
})
body := `{"prompt":"What happened?","finding_id":"finding-123"}`

View file

@ -129,12 +129,22 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
if err != nil {
t.Fatalf("read chat session store: %v", err)
}
chatTypesSource, err := os.ReadFile(filepath.Clean("../ai/chat/types.go"))
if err != nil {
t.Fatalf("read chat types: %v", err)
}
toolsQuerySource, err := os.ReadFile(filepath.Clean("../ai/tools/tools_query.go"))
if err != nil {
t.Fatalf("read AI tools query runtime: %v", err)
}
handlerText := string(handlerSource)
for _, required := range []string{
"handoffContext = buildUnifiedFindingChatContext(f)",
"Prompt: req.Prompt",
"HandoffContext: handoffContext",
"handoffResources = buildUnifiedFindingHandoffResources(f)",
"Prompt:",
"HandoffContext:",
"HandoffResources: handoffResources",
} {
if !strings.Contains(handlerText, required) {
t.Fatalf("ai_handler.go must preserve model-only finding handoff contract: missing %q", required)
@ -149,6 +159,7 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
"handoffContext := strings.TrimSpace(req.HandoffContext)",
"sessions.SetModelHandoffContext(session.ID, handoffContext)",
"sessions.GetModelHandoffContext(session.ID)",
"s.hydrateHandoffResources(session.ID, req.HandoffResources, sessions, unifiedResourceProvider)",
"injectHandoffContextIntoLatestUserMessage(messages, handoffContext)",
"User message: ",
} {
@ -167,6 +178,21 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
t.Fatalf("chat session store must persist model-only handoff metadata outside messages: missing %q", required)
}
}
chatTypesText := string(chatTypesSource)
for _, required := range []string{
"type HandoffResource struct",
"HandoffResources []HandoffResource",
} {
if !strings.Contains(chatTypesText, required) {
t.Fatalf("chat request must carry structured handoff resource scope outside messages: missing %q", required)
}
}
toolsQueryText := string(toolsQuerySource)
if !strings.Contains(toolsQueryText, "func CanonicalHandoffResourceRegistration(provider UnifiedResourceProvider") {
t.Fatal("AI tools runtime must own canonical handoff resource registration")
}
}
func TestContract_ProxmoxSetupScriptUsesPrivilegeSeparatedTokenACLs(t *testing.T) {