Hydrate Assistant handoff resource relationships

This commit is contained in:
rcourtman 2026-05-06 19:02:07 +01:00
parent 7df4588f63
commit 209ea8dfdb
7 changed files with 237 additions and 21 deletions

View file

@ -189,12 +189,16 @@ runtime cost control, and shared AI transport surfaces.
for those handoff resources, using the same unified-resource resolution and
policy presentation helpers that govern mention prefetch and provider-bound
redaction; that context remains model-only handling guidance, not saved user
text or disclosure authority. Assistant runtime may also hydrate recent
changes for those handoff resources from the canonical unified-resource
timeline as model-only context on each turn, but those timeline facts remain
read-only explanation context and do not grant action authority. The runtime
may also persist structured pending-action and approval references from the
same investigation record as
text or disclosure authority. Assistant runtime may also hydrate canonical
relationship context for those handoff resources through
`FormatResourceRelationshipContext(...)` and canonical parent-edge synthesis,
but those topology facts remain read-only explanation context and do not
grant action authority. Assistant runtime may also hydrate recent changes for
those handoff resources from the canonical unified-resource timeline as
model-only context on each turn, but those timeline facts remain read-only
explanation context and do not grant action authority. The runtime may also
persist structured pending-action and approval references from the same
investigation record as
model-context metadata, but those references are review context only: they
must not include raw command text, must not grant approval or execution
authority, and must route any operator decision back through the governed

View file

@ -761,17 +761,20 @@ the canonical monitored-system blocked payload.
resolution and shared policy presentation helpers, but the resulting handling
guidance is read-only, model-only context and must not become saved user text,
disclosure authority, or action authority. Chat execution may also hydrate
recent changes for those handoff resources from the canonical
unified-resource timeline, but the resulting context is read-only, model-only
explanation data and must not become saved user text or action authority. The
backend may also carry structured pending-action and approval references from
the investigation record into chat execution, but those references must omit
raw proposed-fix commands, remain model-only review context, and leave
approval/execution authority with the governed approval and remediation APIs.
Chat execution may refresh approval status snapshots for those references
from the canonical approval store, but that snapshot is read-only,
org-scoped, and must not expose or infer the raw command. Frontend handoff
briefings must derive from
canonical relationship context for those resources through shared
unified-resource relationship presentation and parent-edge synthesis, but
topology context is read-only and must not become saved user text or action
authority. Chat execution may also hydrate recent changes for those handoff
resources from the canonical unified-resource timeline, but the resulting
context is read-only, model-only explanation data and must not become saved
user text or action authority. The backend may also carry structured
pending-action and approval references from the investigation record into chat
execution, but those references must omit raw proposed-fix commands, remain
model-only review context, and leave approval/execution authority with the
governed approval and remediation APIs. Chat execution may refresh approval
status snapshots for those references from the canonical approval store, but
that snapshot is read-only, org-scoped, and must not expose or infer the raw
command. 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

View file

@ -109,10 +109,11 @@ Patrol-specific presentation helpers.
approval's current status for review, but Patrol presentation must still keep
command payloads inside governed approval/remediation context rather than
rendering them as handoff copy. Assistant may also enrich that same handoff
with canonical resource-policy guidance and recent canonical
resource-timeline changes for explanation, but Patrol must keep the visible
finding and drawer briefing tied to the shared investigation payload rather
than forking a Patrol-local policy or timeline summary.
with canonical resource-policy guidance, canonical resource-relationship
context, and recent canonical resource-timeline changes for explanation, but
Patrol must keep the visible finding and drawer briefing tied to the shared
investigation payload rather than forking a Patrol-local policy, topology, or
timeline summary.
## Current State

View file

@ -993,6 +993,13 @@ That same resource model now also owns the canonical
resolve the resource and hand the model the relationship list instead of
rebuilding the relationship section header, ordering, or freshness wording
locally.
Assistant finding handoffs are part of that same relationship-context contract:
when the runtime needs topology context for product-originated handoff
resources, it should resolve the canonical unified resource, synthesize the
canonical parent edge through `ResourceRelationshipsWithCanonicalParent(...)`,
and call `FormatResourceRelationshipContext(...)` rather than rebuilding
relationship markdown in chat or Patrol-local helpers. The resulting topology
block remains model-only explanation context, not action authority.
The same shared relationship presenter also owns the compact change-timeline
relationship summary used by resource change records, so change `from` and
`to` values stay aligned with the canonical relationship labels instead of

View file

@ -528,6 +528,7 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac
handoffResourceProvider := s.unifiedResourceProvider
s.mu.RUnlock()
handoffContext = mergeHandoffResourcePolicyContext(handoffContext, handoffResources, handoffResourceProvider)
handoffContext = mergeHandoffResourceRelationshipContext(handoffContext, handoffResources, handoffResourceProvider)
handoffContext = mergeHandoffResourceTimelineContext(handoffContext, handoffResources, s.actionAuditStore, time.Now())
handoffContext = mergeHandoffActionContext(handoffContext, handoffActions)
injectHandoffContextIntoLatestUserMessage(messages, handoffContext)
@ -923,6 +924,73 @@ func buildHandoffResourcePolicyContext(handoffResources []HandoffResource, provi
return strings.TrimSpace(b.String())
}
func mergeHandoffResourceRelationshipContext(handoffContext string, handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string {
relationshipContext := buildHandoffResourceRelationshipContext(handoffResources, provider)
switch {
case strings.TrimSpace(handoffContext) == "":
return relationshipContext
case relationshipContext == "":
return strings.TrimSpace(handoffContext)
default:
return strings.TrimSpace(handoffContext) + "\n\n" + relationshipContext
}
}
func buildHandoffResourceRelationshipContext(handoffResources []HandoffResource, provider tools.UnifiedResourceProvider) string {
resources := normalizeHandoffResources(handoffResources)
if len(resources) == 0 || provider == nil {
return ""
}
var b strings.Builder
seen := make(map[string]struct{}, len(resources))
count := 0
for _, handoffResource := range resources {
resource, ok := tools.CanonicalHandoffUnifiedResource(provider, handoffResource.ID, handoffResource.Name, handoffResource.Type, handoffResource.Node)
if !ok {
continue
}
key := strings.ToLower(strings.TrimSpace(string(resource.Type)) + "\x00" + strings.TrimSpace(resource.ID))
if key == "\x00" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
resource.Relationships = unifiedresources.ResourceRelationshipsWithCanonicalParent(resource)
relationshipContext := strings.TrimSpace(unifiedresources.FormatResourceRelationshipContext(&resource, 3))
if relationshipContext == "" {
continue
}
if b.Len() == 0 {
b.WriteString("[Resource Relationship Context]")
}
count++
policy, aiSafeSummary := unifiedresources.CanonicalGovernanceMetadata(&resource)
label := unifiedresources.ResourcePolicyLabel(resource.Name, aiSafeSummary, policy)
if label == "" {
label = "resource"
}
contextLabel := "Resource Relationships For"
if count > 1 {
contextLabel = fmt.Sprintf("Resource Relationships %d For", count)
}
appendHandoffContextLine(&b, contextLabel, label)
if b.Len() > 0 {
b.WriteByte('\n')
}
b.WriteString(relationshipContext)
}
if count == 0 {
return ""
}
appendHandoffContextLine(&b, "Relationship Boundary", "Relationships are read-only canonical topology context; they are not approval or execution authority.")
return strings.TrimSpace(b.String())
}
func mergeHandoffActionContext(handoffContext string, handoffActions []HandoffAction) string {
actionContext := buildHandoffActionContext(handoffActions)
switch {

View file

@ -19,6 +19,14 @@ type stubServiceProvider struct {
streamFn func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error
}
type handoffUnifiedProvider struct {
resources map[unifiedresources.ResourceType][]unifiedresources.Resource
}
func (p handoffUnifiedProvider) GetByType(t unifiedresources.ResourceType) []unifiedresources.Resource {
return append([]unifiedresources.Resource(nil), p.resources[t]...)
}
func installTestApprovalStore(t *testing.T, req *approval.ApprovalRequest) {
t.Helper()
previous := approval.GetStore()
@ -367,6 +375,128 @@ func TestService_ExecuteStream_HandoffResourcePolicyContextIsModelOnly(t *testin
}
}
func TestService_ExecuteStream_HandoffResourceRelationshipContextIsModelOnly(t *testing.T) {
tmpDir := t.TempDir()
store, err := NewSessionStore(tmpDir)
if err != nil {
t.Fatalf("failed to create session store: %v", err)
}
now := time.Now()
unifiedProvider := handoffUnifiedProvider{resources: map[unifiedresources.ResourceType][]unifiedresources.Resource{
unifiedresources.ResourceTypeVM: {{
ID: "finance-vm",
Type: unifiedresources.ResourceTypeVM,
Name: "finance-vm",
Status: unifiedresources.StatusOnline,
Tags: []string{"pii"},
Relationships: []unifiedresources.ResourceRelationship{{
SourceID: "finance-vm",
TargetID: "secret-storage",
Type: unifiedresources.RelDependsOn,
Confidence: 0.85,
Active: true,
Discoverer: "pulse_correlation",
ObservedAt: now.Add(-30 * time.Minute),
LastSeenAt: now.Add(-10 * time.Minute),
Metadata: map[string]any{"role": "database"},
}},
}},
unifiedresources.ResourceTypeStorage: {{
ID: "secret-storage",
Type: unifiedresources.ResourceTypeStorage,
Name: "secret-storage",
Status: unifiedresources.StatusOnline,
Tags: []string{"backup"},
}},
}}
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{UnifiedResourceProvider: unifiedProvider})
var capturedMessages []providers.Message
provider := &stubServiceProvider{
streamFn: func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error {
capturedMessages = append([]providers.Message(nil), req.Messages...)
callback(providers.StreamEvent{
Type: "content",
Data: providers.ContentEvent{Text: "noted"},
})
callback(providers.StreamEvent{
Type: "done",
Data: providers.DoneEvent{InputTokens: 1, OutputTokens: 1},
})
return nil
},
}
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-relationship",
Prompt: "Why did this happen?",
HandoffContext: "[Finding Context]\nID: finding-123\nConclusion: finance-vm has storage latency.",
HandoffResources: []HandoffResource{{
ID: "finance-vm",
Name: "finance-vm",
Type: "vm",
}},
}
if err := svc.ExecuteStream(context.Background(), req, func(StreamEvent) {}); err != nil {
t.Fatalf("ExecuteStream failed: %v", err)
}
stored, err := store.GetMessages("sess-handoff-relationship")
if err != nil {
t.Fatalf("GetMessages failed: %v", err)
}
if len(stored) == 0 {
t.Fatal("expected stored messages")
}
if stored[0].Content != "Why did this happen?" {
t.Fatalf("stored user message = %q, want clean prompt", stored[0].Content)
}
if strings.Contains(stored[0].Content, "Resource Relationship Context") {
t.Fatalf("stored user message should not include relationship context: %q", stored[0].Content)
}
if len(capturedMessages) == 0 {
t.Fatal("expected provider messages")
}
modelUserContent := capturedMessages[len(capturedMessages)-1].Content
if !strings.Contains(modelUserContent, "[Resource Relationship Context]") {
t.Fatalf("model user content missing relationship context: %q", modelUserContent)
}
if !strings.Contains(modelUserContent, "### Resource Relationships") {
t.Fatalf("model user content missing canonical relationship heading: %q", modelUserContent)
}
if !strings.Contains(modelUserContent, "Depends on") {
t.Fatalf("model user content missing canonical relationship label: %q", modelUserContent)
}
if !strings.Contains(modelUserContent, "discoverer pulse_correlation") {
t.Fatalf("model user content missing relationship provenance: %q", modelUserContent)
}
if !strings.Contains(modelUserContent, "metadata present") {
t.Fatalf("model user content missing relationship metadata marker: %q", modelUserContent)
}
if !strings.Contains(modelUserContent, "Relationship Boundary: Relationships are read-only canonical topology context") {
t.Fatalf("model user content missing relationship boundary: %q", modelUserContent)
}
if !strings.Contains(modelUserContent, "redacted by policy") {
t.Fatalf("external provider request should redact governed relationship identity: %q", modelUserContent)
}
if strings.Contains(modelUserContent, "finance-vm") || strings.Contains(modelUserContent, "secret-storage") {
t.Fatalf("model user content leaked governed relationship identity: %q", modelUserContent)
}
}
func TestService_ExecuteStream_ReusesModelHandoffContextAcrossFollowUps(t *testing.T) {
installTestApprovalStore(t, &approval.ApprovalRequest{
ID: "approval-123",

View file

@ -168,12 +168,15 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
"sessions.GetModelHandoffActions(session.ID)",
"refreshHandoffActionApprovalStatus(handoffActions, s.orgID)",
"mergeHandoffResourcePolicyContext(handoffContext, handoffResources, handoffResourceProvider)",
"mergeHandoffResourceRelationshipContext(handoffContext, handoffResources, handoffResourceProvider)",
"mergeHandoffResourceTimelineContext(handoffContext, handoffResources, s.actionAuditStore, time.Now())",
"handoffContext = mergeHandoffActionContext(handoffContext, handoffActions)",
"s.hydrateHandoffResources(session.ID, handoffResources, sessions, unifiedResourceProvider)",
"injectHandoffContextIntoLatestUserMessage(messages, handoffContext)",
"Resource Policy Context",
"Policy Boundary",
"Resource Relationship Context",
"Relationship Boundary",
"Timeline Boundary",
"Approval Status",
"Action Boundary",