mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 09:23:27 +00:00
Hydrate Assistant handoff resource relationships
This commit is contained in:
parent
7df4588f63
commit
209ea8dfdb
7 changed files with 237 additions and 21 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue