mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(coderd): wire debug logging into chat lifecycle (#23917)
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatretry"
|
||||
@@ -405,7 +406,8 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
}
|
||||
|
||||
var result stepResult
|
||||
err := chatretry.Retry(ctx, func(retryCtx context.Context) error {
|
||||
stepCtx := chatdebug.ReuseStep(ctx)
|
||||
err := chatretry.Retry(stepCtx, func(retryCtx context.Context) error {
|
||||
attempt, streamErr := guardedStream(
|
||||
retryCtx,
|
||||
provider,
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
@@ -17,6 +19,14 @@ const (
|
||||
minCompactionThresholdPercent = int32(0)
|
||||
maxCompactionThresholdPercent = int32(100)
|
||||
|
||||
// compactionDebugCreateRunTimeout caps the compaction debug
|
||||
// CreateRun budget so a slow or locked DB cannot consume the
|
||||
// compaction's configured Timeout and cause model.Generate to
|
||||
// fail with deadline exceeded. Debug instrumentation is
|
||||
// best-effort; running without the debug row is preferable to
|
||||
// failing the compaction.
|
||||
compactionDebugCreateRunTimeout = 5 * time.Second
|
||||
|
||||
defaultCompactionSummaryPrompt = "You are performing a context compaction. " +
|
||||
"Summarize the conversation so a new assistant can seamlessly " +
|
||||
"continue the work in progress.\n\n" +
|
||||
@@ -46,6 +56,9 @@ type CompactionOptions struct {
|
||||
SystemSummaryPrefix string
|
||||
Timeout time.Duration
|
||||
Persist func(context.Context, CompactionResult) error
|
||||
DebugSvc *chatdebug.Service
|
||||
ChatID uuid.UUID
|
||||
HistoryTipMessageID int64
|
||||
|
||||
// ToolCallID and ToolName identify the synthetic tool call
|
||||
// used to represent compaction in the message stream.
|
||||
@@ -269,6 +282,79 @@ func shouldCompact(contextTokens, contextLimit int64, thresholdPercent int32) (f
|
||||
return usagePercent, usagePercent >= float64(thresholdPercent)
|
||||
}
|
||||
|
||||
func startCompactionDebugRun(
|
||||
ctx context.Context,
|
||||
options CompactionOptions,
|
||||
) (context.Context, func(error)) {
|
||||
if options.DebugSvc == nil || options.ChatID == uuid.Nil {
|
||||
return ctx, func(error) {}
|
||||
}
|
||||
|
||||
parentRun, ok := chatdebug.RunFromContext(ctx)
|
||||
if !ok {
|
||||
return ctx, func(error) {}
|
||||
}
|
||||
|
||||
historyTipMessageID := options.HistoryTipMessageID
|
||||
if historyTipMessageID == 0 {
|
||||
historyTipMessageID = parentRun.HistoryTipMessageID
|
||||
}
|
||||
|
||||
// Use a separate short-lived context for the debug insert so a
|
||||
// slow or locked DB cannot consume the compaction timeout budget
|
||||
// and turn debug slowness into a compaction failure via
|
||||
// model.Generate hitting a deadline exceeded. Detached from the
|
||||
// parent so cancellation of the compaction run still lets the
|
||||
// insert reach a terminal state, matching the best-effort
|
||||
// contract of debug instrumentation.
|
||||
createRunCtx, createRunCancel := context.WithTimeout(
|
||||
context.WithoutCancel(ctx), compactionDebugCreateRunTimeout,
|
||||
)
|
||||
run, err := options.DebugSvc.CreateRun(createRunCtx, chatdebug.CreateRunParams{
|
||||
ChatID: options.ChatID,
|
||||
RootChatID: parentRun.RootChatID,
|
||||
ParentChatID: parentRun.ParentChatID,
|
||||
ModelConfigID: parentRun.ModelConfigID,
|
||||
TriggerMessageID: parentRun.TriggerMessageID,
|
||||
HistoryTipMessageID: historyTipMessageID,
|
||||
Kind: chatdebug.KindCompaction,
|
||||
Status: chatdebug.StatusInProgress,
|
||||
Provider: parentRun.Provider,
|
||||
Model: parentRun.Model,
|
||||
})
|
||||
createRunCancel()
|
||||
if err != nil {
|
||||
// Debug instrumentation must not surface as a compaction failure.
|
||||
return ctx, func(error) {}
|
||||
}
|
||||
|
||||
compactionCtx := chatdebug.ContextWithRun(ctx, &chatdebug.RunContext{
|
||||
RunID: run.ID,
|
||||
ChatID: options.ChatID,
|
||||
RootChatID: parentRun.RootChatID,
|
||||
ParentChatID: parentRun.ParentChatID,
|
||||
ModelConfigID: parentRun.ModelConfigID,
|
||||
TriggerMessageID: parentRun.TriggerMessageID,
|
||||
HistoryTipMessageID: historyTipMessageID,
|
||||
Kind: chatdebug.KindCompaction,
|
||||
Provider: parentRun.Provider,
|
||||
Model: parentRun.Model,
|
||||
})
|
||||
|
||||
return compactionCtx, func(runErr error) {
|
||||
status := chatdebug.ClassifyError(runErr)
|
||||
if runErr != nil && xerrors.Is(runErr, ErrInterrupted) {
|
||||
status = chatdebug.StatusInterrupted
|
||||
}
|
||||
// Debug instrumentation must not surface as a compaction failure.
|
||||
_ = options.DebugSvc.FinalizeRun(compactionCtx, chatdebug.FinalizeRunParams{
|
||||
RunID: run.ID,
|
||||
ChatID: options.ChatID,
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// generateCompactionSummary asks the model to summarize the
|
||||
// conversation so far. The provided messages should contain the
|
||||
// complete history (system prompt, user/assistant turns, tool
|
||||
@@ -279,7 +365,7 @@ func generateCompactionSummary(
|
||||
model fantasy.LanguageModel,
|
||||
messages []fantasy.Message,
|
||||
options CompactionOptions,
|
||||
) (string, error) {
|
||||
) (summary string, err error) {
|
||||
summaryPrompt := make([]fantasy.Message, 0, len(messages)+1)
|
||||
summaryPrompt = append(summaryPrompt, messages...)
|
||||
summaryPrompt = append(summaryPrompt, fantasy.Message{
|
||||
@@ -293,6 +379,22 @@ func generateCompactionSummary(
|
||||
summaryCtx, cancel := context.WithTimeout(ctx, options.Timeout)
|
||||
defer cancel()
|
||||
|
||||
summaryCtx, finishDebugRun := startCompactionDebugRun(summaryCtx, options)
|
||||
defer func() {
|
||||
// If model.Generate (or anything else below) panics, the
|
||||
// named err return is still nil at this point. Without the
|
||||
// recover hook we would finalize the debug run as Completed
|
||||
// in the exact crash path operators rely on to diagnose
|
||||
// failures. Finalize with the panic as an error status and
|
||||
// re-panic so the caller's recovery still observes the
|
||||
// original panic value.
|
||||
if r := recover(); r != nil {
|
||||
finishDebugRun(xerrors.Errorf("panic during compaction summary: %v", r))
|
||||
panic(r)
|
||||
}
|
||||
finishDebugRun(err)
|
||||
}()
|
||||
|
||||
response, err := model.Generate(summaryCtx, fantasy.Call{
|
||||
Prompt: summaryPrompt,
|
||||
ToolChoice: &toolChoice,
|
||||
|
||||
@@ -2,17 +2,240 @@ package chatloop //nolint:testpackage // Uses internal symbols.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestStartCompactionDebugRun_DoesNotReportDebugErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
newParentContext := func(chatID uuid.UUID) context.Context {
|
||||
return chatdebug.ContextWithRun(context.Background(), &chatdebug.RunContext{
|
||||
RunID: uuid.New(),
|
||||
ChatID: chatID,
|
||||
RootChatID: uuid.New(),
|
||||
ParentChatID: uuid.New(),
|
||||
ModelConfigID: uuid.New(),
|
||||
TriggerMessageID: 41,
|
||||
HistoryTipMessageID: 42,
|
||||
Kind: chatdebug.KindChatTurn,
|
||||
Provider: "fake-provider",
|
||||
Model: "fake-model",
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("CreateRun", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
svc := chatdebug.NewService(db, testutil.Logger(t), nil)
|
||||
chatID := uuid.New()
|
||||
reportedErr := make(chan error, 1)
|
||||
|
||||
db.EXPECT().InsertChatDebugRun(
|
||||
gomock.Any(),
|
||||
gomock.AssignableToTypeOf(database.InsertChatDebugRunParams{}),
|
||||
).Return(database.ChatDebugRun{}, xerrors.New("insert compaction debug run"))
|
||||
|
||||
ctx := newParentContext(chatID)
|
||||
compactionCtx, finish := startCompactionDebugRun(ctx, CompactionOptions{
|
||||
DebugSvc: svc,
|
||||
ChatID: chatID,
|
||||
OnError: func(err error) {
|
||||
reportedErr <- err
|
||||
},
|
||||
})
|
||||
require.Same(t, ctx, compactionCtx)
|
||||
finish(nil)
|
||||
select {
|
||||
case err := <-reportedErr:
|
||||
t.Fatalf("unexpected OnError callback: %v", err)
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FinalizeRunAggregatesSummary", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
svc := chatdebug.NewService(db, testutil.Logger(t), nil)
|
||||
chatID := uuid.New()
|
||||
runID := uuid.New()
|
||||
usageJSON, err := json.Marshal(fantasy.Usage{InputTokens: 7, OutputTokens: 3})
|
||||
require.NoError(t, err)
|
||||
attemptsJSON, err := json.Marshal([]chatdebug.Attempt{{
|
||||
Status: "completed",
|
||||
Method: "POST",
|
||||
Path: "/v1/messages",
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
db.EXPECT().InsertChatDebugRun(
|
||||
gomock.Any(),
|
||||
gomock.AssignableToTypeOf(database.InsertChatDebugRunParams{}),
|
||||
).Return(database.ChatDebugRun{ //nolint:exhaustruct // Test only needs IDs.
|
||||
ID: runID,
|
||||
ChatID: chatID,
|
||||
}, nil)
|
||||
db.EXPECT().GetChatDebugStepsByRunID(gomock.Any(), runID).Return([]database.ChatDebugStep{{
|
||||
ID: uuid.New(),
|
||||
RunID: runID,
|
||||
ChatID: chatID,
|
||||
Status: string(chatdebug.StatusCompleted),
|
||||
Usage: pqtype.NullRawMessage{RawMessage: usageJSON, Valid: true},
|
||||
Attempts: attemptsJSON,
|
||||
}}, nil)
|
||||
db.EXPECT().UpdateChatDebugRun(
|
||||
gomock.Any(),
|
||||
gomock.AssignableToTypeOf(database.UpdateChatDebugRunParams{}),
|
||||
).DoAndReturn(func(_ context.Context, params database.UpdateChatDebugRunParams) (database.ChatDebugRun, error) {
|
||||
require.Equal(t, chatID, params.ChatID)
|
||||
require.Equal(t, runID, params.ID)
|
||||
require.True(t, params.Summary.Valid)
|
||||
require.JSONEq(t, `{"endpoint_label":"POST /v1/messages","step_count":1,"total_input_tokens":7,"total_output_tokens":3}`,
|
||||
string(params.Summary.RawMessage))
|
||||
return database.ChatDebugRun{ID: runID, ChatID: chatID}, nil
|
||||
})
|
||||
|
||||
ctx := newParentContext(chatID)
|
||||
compactionCtx, finish := startCompactionDebugRun(ctx, CompactionOptions{
|
||||
DebugSvc: svc,
|
||||
ChatID: chatID,
|
||||
})
|
||||
require.NotSame(t, ctx, compactionCtx)
|
||||
finish(nil)
|
||||
})
|
||||
|
||||
t.Run("FinalizeRun", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
svc := chatdebug.NewService(db, testutil.Logger(t), nil)
|
||||
chatID := uuid.New()
|
||||
reportedErr := make(chan error, 1)
|
||||
runID := uuid.New()
|
||||
|
||||
db.EXPECT().InsertChatDebugRun(
|
||||
gomock.Any(),
|
||||
gomock.AssignableToTypeOf(database.InsertChatDebugRunParams{}),
|
||||
).Return(database.ChatDebugRun{ //nolint:exhaustruct // Test only needs IDs.
|
||||
ID: runID,
|
||||
ChatID: chatID,
|
||||
}, nil)
|
||||
db.EXPECT().GetChatDebugStepsByRunID(gomock.Any(), runID).Return(nil, xerrors.New("aggregate compaction debug run"))
|
||||
db.EXPECT().UpdateChatDebugRun(
|
||||
gomock.Any(),
|
||||
gomock.AssignableToTypeOf(database.UpdateChatDebugRunParams{}),
|
||||
).Return(database.ChatDebugRun{}, xerrors.New("finalize compaction debug run"))
|
||||
|
||||
ctx := newParentContext(chatID)
|
||||
compactionCtx, finish := startCompactionDebugRun(ctx, CompactionOptions{
|
||||
DebugSvc: svc,
|
||||
ChatID: chatID,
|
||||
OnError: func(err error) {
|
||||
reportedErr <- err
|
||||
},
|
||||
})
|
||||
require.NotSame(t, ctx, compactionCtx)
|
||||
finish(nil)
|
||||
select {
|
||||
case err := <-reportedErr:
|
||||
t.Fatalf("unexpected OnError callback: %v", err)
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGenerateCompactionSummary_PanicFinalizesAsError verifies that a
|
||||
// panic originating inside the model call during compaction is
|
||||
// captured by the deferred debug-run finalizer so the run is recorded
|
||||
// with StatusError rather than StatusCompleted. Without the recover
|
||||
// hook the named `err` return is still nil when the defer fires and
|
||||
// the row silently misclassifies the crash path.
|
||||
func TestGenerateCompactionSummary_PanicFinalizesAsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
db := dbmock.NewMockStore(ctrl)
|
||||
svc := chatdebug.NewService(db, testutil.Logger(t), nil)
|
||||
chatID := uuid.New()
|
||||
runID := uuid.New()
|
||||
|
||||
status := make(chan string, 1)
|
||||
|
||||
db.EXPECT().InsertChatDebugRun(
|
||||
gomock.Any(),
|
||||
gomock.AssignableToTypeOf(database.InsertChatDebugRunParams{}),
|
||||
).Return(database.ChatDebugRun{
|
||||
ID: runID,
|
||||
ChatID: chatID,
|
||||
}, nil)
|
||||
db.EXPECT().GetChatDebugStepsByRunID(gomock.Any(), runID).Return(nil, nil)
|
||||
db.EXPECT().UpdateChatDebugRun(
|
||||
gomock.Any(),
|
||||
gomock.AssignableToTypeOf(database.UpdateChatDebugRunParams{}),
|
||||
).DoAndReturn(func(_ context.Context, params database.UpdateChatDebugRunParams) (database.ChatDebugRun, error) {
|
||||
status <- params.Status.String
|
||||
return database.ChatDebugRun{ID: runID, ChatID: chatID}, nil
|
||||
})
|
||||
|
||||
model := &chattest.FakeModel{
|
||||
ProviderName: "fake",
|
||||
GenerateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
panic("compaction model crash")
|
||||
},
|
||||
}
|
||||
|
||||
parentCtx := chatdebug.ContextWithRun(context.Background(), &chatdebug.RunContext{
|
||||
RunID: uuid.New(),
|
||||
ChatID: chatID,
|
||||
ModelConfigID: uuid.New(),
|
||||
TriggerMessageID: 1,
|
||||
HistoryTipMessageID: 2,
|
||||
Kind: chatdebug.KindChatTurn,
|
||||
Provider: "fake",
|
||||
Model: "fake-model",
|
||||
})
|
||||
|
||||
require.PanicsWithValue(t, "compaction model crash", func() {
|
||||
_, _ = generateCompactionSummary(parentCtx, model,
|
||||
[]fantasy.Message{textMessage(fantasy.MessageRoleUser, "hello")},
|
||||
CompactionOptions{
|
||||
DebugSvc: svc,
|
||||
ChatID: chatID,
|
||||
SummaryPrompt: "summarize",
|
||||
Timeout: time.Second,
|
||||
})
|
||||
})
|
||||
|
||||
select {
|
||||
case s := <-status:
|
||||
require.Equal(t, string(chatdebug.StatusError), s,
|
||||
"panic path must finalize the debug run with StatusError")
|
||||
case <-time.After(testutil.WaitShort):
|
||||
t.Fatal("FinalizeRun never reached UpdateChatDebugRun on panic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_Compaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user