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
12 changes: 8 additions & 4 deletions framework/warp/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,16 @@ type Agent struct {
// scope comes from the caller because it must be lifted off the request context
// before the agent's goroutine starts. queryscope treats a missing scope as no
// restriction, so reading it late returns the whole deployment to whoever asked.
func NewAgent(chat ChatFunc, cost CostFunc, logs LogReader, scope Scope, config *schemas.WarpConfig) *Agent {
func NewAgent(chat ChatFunc, cost CostFunc, logs LogReader, scope Scope, config *schemas.WarpConfig, semantic ...*SemanticSearcher) *Agent {
var searcher *SemanticSearcher
if len(semantic) > 0 {
searcher = semantic[0]
}
return &Agent{
chat: chat,
cost: cost,
tools: buildTools(),
deps: &ToolDeps{logManager: logs, scope: scope},
tools: buildToolsFor(searcher),
deps: &ToolDeps{logManager: logs, semantic: searcher, scope: scope},
config: config,
maxIterations: config.EffectiveMaxIterations(),
}
Expand Down Expand Up @@ -359,7 +363,7 @@ func (a *Agent) Run(ctx context.Context, messages []schemas.ResponsesMessage, ou
// system item. The Responses API models instructions as a property of the
// request, not a turn in the transcript, and keeping it out of Input means the
// history bound below counts only real turns.
instructions := systemInstructions(a.config)
instructions := systemInstructions(a.config, a.deps != nil && a.deps.semantic != nil)
conversation := append([]schemas.ResponsesMessage{}, messages...)
var usage *schemas.BifrostLLMUsage

Expand Down
28 changes: 24 additions & 4 deletions framework/warp/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ func TestWarpAgentPassesContextThroughToTools(t *testing.T) {
// The operator's suffix may add to the built-in prompt but must never displace
// it: those instructions are what stop Warp inventing numbers.
func TestWarpSystemPromptAppendsOperatorSuffix(t *testing.T) {
content := systemInstructions(&schemas.WarpConfig{SystemPromptSuffix: "Costs are in EUR."})
content := systemInstructions(&schemas.WarpConfig{SystemPromptSuffix: "Costs are in EUR."}, true)

require.Contains(t, content, "You are Warp")
require.Contains(t, content, "Always get your numbers from a tool")
Expand All @@ -287,7 +287,7 @@ func TestWarpSystemPromptCarriesCurrentTime(t *testing.T) {
Now = func() time.Time { return time.Date(2026, 8, 17, 9, 30, 0, 0, time.UTC) }
defer func() { Now = original }()

content := systemInstructions(&schemas.WarpConfig{})
content := systemInstructions(&schemas.WarpConfig{}, true)
require.Contains(t, content, "2026-08-17 09:30:00")
}

Expand Down Expand Up @@ -368,7 +368,7 @@ func TestWarpAgentSurvivesNilContentOnFinalTurn(t *testing.T) {
// like an answer, so it is read as one. The prompt has to carry both halves -
// admit the gap, and offer somewhere to ask for it.
func TestWarpSystemPromptAdmitsWhatItCannotAnswer(t *testing.T) {
content := systemInstructions(&schemas.WarpConfig{})
content := systemInstructions(&schemas.WarpConfig{}, true)

require.Contains(t, content, "say so in one sentence and stop")
require.Contains(t, content, "Do not answer a different question instead")
Expand All @@ -383,7 +383,7 @@ func TestWarpSystemPromptAdmitsWhatItCannotAnswer(t *testing.T) {
// warp-scope fence. If the prompt stops asking for that exact form, the block
// silently reappears inline in every answer.
func TestWarpPromptRequiresProvenanceFence(t *testing.T) {
content := systemInstructions(&schemas.WarpConfig{})
content := systemInstructions(&schemas.WarpConfig{}, true)

require.Contains(t, content, "```warp-scope")
require.Contains(t, content, "Window:")
Expand Down Expand Up @@ -771,3 +771,23 @@ func TestWarpMergePromptDetailsDoesNotAliasTheProviderResponse(t *testing.T) {
require.Equal(t, 3, provider.CachedWriteTokenDetails.CachedWriteTokens1h)
require.Equal(t, 1, second.CachedWriteTokenDetails.CachedWriteTokens5m)
}

// buildToolsFor omits semantic_search_logs when there is no searcher, so the
// prompt must not name it. Telling the model to use a tool it has not been
// given costs a step to discover otherwise, on every attempt, because nothing
// about the prompt changes between them.
func TestWarpSystemInstructionsOmitSemanticSearchWhenUnavailable(t *testing.T) {
require.NotContains(t, systemInstructions(&schemas.WarpConfig{}, false), "semantic_search_logs")
require.Contains(t, systemInstructions(&schemas.WarpConfig{}, true), "semantic_search_logs")

// And the tool list agrees with the prompt in both directions.
names := func(tools []Tool) []string {
out := make([]string, 0, len(tools))
for _, tool := range tools {
out = append(out, tool.name)
}
return out
}
require.NotContains(t, names(buildToolsFor(nil)), SemanticSearchToolName)
require.Contains(t, names(buildToolsFor(&SemanticSearcher{})), SemanticSearchToolName)
}
16 changes: 12 additions & 4 deletions framework/warp/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,11 @@ type Turn struct {
messages []schemas.ResponsesMessage
config *schemas.WarpConfig
chat ChatFunc
// logs is snapshotted with chat so the pair cannot drift mid-turn.
logs LogReader
// logs and semantic are snapshotted with chat so the three cannot drift
// mid-turn: the searcher holds its own reference to a reader, and a mismatched
// pair searches one backend and hydrates from another.
logs LogReader
semantic *SemanticSearcher
}

// NewTurn validates a chat request and resolves the configuration and model
Expand Down Expand Up @@ -96,7 +99,7 @@ func (s *Service) NewTurn(ctx context.Context, request *ChatRequest, bodyBytes i
// One snapshot for both. Read separately, a turn could keep a usable chat
// func while the reader went nil underneath it, and the first log tool the
// model reached for dereferenced nil inside the agent.
chat, logs := s.turnDeps(ctx, config, conversationID)
chat, logs, semantic := s.turnDeps(ctx, config, conversationID)
if chat == nil {
return nil, ErrNoModelClient
}
Expand All @@ -118,6 +121,7 @@ func (s *Service) NewTurn(ctx context.Context, request *ChatRequest, bodyBytes i
config: config,
chat: chat,
logs: logs,
semantic: semantic,
}, nil
}

Expand All @@ -141,7 +145,11 @@ func (s *Service) RunTurn(ctx context.Context, turn *Turn, sink func(Event) bool
// The scope is read off the snapshotted context, same as the row-level
// queryscope, so it is a fact about who asked rather than anything the
// request body could claim.
agent := NewAgent(turn.chat, s.costFuncFor(turn.config), turn.logs, ScopeFromContext(runCtx), turn.config)
// All three from the turn, not re-read here: chat, the reader and the
// searcher were snapshotted together at NewTurn, so a SetLogReader landing
// mid-turn cannot leave the agent searching one backend while it hydrates
// details from another - or hand it a nil reader it will dereference.
agent := NewAgent(turn.chat, s.costFuncFor(turn.config), turn.logs, ScopeFromContext(runCtx), turn.config, turn.semantic)
agent.questionsAsked = turn.questionsAsked
events := make(chan Event, 16)
go agent.Run(runCtx, turn.messages, events)
Expand Down
3 changes: 3 additions & 0 deletions framework/warp/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
func chatService(model *scriptedModel, fake *fakeLogReader) *Service {
return NewService(nil,
WithConfigStore(&recordingStore{row: validWarpConfigRow()}),
WithVectorStore(newFakeWarpVectorStore()),
WithLogReader(fake),
WithChatFunc(model.respond),
)
Expand Down Expand Up @@ -117,6 +118,7 @@ func TestWarpRunTurnStopsWhenSinkRefuses(t *testing.T) {
}
service := NewService(nil,
WithConfigStore(&recordingStore{row: validWarpConfigRow()}),
WithVectorStore(newFakeWarpVectorStore()),
WithLogReader(&fakeLogReader{}),
WithChatFunc(blocking),
)
Expand Down Expand Up @@ -242,6 +244,7 @@ func TestWarpRunTurnStampsConversationIDOnDone(t *testing.T) {
model := &scriptedModel{turns: []*schemas.BifrostResponsesResponse{TextTurn("42 requests.")}}
service := NewService(nil,
WithConfigStore(&recordingStore{row: validWarpConfigRow()}),
WithVectorStore(newFakeWarpVectorStore()),
WithLogReader(&fakeLogReader{}),
WithChatFunc(model.respond),
WithConversationStore(store),
Expand Down
8 changes: 8 additions & 0 deletions framework/warp/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,14 @@ func (s *Service) Config(ctx context.Context) (*schemas.WarpConfig, error) {
if !config.IsConfigured() {
return nil, ErrUnavailable
}
// Deliberately no vector-store check. This is the read NewTurn makes on every
// chat request, so refusing here failed the whole feature rather than the one
// tool that needs a vector store - the handler maps the error to 503, so a
// deployment without one could not ask Warp anything at all. Semantic search
// is gated where it belongs: buildToolsFor only offers semantic_search_logs
// when a searcher exists, so the loop simply runs with the other tools.
// Enabling Warp without a vector store is still refused in SaveConfig, which
// is a misconfiguration the operator can act on.
return config, nil
}

Expand Down
26 changes: 26 additions & 0 deletions framework/warp/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -592,3 +592,29 @@ func TestWarpSaveConfigRejectsReusingALegacyDefaultNamespace(t *testing.T) {
require.ErrorContains(t, err, "log_vector_store_namespace", name)
}
}

// Warp must still answer questions on a deployment with no vector store.
//
// Config is what NewTurn calls on every chat request, so returning
// ErrNoVectorStore there failed the whole feature - the handler maps it to 503
// - rather than just the semantic tool. Semantic search is one of several
// tools: buildToolsFor already adds semantic_search_logs only when a searcher
// exists, so the loop degrades to the other tools by construction.
func TestWarpConfigDoesNotRequireAVectorStoreToAnswer(t *testing.T) {
service := NewService(nil, WithConfigStore(&recordingStore{row: validWarpConfigRow()}))
require.Nil(t, service.vectorStore, "precondition: no vector store on this deployment")

config, err := service.Config(context.Background())
require.NoError(t, err, "chat must not be refused for want of a vector store")
require.NotNil(t, config)
require.Equal(t, "gpt-4o", config.Model)
}

// Enabling Warp without a vector store is still refused: that is a save the
// operator can fix, and accepting it would promise semantic search the
// deployment cannot provide.
func TestWarpSaveConfigStillRequiresAVectorStoreToEnable(t *testing.T) {
service := NewService(nil, WithConfigStore(&recordingStore{}))
_, err := service.SaveConfig(context.Background(), validWarpConfigInput())
require.ErrorIs(t, err, ErrNoVectorStore)
}
50 changes: 50 additions & 0 deletions framework/warp/flows.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,56 @@ const maxQueryMetrics = 4

// ---------------------------------------------------------------- flow 1: logs

// semanticSearchLogsTool finds requests by conversational meaning. It still
// accepts the shared structured filters, but unlike query_logs the query is
// embedded and compared with the stored user/assistant conversation vectors.
// SemanticSearchToolName is the one tool that needs an embedding executor, so
// it is the one tool the set can be missing.
const SemanticSearchToolName = "semantic_search_logs"

func semanticSearchLogsTool() Tool {
return Tool{
name: SemanticSearchToolName,
description: "Find logged conversations by meaning. Use this when the question is about what users discussed, wanted, reported, or what assistants answered, even when the wording differs. " +
"Use query_logs, count_logs, or query_metrics for exact fields, counts, latency, cost, and trends.",
schemaJSON: `{
"type": "object",
"properties": {
"query": {"type": "string", "maxLength": ` + strconv.Itoa(MaxSemanticQueryChars) + `, "description": "A natural-language description of the conversations to find."},
"filters": ` + FilterSchema + `,
"limit": {"type": "integer", "minimum": 1, "maximum": 25, "description": "Matches to return. Also capped by the configured semantic search limit."}
},
"required": ["query", "filters"]
}`,
execute: func(ctx context.Context, deps *ToolDeps, args map[string]any) (any, error) {
query, _ := args["query"].(string)
Comment thread
akshaydeo marked this conversation as resolved.
if deps.semantic == nil {
return nil, fmt.Errorf("semantic log search is not configured")
}
filters, err := filterArg(args, Now(), deps.scope)
if err != nil {
return nil, err
}
// Fallback 0 means "use the configured semantic limit", which is why
// absent stays 0 here rather than defaulting to a row count.
limit, err := intArg(args, "limit", 0, MaxLogRows)
if err != nil {
return nil, err
}
result, err := deps.semantic.Search(ctx, query, filters, limit)
if err != nil {
return nil, err
}
return map[string]any{
"rows": result.Rows,
"returned": result.Returned,
"threshold": result.Threshold,
"scope": scopeNote(filters, deps.scope),
}, nil
},
}
}

// queryLogsTool is flow 1: individual request logs, projected and row-capped.
func queryLogsTool() Tool {
return Tool{
Expand Down
10 changes: 7 additions & 3 deletions framework/warp/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,13 +438,17 @@ func buildLogIndexItem(entry *logstore.Log) (logIndexItem, bool) {
if text == "" {
return logIndexItem{}, false
}
// The scalar filter fields are stored lowered, because the store-side
// prefilter compares exactly while the post-filter compares with EqualFold
// - appendScalarQuery lowers the filter values to meet these. Hydration
// reads the full row from the logstore, so nothing rendered loses casing.
metadata := map[string]interface{}{
"log_id": entry.ID, "timestamp": entry.Timestamp.Unix(), "object": entry.Object,
"provider": entry.Provider, "model": entry.Model, "status": entry.Status, "warp_log": true,
"provider": strings.ToLower(entry.Provider), "model": strings.ToLower(entry.Model), "status": strings.ToLower(entry.Status), "warp_log": true,
"latency_ms": roundedMetric(entry.Latency, 1), "cost_micro_usd": roundedMetric(entry.Cost, 1_000_000),
"prompt_tokens": entry.PromptTokens, "completion_tokens": entry.CompletionTokens, "total_tokens": entry.TotalTokens,
"parent_request_id": stringValue(entry.ParentRequestID), "app": stringValue(entry.App),
"virtual_key_id": stringValue(entry.VirtualKeyID), "user_id": stringValue(entry.UserID),
"parent_request_id": stringValue(entry.ParentRequestID), "app": strings.ToLower(stringValue(entry.App)),
"virtual_key_id": strings.ToLower(stringValue(entry.VirtualKeyID)), "user_id": strings.ToLower(stringValue(entry.UserID)),
"team_ids": mergedIDs(entry.TeamID, entry.TeamIDs), "customer_ids": mergedIDs(entry.CustomerID, entry.CustomerIDs),
"business_unit_ids": mergedIDs(entry.BusinessUnitID, entry.BusinessUnitIDs),
}
Expand Down
24 changes: 20 additions & 4 deletions framework/warp/indexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ type fakeWarpVectorStore struct {
embeddings map[string][]float32
// existing seeds namespaces the store already had, so a test can tell a
// namespace this save created from one it must not touch.
existing []string
deleted []string
existing []string
deleted []string
nearest []vectorstore.SearchResult
queries []vectorstore.Query
limit int64
limits []int64
threshold float64
// listErr makes namespace discovery fail; addErr makes the next Add fail
// once. createCalls counts CreateNamespace calls, so a test can see whether
// provisioning ran per log or once per configuration.
Expand Down Expand Up @@ -68,8 +73,19 @@ func (f *fakeWarpVectorStore) GetChunks(context.Context, string, []string) ([]ve
func (f *fakeWarpVectorStore) GetAll(context.Context, string, []vectorstore.Query, []string, *string, int64) ([]vectorstore.SearchResult, *string, error) {
return nil, nil, nil
}
func (f *fakeWarpVectorStore) GetNearest(context.Context, string, []float32, []vectorstore.Query, []string, float64, int64) ([]vectorstore.SearchResult, error) {
return nil, nil
func (f *fakeWarpVectorStore) GetNearest(_ context.Context, _ string, _ []float32, queries []vectorstore.Query, _ []string, threshold float64, limit int64) ([]vectorstore.SearchResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.queries = queries
f.threshold = threshold
f.limit = limit
f.limits = append(f.limits, limit)
// A real vector store returns at most top-K. Returning the whole fixture
// regardless hid every bug that only shows up once the cap actually bites.
if limit >= 0 && int64(len(f.nearest)) > limit {
return append([]vectorstore.SearchResult(nil), f.nearest[:limit]...), nil
}
return f.nearest, nil
}
func (f *fakeWarpVectorStore) RequiresVectors() bool { return true }
func (f *fakeWarpVectorStore) Add(_ context.Context, _ string, id string, embedding []float32, metadata map[string]interface{}) error {
Expand Down
12 changes: 12 additions & 0 deletions framework/warp/logreader.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ type LogReader interface {
GetAvailableVirtualKeys(ctx context.Context, limit int, query string) ([]KeyPair, error)
}

// SemanticHydrator reads whole log rows for a set of ids.
//
// Kept out of LogReader deliberately. LogReader is exported and accepted by
// exported APIs - WithLogReader, NewAgent - so adding a method to it breaks
// every reader outside this repo at compile time, including ones that never
// touch semantic search. Semantic search asks for this separately and is
// enabled only when the supplied reader satisfies it, so an older reader keeps
// working with the rest of Warp's tools.
type SemanticHydrator interface {
GetLogsByIDs(ctx context.Context, ids []string) ([]logstore.Log, error)
}

// KeyPair is an id paired with the name it is known by.
type KeyPair struct {
ID string `json:"id"`
Expand Down
15 changes: 14 additions & 1 deletion framework/warp/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,22 @@ When you cannot answer:
// cannot remove the instructions above - which matters because those are what
// keep it from inventing numbers, and a deployment-level setting is not the
// place to switch that off by accident.
func systemInstructions(config *schemas.WarpConfig) string {
// SemanticSearchGuidance is appended only when semantic_search_logs is actually
// registered.
//
// buildToolsFor omits the tool on a deployment with no embedding executor, and
// telling the model to use a tool it has not been given costs it a step to
// discover otherwise - on every single attempt, since nothing about the prompt
// changes between them.
const SemanticSearchGuidance = "\n- Use semantic_search_logs when the question is about what conversations meant, discussed, requested, or answered. " +
"It searches the meaning of logged user and assistant text. Use query_logs, count_logs, and query_metrics for exact fields, counts, totals, rankings, latency, cost, and trends."

func systemInstructions(config *schemas.WarpConfig, semanticAvailable bool) string {
var builder strings.Builder
builder.WriteString(SystemPrompt)
if semanticAvailable {
builder.WriteString(SemanticSearchGuidance)
}
builder.WriteString(QuestionGuidance)
builder.WriteString(fmt.Sprintf("\n\nThe current time is %s (UTC).", Now().Format("2006-01-02 15:04:05")))
if config != nil && strings.TrimSpace(config.SystemPromptSuffix) != "" {
Expand Down
2 changes: 1 addition & 1 deletion framework/warp/question_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ func TestWarpAgentTreatsInvalidQuestionAsAToolError(t *testing.T) {
}

func TestWarpPromptCarriesQuestionRules(t *testing.T) {
content := systemInstructions(&schemas.WarpConfig{})
content := systemInstructions(&schemas.WarpConfig{}, true)

require.Contains(t, content, AskUserTool)
require.Contains(t, content, "Ask about one thing at a time")
Expand Down
2 changes: 1 addition & 1 deletion framework/warp/scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func TestWarpFlowsReportScope(t *testing.T) {

// The prompt has to actually carry the rules, or the mechanism is inert.
func TestWarpSystemPromptExplainsScoping(t *testing.T) {
content := systemInstructions(&schemas.WarpConfig{})
content := systemInstructions(&schemas.WarpConfig{}, true)
require.Contains(t, content, "describe_scope")
require.Contains(t, content, "their own traffic is the default")
require.Contains(t, content, "Ask which team, customer or business unit is meant")
Expand Down
Loading
Loading