Preserve Patrol handoff context across Assistant turns

This commit is contained in:
rcourtman 2026-05-06 17:46:14 +01:00
parent 7de85ff257
commit 25f9172e2e
8 changed files with 282 additions and 17 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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 := ""

View file

@ -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)

View file

@ -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

View file

@ -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 {

View file

@ -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) {