mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Persist Assistant handoff resource references
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user