mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 01:01:20 +00:00
Preserve Patrol handoff context across Assistant turns
This commit is contained in:
parent
7de85ff257
commit
25f9172e2e
8 changed files with 282 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 := ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue