Persist Assistant handoff resource references

This commit is contained in:
rcourtman
2026-05-06 18:15:58 +01:00
parent df71bcdf09
commit f38f3a5409
7 changed files with 216 additions and 17 deletions
@@ -181,10 +181,13 @@ runtime cost control, and shared AI transport surfaces.
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.
and explicit-access checks stay governed. Structured handoff resource
references may persist as session model-context metadata for follow-up turns,
but they remain references only; each turn must rehydrate them from the
current canonical unified-resource model before action validation can use
them. 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.
@@ -753,9 +753,12 @@ the canonical monitored-system blocked payload.
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.
affect session-scoped action validation. If stored for follow-up turns, they
remain model-context references rather than saved user text or action
authority, and the runtime must re-resolve them against current canonical
resources before use. 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
+17 -1
View File
@@ -449,11 +449,27 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac
log.Debug().Str("session_id", session.ID).Msg("[ChatService] Session ensured")
handoffContext := strings.TrimSpace(req.HandoffContext)
handoffResources := normalizeHandoffResources(req.HandoffResources)
if handoffContext != "" {
if err := sessions.SetModelHandoffContext(session.ID, handoffContext); err != nil {
log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to persist model handoff context")
}
if err := sessions.SetModelHandoffResources(session.ID, handoffResources); err != nil {
log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to persist model handoff resources")
}
} else {
if len(handoffResources) > 0 {
if err := sessions.SetModelHandoffResources(session.ID, handoffResources); err != nil {
log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to persist model handoff resources")
}
} else {
storedHandoffResources, err := sessions.GetModelHandoffResources(session.ID)
if err != nil {
log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to load model handoff resources")
} else {
handoffResources = storedHandoffResources
}
}
storedHandoffContext, err := sessions.GetModelHandoffContext(session.ID)
if err != nil {
log.Warn().Err(err).Str("session_id", session.ID).Msg("[ChatService] Failed to load model handoff context")
@@ -507,7 +523,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)
s.hydrateHandoffResources(session.ID, handoffResources, sessions, unifiedResourceProvider)
// Per-request autonomous mode override (used by investigation to avoid
// mutating shared service state from concurrent goroutines).
@@ -292,8 +292,9 @@ func TestService_ExecuteStream_HandoffResourceHydratesResolvedContext(t *testing
}
req := ExecuteRequest{
SessionID: "sess-handoff-resource",
Prompt: "What should I do next?",
SessionID: "sess-handoff-resource",
Prompt: "What should I do next?",
HandoffContext: "[Finding Context]\nID: finding-123",
HandoffResources: []HandoffResource{{
ID: vmResource.ID,
Name: "web-server",
@@ -316,6 +317,35 @@ func TestService_ExecuteStream_HandoffResourceHydratesResolvedContext(t *testing
if _, err := resolved.ValidateResourceForAction(info.GetResourceID(), "restart"); err != nil {
t.Fatalf("expected handoff VM to allow governed restart action: %v", err)
}
reloadedStore, err := NewSessionStore(tmpDir)
if err != nil {
t.Fatalf("failed to reload session store: %v", err)
}
reloadedSvc := &Service{
cfg: &config.AIConfig{ChatModel: "openai:test"},
sessions: reloadedStore,
executor: executor,
agenticLoop: loop,
provider: provider,
unifiedResourceProvider: unifiedProvider,
started: true,
}
followUpReq := ExecuteRequest{
SessionID: "sess-handoff-resource",
Prompt: "Can you restart it?",
}
if err := reloadedSvc.ExecuteStream(context.Background(), followUpReq, func(StreamEvent) {}); err != nil {
t.Fatalf("follow-up ExecuteStream failed: %v", err)
}
reloadedResolved := reloadedStore.GetResolvedContext("sess-handoff-resource")
reloadedInfo, found := reloadedResolved.GetResolvedResourceByAlias("web-server")
if !found {
t.Fatalf("expected stored handoff resource to rehydrate by alias after session reload")
}
if _, err := reloadedResolved.ValidateResourceForAction(reloadedInfo.GetResourceID(), "restart"); err != nil {
t.Fatalf("expected rehydrated handoff VM to allow governed restart action: %v", err)
}
}
func latestProviderUserContent(t *testing.T, messages []providers.Message) string {
+82 -6
View File
@@ -51,8 +51,37 @@ type sessionData struct {
}
type sessionModelContext struct {
HandoffContext string `json:"handoff_context,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
HandoffContext string `json:"handoff_context,omitempty"`
HandoffResources []HandoffResource `json:"handoff_resources,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
func normalizeHandoffResources(resources []HandoffResource) []HandoffResource {
if len(resources) == 0 {
return nil
}
normalized := make([]HandoffResource, 0, len(resources))
seen := make(map[string]struct{}, len(resources))
for _, resource := range resources {
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 == "" {
continue
}
key := strings.ToLower(resource.Type + "\x00" + resource.ID + "\x00" + resource.Name + "\x00" + resource.Node)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
normalized = append(normalized, resource)
}
if len(normalized) == 0 {
return nil
}
return normalized
}
const maxSessionIDLength = 128
@@ -320,9 +349,38 @@ func (s *SessionStore) SetModelHandoffContext(id, handoffContext string) error {
}
now := time.Now()
data.ModelContext = &sessionModelContext{
HandoffContext: handoffContext,
UpdatedAt: now,
if data.ModelContext == nil {
data.ModelContext = &sessionModelContext{}
}
data.ModelContext.HandoffContext = handoffContext
data.ModelContext.UpdatedAt = now
data.UpdatedAt = now
return s.writeSession(*data)
}
// SetModelHandoffResources stores product-originated resource references for
// future turns. These references are not authority by themselves; chat execution
// re-resolves them through the canonical unified-resource model before use.
func (s *SessionStore) SetModelHandoffResources(id string, handoffResources []HandoffResource) error {
resources := normalizeHandoffResources(handoffResources)
s.mu.Lock()
defer s.mu.Unlock()
data, err := s.readSession(id)
if err != nil {
return err
}
now := time.Now()
if data.ModelContext == nil {
data.ModelContext = &sessionModelContext{}
}
data.ModelContext.HandoffResources = resources
data.ModelContext.UpdatedAt = now
if strings.TrimSpace(data.ModelContext.HandoffContext) == "" && len(data.ModelContext.HandoffResources) == 0 {
data.ModelContext = nil
}
data.UpdatedAt = now
@@ -344,12 +402,30 @@ func (s *SessionStore) GetModelHandoffContext(id string) (string, error) {
return strings.TrimSpace(data.ModelContext.HandoffContext), nil
}
// GetModelHandoffResources returns stored handoff resource references for a
// session. Callers must rehydrate them through canonical resource registration
// before using them for action validation.
func (s *SessionStore) GetModelHandoffResources(id string) ([]HandoffResource, error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, err := s.readSession(id)
if err != nil {
return nil, err
}
if data.ModelContext == nil {
return nil, nil
}
return normalizeHandoffResources(data.ModelContext.HandoffResources), nil
}
func (s *SessionStore) clearModelHandoffContextLocked(id string) error {
data, err := s.readSession(id)
if err != nil {
return err
}
if data.ModelContext == nil || strings.TrimSpace(data.ModelContext.HandoffContext) == "" {
if data.ModelContext == nil ||
(strings.TrimSpace(data.ModelContext.HandoffContext) == "" && len(normalizeHandoffResources(data.ModelContext.HandoffResources)) == 0) {
return nil
}
@@ -72,6 +72,57 @@ func TestSessionStore_ModelHandoffContextLifecycle(t *testing.T) {
t.Fatalf("handoff context = %q, want trimmed %q", got, strings.TrimSpace(handoffContext))
}
handoffResources := []HandoffResource{
{ID: " vm-100 ", Name: " web-server ", Type: " vm ", Node: " pve-1 "},
{ID: "vm-100", Name: "web-server", Type: "vm", Node: "pve-1"},
}
if err := store.SetModelHandoffResources(session.ID, handoffResources); err != nil {
t.Fatalf("SetModelHandoffResources failed: %v", err)
}
gotResources, err := store.GetModelHandoffResources(session.ID)
if err != nil {
t.Fatalf("GetModelHandoffResources failed: %v", err)
}
if len(gotResources) != 1 {
t.Fatalf("handoff resources = %#v, want one normalized resource", gotResources)
}
if gotResources[0] != (HandoffResource{ID: "vm-100", Name: "web-server", Type: "vm", Node: "pve-1"}) {
t.Fatalf("handoff resource = %#v, want normalized VM", gotResources[0])
}
reloadedStore, err := NewSessionStore(filepath.Dir(store.dataDir))
if err != nil {
t.Fatalf("failed to reload session store: %v", err)
}
reloadedResources, err := reloadedStore.GetModelHandoffResources(session.ID)
if err != nil {
t.Fatalf("GetModelHandoffResources after reload failed: %v", err)
}
if len(reloadedResources) != 1 || reloadedResources[0].ID != "vm-100" {
t.Fatalf("reloaded handoff resources = %#v, want persisted VM reference", reloadedResources)
}
if err := store.SetModelHandoffResources(session.ID, nil); err != nil {
t.Fatalf("SetModelHandoffResources clear failed: %v", err)
}
gotResources, err = store.GetModelHandoffResources(session.ID)
if err != nil {
t.Fatalf("GetModelHandoffResources after resource clear failed: %v", err)
}
if len(gotResources) != 0 {
t.Fatalf("handoff resources after resource clear = %#v, want empty", gotResources)
}
got, err = store.GetModelHandoffContext(session.ID)
if err != nil {
t.Fatalf("GetModelHandoffContext after resource clear failed: %v", err)
}
if got != strings.TrimSpace(handoffContext) {
t.Fatalf("handoff context after resource clear = %q, want retained context", got)
}
if err := store.SetModelHandoffResources(session.ID, handoffResources); err != nil {
t.Fatalf("SetModelHandoffResources restore failed: %v", err)
}
if err := store.AddMessage(session.ID, Message{Role: "user", Content: "What happened?"}); err != nil {
t.Fatalf("AddMessage failed: %v", err)
}
@@ -91,6 +142,13 @@ func TestSessionStore_ModelHandoffContextLifecycle(t *testing.T) {
if got == "" {
t.Fatalf("expected keep-pinned context clear to retain model handoff")
}
gotResources, err = store.GetModelHandoffResources(session.ID)
if err != nil {
t.Fatalf("GetModelHandoffResources after keep-pinned clear failed: %v", err)
}
if len(gotResources) != 1 {
t.Fatalf("expected keep-pinned context clear to retain handoff resources, got %#v", gotResources)
}
store.ClearSessionState(session.ID, false)
got, err = store.GetModelHandoffContext(session.ID)
@@ -100,6 +158,13 @@ func TestSessionStore_ModelHandoffContextLifecycle(t *testing.T) {
if got != "" {
t.Fatalf("handoff context after full clear = %q, want empty", got)
}
gotResources, err = store.GetModelHandoffResources(session.ID)
if err != nil {
t.Fatalf("GetModelHandoffResources after full clear failed: %v", err)
}
if len(gotResources) != 0 {
t.Fatalf("handoff resources after full clear = %#v, want empty", gotResources)
}
}
func TestSessionStore_ResolvedContextLifecycle(t *testing.T) {
+7 -1
View File
@@ -157,9 +157,12 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
chatServiceText := string(chatServiceSource)
for _, required := range []string{
"handoffContext := strings.TrimSpace(req.HandoffContext)",
"handoffResources := normalizeHandoffResources(req.HandoffResources)",
"sessions.SetModelHandoffContext(session.ID, handoffContext)",
"sessions.GetModelHandoffContext(session.ID)",
"s.hydrateHandoffResources(session.ID, req.HandoffResources, sessions, unifiedResourceProvider)",
"sessions.SetModelHandoffResources(session.ID, handoffResources)",
"sessions.GetModelHandoffResources(session.ID)",
"s.hydrateHandoffResources(session.ID, handoffResources, sessions, unifiedResourceProvider)",
"injectHandoffContextIntoLatestUserMessage(messages, handoffContext)",
"User message: ",
} {
@@ -173,6 +176,9 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
"ModelContext *sessionModelContext",
"SetModelHandoffContext",
"GetModelHandoffContext",
"SetModelHandoffResources",
"GetModelHandoffResources",
"HandoffResources []HandoffResource",
} {
if !strings.Contains(chatSessionText, required) {
t.Fatalf("chat session store must persist model-only handoff metadata outside messages: missing %q", required)