Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/strategy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ All strategies share these components from `pkg/usecase/chat/`:

Each strategy is a separate package under `pkg/usecase/chat/<name>/` with its own prompt templates, execution logic, and configuration options.

## Conversation Memory

The agent's working memory (`gollem.History`) is persisted per **Session**, keyed by `SessionID` (not by ticket). A Slack thread maps to a deterministic Session via `ResolveSlackSession`, so the same thread reuses the same Session across mentions and Warren instances.

This applies to **ticketless threads** as well: a thread that has not been escalated into a ticket gets a deterministic `slack_ticketless_*` Session, and its working memory is saved and restored across mentions just like a ticketed thread. Consequently, conversation context carries over between turns even when no ticket exists.

> The ticket-scoped `latest.json` snapshot (written via `saveLatestHistory`) is a crash-recovery artifact and is only produced for ticketed threads; it is not used for cross-turn conversation continuation, which always reads the `SessionID`-keyed history.

## Execution Flow Overview

Both current strategies follow a common high-level flow:
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dompurify": "^3.4.0",
"dompurify": "^3.4.9",
"graphql": "^16.13.2",
"lucide-react": "^0.511.0",
"react": "^19.2.4",
Expand Down
10 changes: 5 additions & 5 deletions frontend/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 8 additions & 4 deletions pkg/usecase/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,10 +408,14 @@ func (x *UseCases) buildChatContext(ctx context.Context, t *ticket.Ticket, slack

chatCtx.Tools = allTools

// Load history (skip for ticketless and when no Session was
// resolved — the latter leaves working memory empty so the next
// turn starts fresh rather than resurrecting pre-redesign data).
if !ticketless && sess != nil {
// Load history whenever a Session was resolved, regardless of
// ticket association. Session history is keyed by SessionID (not
// TicketID), and ticketless Slack threads get a deterministic
// SessionID, so loading here lets ticketless threads continue a
// conversation across turns just like ticketed ones. When no
// Session was resolved we leave working memory empty so the next
// turn starts fresh rather than resurrecting pre-redesign data.
if sess != nil {
storageSvc := storage.New(x.storageClient, storage.WithPrefix(x.storagePrefix))
history, err := chatpkg.LoadSessionHistory(ctx, sess.ID, storageSvc)
if err != nil {
Expand Down
19 changes: 9 additions & 10 deletions pkg/usecase/chat/aster/aster.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,11 @@ func (c *AsterChat) executeAster(ctx context.Context, ssn *session.Session, mess
msg.Notify(ctx, "💬 %s", planResult.Message)
}

// Direct response (no tasks)
// Direct response (no tasks). saveSessionHistory is keyed by
// SessionID, so it persists working memory for ticketless threads
// too (no-op when no Session was resolved).
if len(planResult.Tasks) == 0 {
if !ticketless {
return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc)
}
return nil
return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc)
}

// Execute phases
Expand Down Expand Up @@ -392,11 +391,11 @@ func (c *AsterChat) executeAster(ctx context.Context, ssn *session.Session, mess
// Trigger fact knowledge reflection in background
c.triggerFactReflection(ctx, buildReflectionSummary(allResults), chatCtx)

if !ticketless {
logger.Debug("aster executeAster: completed, saving history")
return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc)
}
return nil
// Persist working memory keyed by SessionID for both ticketed and
// ticketless threads so the next turn on this thread continues the
// conversation (no-op when no Session was resolved).
logger.Debug("aster executeAster: completed, saving history")
return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc)
}

// buildReflectionSummary aggregates all task results into a text summary for reflection.
Expand Down
37 changes: 37 additions & 0 deletions pkg/usecase/chat/aster/aster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,43 @@ func TestAsterChat_LatestHistorySavedOnDirectResponse(t *testing.T) {
gt.V(t, latest).NotNil()
}

func TestAsterChat_TicketlessSavesSessionHistory(t *testing.T) {
ctx := setupTestContext(t)
repo := repository.NewMemory()
mockStorage := adapter.NewMock()

mockLLM := &mock.LLMClientMock{
NewSessionFunc: func(ctx context.Context, opts ...gollem.SessionOption) (gollem.Session, error) {
ssn := newMockSession()
ssn.GenerateFunc = func(ctx context.Context, input []gollem.Input, opts ...gollem.GenerateOption) (*gollem.Response, error) {
return &gollem.Response{
Texts: []string{`{"message": "Direct response.", "tasks": []}`},
}, nil
}
return ssn, nil
},
}

chatUC := aster.New(repo, mockLLM,
aster.WithStorageClient(mockStorage),
)

// Ticketless thread with a resolved Session. Working memory must be
// persisted under the SessionID so the next mention continues the
// conversation, even without a Ticket.
sess := newDummySession("")
gt.NoError(t, chatUC.Execute(ctx, &chat.RunContext{
Session: sess,
Message: "Hello",
ChatCtx: &chatModel.ChatContext{Ticket: &ticket.Ticket{}, Session: sess},
}))

storageSvc := storage_svc.New(mockStorage)
saved, err := storageSvc.GetSessionHistory(ctx, sess.ID)
gt.NoError(t, err)
gt.V(t, saved).NotNil()
}

func TestAsterChat_LatestHistorySavedAfterReplan(t *testing.T) {
ctx := setupTestContext(t)
repo := repository.NewMemory()
Expand Down
19 changes: 9 additions & 10 deletions pkg/usecase/chat/bluebell/bluebell.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,12 +274,11 @@ func (c *BluebellChat) executeBluebell(ctx context.Context, ssn *session.Session
msg.Notify(ctx, "💬 %s", planResult.Message)
}

// Direct response (no tasks)
// Direct response (no tasks). saveSessionHistory is keyed by
// SessionID, so it persists working memory for ticketless threads
// too (no-op when no Session was resolved).
if len(planResult.Tasks) == 0 {
if !ticketless {
return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc)
}
return nil
return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc)
}

// Execute phases
Expand Down Expand Up @@ -406,11 +405,11 @@ func (c *BluebellChat) executeBluebell(ctx context.Context, ssn *session.Session

c.triggerFactReflection(ctx, buildReflectionSummary(allResults), chatCtx)

if !ticketless {
logger.Debug("bluebell executeBluebell: completed, saving history")
return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc)
}
return nil
// Persist working memory keyed by SessionID for both ticketed and
// ticketless threads so the next turn on this thread continues the
// conversation (no-op when no Session was resolved).
logger.Debug("bluebell executeBluebell: completed, saving history")
return c.saveSessionHistory(ctx, planSession, *chatCtx, storageSvc)
}

// buildReflectionSummary aggregates all task results into a text summary for reflection.
Expand Down
46 changes: 46 additions & 0 deletions pkg/usecase/chat/bluebell/bluebell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/gollem-dev/gollem"
"github.com/m-mizutani/goerr/v2"
"github.com/m-mizutani/gt"
adapter "github.com/secmon-lab/warren/pkg/adapter/storage"
"github.com/secmon-lab/warren/pkg/cli/config"
"github.com/secmon-lab/warren/pkg/domain/mock"
"github.com/secmon-lab/warren/pkg/domain/model/alert"
Expand All @@ -22,6 +23,7 @@ import (
"github.com/secmon-lab/warren/pkg/repository"
svcknowledge "github.com/secmon-lab/warren/pkg/service/knowledge"
slackService "github.com/secmon-lab/warren/pkg/service/slack"
storage_svc "github.com/secmon-lab/warren/pkg/service/storage"
"github.com/secmon-lab/warren/pkg/usecase/chat"
"github.com/secmon-lab/warren/pkg/usecase/chat/bluebell"
"github.com/secmon-lab/warren/pkg/utils/msg"
Expand Down Expand Up @@ -646,3 +648,47 @@ func TestBluebellChat_Ticketless(t *testing.T) {
})
gt.NoError(t, err)
}

func TestBluebellChat_TicketlessSavesSessionHistory(t *testing.T) {
ctx := setupTestContext(t)
repo := repository.NewMemory()
knowledgeSvc := svcknowledge.New(repo, newMockEmbeddingClient())
mockStorage := adapter.NewMock()

mockLLM := &mock.LLMClientMock{
NewSessionFunc: func(ctx context.Context, opts ...gollem.SessionOption) (gollem.Session, error) {
ssn := newMockSession()
ssn.GenerateFunc = func(ctx context.Context, input []gollem.Input, opts ...gollem.GenerateOption) (*gollem.Response, error) {
return &gollem.Response{
Texts: []string{`{"message": "Here is the answer.", "tasks": []}`},
}, nil
}
return ssn, nil
},
}

chatUC, err := bluebell.New(repo, mockLLM,
bluebell.WithKnowledgeService(knowledgeSvc),
bluebell.WithStorageClient(mockStorage),
)
gt.NoError(t, err)

// Ticketless thread, but a Session is resolved (the deterministic
// slack_ticketless_* Session). Working memory must be persisted under the
// SessionID so the next mention on this thread continues the conversation.
sess := newDummySession("")
err = chatUC.Execute(ctx, &chat.RunContext{
Session: sess,
Message: "General question",
ChatCtx: &chatModel.ChatContext{
Ticket: &ticket.Ticket{},
Session: sess,
},
})
gt.NoError(t, err)

storageSvc := storage_svc.New(mockStorage)
saved, err := storageSvc.GetSessionHistory(ctx, sess.ID)
gt.NoError(t, err)
gt.V(t, saved).NotNil()
}
Loading
Loading