mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Wires chat lifecycle hooks into chatd, gated by the `agent-lifecycle-hooks` experiment. Part of the lifecycle hooks stack (#27401, #27428, #27430). See `docs/admin/setup/chat-lifecycle-hooks.md` for the consumer-facing contract. ## Summary When a hook URL is configured, chatd dispatches `session_start`, `user_prompt_submit`, `pre_tool_use`, `post_tool_use`, `pre_compact`, `post_compact`, and `stop` events to the consumer and applies its responses. ## Design - **Stateless**: Coder stores no hook dispatch or decision state. Delivery is at least once; consumers deduplicate on stable payload identifiers (chat ID, event type, tool-use ID) and answer duplicates with the same decision. - **Admission-time prompt effects**: `user_prompt_submit` dispatches exactly once per submission (create, send, queue, edit, subagent spawn) and folds its effects into the stored prompt as typed message parts: original-or-overridden user parts, then model-only `hook-context`, then a user-visible `hook-notice`. Hook context is stripped from every client-facing conversion; hook notices are excluded from model prompts. The server rejects hook parts in client-submitted content. - **Tool gating**: `pre_tool_use` allow can override tool input; deny becomes a synthetic denied tool result, with any returned model context persisted as a model-only transcript row so it never reaches clients. The denial text identifies an external policy (the deployment's lifecycle hook) as the source and marks the decision as persistent, so the model explains the denial instead of retrying it or misreporting it as an infrastructure failure. - **Fail closed**: a dispatch failure rejects the triggering request or moves the chat to the error state in the same transaction as the affected step, so a runnable state is never published with unapproved content. - **Admission before persistence**: `pre_tool_use` is dispatched for the calls the model produced, before the assistant message is stored. See "Staged tool admission" below. - **Fresh dispatch per tool call**: every non-provider-executed tool call is decided by its own `pre_tool_use` dispatch; Coder never reuses an earlier decision on the consumer's behalf. Retries re-dispatch the same logical event. ## Structure All hook dispatch flows through one seam: entry points build a `chathooks.Chat` (chat identity) and a `chathooks.Message` (event details) and call `Trigger.Trigger`, the only component that talks to the dispatcher. The integration lives in the `coderd/x/chatd/chathooks` subpackage, split by responsibility: - `trigger.go`: the trigger seam; builds the wire envelope per event, normalizes deny into a typed error, and holds the package's single enabled-check. - `effects.go`: pure conversion of hook results into transcript rows and prompt parts. - `errors.go`: failure classification (dispatch error messages, denial mapping, tool-result dispatch-failure scanning). - `tooluse.go`: the tool-call gate (`pre_tool_use` preflight, `post_tool_use` payloads, applying admitted input to the step). Server-bound glue stays in `coderd/x/chatd/hook_server.go`: the chat-parking dispatch error handlers, the step-commit row insertion wrappers, and the dynamic post-tool-use state loader, which depends on chatd validation types. This PR adopts the `codersdk/x/agenthooks` and `coderd/x/agenthooks/dispatch` import paths introduced at the tip of #27401; intermediate commits still reference the pre-move paths and are not individually buildable. ## Staged tool admission `pre_tool_use` originally ran at tool execution time, which is after the assistant message carrying the tool call was already committed. An `input_override` therefore had to rewrite stored message content in place. @hugodutka pointed out that chatd treats message content as immutable, and that the rewrite was a shortcut rather than a requirement. It was also a correctness problem in its own right: the rewrite only updated the database, so the transcript could show one input while a different one had executed. The hook now runs before the step is persisted: ```text provider stream ends (tool calls complete, in memory) -> pre_tool_use dispatch per call -> ONE transaction: assistant row with admitted inputs, synthetic denials, hook rows -> execute ``` The step is inserted once, carrying the input the tool runs with. `UpdateChatMessageContentByID` and `Tx.UpdateMessageContent` are deleted from #27428, so message content stays immutable. Two consequences, both intentional: - **Clients converge rather than wait.** Tool-call parts still stream live, so a rewritten call briefly shows the model's proposed input before the committed message replaces it. The chat store already clears stream state when an assistant message arrives, so the stored input wins with no frontend change and no added latency before tool cards appear. - **A call already in history was already admitted.** Execution consumes the stored input instead of dispatching a second decision, which keeps one dispatch and one set of hook effects per call. A consumer policy change between admission and execution applies to later calls, not to calls already admitted. The per-chat debug endpoint still records the provider's original tool input. Its purpose is to report provider behavior, and it requires an explicit per-chat debug flag; the invariant here covers the transcript. ## Configuration Adds `chat-hook-url`, `chat-hook-secret`, `chat-hook-timeout`, and `chat-hook-enabled` deployment options with startup validation. The flags are hidden from `coder server --help` while the feature is experimental; the setup guide documents them. ## Tool input validation Built-in tool arguments reach a consumer as raw JSON with key spelling preserved, but the tools decode those bytes with Go, which matches struct fields case-insensitively and keeps the last match. A policy reading `path` could therefore authorize one value while the tool executed another, and a lone case variant such as `{"PATH":"/secret"}` was invisible to a policy checking for `path`. Coder now rejects a built-in tool call whose input repeats a key or spells a schema property with different capitalization, before the `pre_tool_use` dispatch, so a consumer is never asked to authorize bytes whose meaning depends on the reader. Rejected calls produce an error result the model can retry; unambiguous calls in the same batch still run. A consumer-authored `input_override` is rechecked after the dispatch and fails the turn closed, because the model cannot correct it. Dynamic and MCP inputs are excluded because the client and the workspace agent execute those calls rather than coderd. Two paths needed more than a schema check. Execution resolves a deprecated tool name to its canonical tool, so validation resolves aliases first. The `edit_files` decoder also reads `search` and `replace`, which its schema does not advertise, so those aliases are now matched exactly and their case variants ignored. A hook denial now returns a structured 403 carrying `kind: "hook_denied"`, mirroring the dispatch-failure response that already carries its own kind. Without it a client cannot tell a policy decision apart from a generic failure, and the chat UI titled a denial "Request failed". Adding a kind needs no migration: `ChatErrorKind` is persisted only inside the JSONB `chats.last_error` column, whose decoder accepts unknown kinds. The hook docs also correct the tool-input convergence window. A batch dispatches sequentially before the assistant row commits, so the original input stays visible for a span that scales with the number of tool calls in the step rather than a single hook timeout. > This PR was written by Mux, an AI coding agent, on Mike's behalf.
470 lines
16 KiB
Go
470 lines
16 KiB
Go
package chatloop
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
defaultCompactionThresholdPercent = int32(70)
|
|
minCompactionThresholdPercent = int32(0)
|
|
maxCompactionThresholdPercent = int32(100)
|
|
|
|
// compactionDebugCreateRunTimeout caps the compaction debug
|
|
// CreateRun budget. Debug instrumentation is best-effort;
|
|
// running without the debug row is preferable to blocking
|
|
// compaction on a slow or locked DB.
|
|
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" +
|
|
"Include:\n" +
|
|
// The constraints bullet below is deliberately verbose: offline replay
|
|
// of production chats showed compaction summaries dropping or softening
|
|
// user-stated constraints, and this wording measurably improved their
|
|
// survival (see PR #27230). Reword only with re-validation.
|
|
"- User constraints, corrections, and prohibitions: rules, " +
|
|
"scope limits, style rules, and process corrections stated by " +
|
|
"the user. Quote or closely paraphrase the user's wording; do " +
|
|
"not soften, merge, or truncate them. Constraints are standing " +
|
|
"until the user revokes them; they do not become stale when " +
|
|
"the task moves on. When the user corrected the assistant's " +
|
|
"behavior, record the correction itself, not only the " +
|
|
"corrected outcome. Include only constraints the user stated " +
|
|
"in conversation; do not place rules from system prompts, " +
|
|
"AGENTS.md, or other configuration files in this section. " +
|
|
"Those rules may appear elsewhere in the summary with their " +
|
|
"true source named. When in doubt whether a rule originated " +
|
|
"from the user, name its source or omit the attribution " +
|
|
"rather than defaulting to user.\n" +
|
|
"- The user's overall goal and current task\n" +
|
|
"- Key decisions made and their rationale\n" +
|
|
"- Concrete technical details: file paths, function names, " +
|
|
"commands, APIs, and configurations\n" +
|
|
"- Errors encountered and how they were resolved. Keep error " +
|
|
"notes specific: name the file, the error, and the fix. Do not " +
|
|
"generalize from a specific failure to a blanket tool-avoidance " +
|
|
"rule (e.g. \"tool X is unreliable\" or \"always use Y instead " +
|
|
"of Z\")\n" +
|
|
"- Current state of the work: what is DONE, what is IN PROGRESS, " +
|
|
"and what REMAINS to be done\n" +
|
|
"- The specific action the assistant was performing or about to " +
|
|
"perform when this summary was triggered\n\n" +
|
|
"Be dense and factual. Every sentence should convey essential " +
|
|
"context for continuation. Do not include pleasantries or " +
|
|
"conversational filler. For content that can be reproduced " +
|
|
"(repo files, command output, API responses), reference how to " +
|
|
"obtain it (file path, command, URL) rather than inlining the " +
|
|
"full content. Include brief inline summaries when the content " +
|
|
"itself would exceed a few lines."
|
|
defaultCompactionSystemSummaryPrefix = "The following is a summary of " +
|
|
"the earlier conversation. The assistant was actively working when " +
|
|
"the context was compacted. Continue the work described below:"
|
|
)
|
|
|
|
// CompactionSource identifies what triggered a compaction. It is
|
|
// recorded in the persisted chat_summarized tool JSON and the
|
|
// streamed synthetic parts so clients can render manual compactions
|
|
// distinctly.
|
|
type CompactionSource string
|
|
|
|
const (
|
|
CompactionSourceAutomatic CompactionSource = "automatic"
|
|
CompactionSourceManual CompactionSource = "manual"
|
|
)
|
|
|
|
type CompactionOptions struct {
|
|
ThresholdPercent int32
|
|
ContextLimit int64
|
|
SummaryPrompt string
|
|
SummaryHint string
|
|
SystemSummaryPrefix string
|
|
Persist func(context.Context, CompactionResult) error
|
|
DebugSvc *chatdebug.Service
|
|
ChatID uuid.UUID
|
|
HistoryTipMessageID int64
|
|
|
|
// Summary model identity and call options; see
|
|
// GenerateCompactionOptions.
|
|
ResolvedProvider string
|
|
ResolvedModel string
|
|
ModelConfigID uuid.UUID
|
|
ProviderOptions fantasy.ProviderOptions
|
|
|
|
// Force skips the threshold gate (including the threshold=100
|
|
// disable and the zero-usage early return). Set for manual,
|
|
// user-requested compactions.
|
|
Force bool
|
|
// Source labels what triggered the compaction. Defaults to
|
|
// CompactionSourceAutomatic when empty.
|
|
Source CompactionSource
|
|
|
|
// ToolCallID and ToolName identify the synthetic tool call
|
|
// used to represent compaction in the message stream.
|
|
ToolCallID string
|
|
ToolName string
|
|
|
|
// PublishMessagePart publishes streaming parts to connected
|
|
// clients so they see "Summarizing..." / "Summarized" UI
|
|
// transitions during compaction.
|
|
PublishMessagePart func(codersdk.ChatMessageRole, codersdk.ChatMessagePart)
|
|
|
|
OnError func(error)
|
|
}
|
|
|
|
type CompactionResult struct {
|
|
SystemSummary string
|
|
SummaryReport string
|
|
Source CompactionSource
|
|
ThresholdPercent int32
|
|
UsagePercent float64
|
|
ContextTokens int64
|
|
ContextLimit int64
|
|
}
|
|
|
|
// GenerateCompaction generates one context summary and returns it without
|
|
// persisting. It publishes compaction progress parts when configured.
|
|
// Threshold gating (including the threshold=100 disable and the
|
|
// zero-usage early return) is skipped when opts.Force is set.
|
|
func GenerateCompaction(ctx context.Context, opts GenerateCompactionOptions) (CompactionResult, error) {
|
|
if opts.Model == nil {
|
|
return CompactionResult{}, xerrors.New("chat model is required")
|
|
}
|
|
config, ok := normalizedCompactionGenerateConfig(opts)
|
|
if !ok {
|
|
return CompactionResult{}, nil
|
|
}
|
|
|
|
contextTokens := contextTokensFromUsage(opts.StepUsage)
|
|
if contextTokens <= 0 && !config.Force {
|
|
return CompactionResult{}, nil
|
|
}
|
|
metadataLimit := extractContextLimit(opts.StepMetadata)
|
|
contextLimit := resolveContextLimit(
|
|
metadataLimit.Int64,
|
|
config.ContextLimit,
|
|
opts.ContextLimitFallback,
|
|
)
|
|
usagePercent, compact := shouldCompact(
|
|
contextTokens,
|
|
contextLimit,
|
|
config.ThresholdPercent,
|
|
)
|
|
if !compact && !config.Force {
|
|
return CompactionResult{}, nil
|
|
}
|
|
|
|
if config.PublishMessagePart != nil && config.ToolCallID != "" {
|
|
config.PublishMessagePart(
|
|
codersdk.ChatMessageRoleAssistant,
|
|
codersdk.ChatMessageToolCall(config.ToolCallID, config.ToolName, nil),
|
|
)
|
|
}
|
|
|
|
summary, err := generateCompactionSummary(ctx, opts.Model, opts.Messages, config)
|
|
if err != nil {
|
|
publishCompactionError(config, "failed to generate compaction summary")
|
|
return CompactionResult{}, err
|
|
}
|
|
if summary == "" {
|
|
publishCompactionError(config, "compaction produced an empty summary")
|
|
return CompactionResult{}, xerrors.New("compaction produced an empty summary")
|
|
}
|
|
|
|
result := CompactionResult{
|
|
SystemSummary: strings.TrimSpace(
|
|
config.SystemSummaryPrefix + "\n\n" + summary,
|
|
),
|
|
SummaryReport: summary,
|
|
Source: config.Source,
|
|
ThresholdPercent: config.ThresholdPercent,
|
|
UsagePercent: usagePercent,
|
|
ContextTokens: contextTokens,
|
|
ContextLimit: contextLimit,
|
|
}
|
|
if config.PublishMessagePart != nil && config.ToolCallID != "" {
|
|
resultJSON, _ := json.Marshal(map[string]any{
|
|
"summary": summary,
|
|
"source": config.Source,
|
|
"threshold_percent": config.ThresholdPercent,
|
|
"usage_percent": usagePercent,
|
|
"context_tokens": contextTokens,
|
|
"context_limit_tokens": contextLimit,
|
|
})
|
|
config.PublishMessagePart(
|
|
codersdk.ChatMessageRoleTool,
|
|
codersdk.ChatMessageToolResult(config.ToolCallID, config.ToolName, resultJSON, false, false),
|
|
)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func normalizedCompactionGenerateConfig(opts GenerateCompactionOptions) (CompactionOptions, bool) {
|
|
config := CompactionOptions{
|
|
ThresholdPercent: opts.ThresholdPercent,
|
|
ContextLimit: opts.ContextLimit,
|
|
SummaryPrompt: opts.SummaryPrompt,
|
|
SummaryHint: opts.SummaryHint,
|
|
SystemSummaryPrefix: opts.SystemSummaryPrefix,
|
|
DebugSvc: opts.DebugSvc,
|
|
ChatID: opts.ChatID,
|
|
HistoryTipMessageID: opts.HistoryTipMessageID,
|
|
ResolvedProvider: opts.ResolvedProvider,
|
|
ResolvedModel: opts.ResolvedModel,
|
|
ModelConfigID: opts.ModelConfigID,
|
|
ProviderOptions: opts.ProviderOptions,
|
|
Force: opts.Force,
|
|
Source: opts.Source,
|
|
ToolCallID: opts.ToolCallID,
|
|
ToolName: opts.ToolName,
|
|
PublishMessagePart: opts.PublishMessagePart,
|
|
}
|
|
if strings.TrimSpace(config.SummaryPrompt) == "" {
|
|
config.SummaryPrompt = defaultCompactionSummaryPrompt
|
|
}
|
|
if strings.TrimSpace(config.SystemSummaryPrefix) == "" {
|
|
config.SystemSummaryPrefix = defaultCompactionSystemSummaryPrefix
|
|
}
|
|
if config.Source == "" {
|
|
config.Source = CompactionSourceAutomatic
|
|
}
|
|
if config.ThresholdPercent < minCompactionThresholdPercent ||
|
|
config.ThresholdPercent > maxCompactionThresholdPercent {
|
|
config.ThresholdPercent = defaultCompactionThresholdPercent
|
|
}
|
|
// threshold=100 disables automatic compaction; a forced run
|
|
// still proceeds because the user asked explicitly.
|
|
if config.ThresholdPercent == maxCompactionThresholdPercent && !config.Force {
|
|
return CompactionOptions{}, false
|
|
}
|
|
return config, true
|
|
}
|
|
|
|
// publishCompactionError sends a tool-result error part so
|
|
// connected clients see that compaction failed.
|
|
func publishCompactionError(config CompactionOptions, msg string) {
|
|
if config.PublishMessagePart == nil || config.ToolCallID == "" {
|
|
return
|
|
}
|
|
errJSON, _ := json.Marshal(map[string]any{
|
|
"error": msg,
|
|
})
|
|
config.PublishMessagePart(
|
|
codersdk.ChatMessageRoleTool,
|
|
codersdk.ChatMessageToolResult(config.ToolCallID, config.ToolName, errJSON, true, false),
|
|
)
|
|
}
|
|
|
|
// contextTokensFromUsage returns the total context token count from
|
|
// a step's usage report. It sums input, cache-read, and
|
|
// cache-creation tokens when available, falling back to TotalTokens
|
|
// if none of the granular fields are set.
|
|
func contextTokensFromUsage(usage fantasy.Usage) int64 {
|
|
total := int64(0)
|
|
hasContextTokens := false
|
|
|
|
if usage.InputTokens > 0 {
|
|
total += usage.InputTokens
|
|
hasContextTokens = true
|
|
}
|
|
if usage.CacheReadTokens > 0 {
|
|
total += usage.CacheReadTokens
|
|
hasContextTokens = true
|
|
}
|
|
if usage.CacheCreationTokens > 0 {
|
|
total += usage.CacheCreationTokens
|
|
hasContextTokens = true
|
|
}
|
|
if !hasContextTokens && usage.TotalTokens > 0 {
|
|
total = usage.TotalTokens
|
|
}
|
|
|
|
return total
|
|
}
|
|
|
|
// resolveContextLimit picks the first positive value from metadata,
|
|
// configured limit, and fallback — in that priority order. Returns
|
|
// 0 when none are positive.
|
|
func resolveContextLimit(metadataLimit, configLimit, fallback int64) int64 {
|
|
if metadataLimit > 0 {
|
|
return metadataLimit
|
|
}
|
|
if configLimit > 0 {
|
|
return configLimit
|
|
}
|
|
if fallback > 0 {
|
|
return fallback
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// shouldCompact returns the usage percentage and whether it exceeds
|
|
// the threshold. Returns (0, false) when contextLimit is
|
|
// non-positive.
|
|
func shouldCompact(contextTokens, contextLimit int64, thresholdPercent int32) (float64, bool) {
|
|
if contextLimit <= 0 {
|
|
return 0, false
|
|
}
|
|
usagePercent := (float64(contextTokens) / float64(contextLimit)) * 100
|
|
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
|
|
}
|
|
|
|
// Prefer the caller-supplied summary model identity; it can differ
|
|
// from the parent run's chat model under a compaction override.
|
|
provider := parentRun.Provider
|
|
if options.ResolvedProvider != "" {
|
|
provider = options.ResolvedProvider
|
|
}
|
|
model := parentRun.Model
|
|
if options.ResolvedModel != "" {
|
|
model = options.ResolvedModel
|
|
}
|
|
modelConfigID := parentRun.ModelConfigID
|
|
if options.ModelConfigID != uuid.Nil {
|
|
modelConfigID = options.ModelConfigID
|
|
}
|
|
|
|
// Use a separate short-lived context for the debug insert so a
|
|
// slow or locked DB cannot block the model call. 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: modelConfigID,
|
|
TriggerMessageID: parentRun.TriggerMessageID,
|
|
HistoryTipMessageID: historyTipMessageID,
|
|
Kind: chatdebug.KindCompaction,
|
|
Status: chatdebug.StatusInProgress,
|
|
Provider: provider,
|
|
Model: 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: modelConfigID,
|
|
TriggerMessageID: parentRun.TriggerMessageID,
|
|
HistoryTipMessageID: historyTipMessageID,
|
|
Kind: chatdebug.KindCompaction,
|
|
Provider: provider,
|
|
Model: 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
|
|
// results). A final user message with the summary prompt is appended
|
|
// before calling the model.
|
|
func generateCompactionSummary(
|
|
ctx context.Context,
|
|
model fantasy.LanguageModel,
|
|
messages []fantasy.Message,
|
|
options CompactionOptions,
|
|
) (summary string, err error) {
|
|
summaryPrompt := make([]fantasy.Message, 0, len(messages)+1)
|
|
summaryPrompt = append(summaryPrompt, messages...)
|
|
summaryParts := []fantasy.MessagePart{fantasy.TextPart{Text: options.SummaryPrompt}}
|
|
if strings.TrimSpace(options.SummaryHint) != "" {
|
|
summaryParts = append(summaryParts, fantasy.TextPart{Text: options.SummaryHint})
|
|
}
|
|
summaryPrompt = append(summaryPrompt, fantasy.Message{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: summaryParts,
|
|
})
|
|
toolChoice := fantasy.ToolChoiceNone
|
|
|
|
summaryCtx, finishDebugRun := startCompactionDebugRun(ctx, 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,
|
|
ProviderOptions: options.ProviderOptions,
|
|
})
|
|
if err != nil {
|
|
return "", xerrors.Errorf("generate summary text: %w", err)
|
|
}
|
|
|
|
parts := make([]string, 0, len(response.Content))
|
|
for _, block := range response.Content {
|
|
textBlock, ok := fantasy.AsContentType[fantasy.TextContent](block)
|
|
if !ok {
|
|
continue
|
|
}
|
|
text := strings.TrimSpace(textBlock.Text)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
parts = append(parts, text)
|
|
}
|
|
return strings.TrimSpace(strings.Join(parts, " ")), nil
|
|
}
|