mirror of
https://github.com/coder/coder.git
synced 2026-06-04 13:38:21 +00:00
4a79af1a0d
Migration 000434 converts chat_messages.role from text to a Postgres enum, rebuilds the partial index, and adds content_version smallint. The column is backfilled with DEFAULT 0, then the default is dropped so future inserts must set it explicitly. Version 0 uses the role-aware heuristic from #22958. Version 1 (all new inserts) stores []ChatMessagePart JSON for all roles, including system messages. ParseContent takes database.ChatMessage directly and dispatches on version internally. Unknown versions error. All string(codersdk.ChatMessageRole*) casts at DB write sites are replaced with database.ChatMessageRole* constants from sqlc. Refs #22958
1219 lines
38 KiB
Go
1219 lines
38 KiB
Go
package chatprompt
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"charm.land/fantasy"
|
|
"github.com/google/uuid"
|
|
"github.com/sqlc-dev/pqtype"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
var toolCallIDSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
|
|
|
// FileData holds resolved file content for LLM prompt building.
|
|
type FileData struct {
|
|
Data []byte
|
|
MediaType string
|
|
}
|
|
|
|
// FileResolver fetches file content by ID for LLM prompt building.
|
|
type FileResolver func(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]FileData, error)
|
|
|
|
// ExtractFileID parses the file_id from a serialized file content
|
|
// block envelope. Returns uuid.Nil and an error when the block is
|
|
// not a file-type block or has no file_id.
|
|
func ExtractFileID(raw json.RawMessage) (uuid.UUID, error) {
|
|
var envelope struct {
|
|
Type string `json:"type"`
|
|
Data struct {
|
|
FileID string `json:"file_id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
|
return uuid.Nil, xerrors.Errorf("unmarshal content block: %w", err)
|
|
}
|
|
if !strings.EqualFold(envelope.Type, string(fantasy.ContentTypeFile)) {
|
|
return uuid.Nil, xerrors.Errorf("not a file content block: %s", envelope.Type)
|
|
}
|
|
if envelope.Data.FileID == "" {
|
|
return uuid.Nil, xerrors.New("no file_id")
|
|
}
|
|
return uuid.Parse(envelope.Data.FileID)
|
|
}
|
|
|
|
// ConvertMessages converts persisted chat messages into LLM prompt
|
|
// messages without resolving file references from storage. Inline
|
|
// file data is preserved when present (backward compat).
|
|
func ConvertMessages(
|
|
messages []database.ChatMessage,
|
|
) ([]fantasy.Message, error) {
|
|
return ConvertMessagesWithFiles(context.Background(), messages, nil, slog.Logger{})
|
|
}
|
|
|
|
// ConvertMessagesWithFiles converts persisted chat messages into LLM
|
|
// prompt messages, resolving file references via the provided
|
|
// resolver. When resolver is nil, file blocks without inline data
|
|
// are passed through as-is (same behavior as ConvertMessages).
|
|
func ConvertMessagesWithFiles(
|
|
ctx context.Context,
|
|
messages []database.ChatMessage,
|
|
resolver FileResolver,
|
|
logger slog.Logger,
|
|
) ([]fantasy.Message, error) {
|
|
// Phase 1: Parse all messages via ParseContent (→ SDK parts)
|
|
// and collect file_id references from user messages for batch
|
|
// resolution.
|
|
type parsedMessage struct {
|
|
role codersdk.ChatMessageRole
|
|
parts []codersdk.ChatMessagePart
|
|
}
|
|
parsed := make([]parsedMessage, len(messages))
|
|
var allFileIDs []uuid.UUID
|
|
seenFileIDs := make(map[uuid.UUID]struct{})
|
|
|
|
for i, msg := range messages {
|
|
visibility := msg.Visibility
|
|
if visibility == "" {
|
|
visibility = database.ChatMessageVisibilityBoth
|
|
}
|
|
if visibility != database.ChatMessageVisibilityModel &&
|
|
visibility != database.ChatMessageVisibilityBoth {
|
|
continue
|
|
}
|
|
|
|
parts, err := ParseContent(msg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parsed[i] = parsedMessage{role: codersdk.ChatMessageRole(msg.Role), parts: parts}
|
|
|
|
// Collect file IDs from user messages for resolution.
|
|
if resolver != nil && msg.Role == database.ChatMessageRoleUser {
|
|
for _, part := range parts {
|
|
if part.Type == codersdk.ChatMessagePartTypeFile && part.FileID.Valid {
|
|
if _, seen := seenFileIDs[part.FileID.UUID]; !seen {
|
|
seenFileIDs[part.FileID.UUID] = struct{}{}
|
|
allFileIDs = append(allFileIDs, part.FileID.UUID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 2: Batch resolve file data.
|
|
var resolved map[uuid.UUID]FileData
|
|
if len(allFileIDs) > 0 {
|
|
var err error
|
|
resolved, err = resolver(ctx, allFileIDs)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("resolve chat files: %w", err)
|
|
}
|
|
}
|
|
|
|
// Phase 3: Build fantasy messages from SDK parts via
|
|
// partsToMessageParts. Track tool names for injection.
|
|
prompt := make([]fantasy.Message, 0, len(messages))
|
|
toolNameByCallID := make(map[string]string)
|
|
for _, pm := range parsed {
|
|
if len(pm.parts) == 0 {
|
|
continue
|
|
}
|
|
|
|
switch pm.role {
|
|
case codersdk.ChatMessageRoleSystem:
|
|
// System parts are always a single text part.
|
|
prompt = append(prompt, fantasy.Message{
|
|
Role: fantasy.MessageRoleSystem,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: pm.parts[0].Text},
|
|
},
|
|
})
|
|
case codersdk.ChatMessageRoleUser:
|
|
prompt = append(prompt, fantasy.Message{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: partsToMessageParts(logger, pm.parts, resolved),
|
|
})
|
|
case codersdk.ChatMessageRoleAssistant:
|
|
fantasyParts := normalizeAssistantToolCallInputs(
|
|
partsToMessageParts(logger, pm.parts, resolved),
|
|
)
|
|
for _, toolCall := range ExtractToolCalls(fantasyParts) {
|
|
if toolCall.ToolCallID == "" || strings.TrimSpace(toolCall.ToolName) == "" {
|
|
continue
|
|
}
|
|
toolNameByCallID[sanitizeToolCallID(toolCall.ToolCallID)] = toolCall.ToolName
|
|
}
|
|
prompt = append(prompt, fantasy.Message{
|
|
Role: fantasy.MessageRoleAssistant,
|
|
Content: fantasyParts,
|
|
})
|
|
case codersdk.ChatMessageRoleTool:
|
|
// Track tool names from SDK parts before conversion.
|
|
for _, part := range pm.parts {
|
|
if part.Type == codersdk.ChatMessagePartTypeToolResult {
|
|
if part.ToolCallID != "" && part.ToolName != "" {
|
|
toolNameByCallID[sanitizeToolCallID(part.ToolCallID)] = part.ToolName
|
|
}
|
|
}
|
|
}
|
|
prompt = append(prompt, fantasy.Message{
|
|
Role: fantasy.MessageRoleTool,
|
|
Content: partsToMessageParts(logger, pm.parts, resolved),
|
|
})
|
|
}
|
|
}
|
|
prompt = injectMissingToolResults(prompt)
|
|
prompt = injectMissingToolUses(
|
|
prompt,
|
|
toolNameByCallID,
|
|
)
|
|
return prompt, nil
|
|
}
|
|
|
|
// PrependSystem prepends a system message unless an existing system
|
|
// message already mentions create_workspace guidance.
|
|
func PrependSystem(prompt []fantasy.Message, instruction string) []fantasy.Message {
|
|
instruction = strings.TrimSpace(instruction)
|
|
if instruction == "" {
|
|
return prompt
|
|
}
|
|
for _, message := range prompt {
|
|
if message.Role != fantasy.MessageRoleSystem {
|
|
continue
|
|
}
|
|
for _, part := range message.Content {
|
|
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](part)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if strings.Contains(strings.ToLower(textPart.Text), "create_workspace") {
|
|
return prompt
|
|
}
|
|
}
|
|
}
|
|
|
|
out := make([]fantasy.Message, 0, len(prompt)+1)
|
|
out = append(out, fantasy.Message{
|
|
Role: fantasy.MessageRoleSystem,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: instruction},
|
|
},
|
|
})
|
|
out = append(out, prompt...)
|
|
return out
|
|
}
|
|
|
|
// InsertSystem inserts a system message after the existing system
|
|
// block and before the first non-system message.
|
|
func InsertSystem(prompt []fantasy.Message, instruction string) []fantasy.Message {
|
|
instruction = strings.TrimSpace(instruction)
|
|
if instruction == "" {
|
|
return prompt
|
|
}
|
|
|
|
systemMessage := fantasy.Message{
|
|
Role: fantasy.MessageRoleSystem,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: instruction},
|
|
},
|
|
}
|
|
|
|
out := make([]fantasy.Message, 0, len(prompt)+1)
|
|
inserted := false
|
|
for _, message := range prompt {
|
|
if !inserted && message.Role != fantasy.MessageRoleSystem {
|
|
out = append(out, systemMessage)
|
|
inserted = true
|
|
}
|
|
out = append(out, message)
|
|
}
|
|
if !inserted {
|
|
out = append(out, systemMessage)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// AppendUser appends an instruction as a user message at the end of
|
|
// the prompt.
|
|
func AppendUser(prompt []fantasy.Message, instruction string) []fantasy.Message {
|
|
instruction = strings.TrimSpace(instruction)
|
|
if instruction == "" {
|
|
return prompt
|
|
}
|
|
out := make([]fantasy.Message, 0, len(prompt)+1)
|
|
out = append(out, prompt...)
|
|
out = append(out, fantasy.Message{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: instruction},
|
|
},
|
|
})
|
|
return out
|
|
}
|
|
|
|
const (
|
|
// ContentVersionV0 is the legacy content format. Parsing uses
|
|
// role-aware heuristics to distinguish fantasy envelope format
|
|
// from SDK parts.
|
|
ContentVersionV0 int16 = 0
|
|
// ContentVersionV1 stores content as []codersdk.ChatMessagePart
|
|
// JSON for all roles.
|
|
ContentVersionV1 int16 = 1
|
|
|
|
// CurrentContentVersion is the version used for new inserts.
|
|
CurrentContentVersion = ContentVersionV1
|
|
)
|
|
|
|
// ParseContent decodes persisted chat message content blocks into
|
|
// SDK parts. Dispatches on content version: version 0 (legacy) uses
|
|
// a role-aware heuristic to distinguish fantasy envelope format
|
|
// from SDK parts, version 1 (current) unmarshals SDK-format
|
|
// []ChatMessagePart directly.
|
|
func ParseContent(msg database.ChatMessage) ([]codersdk.ChatMessagePart, error) {
|
|
if !msg.Content.Valid || len(msg.Content.RawMessage) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
role := codersdk.ChatMessageRole(msg.Role)
|
|
|
|
switch msg.ContentVersion {
|
|
case ContentVersionV0:
|
|
return parseLegacyContent(role, msg.Content)
|
|
case ContentVersionV1:
|
|
return parseContentV1(role, msg.Content)
|
|
default:
|
|
return nil, xerrors.Errorf("unsupported content version %d", msg.ContentVersion)
|
|
}
|
|
}
|
|
|
|
// parseLegacyContent handles content version 0, where the format
|
|
// varies by role and era. Uses structural heuristics to distinguish
|
|
// fantasy envelope format from SDK parts.
|
|
func parseLegacyContent(role codersdk.ChatMessageRole, raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
|
|
switch role {
|
|
case codersdk.ChatMessageRoleSystem:
|
|
return parseSystemRole(raw)
|
|
case codersdk.ChatMessageRoleAssistant:
|
|
return parseAssistantRole(raw)
|
|
case codersdk.ChatMessageRoleTool:
|
|
return parseToolRole(raw)
|
|
case codersdk.ChatMessageRoleUser:
|
|
return parseUserRole(raw)
|
|
default:
|
|
return nil, xerrors.Errorf("unsupported chat message role %q", role)
|
|
}
|
|
}
|
|
|
|
// parseContentV1 handles content version 1. Content is a JSON
|
|
// array of ChatMessagePart structs.
|
|
func parseContentV1(role codersdk.ChatMessageRole, raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
|
|
var parts []codersdk.ChatMessagePart
|
|
if err := json.Unmarshal(raw.RawMessage, &parts); err != nil {
|
|
return nil, xerrors.Errorf("parse %s content: %w", role, err)
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
// parseSystemRole decodes a system message (JSON string) into a
|
|
// single text part.
|
|
func parseSystemRole(raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
|
|
var text string
|
|
if err := json.Unmarshal(raw.RawMessage, &text); err != nil {
|
|
return nil, xerrors.Errorf("parse system content: %w", err)
|
|
}
|
|
if strings.TrimSpace(text) == "" {
|
|
return nil, nil
|
|
}
|
|
return []codersdk.ChatMessagePart{codersdk.ChatMessageText(text)}, nil
|
|
}
|
|
|
|
// parseAssistantRole uses the structural heuristic to distinguish
|
|
// legacy fantasy envelope from new SDK parts. We don't use
|
|
// try/fallback here because json.Unmarshal of a fantasy envelope
|
|
// into []ChatMessagePart can partially succeed (Type gets set from
|
|
// the envelope's "type" field) while silently losing content. The
|
|
// only thing preventing that today is that Data ([]byte) rejects
|
|
// the envelope's "data" JSON object, but that's a brittle
|
|
// invariant tied to Go's json decoder behavior for []byte.
|
|
func parseAssistantRole(raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
|
|
if isFantasyEnvelopeFormat(raw.RawMessage) {
|
|
return parseLegacyFantasyBlocks(string(codersdk.ChatMessageRoleAssistant), raw)
|
|
}
|
|
|
|
// New SDK format.
|
|
var parts []codersdk.ChatMessagePart
|
|
if err := json.Unmarshal(raw.RawMessage, &parts); err != nil {
|
|
return nil, xerrors.Errorf("parse assistant content: %w", err)
|
|
}
|
|
if !hasNonEmptyType(parts) {
|
|
return nil, nil
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
// parseToolRole tries SDK parts first, then falls back to legacy
|
|
// tool result rows. Unlike assistant/user roles, tool messages
|
|
// don't need the isFantasyEnvelopeFormat heuristic: legacy tool
|
|
// result rows have no "type" field (just tool_call_id, tool_name,
|
|
// result), so hasToolResultType reliably rejects them.
|
|
func parseToolRole(raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
|
|
// Try SDK parts.
|
|
var parts []codersdk.ChatMessagePart
|
|
if err := json.Unmarshal(raw.RawMessage, &parts); err == nil && hasToolResultType(parts) {
|
|
return parts, nil
|
|
}
|
|
|
|
// Fall back to legacy tool result rows.
|
|
rows, err := parseToolResultRows(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parts = make([]codersdk.ChatMessagePart, 0, len(rows))
|
|
for _, row := range rows {
|
|
part := codersdk.ChatMessageToolResult(row.ToolCallID, row.ToolName, row.Result, row.IsError)
|
|
part.ProviderExecuted = row.ProviderExecuted
|
|
part.ProviderMetadata = row.ProviderMetadata
|
|
parts = append(parts, part)
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
// parseUserRole uses a structural heuristic to distinguish legacy
|
|
// fantasy envelope from new SDK parts.
|
|
func parseUserRole(raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
|
|
// Legacy: plain JSON string (very old format).
|
|
var text string
|
|
if err := json.Unmarshal(raw.RawMessage, &text); err == nil {
|
|
if strings.TrimSpace(text) == "" {
|
|
return nil, nil
|
|
}
|
|
return []codersdk.ChatMessagePart{codersdk.ChatMessageText(text)}, nil
|
|
}
|
|
|
|
if isFantasyEnvelopeFormat(raw.RawMessage) {
|
|
return parseLegacyUserBlocks(raw)
|
|
}
|
|
|
|
// New SDK format.
|
|
var parts []codersdk.ChatMessagePart
|
|
if err := json.Unmarshal(raw.RawMessage, &parts); err != nil {
|
|
return nil, xerrors.Errorf("parse user content: %w", err)
|
|
}
|
|
if !hasNonEmptyType(parts) {
|
|
return nil, nil
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
// parseLegacyUserBlocks decodes a user message stored in fantasy
|
|
// envelope format, extracting file_id references from the raw
|
|
// envelope for file-type blocks.
|
|
func parseLegacyUserBlocks(raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
|
|
var rawBlocks []json.RawMessage
|
|
if err := json.Unmarshal(raw.RawMessage, &rawBlocks); err != nil {
|
|
return nil, xerrors.Errorf("parse user content: %w", err)
|
|
}
|
|
|
|
parts := make([]codersdk.ChatMessagePart, 0, len(rawBlocks))
|
|
for i, rawBlock := range rawBlocks {
|
|
block, err := fantasy.UnmarshalContent(rawBlock)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("parse user content block %d: %w", i, err)
|
|
}
|
|
part := PartFromContent(block)
|
|
if part.Type == "" {
|
|
continue
|
|
}
|
|
// For file-type blocks, extract file_id from the raw
|
|
// envelope's data sub-object.
|
|
if part.Type == codersdk.ChatMessagePartTypeFile {
|
|
if fid, err := ExtractFileID(rawBlock); err == nil {
|
|
part.FileID = uuid.NullUUID{UUID: fid, Valid: true}
|
|
// Clear inline data when file_id is present;
|
|
// resolved at LLM dispatch time.
|
|
part.Data = nil
|
|
}
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
// parseLegacyFantasyBlocks decodes an assistant message stored in
|
|
// fantasy envelope format, converting each block via PartFromContent
|
|
// which preserves ProviderMetadata.
|
|
func parseLegacyFantasyBlocks(role string, raw pqtype.NullRawMessage) ([]codersdk.ChatMessagePart, error) {
|
|
var rawBlocks []json.RawMessage
|
|
if err := json.Unmarshal(raw.RawMessage, &rawBlocks); err != nil {
|
|
return nil, xerrors.Errorf("parse %s content: %w", role, err)
|
|
}
|
|
|
|
parts := make([]codersdk.ChatMessagePart, 0, len(rawBlocks))
|
|
for i, rawBlock := range rawBlocks {
|
|
block, err := fantasy.UnmarshalContent(rawBlock)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("parse %s content block %d: %w", role, i, err)
|
|
}
|
|
part := PartFromContent(block)
|
|
if part.Type == "" {
|
|
continue
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
// hasNonEmptyType returns true if at least one part has a non-empty
|
|
// Type field, indicating a valid SDK parts array.
|
|
func hasNonEmptyType(parts []codersdk.ChatMessagePart) bool {
|
|
for _, p := range parts {
|
|
if p.Type != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasToolResultType returns true if at least one part has Type ==
|
|
// ToolResult, indicating a valid SDK tool-result array.
|
|
func hasToolResultType(parts []codersdk.ChatMessagePart) bool {
|
|
for _, p := range parts {
|
|
if p.Type == codersdk.ChatMessagePartTypeToolResult {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// toolResultRaw is an untyped representation of a persisted tool
|
|
// result row. We intentionally avoid a strict Go struct so that
|
|
// historical shapes are never rejected.
|
|
type toolResultRaw struct {
|
|
ToolCallID string `json:"tool_call_id"`
|
|
ToolName string `json:"tool_name"`
|
|
Result json.RawMessage `json:"result"`
|
|
IsError bool `json:"is_error,omitempty"`
|
|
ProviderExecuted bool `json:"provider_executed,omitempty"`
|
|
ProviderMetadata json.RawMessage `json:"provider_metadata,omitempty"`
|
|
}
|
|
|
|
// parseToolResultRows decodes persisted tool result rows.
|
|
func parseToolResultRows(raw pqtype.NullRawMessage) ([]toolResultRaw, error) {
|
|
if !raw.Valid || len(raw.RawMessage) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var rows []toolResultRaw
|
|
if err := json.Unmarshal(raw.RawMessage, &rows); err != nil {
|
|
return nil, xerrors.Errorf("parse tool content: %w", err)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// extractErrorString pulls the "error" field from a JSON object if
|
|
// present, returning it as a string. Returns "" if the field is
|
|
// missing or the input is not an object.
|
|
func extractErrorString(raw json.RawMessage) string {
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
|
return ""
|
|
}
|
|
errField, ok := fields["error"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(errField, &s); err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
func normalizeAssistantToolCallInputs(
|
|
parts []fantasy.MessagePart,
|
|
) []fantasy.MessagePart {
|
|
normalized := make([]fantasy.MessagePart, 0, len(parts))
|
|
for _, part := range parts {
|
|
toolCall, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](part)
|
|
if !ok {
|
|
normalized = append(normalized, part)
|
|
continue
|
|
}
|
|
|
|
toolCall.Input = normalizeToolCallInput(toolCall.Input)
|
|
normalized = append(normalized, toolCall)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
// normalizeToolCallInput guarantees tool call input is a JSON object string.
|
|
// Anthropic drops assistant tool calls with malformed input, which can leave
|
|
// following tool results orphaned.
|
|
func normalizeToolCallInput(input string) string {
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return "{}"
|
|
}
|
|
|
|
var object map[string]any
|
|
if err := json.Unmarshal([]byte(input), &object); err != nil || object == nil {
|
|
return "{}"
|
|
}
|
|
|
|
return input
|
|
}
|
|
|
|
// ExtractToolCalls returns all tool call parts as content blocks.
|
|
func ExtractToolCalls(parts []fantasy.MessagePart) []fantasy.ToolCallContent {
|
|
toolCalls := make([]fantasy.ToolCallContent, 0, len(parts))
|
|
for _, part := range parts {
|
|
toolCall, ok := fantasy.AsMessagePart[fantasy.ToolCallPart](part)
|
|
if !ok {
|
|
continue
|
|
}
|
|
toolCalls = append(toolCalls, fantasy.ToolCallContent{
|
|
ToolCallID: toolCall.ToolCallID,
|
|
ToolName: toolCall.ToolName,
|
|
Input: toolCall.Input,
|
|
ProviderExecuted: toolCall.ProviderExecuted,
|
|
})
|
|
}
|
|
return toolCalls
|
|
}
|
|
|
|
// MarshalContent encodes message content blocks in legacy fantasy
|
|
// envelope format. Retained for backward-compatible test fixtures
|
|
// that create legacy-format DB rows. Production write paths use
|
|
// MarshalParts instead.
|
|
func MarshalContent(blocks []fantasy.Content, fileIDs map[int]uuid.UUID) (pqtype.NullRawMessage, error) {
|
|
if len(blocks) == 0 {
|
|
return pqtype.NullRawMessage{}, nil
|
|
}
|
|
|
|
encodedBlocks := make([]json.RawMessage, 0, len(blocks))
|
|
for i, block := range blocks {
|
|
encoded, err := json.Marshal(block)
|
|
if err != nil {
|
|
return pqtype.NullRawMessage{}, xerrors.Errorf(
|
|
"encode content block %d: %w",
|
|
i,
|
|
err,
|
|
)
|
|
}
|
|
if fid, ok := fileIDs[i]; ok {
|
|
// Inline file_id injection into the fantasy envelope's
|
|
// data sub-object, stripping inline data.
|
|
var envelope struct {
|
|
Type string `json:"type"`
|
|
Data struct {
|
|
MediaType string `json:"media_type"`
|
|
Data json.RawMessage `json:"data,omitempty"`
|
|
FileID string `json:"file_id,omitempty"`
|
|
ProviderMetadata *json.RawMessage `json:"provider_metadata,omitempty"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(encoded, &envelope); err == nil {
|
|
envelope.Data.FileID = fid.String()
|
|
envelope.Data.Data = nil
|
|
if patched, err := json.Marshal(envelope); err == nil {
|
|
encoded = patched
|
|
}
|
|
}
|
|
}
|
|
encodedBlocks = append(encodedBlocks, encoded)
|
|
}
|
|
|
|
data, err := json.Marshal(encodedBlocks)
|
|
if err != nil {
|
|
return pqtype.NullRawMessage{}, xerrors.Errorf("encode content blocks: %w", err)
|
|
}
|
|
return pqtype.NullRawMessage{RawMessage: data, Valid: true}, nil
|
|
}
|
|
|
|
// MarshalToolResult encodes a single tool result in the legacy
|
|
// tool-row format. Retained for test fixtures that create
|
|
// legacy-format DB rows. Production write paths use MarshalParts.
|
|
// The stored shape is
|
|
// [{"tool_call_id":…,"tool_name":…,"result":…,"is_error":…}].
|
|
func MarshalToolResult(toolCallID, toolName string, result json.RawMessage, isError bool, providerExecuted bool, providerMetadata fantasy.ProviderMetadata) (pqtype.NullRawMessage, error) {
|
|
var metaJSON json.RawMessage
|
|
if len(providerMetadata) > 0 {
|
|
var err error
|
|
metaJSON, err = json.Marshal(providerMetadata)
|
|
if err != nil {
|
|
return pqtype.NullRawMessage{}, xerrors.Errorf("encode provider metadata: %w", err)
|
|
}
|
|
}
|
|
row := toolResultRaw{
|
|
ToolCallID: toolCallID,
|
|
ToolName: toolName,
|
|
Result: result,
|
|
IsError: isError,
|
|
ProviderExecuted: providerExecuted,
|
|
ProviderMetadata: metaJSON,
|
|
}
|
|
data, err := json.Marshal([]toolResultRaw{row})
|
|
if err != nil {
|
|
return pqtype.NullRawMessage{}, xerrors.Errorf("encode tool result: %w", err)
|
|
}
|
|
return pqtype.NullRawMessage{RawMessage: data, Valid: true}, nil
|
|
}
|
|
|
|
// PartFromContent converts fantasy content into a SDK chat message
|
|
// part, preserving ProviderMetadata and ProviderExecuted fields.
|
|
func PartFromContent(block fantasy.Content) codersdk.ChatMessagePart {
|
|
switch value := block.(type) {
|
|
case fantasy.TextContent:
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeText,
|
|
Text: value.Text,
|
|
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
|
|
}
|
|
case *fantasy.TextContent:
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeText,
|
|
Text: value.Text,
|
|
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
|
|
}
|
|
case fantasy.ReasoningContent:
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeReasoning,
|
|
Text: value.Text,
|
|
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
|
|
}
|
|
case *fantasy.ReasoningContent:
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeReasoning,
|
|
Text: value.Text,
|
|
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
|
|
}
|
|
case fantasy.ToolCallContent:
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: value.ToolCallID,
|
|
ToolName: value.ToolName,
|
|
Args: safeToolCallArgs(value.Input),
|
|
ProviderExecuted: value.ProviderExecuted,
|
|
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
|
|
}
|
|
case *fantasy.ToolCallContent:
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeToolCall,
|
|
ToolCallID: value.ToolCallID,
|
|
ToolName: value.ToolName,
|
|
Args: safeToolCallArgs(value.Input),
|
|
ProviderExecuted: value.ProviderExecuted,
|
|
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
|
|
}
|
|
case fantasy.SourceContent:
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeSource,
|
|
SourceID: value.ID,
|
|
URL: value.URL,
|
|
Title: value.Title,
|
|
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
|
|
}
|
|
case *fantasy.SourceContent:
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeSource,
|
|
SourceID: value.ID,
|
|
URL: value.URL,
|
|
Title: value.Title,
|
|
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
|
|
}
|
|
case fantasy.FileContent:
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeFile,
|
|
MediaType: value.MediaType,
|
|
Data: value.Data,
|
|
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
|
|
}
|
|
case *fantasy.FileContent:
|
|
return codersdk.ChatMessagePart{
|
|
Type: codersdk.ChatMessagePartTypeFile,
|
|
MediaType: value.MediaType,
|
|
Data: value.Data,
|
|
ProviderMetadata: marshalProviderMetadata(value.ProviderMetadata),
|
|
}
|
|
case fantasy.ToolResultContent:
|
|
return toolResultContentToPart(value)
|
|
case *fantasy.ToolResultContent:
|
|
return toolResultContentToPart(*value)
|
|
default:
|
|
return codersdk.ChatMessagePart{}
|
|
}
|
|
}
|
|
|
|
// ToolResultToPart converts a tool call ID, raw result, and error
|
|
// flag into a ChatMessagePart. This is the minimal conversion used
|
|
// both during streaming and when reading from the database.
|
|
func ToolResultToPart(toolCallID, toolName string, result json.RawMessage, isError bool) codersdk.ChatMessagePart {
|
|
return codersdk.ChatMessageToolResult(toolCallID, toolName, result, isError)
|
|
}
|
|
|
|
// toolResultContentToPart converts a fantasy ToolResultContent
|
|
// directly into a ChatMessagePart without an intermediate struct.
|
|
func toolResultContentToPart(content fantasy.ToolResultContent) codersdk.ChatMessagePart {
|
|
var result json.RawMessage
|
|
var isError bool
|
|
|
|
switch output := content.Result.(type) {
|
|
case fantasy.ToolResultOutputContentError:
|
|
isError = true
|
|
if output.Error != nil {
|
|
result, _ = json.Marshal(map[string]any{"error": output.Error.Error()})
|
|
} else {
|
|
result = []byte(`{"error":""}`)
|
|
}
|
|
case fantasy.ToolResultOutputContentText:
|
|
result = json.RawMessage(output.Text)
|
|
// Ensure valid JSON; wrap in an object if not.
|
|
if !json.Valid(result) {
|
|
result, _ = json.Marshal(map[string]any{"output": output.Text})
|
|
}
|
|
case fantasy.ToolResultOutputContentMedia:
|
|
result, _ = json.Marshal(map[string]any{
|
|
"data": output.Data,
|
|
"mime_type": output.MediaType,
|
|
"text": output.Text,
|
|
})
|
|
default:
|
|
result = []byte(`{}`)
|
|
}
|
|
|
|
part := ToolResultToPart(content.ToolCallID, content.ToolName, result, isError)
|
|
part.ProviderExecuted = content.ProviderExecuted
|
|
part.ProviderMetadata = marshalProviderMetadata(content.ProviderMetadata)
|
|
return part
|
|
}
|
|
|
|
func injectMissingToolResults(prompt []fantasy.Message) []fantasy.Message {
|
|
result := make([]fantasy.Message, 0, len(prompt))
|
|
for i := 0; i < len(prompt); i++ {
|
|
msg := prompt[i]
|
|
result = append(result, msg)
|
|
|
|
if msg.Role != fantasy.MessageRoleAssistant {
|
|
continue
|
|
}
|
|
toolCalls := ExtractToolCalls(msg.Content)
|
|
if len(toolCalls) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Collect the tool call IDs that have results in the
|
|
// following tool message(s).
|
|
answered := make(map[string]struct{})
|
|
j := i + 1
|
|
for ; j < len(prompt); j++ {
|
|
if prompt[j].Role != fantasy.MessageRoleTool {
|
|
break
|
|
}
|
|
for _, part := range prompt[j].Content {
|
|
tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
|
|
if !ok {
|
|
continue
|
|
}
|
|
answered[tr.ToolCallID] = struct{}{}
|
|
}
|
|
}
|
|
if i+1 < j {
|
|
// Preserve persisted tool result ordering and inject any
|
|
// synthetic results after the existing contiguous tool messages.
|
|
result = append(result, prompt[i+1:j]...)
|
|
i = j - 1
|
|
}
|
|
|
|
// Build synthetic results for any unanswered tool calls.
|
|
// Provider-executed tool calls (e.g. web_search) are
|
|
// handled server-side by the LLM provider. Their results
|
|
// may arrive in a later step and end up stored out of
|
|
// position, so we must not inject synthetic error results
|
|
// for them. The provider will re-execute the tool when it
|
|
// sees the server_tool_use without a matching result.
|
|
var missing []fantasy.MessagePart
|
|
for _, tc := range toolCalls {
|
|
if tc.ProviderExecuted {
|
|
continue
|
|
}
|
|
if _, ok := answered[tc.ToolCallID]; !ok {
|
|
missing = append(missing, fantasy.ToolResultPart{
|
|
ToolCallID: tc.ToolCallID,
|
|
Output: fantasy.ToolResultOutputContentError{
|
|
Error: xerrors.New("tool call was interrupted and did not receive a result"),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
result = append(result, fantasy.Message{
|
|
Role: fantasy.MessageRoleTool,
|
|
Content: missing,
|
|
})
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func injectMissingToolUses(
|
|
prompt []fantasy.Message,
|
|
toolNameByCallID map[string]string,
|
|
) []fantasy.Message {
|
|
result := make([]fantasy.Message, 0, len(prompt))
|
|
for _, msg := range prompt {
|
|
if msg.Role != fantasy.MessageRoleTool {
|
|
result = append(result, msg)
|
|
continue
|
|
}
|
|
|
|
allToolResults := make([]fantasy.ToolResultPart, 0, len(msg.Content))
|
|
for _, part := range msg.Content {
|
|
toolResult, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
|
|
if !ok {
|
|
continue
|
|
}
|
|
allToolResults = append(allToolResults, toolResult)
|
|
}
|
|
if len(allToolResults) == 0 {
|
|
result = append(result, msg)
|
|
continue
|
|
}
|
|
|
|
// Provider-executed tool results (e.g. web_search) may be
|
|
// persisted in a later step than the assistant message that
|
|
// initiated the tool call. When that happens they appear as
|
|
// orphans after the wrong assistant message. Filter them
|
|
// out before matching — the provider will re-execute the
|
|
// tool, and the search results are already captured in the
|
|
// subsequent assistant message's sources/text.
|
|
toolResults := make([]fantasy.ToolResultPart, 0, len(allToolResults))
|
|
for _, tr := range allToolResults {
|
|
if !tr.ProviderExecuted {
|
|
toolResults = append(toolResults, tr)
|
|
}
|
|
}
|
|
if len(toolResults) == 0 {
|
|
// All results were provider-executed; drop the message.
|
|
continue
|
|
}
|
|
|
|
// Walk backwards through the result to find the nearest
|
|
// preceding assistant message (skipping over other tool
|
|
// messages that belong to the same batch of results).
|
|
answeredByPrevious := make(map[string]struct{})
|
|
for k := len(result) - 1; k >= 0; k-- {
|
|
if result[k].Role == fantasy.MessageRoleAssistant {
|
|
for _, toolCall := range ExtractToolCalls(result[k].Content) {
|
|
toolCallID := sanitizeToolCallID(toolCall.ToolCallID)
|
|
if toolCallID == "" {
|
|
continue
|
|
}
|
|
answeredByPrevious[toolCallID] = struct{}{}
|
|
}
|
|
break
|
|
}
|
|
if result[k].Role != fantasy.MessageRoleTool {
|
|
break
|
|
}
|
|
}
|
|
|
|
matchingResults := make([]fantasy.ToolResultPart, 0, len(toolResults))
|
|
orphanResults := make([]fantasy.ToolResultPart, 0, len(toolResults))
|
|
for _, toolResult := range toolResults {
|
|
toolCallID := sanitizeToolCallID(toolResult.ToolCallID)
|
|
if _, ok := answeredByPrevious[toolCallID]; ok {
|
|
matchingResults = append(matchingResults, toolResult)
|
|
continue
|
|
}
|
|
orphanResults = append(orphanResults, toolResult)
|
|
}
|
|
|
|
if len(orphanResults) == 0 {
|
|
// Rebuild the message from the filtered results so
|
|
// dropped provider-executed results are excluded.
|
|
result = append(result, toolMessageFromToolResultParts(matchingResults))
|
|
continue
|
|
}
|
|
|
|
syntheticToolUse := syntheticToolUseMessage(
|
|
orphanResults,
|
|
toolNameByCallID,
|
|
)
|
|
if len(syntheticToolUse.Content) == 0 {
|
|
result = append(result, msg)
|
|
continue
|
|
}
|
|
|
|
if len(matchingResults) > 0 {
|
|
result = append(result, toolMessageFromToolResultParts(matchingResults))
|
|
}
|
|
result = append(result, syntheticToolUse)
|
|
result = append(result, toolMessageFromToolResultParts(orphanResults))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func toolMessageFromToolResultParts(results []fantasy.ToolResultPart) fantasy.Message {
|
|
parts := make([]fantasy.MessagePart, 0, len(results))
|
|
for _, result := range results {
|
|
parts = append(parts, result)
|
|
}
|
|
return fantasy.Message{
|
|
Role: fantasy.MessageRoleTool,
|
|
Content: parts,
|
|
}
|
|
}
|
|
|
|
func syntheticToolUseMessage(
|
|
toolResults []fantasy.ToolResultPart,
|
|
toolNameByCallID map[string]string,
|
|
) fantasy.Message {
|
|
parts := make([]fantasy.MessagePart, 0, len(toolResults))
|
|
seen := make(map[string]struct{}, len(toolResults))
|
|
|
|
for _, toolResult := range toolResults {
|
|
toolCallID := sanitizeToolCallID(toolResult.ToolCallID)
|
|
if toolCallID == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[toolCallID]; ok {
|
|
continue
|
|
}
|
|
|
|
toolName := strings.TrimSpace(toolNameByCallID[toolCallID])
|
|
if toolName == "" {
|
|
continue
|
|
}
|
|
|
|
seen[toolCallID] = struct{}{}
|
|
parts = append(parts, fantasy.ToolCallPart{
|
|
ToolCallID: toolCallID,
|
|
ToolName: toolName,
|
|
Input: "{}",
|
|
})
|
|
}
|
|
|
|
return fantasy.Message{
|
|
Role: fantasy.MessageRoleAssistant,
|
|
Content: parts,
|
|
}
|
|
}
|
|
|
|
func sanitizeToolCallID(id string) string {
|
|
if id == "" {
|
|
return ""
|
|
}
|
|
return toolCallIDSanitizer.ReplaceAllString(id, "_")
|
|
}
|
|
|
|
// MarshalParts encodes SDK chat message parts for persistence.
|
|
func MarshalParts(parts []codersdk.ChatMessagePart) (pqtype.NullRawMessage, error) {
|
|
if len(parts) == 0 {
|
|
return pqtype.NullRawMessage{}, nil
|
|
}
|
|
data, err := json.Marshal(parts)
|
|
if err != nil {
|
|
return pqtype.NullRawMessage{}, xerrors.Errorf("encode chat message parts: %w", err)
|
|
}
|
|
return pqtype.NullRawMessage{RawMessage: data, Valid: true}, nil
|
|
}
|
|
|
|
// isFantasyEnvelopeFormat checks whether raw message content uses
|
|
// the fantasy envelope format (legacy) vs SDK parts (new). It
|
|
// examines the first array element for a "data" field containing a
|
|
// JSON object (starts with '{'). Fantasy always serializes Data
|
|
// from json.Marshal(struct{...}), producing a JSON object.
|
|
// ChatMessagePart.Data is []byte, which serializes to a base64
|
|
// string or is omitted via omitempty. This structural invariant
|
|
// means a "data" field starting with '{' can only come from
|
|
// fantasy.
|
|
func isFantasyEnvelopeFormat(raw json.RawMessage) bool {
|
|
var arr []json.RawMessage
|
|
if err := json.Unmarshal(raw, &arr); err != nil || len(arr) == 0 {
|
|
return false
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(arr[0], &fields); err != nil {
|
|
return false
|
|
}
|
|
data, ok := fields["data"]
|
|
if !ok {
|
|
return false
|
|
}
|
|
trimmed := bytes.TrimSpace(data)
|
|
return len(trimmed) > 0 && trimmed[0] == '{'
|
|
}
|
|
|
|
// marshalProviderMetadata converts fantasy provider metadata to raw
|
|
// JSON for storage in SDK parts.
|
|
func marshalProviderMetadata(metadata fantasy.ProviderMetadata) json.RawMessage {
|
|
if len(metadata) == 0 {
|
|
return nil
|
|
}
|
|
data, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return data
|
|
}
|
|
|
|
// providerMetadataToOptions reconstructs fantasy ProviderOptions
|
|
// from raw JSON stored in an SDK part's ProviderMetadata field.
|
|
// Uses fantasy.UnmarshalProviderOptions to restore registered
|
|
// provider-specific types. Returns nil on failure.
|
|
func providerMetadataToOptions(logger slog.Logger, raw json.RawMessage) fantasy.ProviderOptions {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var intermediate map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &intermediate); err != nil {
|
|
logger.Warn(context.Background(), "failed to unmarshal provider metadata", slog.Error(err))
|
|
return nil
|
|
}
|
|
opts, err := fantasy.UnmarshalProviderOptions(intermediate)
|
|
if err != nil {
|
|
logger.Warn(context.Background(), "failed to decode provider options", slog.Error(err))
|
|
return nil
|
|
}
|
|
return opts
|
|
}
|
|
|
|
// safeToolCallArgs ensures tool call args are valid JSON. Returns
|
|
// nil for empty or invalid input so the field is omitted.
|
|
func safeToolCallArgs(input string) json.RawMessage {
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return nil
|
|
}
|
|
raw := json.RawMessage(input)
|
|
if !json.Valid(raw) {
|
|
return nil
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// fileReferencePartToText formats a file-reference SDK part as
|
|
// plain text for LLM consumption. LLMs don't understand
|
|
// file-reference natively, so we convert to a readable text
|
|
// representation.
|
|
func fileReferencePartToText(part codersdk.ChatMessagePart) string {
|
|
lineRange := fmt.Sprintf("%d", part.StartLine)
|
|
if part.StartLine != part.EndLine {
|
|
lineRange = fmt.Sprintf("%d-%d", part.StartLine, part.EndLine)
|
|
}
|
|
var sb strings.Builder
|
|
_, _ = fmt.Fprintf(&sb, "[file-reference] %s:%s", part.FileName, lineRange)
|
|
if content := strings.TrimSpace(part.Content); content != "" {
|
|
_, _ = fmt.Fprintf(&sb, "\n```%s\n%s\n```", part.FileName, content)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// toolResultPartToMessagePart converts an SDK tool-result part
|
|
// into a fantasy ToolResultPart for LLM dispatch.
|
|
func toolResultPartToMessagePart(logger slog.Logger, part codersdk.ChatMessagePart) fantasy.ToolResultPart {
|
|
toolCallID := sanitizeToolCallID(part.ToolCallID)
|
|
resultText := string(part.Result)
|
|
if resultText == "" || resultText == "null" {
|
|
resultText = "{}"
|
|
}
|
|
|
|
opts := providerMetadataToOptions(logger, part.ProviderMetadata)
|
|
|
|
if part.IsError {
|
|
message := strings.TrimSpace(resultText)
|
|
if extracted := extractErrorString(part.Result); extracted != "" {
|
|
message = extracted
|
|
}
|
|
return fantasy.ToolResultPart{
|
|
ToolCallID: toolCallID,
|
|
ProviderExecuted: part.ProviderExecuted,
|
|
Output: fantasy.ToolResultOutputContentError{
|
|
Error: xerrors.New(message),
|
|
},
|
|
ProviderOptions: opts,
|
|
}
|
|
}
|
|
|
|
return fantasy.ToolResultPart{
|
|
ToolCallID: toolCallID,
|
|
ProviderExecuted: part.ProviderExecuted,
|
|
Output: fantasy.ToolResultOutputContentText{
|
|
Text: resultText,
|
|
},
|
|
ProviderOptions: opts,
|
|
}
|
|
}
|
|
|
|
// partsToMessageParts converts SDK chat message parts into fantasy
|
|
// message parts for LLM dispatch. It handles file data injection
|
|
// from resolved files, file-reference to text conversion, and
|
|
// source part skipping.
|
|
func partsToMessageParts(
|
|
logger slog.Logger,
|
|
parts []codersdk.ChatMessagePart,
|
|
resolved map[uuid.UUID]FileData,
|
|
) []fantasy.MessagePart {
|
|
result := make([]fantasy.MessagePart, 0, len(parts))
|
|
for _, part := range parts {
|
|
switch part.Type {
|
|
case codersdk.ChatMessagePartTypeText:
|
|
result = append(result, fantasy.TextPart{
|
|
Text: part.Text,
|
|
ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata),
|
|
})
|
|
case codersdk.ChatMessagePartTypeReasoning:
|
|
result = append(result, fantasy.ReasoningPart{
|
|
Text: part.Text,
|
|
ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata),
|
|
})
|
|
case codersdk.ChatMessagePartTypeToolCall:
|
|
result = append(result, fantasy.ToolCallPart{
|
|
ToolCallID: sanitizeToolCallID(part.ToolCallID),
|
|
ToolName: part.ToolName,
|
|
Input: string(part.Args),
|
|
ProviderExecuted: part.ProviderExecuted,
|
|
ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata),
|
|
})
|
|
case codersdk.ChatMessagePartTypeToolResult:
|
|
result = append(result, toolResultPartToMessagePart(logger, part))
|
|
case codersdk.ChatMessagePartTypeFile:
|
|
data := part.Data
|
|
mediaType := part.MediaType
|
|
if part.FileID.Valid {
|
|
if fd, ok := resolved[part.FileID.UUID]; ok {
|
|
data = fd.Data
|
|
if mediaType == "" {
|
|
mediaType = fd.MediaType
|
|
}
|
|
}
|
|
}
|
|
result = append(result, fantasy.FilePart{
|
|
Data: data,
|
|
MediaType: mediaType,
|
|
ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata),
|
|
})
|
|
case codersdk.ChatMessagePartTypeFileReference:
|
|
// LLMs don't understand file-reference natively.
|
|
result = append(result, fantasy.TextPart{
|
|
Text: fileReferencePartToText(part),
|
|
})
|
|
case codersdk.ChatMessagePartTypeSource:
|
|
// Source parts are metadata-only, not sent to LLM.
|
|
continue
|
|
}
|
|
}
|
|
return result
|
|
}
|