diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 8f7fd992b..17bf3cf9a 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -357,9 +357,9 @@ profile and assignment columns, but embedded table framing must route through `finding_id` follows the same rule: lifecycle-owned command execution and 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 is also not agent lifecycle state and must not - be used as enrollment evidence, command-websocket identity, or installer - authority. + 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. 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 diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 9e4135536..86a6b0ed3 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -176,9 +176,11 @@ runtime cost control, and shared AI transport surfaces. investigation context. When `/api/ai/chat` receives `finding_id`, the runtime must enrich the provider turn from that durable record while preserving the user's authored prompt as the persisted conversation - message; 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. + 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. 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. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 14c1ed002..1641b33ef 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -748,10 +748,11 @@ the canonical monitored-system blocked payload. payloads carrying `finding_id` may hydrate a structured investigation summary from the unified finding, but raw proposed-fix commands must stay 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 frontend handoff briefings must derive from the same - shared investigation payload rather than inventing a second finding-context - transport shape. + 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. 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 diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index e1d96cdf8..4b5cd1180 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -448,6 +448,20 @@ 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) + 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") + } + } else { + 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") + } else { + handoffContext = storedHandoffContext + } + } + // Add user message userMsg := Message{ ID: uuid.New().String(), @@ -471,7 +485,7 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac Int("message_count", len(messages)). Msg("[ChatService] Got messages, calling agentic loop") - injectHandoffContextIntoLatestUserMessage(messages, req.HandoffContext) + injectHandoffContextIntoLatestUserMessage(messages, handoffContext) // Determine which model/loop to use for this request. selectedModel := "" diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index 8402078ee..b95300a15 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -163,6 +163,111 @@ func TestService_ExecuteStream_HandoffContextIsModelOnly(t *testing.T) { } } +func TestService_ExecuteStream_ReusesModelHandoffContextAcrossFollowUps(t *testing.T) { + tmpDir := t.TempDir() + store, err := NewSessionStore(tmpDir) + if err != nil { + t.Fatalf("failed to create session store: %v", err) + } + + executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{}) + var capturedRequests [][]providers.Message + provider := &stubServiceProvider{ + streamFn: func(ctx context.Context, req providers.ChatRequest, callback providers.StreamCallback) error { + capturedRequests = append(capturedRequests, 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, + started: true, + } + + handoffContext := "[Finding Context]\nID: finding-123\nConclusion: CPU saturated after backup." + firstReq := ExecuteRequest{ + SessionID: "sess-handoff-followup", + Prompt: "What happened?", + HandoffContext: handoffContext, + } + if err := svc.ExecuteStream(context.Background(), firstReq, func(StreamEvent) {}); err != nil { + t.Fatalf("first ExecuteStream failed: %v", err) + } + + secondReq := ExecuteRequest{ + SessionID: "sess-handoff-followup", + Prompt: "What should I do next?", + } + if err := svc.ExecuteStream(context.Background(), secondReq, func(StreamEvent) {}); err != nil { + t.Fatalf("second ExecuteStream failed: %v", err) + } + + stored, err := store.GetMessages("sess-handoff-followup") + if err != nil { + t.Fatalf("GetMessages failed: %v", err) + } + var storedUserMessages []string + for _, msg := range stored { + if msg.Role == "user" { + storedUserMessages = append(storedUserMessages, msg.Content) + } + } + if len(storedUserMessages) != 2 { + t.Fatalf("stored user messages = %d, want 2", len(storedUserMessages)) + } + for _, content := range storedUserMessages { + if strings.Contains(content, "[Finding Context]") { + t.Fatalf("stored user message should not include handoff context: %q", content) + } + } + if storedUserMessages[0] != "What happened?" || storedUserMessages[1] != "What should I do next?" { + t.Fatalf("stored user messages = %#v, want clean prompts", storedUserMessages) + } + + if len(capturedRequests) != 2 { + t.Fatalf("provider request count = %d, want 2", len(capturedRequests)) + } + firstModelUserContent := latestProviderUserContent(t, capturedRequests[0]) + if !strings.Contains(firstModelUserContent, "[Finding Context]") { + t.Fatalf("first provider turn missing handoff context: %q", firstModelUserContent) + } + if !strings.Contains(firstModelUserContent, "User message: What happened?") { + t.Fatalf("first provider turn missing clean user message: %q", firstModelUserContent) + } + secondModelUserContent := latestProviderUserContent(t, capturedRequests[1]) + if !strings.Contains(secondModelUserContent, "[Finding Context]") { + t.Fatalf("follow-up provider turn missing stored handoff context: %q", secondModelUserContent) + } + if !strings.Contains(secondModelUserContent, "User message: What should I do next?") { + t.Fatalf("follow-up provider turn missing clean user message: %q", secondModelUserContent) + } +} + +func latestProviderUserContent(t *testing.T, messages []providers.Message) string { + t.Helper() + + for idx := len(messages) - 1; idx >= 0; idx-- { + if messages[idx].Role == "user" { + return messages[idx].Content + } + } + t.Fatal("expected provider request to include a user message") + return "" +} + func TestService_ExecuteStream_PrefetchMentionsAndOverrideModel(t *testing.T) { tmpDir := t.TempDir() store, err := NewSessionStore(tmpDir) diff --git a/internal/ai/chat/session.go b/internal/ai/chat/session.go index b586ab3d7..6d07cae8b 100644 --- a/internal/ai/chat/session.go +++ b/internal/ai/chat/session.go @@ -42,11 +42,17 @@ type SessionStore struct { // sessionData is the on-disk format for a session type sessionData struct { - ID string `json:"id"` - Title string `json:"title"` - Messages []Message `json:"messages"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Title string `json:"title"` + Messages []Message `json:"messages"` + ModelContext *sessionModelContext `json:"model_context,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type sessionModelContext struct { + HandoffContext string `json:"handoff_context,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } const maxSessionIDLength = 128 @@ -297,6 +303,61 @@ func (s *SessionStore) AddMessage(id string, msg Message) error { return s.writeSession(*data) } +// SetModelHandoffContext stores model-only handoff context for future turns. +// It is intentionally session metadata, not a user-authored chat message. +func (s *SessionStore) SetModelHandoffContext(id, handoffContext string) error { + handoffContext = strings.TrimSpace(handoffContext) + if handoffContext == "" { + return nil + } + + s.mu.Lock() + defer s.mu.Unlock() + + data, err := s.readSession(id) + if err != nil { + return err + } + + now := time.Now() + data.ModelContext = &sessionModelContext{ + HandoffContext: handoffContext, + UpdatedAt: now, + } + data.UpdatedAt = now + + return s.writeSession(*data) +} + +// GetModelHandoffContext returns model-only handoff context for a session. +func (s *SessionStore) GetModelHandoffContext(id string) (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := s.readSession(id) + if err != nil { + return "", err + } + if data.ModelContext == nil { + return "", nil + } + return strings.TrimSpace(data.ModelContext.HandoffContext), 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) == "" { + return nil + } + + data.ModelContext = nil + data.UpdatedAt = time.Now() + return s.writeSession(*data) +} + // UpdateLastMessage updates the last message in a session (for streaming updates) func (s *SessionStore) UpdateLastMessage(id string, msg Message) error { s.mu.Lock() @@ -596,6 +657,9 @@ func (s *SessionStore) ClearSessionState(sessionID string, keepPinned bool) { if !keepPinned { delete(s.sessionToolSets, sessionID) delete(s.knowledgeAccumulators, sessionID) + if err := s.clearModelHandoffContextLocked(sessionID); err != nil { + log.Warn().Err(err).Str("session_id", sessionID).Msg("[SessionStore] Failed to clear model handoff context") + } } // Reset FSM coherently with context state diff --git a/internal/ai/chat/session_additional_test.go b/internal/ai/chat/session_additional_test.go index c7adb6f95..5f2c109b3 100644 --- a/internal/ai/chat/session_additional_test.go +++ b/internal/ai/chat/session_additional_test.go @@ -41,6 +41,67 @@ func TestSessionStore_KnowledgeAndToolSets(t *testing.T) { } } +func TestSessionStore_ModelHandoffContextLifecycle(t *testing.T) { + store, err := NewSessionStore(t.TempDir()) + if err != nil { + t.Fatalf("failed to create session store: %v", err) + } + + session, err := store.Create() + if err != nil { + t.Fatalf("failed to create session: %v", err) + } + + initial, err := store.GetModelHandoffContext(session.ID) + if err != nil { + t.Fatalf("GetModelHandoffContext failed: %v", err) + } + if initial != "" { + t.Fatalf("initial handoff context = %q, want empty", initial) + } + + handoffContext := " [Finding Context]\nID: finding-123\nConclusion: CPU saturated after backup. " + if err := store.SetModelHandoffContext(session.ID, handoffContext); err != nil { + t.Fatalf("SetModelHandoffContext failed: %v", err) + } + got, err := store.GetModelHandoffContext(session.ID) + if err != nil { + t.Fatalf("GetModelHandoffContext failed: %v", err) + } + if got != strings.TrimSpace(handoffContext) { + t.Fatalf("handoff context = %q, want trimmed %q", got, strings.TrimSpace(handoffContext)) + } + + if err := store.AddMessage(session.ID, Message{Role: "user", Content: "What happened?"}); err != nil { + t.Fatalf("AddMessage failed: %v", err) + } + messages, err := store.GetMessages(session.ID) + if err != nil { + t.Fatalf("GetMessages failed: %v", err) + } + if len(messages) != 1 || messages[0].Content != "What happened?" { + t.Fatalf("stored messages = %#v, want clean user prompt only", messages) + } + + store.ClearSessionState(session.ID, true) + got, err = store.GetModelHandoffContext(session.ID) + if err != nil { + t.Fatalf("GetModelHandoffContext after keep-pinned clear failed: %v", err) + } + if got == "" { + t.Fatalf("expected keep-pinned context clear to retain model handoff") + } + + store.ClearSessionState(session.ID, false) + got, err = store.GetModelHandoffContext(session.ID) + if err != nil { + t.Fatalf("GetModelHandoffContext after full clear failed: %v", err) + } + if got != "" { + t.Fatalf("handoff context after full clear = %q, want empty", got) + } +} + func TestSessionStore_ResolvedContextLifecycle(t *testing.T) { store, err := NewSessionStore(t.TempDir()) if err != nil { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 21ea36f53..c546b4429 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -125,6 +125,10 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { if err != nil { t.Fatalf("read chat service: %v", err) } + chatSessionSource, err := os.ReadFile(filepath.Clean("../ai/chat/session.go")) + if err != nil { + t.Fatalf("read chat session store: %v", err) + } handlerText := string(handlerSource) for _, required := range []string{ @@ -142,13 +146,27 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) { chatServiceText := string(chatServiceSource) for _, required := range []string{ - "injectHandoffContextIntoLatestUserMessage(messages, req.HandoffContext)", + "handoffContext := strings.TrimSpace(req.HandoffContext)", + "sessions.SetModelHandoffContext(session.ID, handoffContext)", + "sessions.GetModelHandoffContext(session.ID)", + "injectHandoffContextIntoLatestUserMessage(messages, handoffContext)", "User message: ", } { if !strings.Contains(chatServiceText, required) { t.Fatalf("chat service must inject handoff context into model-only turn: missing %q", required) } } + + chatSessionText := string(chatSessionSource) + for _, required := range []string{ + "ModelContext *sessionModelContext", + "SetModelHandoffContext", + "GetModelHandoffContext", + } { + if !strings.Contains(chatSessionText, required) { + t.Fatalf("chat session store must persist model-only handoff metadata outside messages: missing %q", required) + } + } } func TestContract_ProxmoxSetupScriptUsesPrivilegeSeparatedTokenACLs(t *testing.T) {