mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add experimental agents support (#22290)
feat: add AI chat system with agent tools and chat UI Introduce the chatd subsystem and Agents UI for AI-powered chat within Coder workspaces. - Add chatd package with chat loop, message compaction, prompt management, and LLM provider integration (OpenAI, Anthropic) - Add agent tools: create workspace, list/read templates, read/write/ edit files, execute commands - Add chat API endpoints with streaming, message editing, and durable reconnection - Add database schema and migrations for chats, chat messages, chat providers, and chat model configs - Add RBAC policies and dbauthz enforcement for chat resources - Add Agents UI pages with conversation timeline, queued messages list, diff viewer, and model configuration panel - Add comprehensive test coverage including coderd integration tests, chatd unit tests, and Storybook stories - Gate feature behind experiments flag --------- Co-authored-by: Cian Johnston <cian@coder.com> Co-authored-by: Danielle Maywood <danielle@themaywoods.com> Co-authored-by: Jeremy Ruppel <jeremy@coder.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Cian Johnston
Danielle Maywood
Jeremy Ruppel
Claude Sonnet 4.6
parent
67da4e8b56
commit
edee917d88
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,461 @@
|
||||
package chatd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/v3/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/coderd/chatd"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestInterruptChatBroadcastsStatusAcrossInstances(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
replicaA := newTestServer(t, db, ps, uuid.New())
|
||||
replicaB := newTestServer(t, db, ps, uuid.New())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat, err := replicaA.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "interrupt-me",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
runningWorker := uuid.New()
|
||||
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
Status: database.ChatStatusRunning,
|
||||
WorkerID: uuid.NullUUID{UUID: runningWorker, Valid: true},
|
||||
StartedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, events, cancel, ok := replicaB.Subscribe(ctx, chat.ID, nil)
|
||||
require.True(t, ok)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
updated := replicaA.InterruptChat(ctx, chat)
|
||||
require.Equal(t, database.ChatStatusWaiting, updated.Status)
|
||||
require.False(t, updated.WorkerID.Valid)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
select {
|
||||
case event := <-events:
|
||||
if event.Type != codersdk.ChatStreamEventTypeStatus || event.Status == nil {
|
||||
return false
|
||||
}
|
||||
return event.Status.Status == codersdk.ChatStatusWaiting
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}, testutil.WaitMedium, testutil.IntervalFast)
|
||||
}
|
||||
|
||||
func TestInterruptChatClearsWorkerInDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
replica := newTestServer(t, db, ps, uuid.New())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "db-transition",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
Status: database.ChatStatusRunning,
|
||||
WorkerID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
StartedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := replica.InterruptChat(ctx, chat)
|
||||
require.Equal(t, database.ChatStatusWaiting, updated.Status)
|
||||
require.False(t, updated.WorkerID.Valid)
|
||||
|
||||
fromDB, err := db.GetChatByID(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, database.ChatStatusWaiting, fromDB.Status)
|
||||
require.False(t, fromDB.WorkerID.Valid)
|
||||
}
|
||||
|
||||
func TestUpdateChatHeartbeatRequiresOwnership(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
replica := newTestServer(t, db, ps, uuid.New())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "heartbeat-ownership",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
workerID := uuid.New()
|
||||
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
Status: database.ChatStatusRunning,
|
||||
WorkerID: uuid.NullUUID{UUID: workerID, Valid: true},
|
||||
StartedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rows, err := db.UpdateChatHeartbeat(ctx, database.UpdateChatHeartbeatParams{
|
||||
ID: chat.ID,
|
||||
WorkerID: uuid.New(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(0), rows)
|
||||
|
||||
rows, err = db.UpdateChatHeartbeat(ctx, database.UpdateChatHeartbeatParams{
|
||||
ID: chat.ID,
|
||||
WorkerID: workerID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), rows)
|
||||
}
|
||||
|
||||
func TestSendMessageQueueBehaviorQueuesWhenBusy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
replica := newTestServer(t, db, ps, uuid.New())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "queue-when-busy",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
workerID := uuid.New()
|
||||
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
Status: database.ChatStatusRunning,
|
||||
WorkerID: uuid.NullUUID{UUID: workerID, Valid: true},
|
||||
StartedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := replica.SendMessage(ctx, chatd.SendMessageOptions{
|
||||
ChatID: chat.ID,
|
||||
Content: []fantasy.Content{fantasy.TextContent{Text: "queued"}},
|
||||
BusyBehavior: chatd.SendMessageBusyBehaviorQueue,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Queued)
|
||||
require.NotNil(t, result.QueuedMessage)
|
||||
require.Equal(t, database.ChatStatusRunning, result.Chat.Status)
|
||||
require.Equal(t, workerID, result.Chat.WorkerID.UUID)
|
||||
require.True(t, result.Chat.WorkerID.Valid)
|
||||
|
||||
queued, err := db.GetChatQueuedMessages(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, queued, 1)
|
||||
|
||||
messages, err := db.GetChatMessagesByChatID(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
}
|
||||
|
||||
func TestSendMessageInterruptBehaviorSendsImmediatelyWhenBusy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
replica := newTestServer(t, db, ps, uuid.New())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "interrupt-when-busy",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
Status: database.ChatStatusRunning,
|
||||
WorkerID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
StartedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := replica.SendMessage(ctx, chatd.SendMessageOptions{
|
||||
ChatID: chat.ID,
|
||||
Content: []fantasy.Content{fantasy.TextContent{Text: "interrupt"}},
|
||||
BusyBehavior: chatd.SendMessageBusyBehaviorInterrupt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.False(t, result.Queued)
|
||||
require.Equal(t, database.ChatStatusPending, result.Chat.Status)
|
||||
require.False(t, result.Chat.WorkerID.Valid)
|
||||
|
||||
fromDB, err := db.GetChatByID(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, database.ChatStatusPending, fromDB.Status)
|
||||
require.False(t, fromDB.WorkerID.Valid)
|
||||
|
||||
queued, err := db.GetChatQueuedMessages(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, queued, 0)
|
||||
|
||||
messages, err := db.GetChatMessagesByChatID(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 2)
|
||||
require.Equal(t, messages[len(messages)-1].ID, result.Message.ID)
|
||||
}
|
||||
|
||||
func TestEditMessageUpdatesAndTruncatesAndClearsQueue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
replica := newTestServer(t, db, ps, uuid.New())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "edit-message",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "original"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
initialMessages, err := db.GetChatMessagesByChatID(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, initialMessages, 1)
|
||||
editedMessageID := initialMessages[0].ID
|
||||
|
||||
_, err = replica.SendMessage(ctx, chatd.SendMessageOptions{
|
||||
ChatID: chat.ID,
|
||||
Content: []fantasy.Content{fantasy.TextContent{Text: "follow-up"}},
|
||||
BusyBehavior: chatd.SendMessageBusyBehaviorInterrupt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = replica.SendMessage(ctx, chatd.SendMessageOptions{
|
||||
ChatID: chat.ID,
|
||||
Content: []fantasy.Content{fantasy.TextContent{Text: "another"}},
|
||||
BusyBehavior: chatd.SendMessageBusyBehaviorInterrupt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.InsertChatQueuedMessage(ctx, database.InsertChatQueuedMessageParams{
|
||||
ChatID: chat.ID,
|
||||
Content: json.RawMessage(`"queued"`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
|
||||
ID: chat.ID,
|
||||
Status: database.ChatStatusRunning,
|
||||
WorkerID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
|
||||
StartedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
HeartbeatAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
editResult, err := replica.EditMessage(ctx, chatd.EditMessageOptions{
|
||||
ChatID: chat.ID,
|
||||
EditedMessageID: editedMessageID,
|
||||
Content: []fantasy.Content{fantasy.TextContent{Text: "edited"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, editedMessageID, editResult.Message.ID)
|
||||
require.Equal(t, database.ChatStatusPending, editResult.Chat.Status)
|
||||
require.False(t, editResult.Chat.WorkerID.Valid)
|
||||
|
||||
editedSDK := db2sdk.ChatMessage(editResult.Message)
|
||||
require.Len(t, editedSDK.Content, 1)
|
||||
require.Equal(t, "edited", editedSDK.Content[0].Text)
|
||||
|
||||
messages, err := db.GetChatMessagesByChatID(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, messages, 1)
|
||||
require.Equal(t, editedMessageID, messages[0].ID)
|
||||
onlyMessage := db2sdk.ChatMessage(messages[0])
|
||||
require.Len(t, onlyMessage.Content, 1)
|
||||
require.Equal(t, "edited", onlyMessage.Content[0].Text)
|
||||
|
||||
queued, err := db.GetChatQueuedMessages(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, queued, 0)
|
||||
|
||||
chatFromDB, err := db.GetChatByID(ctx, chat.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, database.ChatStatusPending, chatFromDB.Status)
|
||||
require.False(t, chatFromDB.WorkerID.Valid)
|
||||
}
|
||||
|
||||
func TestEditMessageRejectsMissingMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
replica := newTestServer(t, db, ps, uuid.New())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "missing-edited-message",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = replica.EditMessage(ctx, chatd.EditMessageOptions{
|
||||
ChatID: chat.ID,
|
||||
EditedMessageID: 999999,
|
||||
Content: []fantasy.Content{fantasy.TextContent{Text: "edited"}},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, chatd.ErrEditedMessageNotFound))
|
||||
}
|
||||
|
||||
func TestEditMessageRejectsNonUserMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, ps := dbtestutil.NewDB(t)
|
||||
replica := newTestServer(t, db, ps, uuid.New())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
user, model := seedChatDependencies(ctx, t, db)
|
||||
|
||||
chat, err := replica.CreateChat(ctx, chatd.CreateOptions{
|
||||
OwnerID: user.ID,
|
||||
Title: "non-user-edited-message",
|
||||
ModelConfigID: model.ID,
|
||||
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: "hello"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assistantMessage, err := db.InsertChatMessage(ctx, database.InsertChatMessageParams{
|
||||
ChatID: chat.ID,
|
||||
ModelConfigID: uuid.NullUUID{UUID: model.ID, Valid: true},
|
||||
Role: "assistant",
|
||||
Content: pqtype.NullRawMessage{
|
||||
RawMessage: json.RawMessage(`"assistant"`),
|
||||
Valid: true,
|
||||
},
|
||||
Visibility: database.ChatMessageVisibilityBoth,
|
||||
InputTokens: sql.NullInt64{},
|
||||
OutputTokens: sql.NullInt64{},
|
||||
TotalTokens: sql.NullInt64{},
|
||||
ReasoningTokens: sql.NullInt64{},
|
||||
CacheCreationTokens: sql.NullInt64{},
|
||||
CacheReadTokens: sql.NullInt64{},
|
||||
ContextLimit: sql.NullInt64{},
|
||||
Compressed: sql.NullBool{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = replica.EditMessage(ctx, chatd.EditMessageOptions{
|
||||
ChatID: chat.ID,
|
||||
EditedMessageID: assistantMessage.ID,
|
||||
Content: []fantasy.Content{fantasy.TextContent{Text: "edited"}},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, chatd.ErrEditedMessageNotUser))
|
||||
}
|
||||
|
||||
func newTestServer(
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
ps dbpubsub.Pubsub,
|
||||
replicaID uuid.UUID,
|
||||
) *chatd.Server {
|
||||
t.Helper()
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
server := chatd.New(chatd.Config{
|
||||
Logger: logger,
|
||||
Database: db,
|
||||
ReplicaID: replicaID,
|
||||
Pubsub: ps,
|
||||
PendingChatAcquireInterval: testutil.WaitSuperLong,
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, server.Close())
|
||||
})
|
||||
return server
|
||||
}
|
||||
|
||||
func seedChatDependencies(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
db database.Store,
|
||||
) (database.User, database.ChatModelConfig) {
|
||||
t.Helper()
|
||||
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
_, err := db.InsertChatProvider(ctx, database.InsertChatProviderParams{
|
||||
Provider: "openai",
|
||||
DisplayName: "OpenAI",
|
||||
APIKey: "test-key",
|
||||
BaseUrl: "",
|
||||
ApiKeyKeyID: sql.NullString{},
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
Enabled: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
model, err := db.InsertChatModelConfig(ctx, database.InsertChatModelConfigParams{
|
||||
Provider: "openai",
|
||||
Model: "gpt-4o-mini",
|
||||
DisplayName: "Test Model",
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
UpdatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
Enabled: true,
|
||||
IsDefault: true,
|
||||
ContextLimit: 128000,
|
||||
CompressionThreshold: 70,
|
||||
Options: json.RawMessage(`{}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return user, model
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
package chatloop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
interruptedToolResultErrorMessage = "tool call was interrupted before it produced a result"
|
||||
)
|
||||
|
||||
var ErrInterrupted = xerrors.New("chat interrupted")
|
||||
|
||||
// PersistedStep contains the full content of a completed or
|
||||
// interrupted agent step. Content includes both assistant blocks
|
||||
// (text, reasoning, tool calls) and tool result blocks, mirroring
|
||||
// what fantasy provides in StepResult.Content. The persistence
|
||||
// layer is responsible for splitting these into separate database
|
||||
// messages by role.
|
||||
type PersistedStep struct {
|
||||
Content []fantasy.Content
|
||||
Usage fantasy.Usage
|
||||
ContextLimit sql.NullInt64
|
||||
}
|
||||
|
||||
// RunOptions configures a single streaming chat loop run.
|
||||
type RunOptions struct {
|
||||
Model fantasy.LanguageModel
|
||||
Messages []fantasy.Message
|
||||
Tools []fantasy.AgentTool
|
||||
StreamCall fantasy.AgentStreamCall
|
||||
MaxSteps int
|
||||
|
||||
ActiveTools []string
|
||||
ContextLimitFallback int64
|
||||
|
||||
PersistStep func(context.Context, PersistedStep) error
|
||||
PublishMessagePart func(
|
||||
role fantasy.MessageRole,
|
||||
part codersdk.ChatMessagePart,
|
||||
)
|
||||
Compaction *CompactionOptions
|
||||
|
||||
OnInterruptedPersistError func(error)
|
||||
}
|
||||
|
||||
// Run executes the chat step-stream loop and delegates persistence/publishing to callbacks.
|
||||
func Run(ctx context.Context, opts RunOptions) (*fantasy.AgentResult, error) {
|
||||
if opts.Model == nil {
|
||||
return nil, xerrors.New("chat model is required")
|
||||
}
|
||||
if opts.PersistStep == nil {
|
||||
return nil, xerrors.New("persist step callback is required")
|
||||
}
|
||||
if opts.MaxSteps <= 0 {
|
||||
opts.MaxSteps = 1
|
||||
}
|
||||
|
||||
publishMessagePart := func(role fantasy.MessageRole, part codersdk.ChatMessagePart) {
|
||||
if opts.PublishMessagePart == nil {
|
||||
return
|
||||
}
|
||||
opts.PublishMessagePart(role, part)
|
||||
}
|
||||
|
||||
var (
|
||||
stepStateMu sync.Mutex
|
||||
streamToolNames map[string]string
|
||||
streamReasoningTitles map[string]string
|
||||
streamReasoningText map[string]string
|
||||
// stepToolResultContents tracks tool results received during
|
||||
// streaming. These are needed for the interrupted-step path
|
||||
// where OnStepFinish never fires.
|
||||
stepToolResultContents []fantasy.ToolResultContent
|
||||
stepAssistantDraft []fantasy.Content
|
||||
stepToolCallIndexByID map[string]int
|
||||
)
|
||||
|
||||
resetStepState := func() {
|
||||
stepStateMu.Lock()
|
||||
streamToolNames = make(map[string]string)
|
||||
streamReasoningTitles = make(map[string]string)
|
||||
streamReasoningText = make(map[string]string)
|
||||
stepToolResultContents = nil
|
||||
stepAssistantDraft = nil
|
||||
stepToolCallIndexByID = make(map[string]int)
|
||||
stepStateMu.Unlock()
|
||||
}
|
||||
|
||||
setReasoningTitleFromText := func(id string, text string) {
|
||||
if id == "" || strings.TrimSpace(text) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
stepStateMu.Lock()
|
||||
defer stepStateMu.Unlock()
|
||||
|
||||
if streamReasoningTitles[id] != "" {
|
||||
return
|
||||
}
|
||||
|
||||
streamReasoningText[id] += text
|
||||
if !strings.ContainsAny(streamReasoningText[id], "\r\n") {
|
||||
return
|
||||
}
|
||||
title := chatprompt.ReasoningTitleFromFirstLine(streamReasoningText[id])
|
||||
if title == "" {
|
||||
return
|
||||
}
|
||||
|
||||
streamReasoningTitles[id] = title
|
||||
}
|
||||
|
||||
appendDraftText := func(text string) {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
stepStateMu.Lock()
|
||||
defer stepStateMu.Unlock()
|
||||
|
||||
if len(stepAssistantDraft) > 0 {
|
||||
lastIndex := len(stepAssistantDraft) - 1
|
||||
switch last := stepAssistantDraft[lastIndex].(type) {
|
||||
case fantasy.TextContent:
|
||||
last.Text += text
|
||||
stepAssistantDraft[lastIndex] = last
|
||||
return
|
||||
case *fantasy.TextContent:
|
||||
last.Text += text
|
||||
stepAssistantDraft[lastIndex] = fantasy.TextContent{Text: last.Text}
|
||||
return
|
||||
}
|
||||
}
|
||||
stepAssistantDraft = append(stepAssistantDraft, fantasy.TextContent{Text: text})
|
||||
}
|
||||
|
||||
appendDraftReasoning := func(text string) {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
|
||||
stepStateMu.Lock()
|
||||
defer stepStateMu.Unlock()
|
||||
|
||||
if len(stepAssistantDraft) > 0 {
|
||||
lastIndex := len(stepAssistantDraft) - 1
|
||||
switch last := stepAssistantDraft[lastIndex].(type) {
|
||||
case fantasy.ReasoningContent:
|
||||
last.Text += text
|
||||
stepAssistantDraft[lastIndex] = last
|
||||
return
|
||||
case *fantasy.ReasoningContent:
|
||||
last.Text += text
|
||||
stepAssistantDraft[lastIndex] = fantasy.ReasoningContent{Text: last.Text}
|
||||
return
|
||||
}
|
||||
}
|
||||
stepAssistantDraft = append(stepAssistantDraft, fantasy.ReasoningContent{Text: text})
|
||||
}
|
||||
|
||||
upsertDraftToolCall := func(toolCallID, toolName, input string, appendInput bool) {
|
||||
if toolCallID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
stepStateMu.Lock()
|
||||
defer stepStateMu.Unlock()
|
||||
|
||||
if strings.TrimSpace(toolName) != "" {
|
||||
streamToolNames[toolCallID] = toolName
|
||||
}
|
||||
|
||||
index, exists := stepToolCallIndexByID[toolCallID]
|
||||
if !exists {
|
||||
stepToolCallIndexByID[toolCallID] = len(stepAssistantDraft)
|
||||
stepAssistantDraft = append(stepAssistantDraft, fantasy.ToolCallContent{
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: toolName,
|
||||
Input: input,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if index < 0 || index >= len(stepAssistantDraft) {
|
||||
stepToolCallIndexByID[toolCallID] = len(stepAssistantDraft)
|
||||
stepAssistantDraft = append(stepAssistantDraft, fantasy.ToolCallContent{
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: toolName,
|
||||
Input: input,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
existingCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](stepAssistantDraft[index])
|
||||
if !ok {
|
||||
if ptrCall, ptrOK := fantasy.AsContentType[*fantasy.ToolCallContent](stepAssistantDraft[index]); ptrOK && ptrCall != nil {
|
||||
existingCall = *ptrCall
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
stepToolCallIndexByID[toolCallID] = len(stepAssistantDraft)
|
||||
stepAssistantDraft = append(stepAssistantDraft, fantasy.ToolCallContent{
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: toolName,
|
||||
Input: input,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(toolName) != "" {
|
||||
existingCall.ToolName = toolName
|
||||
}
|
||||
if appendInput {
|
||||
existingCall.Input += input
|
||||
} else if input != "" || existingCall.Input == "" {
|
||||
existingCall.Input = input
|
||||
}
|
||||
stepAssistantDraft[index] = existingCall
|
||||
}
|
||||
|
||||
appendDraftSource := func(source fantasy.SourceContent) {
|
||||
stepStateMu.Lock()
|
||||
stepAssistantDraft = append(stepAssistantDraft, source)
|
||||
stepStateMu.Unlock()
|
||||
}
|
||||
|
||||
persistInterruptedStep := func() error {
|
||||
stepStateMu.Lock()
|
||||
draft := append([]fantasy.Content(nil), stepAssistantDraft...)
|
||||
toolResults := append([]fantasy.ToolResultContent(nil), stepToolResultContents...)
|
||||
toolNameByCallID := make(map[string]string, len(streamToolNames))
|
||||
for id, name := range streamToolNames {
|
||||
toolNameByCallID[id] = name
|
||||
}
|
||||
stepStateMu.Unlock()
|
||||
|
||||
if len(draft) == 0 && len(toolResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Track which tool calls already have results.
|
||||
answeredToolCalls := make(map[string]struct{}, len(toolResults))
|
||||
for _, tr := range toolResults {
|
||||
if tr.ToolCallID != "" {
|
||||
answeredToolCalls[tr.ToolCallID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the combined content: draft + received tool results
|
||||
// + synthetic interrupted results for unanswered tool calls.
|
||||
content := make([]fantasy.Content, 0, len(draft)+len(toolResults))
|
||||
content = append(content, draft...)
|
||||
for _, tr := range toolResults {
|
||||
content = append(content, tr)
|
||||
}
|
||||
|
||||
for _, block := range draft {
|
||||
toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](block)
|
||||
if !ok {
|
||||
if ptrCall, ptrOK := fantasy.AsContentType[*fantasy.ToolCallContent](block); ptrOK && ptrCall != nil {
|
||||
toolCall = *ptrCall
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok || toolCall.ToolCallID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := answeredToolCalls[toolCall.ToolCallID]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
toolName := strings.TrimSpace(toolCall.ToolName)
|
||||
if toolName == "" {
|
||||
toolName = strings.TrimSpace(toolNameByCallID[toolCall.ToolCallID])
|
||||
}
|
||||
|
||||
content = append(content, fantasy.ToolResultContent{
|
||||
ToolCallID: toolCall.ToolCallID,
|
||||
ToolName: toolName,
|
||||
Result: fantasy.ToolResultOutputContentError{
|
||||
Error: xerrors.New(interruptedToolResultErrorMessage),
|
||||
},
|
||||
})
|
||||
answeredToolCalls[toolCall.ToolCallID] = struct{}{}
|
||||
}
|
||||
|
||||
persistCtx := context.WithoutCancel(ctx)
|
||||
return opts.PersistStep(persistCtx, PersistedStep{
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
resetStepState()
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
opts.Model,
|
||||
fantasy.WithTools(opts.Tools...),
|
||||
fantasy.WithStopConditions(fantasy.StepCountIs(opts.MaxSteps)),
|
||||
)
|
||||
applyAnthropicCaching := shouldApplyAnthropicPromptCaching(opts.Model)
|
||||
// Fantasy's AgentStreamCall currently requires a non-empty Prompt and always
|
||||
// appends it as a user message. chatd already supplies the full history in
|
||||
// Messages, so we pass and then strip a sentinel user message in PrepareStep.
|
||||
sentinelPrompt := "__chatd_agent_prompt_sentinel_" + uuid.NewString()
|
||||
|
||||
streamCall := opts.StreamCall
|
||||
streamCall.Prompt = sentinelPrompt
|
||||
streamCall.Messages = opts.Messages
|
||||
streamCall.PrepareStep = func(
|
||||
stepCtx context.Context,
|
||||
options fantasy.PrepareStepFunctionOptions,
|
||||
) (context.Context, fantasy.PrepareStepResult, error) {
|
||||
return stepCtx, prepareStepResult(
|
||||
options.Messages,
|
||||
sentinelPrompt,
|
||||
opts.ActiveTools,
|
||||
applyAnthropicCaching,
|
||||
), nil
|
||||
}
|
||||
streamCall.OnStepStart = func(_ int) error {
|
||||
resetStepState()
|
||||
return nil
|
||||
}
|
||||
streamCall.OnTextDelta = func(_ string, text string) error {
|
||||
appendDraftText(text)
|
||||
publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeText,
|
||||
Text: text,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
streamCall.OnReasoningDelta = func(id string, text string) error {
|
||||
appendDraftReasoning(text)
|
||||
setReasoningTitleFromText(id, text)
|
||||
stepStateMu.Lock()
|
||||
title := streamReasoningTitles[id]
|
||||
stepStateMu.Unlock()
|
||||
publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeReasoning,
|
||||
Text: text,
|
||||
Title: title,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
streamCall.OnReasoningEnd = func(id string, _ fantasy.ReasoningContent) error {
|
||||
stepStateMu.Lock()
|
||||
if streamReasoningTitles[id] == "" {
|
||||
// At the end of reasoning we have the full text, so we can
|
||||
// safely evaluate first-line title format even if no newline
|
||||
// ever arrived in deltas.
|
||||
streamReasoningTitles[id] = chatprompt.ReasoningTitleFromFirstLine(
|
||||
streamReasoningText[id],
|
||||
)
|
||||
}
|
||||
title := streamReasoningTitles[id]
|
||||
stepStateMu.Unlock()
|
||||
if title != "" {
|
||||
// Publish a title-only reasoning part so clients can update the
|
||||
// reasoning header when metadata arrives at the end of streaming.
|
||||
publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeReasoning,
|
||||
Title: title,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
streamCall.OnToolInputStart = func(id, toolName string) error {
|
||||
upsertDraftToolCall(id, toolName, "", false)
|
||||
return nil
|
||||
}
|
||||
streamCall.OnToolInputDelta = func(id, delta string) error {
|
||||
stepStateMu.Lock()
|
||||
toolName := streamToolNames[id]
|
||||
stepStateMu.Unlock()
|
||||
upsertDraftToolCall(id, toolName, delta, true)
|
||||
publishMessagePart(fantasy.MessageRoleAssistant, codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeToolCall,
|
||||
ToolCallID: id,
|
||||
ToolName: toolName,
|
||||
ArgsDelta: delta,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
streamCall.OnToolCall = func(toolCall fantasy.ToolCallContent) error {
|
||||
upsertDraftToolCall(toolCall.ToolCallID, toolCall.ToolName, toolCall.Input, false)
|
||||
publishMessagePart(
|
||||
fantasy.MessageRoleAssistant,
|
||||
chatprompt.PartFromContent(toolCall),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
streamCall.OnSource = func(source fantasy.SourceContent) error {
|
||||
appendDraftSource(source)
|
||||
publishMessagePart(
|
||||
fantasy.MessageRoleAssistant,
|
||||
chatprompt.PartFromContent(source),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
streamCall.OnToolResult = func(result fantasy.ToolResultContent) error {
|
||||
publishMessagePart(
|
||||
fantasy.MessageRoleTool,
|
||||
chatprompt.PartFromContent(result),
|
||||
)
|
||||
|
||||
stepStateMu.Lock()
|
||||
if result.ToolCallID != "" && strings.TrimSpace(result.ToolName) != "" {
|
||||
streamToolNames[result.ToolCallID] = result.ToolName
|
||||
}
|
||||
stepToolResultContents = append(stepToolResultContents, result)
|
||||
stepStateMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
streamCall.OnStepFinish = func(stepResult fantasy.StepResult) error {
|
||||
contextLimit := extractContextLimit(stepResult.ProviderMetadata)
|
||||
if !contextLimit.Valid && opts.ContextLimitFallback > 0 {
|
||||
contextLimit = sql.NullInt64{
|
||||
Int64: opts.ContextLimitFallback,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
return opts.PersistStep(ctx, PersistedStep{
|
||||
Content: stepResult.Content,
|
||||
Usage: stepResult.Usage,
|
||||
ContextLimit: contextLimit,
|
||||
})
|
||||
}
|
||||
|
||||
result, err := agent.Stream(ctx, streamCall)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) &&
|
||||
errors.Is(context.Cause(ctx), ErrInterrupted) {
|
||||
if persistErr := persistInterruptedStep(); persistErr != nil {
|
||||
if opts.OnInterruptedPersistError != nil {
|
||||
opts.OnInterruptedPersistError(persistErr)
|
||||
}
|
||||
}
|
||||
return nil, ErrInterrupted
|
||||
}
|
||||
return nil, xerrors.Errorf("stream response: %w", err)
|
||||
}
|
||||
if opts.Compaction != nil {
|
||||
if err := maybeCompact(ctx, opts, result); err != nil {
|
||||
if opts.Compaction.OnError != nil {
|
||||
opts.Compaction.OnError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
//nolint:revive // Boolean controls Anthropic-specific caching behavior.
|
||||
func prepareStepResult(
|
||||
messages []fantasy.Message,
|
||||
sentinel string,
|
||||
activeTools []string,
|
||||
anthropicCaching bool,
|
||||
) fantasy.PrepareStepResult {
|
||||
filtered := make([]fantasy.Message, 0, len(messages))
|
||||
removed := false
|
||||
for _, message := range messages {
|
||||
if !removed &&
|
||||
message.Role == fantasy.MessageRoleUser &&
|
||||
len(message.Content) == 1 {
|
||||
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](message.Content[0])
|
||||
if ok && textPart.Text == sentinel {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, message)
|
||||
}
|
||||
|
||||
result := fantasy.PrepareStepResult{
|
||||
Messages: filtered,
|
||||
}
|
||||
if anthropicCaching {
|
||||
result.Messages = addAnthropicPromptCaching(result.Messages)
|
||||
}
|
||||
if len(activeTools) > 0 {
|
||||
result.ActiveTools = append([]string(nil), activeTools...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func shouldApplyAnthropicPromptCaching(model fantasy.LanguageModel) bool {
|
||||
if model == nil {
|
||||
return false
|
||||
}
|
||||
return model.Provider() == fantasyanthropic.Name
|
||||
}
|
||||
|
||||
func addAnthropicPromptCaching(messages []fantasy.Message) []fantasy.Message {
|
||||
for i := range messages {
|
||||
messages[i].ProviderOptions = nil
|
||||
}
|
||||
|
||||
providerOption := fantasy.ProviderOptions{
|
||||
fantasyanthropic.Name: &fantasyanthropic.ProviderCacheControlOptions{
|
||||
CacheControl: fantasyanthropic.CacheControl{Type: "ephemeral"},
|
||||
},
|
||||
}
|
||||
|
||||
lastSystemRoleIdx := -1
|
||||
systemMessageUpdated := false
|
||||
for i, msg := range messages {
|
||||
if msg.Role == fantasy.MessageRoleSystem {
|
||||
lastSystemRoleIdx = i
|
||||
} else if !systemMessageUpdated && lastSystemRoleIdx >= 0 {
|
||||
messages[lastSystemRoleIdx].ProviderOptions = providerOption
|
||||
systemMessageUpdated = true
|
||||
}
|
||||
if i > len(messages)-3 {
|
||||
messages[i].ProviderOptions = providerOption
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
func extractContextLimit(metadata fantasy.ProviderMetadata) sql.NullInt64 {
|
||||
if len(metadata) == 0 {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(metadata)
|
||||
if err != nil || len(encoded) == 0 {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
|
||||
var payload any
|
||||
if err := json.Unmarshal(encoded, &payload); err != nil {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
|
||||
limit, ok := findContextLimitValue(payload)
|
||||
if !ok {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
|
||||
return sql.NullInt64{
|
||||
Int64: limit,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func findContextLimitValue(value any) (int64, bool) {
|
||||
var (
|
||||
limit int64
|
||||
found bool
|
||||
)
|
||||
|
||||
collectContextLimitValues(value, func(candidate int64) {
|
||||
if !found || candidate > limit {
|
||||
limit = candidate
|
||||
found = true
|
||||
}
|
||||
})
|
||||
|
||||
return limit, found
|
||||
}
|
||||
|
||||
func collectContextLimitValues(value any, onValue func(int64)) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
if isContextLimitKey(key) {
|
||||
if numeric, ok := numericContextLimitValue(child); ok {
|
||||
onValue(numeric)
|
||||
}
|
||||
}
|
||||
collectContextLimitValues(child, onValue)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
collectContextLimitValues(child, onValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isContextLimitKey(key string) bool {
|
||||
normalized := normalizeMetadataKey(key)
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
switch normalized {
|
||||
case
|
||||
"contextlimit",
|
||||
"contextwindow",
|
||||
"contextlength",
|
||||
"maxcontext",
|
||||
"maxcontexttokens",
|
||||
"maxinputtokens",
|
||||
"maxinputtoken",
|
||||
"inputtokenlimit":
|
||||
return true
|
||||
}
|
||||
|
||||
return strings.Contains(normalized, "context") &&
|
||||
(strings.Contains(normalized, "limit") ||
|
||||
strings.Contains(normalized, "window") ||
|
||||
strings.Contains(normalized, "length") ||
|
||||
strings.HasPrefix(normalized, "max"))
|
||||
}
|
||||
|
||||
func normalizeMetadataKey(key string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(key))
|
||||
|
||||
for _, r := range key {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
_, _ = b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
_, _ = b.WriteRune(r + ('a' - 'A'))
|
||||
case r >= '0' && r <= '9':
|
||||
_, _ = b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func numericContextLimitValue(value any) (int64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int64:
|
||||
return positiveInt64(typed)
|
||||
case int32:
|
||||
return positiveInt64(int64(typed))
|
||||
case int:
|
||||
return positiveInt64(int64(typed))
|
||||
case float64:
|
||||
casted := int64(typed)
|
||||
if typed > 0 && float64(casted) == typed {
|
||||
return casted, true
|
||||
}
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
if err == nil {
|
||||
return positiveInt64(parsed)
|
||||
}
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
if err == nil {
|
||||
return positiveInt64(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func positiveInt64(value int64) (int64, bool) {
|
||||
if value <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package chatloop //nolint:testpackage // Uses internal symbols.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"iter"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const activeToolName = "read_file"
|
||||
|
||||
func TestRun_ActiveToolsPrepareBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var capturedCall fantasy.Call
|
||||
model := &loopTestModel{
|
||||
provider: fantasyanthropic.Name,
|
||||
streamFn: func(_ context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
capturedCall = call
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop},
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
persistStepCalls := 0
|
||||
var persistedStep PersistedStep
|
||||
|
||||
_, err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleSystem, "sys-1"),
|
||||
textMessage(fantasy.MessageRoleSystem, "sys-2"),
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
textMessage(fantasy.MessageRoleAssistant, "working"),
|
||||
textMessage(fantasy.MessageRoleUser, "continue"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{
|
||||
newNoopTool(activeToolName),
|
||||
newNoopTool("write_file"),
|
||||
},
|
||||
MaxSteps: 3,
|
||||
ActiveTools: []string{activeToolName},
|
||||
ContextLimitFallback: 4096,
|
||||
PersistStep: func(_ context.Context, step PersistedStep) error {
|
||||
persistStepCalls++
|
||||
persistedStep = step
|
||||
return nil
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, 1, persistStepCalls)
|
||||
require.True(t, persistedStep.ContextLimit.Valid)
|
||||
require.Equal(t, int64(4096), persistedStep.ContextLimit.Int64)
|
||||
|
||||
require.NotEmpty(t, capturedCall.Prompt)
|
||||
require.False(t, containsPromptSentinel(capturedCall.Prompt))
|
||||
require.Len(t, capturedCall.Tools, 1)
|
||||
require.Equal(t, activeToolName, capturedCall.Tools[0].GetName())
|
||||
|
||||
require.Len(t, capturedCall.Prompt, 5)
|
||||
require.False(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[0]))
|
||||
require.True(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[1]))
|
||||
require.False(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[2]))
|
||||
require.True(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[3]))
|
||||
require.True(t, hasAnthropicEphemeralCacheControl(capturedCall.Prompt[4]))
|
||||
}
|
||||
|
||||
func TestRun_InterruptedStepPersistsSyntheticToolResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
started := make(chan struct{})
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(ctx context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return iter.Seq[fantasy.StreamPart](func(yield func(fantasy.StreamPart) bool) {
|
||||
parts := []fantasy.StreamPart{
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolInputStart,
|
||||
ID: "interrupt-tool-1",
|
||||
ToolCallName: "read_file",
|
||||
},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeToolInputDelta,
|
||||
ID: "interrupt-tool-1",
|
||||
ToolCallName: "read_file",
|
||||
Delta: `{"path":"main.go"`,
|
||||
},
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "partial assistant output"},
|
||||
}
|
||||
for _, part := range parts {
|
||||
if !yield(part) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
default:
|
||||
close(started)
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
_ = yield(fantasy.StreamPart{
|
||||
Type: fantasy.StreamPartTypeError,
|
||||
Error: ctx.Err(),
|
||||
})
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
defer cancel(nil)
|
||||
|
||||
go func() {
|
||||
<-started
|
||||
cancel(ErrInterrupted)
|
||||
}()
|
||||
|
||||
persistedAssistantCtxErr := xerrors.New("unset")
|
||||
var persistedContent []fantasy.Content
|
||||
|
||||
_, err := Run(ctx, RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
Tools: []fantasy.AgentTool{
|
||||
newNoopTool("read_file"),
|
||||
},
|
||||
MaxSteps: 3,
|
||||
PersistStep: func(persistCtx context.Context, step PersistedStep) error {
|
||||
persistedAssistantCtxErr = persistCtx.Err()
|
||||
persistedContent = append([]fantasy.Content(nil), step.Content...)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
require.ErrorIs(t, err, ErrInterrupted)
|
||||
require.NoError(t, persistedAssistantCtxErr)
|
||||
|
||||
require.NotEmpty(t, persistedContent)
|
||||
var (
|
||||
foundText bool
|
||||
foundToolCall bool
|
||||
foundToolResult bool
|
||||
)
|
||||
for _, block := range persistedContent {
|
||||
if text, ok := fantasy.AsContentType[fantasy.TextContent](block); ok {
|
||||
if strings.Contains(text.Text, "partial assistant output") {
|
||||
foundText = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](block); ok {
|
||||
if toolCall.ToolCallID == "interrupt-tool-1" &&
|
||||
toolCall.ToolName == "read_file" &&
|
||||
strings.Contains(toolCall.Input, `"path":"main.go"`) {
|
||||
foundToolCall = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if toolResult, ok := fantasy.AsContentType[fantasy.ToolResultContent](block); ok {
|
||||
if toolResult.ToolCallID == "interrupt-tool-1" &&
|
||||
toolResult.ToolName == "read_file" {
|
||||
_, isErr := toolResult.Result.(fantasy.ToolResultOutputContentError)
|
||||
require.True(t, isErr, "interrupted tool result should be an error")
|
||||
foundToolResult = true
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, foundText)
|
||||
require.True(t, foundToolCall)
|
||||
require.True(t, foundToolResult)
|
||||
}
|
||||
|
||||
type loopTestModel struct {
|
||||
provider string
|
||||
model string
|
||||
generateFn func(context.Context, fantasy.Call) (*fantasy.Response, error)
|
||||
streamFn func(context.Context, fantasy.Call) (fantasy.StreamResponse, error)
|
||||
}
|
||||
|
||||
func (m *loopTestModel) Provider() string {
|
||||
if m.provider != "" {
|
||||
return m.provider
|
||||
}
|
||||
return "fake"
|
||||
}
|
||||
|
||||
func (m *loopTestModel) Model() string {
|
||||
if m.model != "" {
|
||||
return m.model
|
||||
}
|
||||
return "fake"
|
||||
}
|
||||
|
||||
func (m *loopTestModel) Generate(ctx context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
||||
if m.generateFn != nil {
|
||||
return m.generateFn(ctx, call)
|
||||
}
|
||||
return &fantasy.Response{}, nil
|
||||
}
|
||||
|
||||
func (m *loopTestModel) Stream(ctx context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
if m.streamFn != nil {
|
||||
return m.streamFn(ctx, call)
|
||||
}
|
||||
return streamFromParts([]fantasy.StreamPart{{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
}}), nil
|
||||
}
|
||||
|
||||
func (*loopTestModel) GenerateObject(context.Context, fantasy.ObjectCall) (*fantasy.ObjectResponse, error) {
|
||||
return nil, xerrors.New("not implemented")
|
||||
}
|
||||
|
||||
func (*loopTestModel) StreamObject(context.Context, fantasy.ObjectCall) (fantasy.ObjectStreamResponse, error) {
|
||||
return nil, xerrors.New("not implemented")
|
||||
}
|
||||
|
||||
func streamFromParts(parts []fantasy.StreamPart) fantasy.StreamResponse {
|
||||
return iter.Seq[fantasy.StreamPart](func(yield func(fantasy.StreamPart) bool) {
|
||||
for _, part := range parts {
|
||||
if !yield(part) {
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newNoopTool(name string) fantasy.AgentTool {
|
||||
return fantasy.NewAgentTool(
|
||||
name,
|
||||
"test noop tool",
|
||||
func(context.Context, struct{}, fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
return fantasy.ToolResponse{}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func textMessage(role fantasy.MessageRole, text string) fantasy.Message {
|
||||
return fantasy.Message{
|
||||
Role: role,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: text},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func containsPromptSentinel(prompt []fantasy.Message) bool {
|
||||
for _, message := range prompt {
|
||||
if message.Role != fantasy.MessageRoleUser || len(message.Content) != 1 {
|
||||
continue
|
||||
}
|
||||
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](message.Content[0])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(textPart.Text, "__chatd_agent_prompt_sentinel_") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasAnthropicEphemeralCacheControl(message fantasy.Message) bool {
|
||||
if len(message.ProviderOptions) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
options, ok := message.ProviderOptions[fantasyanthropic.Name]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
cacheOptions, ok := options.(*fantasyanthropic.ProviderCacheControlOptions)
|
||||
return ok && cacheOptions.CacheControl.Type == "ephemeral"
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package chatloop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCompactionThresholdPercent = int32(70)
|
||||
minCompactionThresholdPercent = int32(0)
|
||||
maxCompactionThresholdPercent = int32(100)
|
||||
|
||||
defaultCompactionSummaryPrompt = "Summarize the current chat so a " +
|
||||
"new assistant can continue seamlessly. Include the user's goals, " +
|
||||
"decisions made, concrete technical details (files, commands, APIs), " +
|
||||
"errors encountered and fixes, and open questions. Be dense and factual. " +
|
||||
"Omit pleasantries and next-step suggestions."
|
||||
defaultCompactionSystemSummaryPrefix = "Summary of earlier chat context:"
|
||||
defaultCompactionTimeout = 90 * time.Second
|
||||
)
|
||||
|
||||
type CompactionOptions struct {
|
||||
ThresholdPercent int32
|
||||
ContextLimit int64
|
||||
SummaryPrompt string
|
||||
SystemSummaryPrefix string
|
||||
Timeout time.Duration
|
||||
Persist func(context.Context, CompactionResult) error
|
||||
OnError func(error)
|
||||
}
|
||||
|
||||
type CompactionResult struct {
|
||||
SystemSummary string
|
||||
SummaryReport string
|
||||
ThresholdPercent int32
|
||||
UsagePercent float64
|
||||
ContextTokens int64
|
||||
ContextLimit int64
|
||||
}
|
||||
|
||||
func maybeCompact(
|
||||
ctx context.Context,
|
||||
runOpts RunOptions,
|
||||
runResult *fantasy.AgentResult,
|
||||
) error {
|
||||
if runResult == nil || runOpts.Compaction == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
config := *runOpts.Compaction
|
||||
if config.Persist == nil {
|
||||
return xerrors.New("compaction persist callback is required")
|
||||
}
|
||||
if strings.TrimSpace(config.SummaryPrompt) == "" {
|
||||
config.SummaryPrompt = defaultCompactionSummaryPrompt
|
||||
}
|
||||
if strings.TrimSpace(config.SystemSummaryPrefix) == "" {
|
||||
config.SystemSummaryPrefix = defaultCompactionSystemSummaryPrefix
|
||||
}
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = defaultCompactionTimeout
|
||||
}
|
||||
if config.ThresholdPercent < minCompactionThresholdPercent ||
|
||||
config.ThresholdPercent > maxCompactionThresholdPercent {
|
||||
config.ThresholdPercent = defaultCompactionThresholdPercent
|
||||
}
|
||||
|
||||
if config.ThresholdPercent >= maxCompactionThresholdPercent {
|
||||
return nil
|
||||
}
|
||||
if runOpts.MaxSteps > 0 && len(runResult.Steps) >= runOpts.MaxSteps {
|
||||
lastStep := runResult.Steps[len(runResult.Steps)-1]
|
||||
if lastStep.FinishReason == fantasy.FinishReasonToolCalls &&
|
||||
len(lastStep.Content.ToolCalls()) > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
contextTokens := int64(0)
|
||||
contextLimitFromMetadata := int64(0)
|
||||
for i := len(runResult.Steps) - 1; i >= 0; i-- {
|
||||
usage := runResult.Steps[i].Usage
|
||||
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
|
||||
hasContextTokens = true
|
||||
}
|
||||
if !hasContextTokens || total <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
contextTokens = total
|
||||
metadataLimit := extractContextLimit(runResult.Steps[i].ProviderMetadata)
|
||||
if metadataLimit.Valid && metadataLimit.Int64 > 0 {
|
||||
contextLimitFromMetadata = metadataLimit.Int64
|
||||
}
|
||||
break
|
||||
}
|
||||
if contextTokens <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
contextLimit := contextLimitFromMetadata
|
||||
if contextLimit <= 0 && config.ContextLimit > 0 {
|
||||
contextLimit = config.ContextLimit
|
||||
}
|
||||
if contextLimit <= 0 && runOpts.ContextLimitFallback > 0 {
|
||||
contextLimit = runOpts.ContextLimitFallback
|
||||
}
|
||||
if contextLimit <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
usagePercent := (float64(contextTokens) / float64(contextLimit)) * 100
|
||||
if usagePercent < float64(config.ThresholdPercent) {
|
||||
return nil
|
||||
}
|
||||
|
||||
summary, err := generateCompactionSummary(
|
||||
ctx,
|
||||
runOpts.Model,
|
||||
runOpts.Messages,
|
||||
runResult.Steps,
|
||||
config,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if summary == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
systemSummary := strings.TrimSpace(
|
||||
config.SystemSummaryPrefix + "\n\n" + summary,
|
||||
)
|
||||
|
||||
return config.Persist(ctx, CompactionResult{
|
||||
SystemSummary: systemSummary,
|
||||
SummaryReport: summary,
|
||||
ThresholdPercent: config.ThresholdPercent,
|
||||
UsagePercent: usagePercent,
|
||||
ContextTokens: contextTokens,
|
||||
ContextLimit: contextLimit,
|
||||
})
|
||||
}
|
||||
|
||||
func generateCompactionSummary(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
messages []fantasy.Message,
|
||||
steps []fantasy.StepResult,
|
||||
options CompactionOptions,
|
||||
) (string, error) {
|
||||
summaryPrompt := make([]fantasy.Message, 0, len(messages)+len(steps)+1)
|
||||
summaryPrompt = append(summaryPrompt, messages...)
|
||||
for _, step := range steps {
|
||||
summaryPrompt = append(summaryPrompt, step.Messages...)
|
||||
}
|
||||
summaryPrompt = append(summaryPrompt, fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: options.SummaryPrompt},
|
||||
},
|
||||
})
|
||||
toolChoice := fantasy.ToolChoiceNone
|
||||
|
||||
summaryCtx, cancel := context.WithTimeout(ctx, options.Timeout)
|
||||
defer cancel()
|
||||
|
||||
response, err := model.Generate(summaryCtx, fantasy.Call{
|
||||
Prompt: summaryPrompt,
|
||||
ToolChoice: &toolChoice,
|
||||
})
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package chatloop //nolint:testpackage // Uses internal symbols.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func TestRun_Compaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("PersistsWhenThresholdReached", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
persistCompactionCalls := 0
|
||||
var persistedCompaction CompactionResult
|
||||
const summaryText = "summary text for compaction"
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{Type: fantasy.StreamPartTypeTextStart, ID: "text-1"},
|
||||
{Type: fantasy.StreamPartTypeTextDelta, ID: "text-1", Delta: "done"},
|
||||
{Type: fantasy.StreamPartTypeTextEnd, ID: "text-1"},
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 80,
|
||||
TotalTokens: 85,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
},
|
||||
generateFn: func(_ context.Context, call fantasy.Call) (*fantasy.Response, error) {
|
||||
require.NotEmpty(t, call.Prompt)
|
||||
lastPrompt := call.Prompt[len(call.Prompt)-1]
|
||||
require.Equal(t, fantasy.MessageRoleUser, lastPrompt.Role)
|
||||
require.Len(t, lastPrompt.Content, 1)
|
||||
|
||||
instruction, ok := fantasy.AsMessagePart[fantasy.TextPart](lastPrompt.Content[0])
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "summarize now", instruction.Text)
|
||||
|
||||
return &fantasy.Response{
|
||||
Content: []fantasy.Content{
|
||||
fantasy.TextContent{Text: summaryText},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
MaxSteps: 1,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
SummaryPrompt: "summarize now",
|
||||
Persist: func(_ context.Context, result CompactionResult) error {
|
||||
persistCompactionCalls++
|
||||
persistedCompaction = result
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, persistCompactionCalls)
|
||||
require.Contains(t, persistedCompaction.SystemSummary, summaryText)
|
||||
require.Equal(t, summaryText, persistedCompaction.SummaryReport)
|
||||
require.Equal(t, int64(80), persistedCompaction.ContextTokens)
|
||||
require.Equal(t, int64(100), persistedCompaction.ContextLimit)
|
||||
require.InDelta(t, 80.0, persistedCompaction.UsagePercent, 0.0001)
|
||||
})
|
||||
|
||||
t.Run("ErrorsAreReported", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
model := &loopTestModel{
|
||||
provider: "fake",
|
||||
streamFn: func(_ context.Context, _ fantasy.Call) (fantasy.StreamResponse, error) {
|
||||
return streamFromParts([]fantasy.StreamPart{
|
||||
{
|
||||
Type: fantasy.StreamPartTypeFinish,
|
||||
FinishReason: fantasy.FinishReasonStop,
|
||||
Usage: fantasy.Usage{
|
||||
InputTokens: 80,
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
},
|
||||
generateFn: func(_ context.Context, _ fantasy.Call) (*fantasy.Response, error) {
|
||||
return nil, xerrors.New("generate failed")
|
||||
},
|
||||
}
|
||||
|
||||
compactionErr := xerrors.New("unset")
|
||||
_, err := Run(context.Background(), RunOptions{
|
||||
Model: model,
|
||||
Messages: []fantasy.Message{
|
||||
textMessage(fantasy.MessageRoleUser, "hello"),
|
||||
},
|
||||
MaxSteps: 1,
|
||||
PersistStep: func(_ context.Context, _ PersistedStep) error {
|
||||
return nil
|
||||
},
|
||||
ContextLimitFallback: 100,
|
||||
Compaction: &CompactionOptions{
|
||||
ThresholdPercent: 70,
|
||||
Persist: func(_ context.Context, _ CompactionResult) error {
|
||||
return nil
|
||||
},
|
||||
OnError: func(err error) {
|
||||
compactionErr = err
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Error(t, compactionErr)
|
||||
require.ErrorContains(t, compactionErr, "generate summary text")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,982 @@
|
||||
package chatprompt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
var toolCallIDSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
||||
|
||||
func ConvertMessages(
|
||||
messages []database.ChatMessage,
|
||||
) ([]fantasy.Message, error) {
|
||||
prompt := make([]fantasy.Message, 0, len(messages))
|
||||
toolNameByCallID := make(map[string]string)
|
||||
for _, message := range messages {
|
||||
visibility := message.Visibility
|
||||
if visibility == "" {
|
||||
visibility = database.ChatMessageVisibilityBoth
|
||||
}
|
||||
if visibility != database.ChatMessageVisibilityModel &&
|
||||
visibility != database.ChatMessageVisibilityBoth {
|
||||
continue
|
||||
}
|
||||
|
||||
switch message.Role {
|
||||
case string(fantasy.MessageRoleSystem):
|
||||
content, err := parseSystemContent(message.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
continue
|
||||
}
|
||||
prompt = append(prompt, fantasy.Message{
|
||||
Role: fantasy.MessageRoleSystem,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: content},
|
||||
},
|
||||
})
|
||||
case string(fantasy.MessageRoleUser):
|
||||
content, err := ParseContent(string(fantasy.MessageRoleUser), message.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prompt = append(prompt, fantasy.Message{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: ToMessageParts(content),
|
||||
})
|
||||
case string(fantasy.MessageRoleAssistant):
|
||||
content, err := ParseContent(string(fantasy.MessageRoleAssistant), message.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := normalizeAssistantToolCallInputs(ToMessageParts(content))
|
||||
for _, toolCall := range ExtractToolCalls(parts) {
|
||||
if toolCall.ToolCallID == "" || strings.TrimSpace(toolCall.ToolName) == "" {
|
||||
continue
|
||||
}
|
||||
toolNameByCallID[sanitizeToolCallID(toolCall.ToolCallID)] = toolCall.ToolName
|
||||
}
|
||||
prompt = append(prompt, fantasy.Message{
|
||||
Role: fantasy.MessageRoleAssistant,
|
||||
Content: parts,
|
||||
})
|
||||
case string(fantasy.MessageRoleTool):
|
||||
rows, err := parseToolResultRows(message.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := make([]fantasy.MessagePart, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.ToolCallID != "" && row.ToolName != "" {
|
||||
toolNameByCallID[sanitizeToolCallID(row.ToolCallID)] = row.ToolName
|
||||
}
|
||||
parts = append(parts, row.toToolResultPart())
|
||||
}
|
||||
prompt = append(prompt, fantasy.Message{
|
||||
Role: fantasy.MessageRoleTool,
|
||||
Content: parts,
|
||||
})
|
||||
default:
|
||||
return nil, xerrors.Errorf("unsupported chat message role %q", message.Role)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ParseContent decodes persisted chat message content blocks.
|
||||
func ParseContent(role string, raw pqtype.NullRawMessage) ([]fantasy.Content, error) {
|
||||
if !raw.Valid || len(raw.RawMessage) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var text string
|
||||
if err := json.Unmarshal(raw.RawMessage, &text); err == nil {
|
||||
return []fantasy.Content{fantasy.TextContent{Text: text}}, nil
|
||||
}
|
||||
|
||||
var rawBlocks []json.RawMessage
|
||||
if err := json.Unmarshal(raw.RawMessage, &rawBlocks); err != nil {
|
||||
return nil, xerrors.Errorf("parse %s content: %w", role, err)
|
||||
}
|
||||
|
||||
content := make([]fantasy.Content, 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)
|
||||
}
|
||||
content = append(content, block)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (r toolResultRaw) toToolResultPart() fantasy.ToolResultPart {
|
||||
toolCallID := sanitizeToolCallID(r.ToolCallID)
|
||||
resultText := string(r.Result)
|
||||
if resultText == "" || resultText == "null" {
|
||||
resultText = "{}"
|
||||
}
|
||||
|
||||
if r.IsError {
|
||||
message := strings.TrimSpace(resultText)
|
||||
if extracted := extractErrorString(r.Result); extracted != "" {
|
||||
message = extracted
|
||||
}
|
||||
return fantasy.ToolResultPart{
|
||||
ToolCallID: toolCallID,
|
||||
Output: fantasy.ToolResultOutputContentError{
|
||||
Error: xerrors.New(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return fantasy.ToolResultPart{
|
||||
ToolCallID: toolCallID,
|
||||
Output: fantasy.ToolResultOutputContentText{
|
||||
Text: resultText,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ToMessageParts converts fantasy content blocks into message parts.
|
||||
func ToMessageParts(content []fantasy.Content) []fantasy.MessagePart {
|
||||
parts := make([]fantasy.MessagePart, 0, len(content))
|
||||
for _, block := range content {
|
||||
switch value := block.(type) {
|
||||
case fantasy.TextContent:
|
||||
parts = append(parts, fantasy.TextPart{
|
||||
Text: value.Text,
|
||||
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
|
||||
})
|
||||
case *fantasy.TextContent:
|
||||
parts = append(parts, fantasy.TextPart{
|
||||
Text: value.Text,
|
||||
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
|
||||
})
|
||||
case fantasy.ReasoningContent:
|
||||
parts = append(parts, fantasy.ReasoningPart{
|
||||
Text: value.Text,
|
||||
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
|
||||
})
|
||||
case *fantasy.ReasoningContent:
|
||||
parts = append(parts, fantasy.ReasoningPart{
|
||||
Text: value.Text,
|
||||
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
|
||||
})
|
||||
case fantasy.ToolCallContent:
|
||||
parts = append(parts, fantasy.ToolCallPart{
|
||||
ToolCallID: sanitizeToolCallID(value.ToolCallID),
|
||||
ToolName: value.ToolName,
|
||||
Input: value.Input,
|
||||
ProviderExecuted: value.ProviderExecuted,
|
||||
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
|
||||
})
|
||||
case *fantasy.ToolCallContent:
|
||||
parts = append(parts, fantasy.ToolCallPart{
|
||||
ToolCallID: sanitizeToolCallID(value.ToolCallID),
|
||||
ToolName: value.ToolName,
|
||||
Input: value.Input,
|
||||
ProviderExecuted: value.ProviderExecuted,
|
||||
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
|
||||
})
|
||||
case fantasy.FileContent:
|
||||
parts = append(parts, fantasy.FilePart{
|
||||
Data: value.Data,
|
||||
MediaType: value.MediaType,
|
||||
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
|
||||
})
|
||||
case *fantasy.FileContent:
|
||||
parts = append(parts, fantasy.FilePart{
|
||||
Data: value.Data,
|
||||
MediaType: value.MediaType,
|
||||
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
|
||||
})
|
||||
case fantasy.ToolResultContent:
|
||||
parts = append(parts, fantasy.ToolResultPart{
|
||||
ToolCallID: sanitizeToolCallID(value.ToolCallID),
|
||||
Output: value.Result,
|
||||
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
|
||||
})
|
||||
case *fantasy.ToolResultContent:
|
||||
parts = append(parts, fantasy.ToolResultPart{
|
||||
ToolCallID: sanitizeToolCallID(value.ToolCallID),
|
||||
Output: value.Result,
|
||||
ProviderOptions: fantasy.ProviderOptions(value.ProviderMetadata),
|
||||
})
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
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 for persistence.
|
||||
func MarshalContent(blocks []fantasy.Content) (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 := marshalContentBlock(block)
|
||||
if err != nil {
|
||||
return pqtype.NullRawMessage{}, xerrors.Errorf(
|
||||
"encode content block %d: %w",
|
||||
i,
|
||||
err,
|
||||
)
|
||||
}
|
||||
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 for persistence as
|
||||
// an opaque JSON blob. The stored shape is
|
||||
// [{"tool_call_id":…,"tool_name":…,"result":…,"is_error":…}].
|
||||
func MarshalToolResult(toolCallID, toolName string, result json.RawMessage, isError bool) (pqtype.NullRawMessage, error) {
|
||||
row := toolResultRaw{
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: toolName,
|
||||
Result: result,
|
||||
IsError: isError,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// MarshalToolResultContent encodes a fantasy tool result content
|
||||
// block for persistence. It extracts the raw fields and delegates
|
||||
// to MarshalToolResult.
|
||||
func MarshalToolResultContent(content fantasy.ToolResultContent) (pqtype.NullRawMessage, error) {
|
||||
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)
|
||||
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(`{}`)
|
||||
}
|
||||
|
||||
return MarshalToolResult(content.ToolCallID, content.ToolName, result, isError)
|
||||
}
|
||||
|
||||
// PartFromContent converts fantasy content into a SDK chat message part.
|
||||
func PartFromContent(block fantasy.Content) codersdk.ChatMessagePart {
|
||||
switch value := block.(type) {
|
||||
case fantasy.TextContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeText,
|
||||
Text: value.Text,
|
||||
}
|
||||
case *fantasy.TextContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeText,
|
||||
Text: value.Text,
|
||||
}
|
||||
case fantasy.ReasoningContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeReasoning,
|
||||
Text: value.Text,
|
||||
Title: reasoningSummaryTitle(value.ProviderMetadata),
|
||||
}
|
||||
case *fantasy.ReasoningContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeReasoning,
|
||||
Text: value.Text,
|
||||
Title: reasoningSummaryTitle(value.ProviderMetadata),
|
||||
}
|
||||
case fantasy.ToolCallContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeToolCall,
|
||||
ToolCallID: value.ToolCallID,
|
||||
ToolName: value.ToolName,
|
||||
Args: []byte(value.Input),
|
||||
}
|
||||
case *fantasy.ToolCallContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeToolCall,
|
||||
ToolCallID: value.ToolCallID,
|
||||
ToolName: value.ToolName,
|
||||
Args: []byte(value.Input),
|
||||
}
|
||||
case fantasy.SourceContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeSource,
|
||||
SourceID: value.ID,
|
||||
URL: value.URL,
|
||||
Title: value.Title,
|
||||
}
|
||||
case *fantasy.SourceContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeSource,
|
||||
SourceID: value.ID,
|
||||
URL: value.URL,
|
||||
Title: value.Title,
|
||||
}
|
||||
case fantasy.FileContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeFile,
|
||||
MediaType: value.MediaType,
|
||||
Data: value.Data,
|
||||
}
|
||||
case *fantasy.FileContent:
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeFile,
|
||||
MediaType: value.MediaType,
|
||||
Data: value.Data,
|
||||
}
|
||||
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.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeToolResult,
|
||||
ToolCallID: toolCallID,
|
||||
ToolName: toolName,
|
||||
Result: result,
|
||||
IsError: 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(`{}`)
|
||||
}
|
||||
|
||||
return ToolResultToPart(content.ToolCallID, content.ToolName, result, isError)
|
||||
}
|
||||
|
||||
// ReasoningTitleFromFirstLine extracts a compact markdown title.
|
||||
func ReasoningTitleFromFirstLine(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
firstLine := text
|
||||
if idx := strings.IndexAny(firstLine, "\r\n"); idx >= 0 {
|
||||
firstLine = firstLine[:idx]
|
||||
}
|
||||
firstLine = strings.TrimSpace(firstLine)
|
||||
if firstLine == "" || !strings.HasPrefix(firstLine, "**") {
|
||||
return ""
|
||||
}
|
||||
|
||||
rest := firstLine[2:]
|
||||
end := strings.Index(rest, "**")
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(rest[:end])
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Require the first line to be exactly "**title**" (ignoring
|
||||
// surrounding whitespace) so providers without this format don't
|
||||
// accidentally emit a title.
|
||||
if strings.TrimSpace(rest[end+2:]) != "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return compactReasoningSummaryTitle(title)
|
||||
}
|
||||
|
||||
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.
|
||||
var missing []fantasy.MessagePart
|
||||
for _, tc := range toolCalls {
|
||||
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
|
||||
}
|
||||
|
||||
toolResults := make([]fantasy.ToolResultPart, 0, len(msg.Content))
|
||||
for _, part := range msg.Content {
|
||||
toolResult, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
toolResults = append(toolResults, toolResult)
|
||||
}
|
||||
if len(toolResults) == 0 {
|
||||
result = append(result, msg)
|
||||
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 {
|
||||
result = append(result, msg)
|
||||
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 parseSystemContent(raw pqtype.NullRawMessage) (string, error) {
|
||||
if !raw.Valid || len(raw.RawMessage) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var content string
|
||||
if err := json.Unmarshal(raw.RawMessage, &content); err != nil {
|
||||
return "", xerrors.Errorf("parse system message content: %w", err)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func sanitizeToolCallID(id string) string {
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
return toolCallIDSanitizer.ReplaceAllString(id, "_")
|
||||
}
|
||||
|
||||
func marshalContentBlock(block fantasy.Content) (json.RawMessage, error) {
|
||||
encoded, err := json.Marshal(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
title, ok := reasoningTitleFromContent(block)
|
||||
if !ok || title == "" {
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Type string `json:"type"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(encoded, &envelope); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !strings.EqualFold(envelope.Type, string(fantasy.ContentTypeReasoning)) {
|
||||
return encoded, nil
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
envelope.Data = map[string]any{}
|
||||
}
|
||||
envelope.Data["title"] = title
|
||||
|
||||
encodedWithTitle, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodedWithTitle, nil
|
||||
}
|
||||
|
||||
func reasoningTitleFromContent(block fantasy.Content) (string, bool) {
|
||||
switch value := block.(type) {
|
||||
case fantasy.ReasoningContent:
|
||||
return ReasoningTitleFromFirstLine(value.Text), true
|
||||
case *fantasy.ReasoningContent:
|
||||
if value == nil {
|
||||
return "", false
|
||||
}
|
||||
return ReasoningTitleFromFirstLine(value.Text), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func reasoningSummaryTitle(metadata fantasy.ProviderMetadata) string {
|
||||
if len(metadata) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
reasoningMetadata := fantasyopenai.GetReasoningMetadata(
|
||||
fantasy.ProviderOptions(metadata),
|
||||
)
|
||||
if reasoningMetadata == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, summary := range reasoningMetadata.Summary {
|
||||
if title := compactReasoningSummaryTitle(summary); title != "" {
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func compactReasoningSummaryTitle(summary string) string {
|
||||
const maxWords = 8
|
||||
const maxRunes = 80
|
||||
|
||||
summary = strings.TrimSpace(summary)
|
||||
if summary == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
summary = strings.Trim(summary, "\"'`")
|
||||
summary = reasoningSummaryHeadline(summary)
|
||||
words := strings.Fields(summary)
|
||||
if len(words) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
truncated := false
|
||||
if len(words) > maxWords {
|
||||
words = words[:maxWords]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
title := strings.Join(words, " ")
|
||||
if truncated {
|
||||
title += "…"
|
||||
}
|
||||
return truncateRunes(title, maxRunes)
|
||||
}
|
||||
|
||||
func reasoningSummaryHeadline(summary string) string {
|
||||
summary = strings.TrimSpace(summary)
|
||||
if summary == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// OpenAI summary_text may be markdown like:
|
||||
// "**Title**\n\nLonger explanation ...".
|
||||
// Keep only the heading segment for UI titles.
|
||||
if idx := strings.Index(summary, "\n\n"); idx >= 0 {
|
||||
summary = summary[:idx]
|
||||
}
|
||||
|
||||
if idx := strings.IndexAny(summary, "\r\n"); idx >= 0 {
|
||||
summary = summary[:idx]
|
||||
}
|
||||
|
||||
summary = strings.TrimSpace(summary)
|
||||
if summary == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if strings.HasPrefix(summary, "**") {
|
||||
rest := summary[2:]
|
||||
if end := strings.Index(rest, "**"); end >= 0 {
|
||||
bold := strings.TrimSpace(rest[:end])
|
||||
if bold != "" {
|
||||
summary = bold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(strings.Trim(summary, "\"'`"))
|
||||
}
|
||||
|
||||
func truncateRunes(value string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxLen {
|
||||
return value
|
||||
}
|
||||
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package chatprompt_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
func TestConvertMessages_NormalizesAssistantToolCallInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
expected: "{}",
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
input: "{\"command\":",
|
||||
expected: "{}",
|
||||
},
|
||||
{
|
||||
name: "non-object json",
|
||||
input: "[]",
|
||||
expected: "{}",
|
||||
},
|
||||
{
|
||||
name: "valid object json",
|
||||
input: "{\"command\":\"ls\"}",
|
||||
expected: "{\"command\":\"ls\"}",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assistantContent, err := chatprompt.MarshalContent([]fantasy.Content{
|
||||
fantasy.ToolCallContent{
|
||||
ToolCallID: "toolu_01C4PqN6F2493pi7Ebag8Vg7",
|
||||
ToolName: "execute",
|
||||
Input: tc.input,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
toolContent, err := chatprompt.MarshalToolResult(
|
||||
"toolu_01C4PqN6F2493pi7Ebag8Vg7",
|
||||
"execute",
|
||||
json.RawMessage(`{"error":"tool call was interrupted before it produced a result"}`),
|
||||
true,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
prompt, err := chatprompt.ConvertMessages([]database.ChatMessage{
|
||||
{
|
||||
Role: string(fantasy.MessageRoleAssistant),
|
||||
Visibility: database.ChatMessageVisibilityBoth,
|
||||
Content: assistantContent,
|
||||
},
|
||||
{
|
||||
Role: string(fantasy.MessageRoleTool),
|
||||
Visibility: database.ChatMessageVisibilityBoth,
|
||||
Content: toolContent,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, prompt, 2)
|
||||
|
||||
require.Equal(t, fantasy.MessageRoleAssistant, prompt[0].Role)
|
||||
toolCalls := chatprompt.ExtractToolCalls(prompt[0].Content)
|
||||
require.Len(t, toolCalls, 1)
|
||||
require.Equal(t, tc.expected, toolCalls[0].Input)
|
||||
require.Equal(t, "execute", toolCalls[0].ToolName)
|
||||
require.Equal(t, "toolu_01C4PqN6F2493pi7Ebag8Vg7", toolCalls[0].ToolCallID)
|
||||
|
||||
require.Equal(t, fantasy.MessageRoleTool, prompt[1].Role)
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,191 @@
|
||||
package chatprovider_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
|
||||
fantasyvercel "charm.land/fantasy/providers/vercel"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/chatd/chatprovider"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestReasoningEffortFromChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
input *string
|
||||
want *string
|
||||
}{
|
||||
{
|
||||
name: "OpenAICaseInsensitive",
|
||||
provider: "openai",
|
||||
input: stringPtr(" HIGH "),
|
||||
want: stringPtr(string(fantasyopenai.ReasoningEffortHigh)),
|
||||
},
|
||||
{
|
||||
name: "AnthropicEffort",
|
||||
provider: "anthropic",
|
||||
input: stringPtr("max"),
|
||||
want: stringPtr(string(fantasyanthropic.EffortMax)),
|
||||
},
|
||||
{
|
||||
name: "OpenRouterEffort",
|
||||
provider: "openrouter",
|
||||
input: stringPtr("medium"),
|
||||
want: stringPtr(string(fantasyopenrouter.ReasoningEffortMedium)),
|
||||
},
|
||||
{
|
||||
name: "VercelEffort",
|
||||
provider: "vercel",
|
||||
input: stringPtr("xhigh"),
|
||||
want: stringPtr(string(fantasyvercel.ReasoningEffortXHigh)),
|
||||
},
|
||||
{
|
||||
name: "InvalidEffortReturnsNil",
|
||||
provider: "openai",
|
||||
input: stringPtr("unknown"),
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "UnsupportedProviderReturnsNil",
|
||||
provider: "bedrock",
|
||||
input: stringPtr("high"),
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "NilInputReturnsNil",
|
||||
provider: "openai",
|
||||
input: nil,
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := chatprovider.ReasoningEffortFromChat(tt.provider, tt.input)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeMissingProviderOptions_OpenRouterNested(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
options := &codersdk.ChatModelProviderOptions{
|
||||
OpenRouter: &codersdk.ChatModelOpenRouterProviderOptions{
|
||||
Reasoning: &codersdk.ChatModelOpenRouterReasoningOptions{
|
||||
Enabled: boolPtr(true),
|
||||
},
|
||||
Provider: &codersdk.ChatModelOpenRouterProvider{
|
||||
Order: []string{"openai"},
|
||||
},
|
||||
},
|
||||
}
|
||||
defaults := &codersdk.ChatModelProviderOptions{
|
||||
OpenRouter: &codersdk.ChatModelOpenRouterProviderOptions{
|
||||
Reasoning: &codersdk.ChatModelOpenRouterReasoningOptions{
|
||||
Enabled: boolPtr(false),
|
||||
Exclude: boolPtr(true),
|
||||
MaxTokens: int64Ptr(123),
|
||||
Effort: stringPtr("high"),
|
||||
},
|
||||
IncludeUsage: boolPtr(true),
|
||||
Provider: &codersdk.ChatModelOpenRouterProvider{
|
||||
Order: []string{"anthropic"},
|
||||
AllowFallbacks: boolPtr(true),
|
||||
RequireParameters: boolPtr(false),
|
||||
DataCollection: stringPtr("allow"),
|
||||
Only: []string{"openai"},
|
||||
Ignore: []string{"foo"},
|
||||
Quantizations: []string{"int8"},
|
||||
Sort: stringPtr("latency"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
chatprovider.MergeMissingProviderOptions(&options, defaults)
|
||||
|
||||
require.NotNil(t, options)
|
||||
require.NotNil(t, options.OpenRouter)
|
||||
require.NotNil(t, options.OpenRouter.Reasoning)
|
||||
require.True(t, *options.OpenRouter.Reasoning.Enabled)
|
||||
require.Equal(t, true, *options.OpenRouter.Reasoning.Exclude)
|
||||
require.EqualValues(t, 123, *options.OpenRouter.Reasoning.MaxTokens)
|
||||
require.Equal(t, "high", *options.OpenRouter.Reasoning.Effort)
|
||||
require.NotNil(t, options.OpenRouter.IncludeUsage)
|
||||
require.True(t, *options.OpenRouter.IncludeUsage)
|
||||
|
||||
require.NotNil(t, options.OpenRouter.Provider)
|
||||
require.Equal(t, []string{"openai"}, options.OpenRouter.Provider.Order)
|
||||
require.NotNil(t, options.OpenRouter.Provider.AllowFallbacks)
|
||||
require.True(t, *options.OpenRouter.Provider.AllowFallbacks)
|
||||
require.NotNil(t, options.OpenRouter.Provider.RequireParameters)
|
||||
require.False(t, *options.OpenRouter.Provider.RequireParameters)
|
||||
require.Equal(t, "allow", *options.OpenRouter.Provider.DataCollection)
|
||||
require.Equal(t, []string{"openai"}, options.OpenRouter.Provider.Only)
|
||||
require.Equal(t, []string{"foo"}, options.OpenRouter.Provider.Ignore)
|
||||
require.Equal(t, []string{"int8"}, options.OpenRouter.Provider.Quantizations)
|
||||
require.Equal(t, "latency", *options.OpenRouter.Provider.Sort)
|
||||
}
|
||||
|
||||
func TestMergeMissingCallConfig_FillsUnsetFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dst := codersdk.ChatModelCallConfig{
|
||||
Temperature: float64Ptr(0.2),
|
||||
ProviderOptions: &codersdk.ChatModelProviderOptions{
|
||||
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{
|
||||
User: stringPtr("alice"),
|
||||
},
|
||||
},
|
||||
}
|
||||
defaults := codersdk.ChatModelCallConfig{
|
||||
MaxOutputTokens: int64Ptr(512),
|
||||
Temperature: float64Ptr(0.9),
|
||||
TopP: float64Ptr(0.8),
|
||||
ProviderOptions: &codersdk.ChatModelProviderOptions{
|
||||
OpenAI: &codersdk.ChatModelOpenAIProviderOptions{
|
||||
User: stringPtr("bob"),
|
||||
ReasoningEffort: stringPtr("medium"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
chatprovider.MergeMissingCallConfig(&dst, defaults)
|
||||
|
||||
require.NotNil(t, dst.MaxOutputTokens)
|
||||
require.EqualValues(t, 512, *dst.MaxOutputTokens)
|
||||
require.NotNil(t, dst.Temperature)
|
||||
require.Equal(t, 0.2, *dst.Temperature)
|
||||
require.NotNil(t, dst.TopP)
|
||||
require.Equal(t, 0.8, *dst.TopP)
|
||||
require.NotNil(t, dst.ProviderOptions)
|
||||
require.NotNil(t, dst.ProviderOptions.OpenAI)
|
||||
require.Equal(t, "alice", *dst.ProviderOptions.OpenAI.User)
|
||||
require.Equal(t, "medium", *dst.ProviderOptions.OpenAI.ReasoningEffort)
|
||||
}
|
||||
|
||||
func stringPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func int64Ptr(value int64) *int64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func float64Ptr(value float64) *float64 {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package chattest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AnthropicHandler handles Anthropic API requests and returns a response.
|
||||
type AnthropicHandler func(req *AnthropicRequest) AnthropicResponse
|
||||
|
||||
// AnthropicResponse represents a response to an Anthropic request.
|
||||
// Either StreamingChunks or Response should be set, not both.
|
||||
type AnthropicResponse struct {
|
||||
StreamingChunks <-chan AnthropicChunk
|
||||
Response *AnthropicMessage
|
||||
}
|
||||
|
||||
// AnthropicRequest represents an Anthropic messages request.
|
||||
type AnthropicRequest struct {
|
||||
*http.Request // Embed http.Request
|
||||
Model string `json:"model"`
|
||||
Messages []AnthropicRequestMessage `json:"messages"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
// TODO: encoding/json ignores inline tags. Add custom UnmarshalJSON to capture unknown keys.
|
||||
Options map[string]interface{} `json:",inline"` //nolint:revive
|
||||
}
|
||||
|
||||
// AnthropicRequestMessage represents a message in an Anthropic request.
|
||||
// Content may be either a string or a structured content array.
|
||||
type AnthropicRequestMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
|
||||
// AnthropicMessage represents a message in an Anthropic response.
|
||||
type AnthropicMessage struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
Usage AnthropicUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// AnthropicUsage represents usage information in an Anthropic response.
|
||||
type AnthropicUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// AnthropicChunk represents a streaming chunk from Anthropic.
|
||||
type AnthropicChunk struct {
|
||||
Type string `json:"type"`
|
||||
Index int `json:"index,omitempty"`
|
||||
Message AnthropicChunkMessage `json:"message,omitempty"`
|
||||
ContentBlock AnthropicContentBlock `json:"content_block,omitempty"`
|
||||
Delta AnthropicDeltaBlock `json:"delta,omitempty"`
|
||||
StopReason string `json:"stop_reason,omitempty"`
|
||||
StopSequence *string `json:"stop_sequence,omitempty"`
|
||||
Usage AnthropicUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
// AnthropicChunkMessage represents message metadata in a chunk.
|
||||
type AnthropicChunkMessage struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// AnthropicContentBlock represents a content block in a chunk.
|
||||
type AnthropicContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
// AnthropicDeltaBlock represents a delta block in a chunk.
|
||||
type AnthropicDeltaBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
PartialJSON string `json:"partial_json,omitempty"`
|
||||
}
|
||||
|
||||
// anthropicServer is a test server that mocks the Anthropic API.
|
||||
type anthropicServer struct {
|
||||
mu sync.Mutex
|
||||
server *httptest.Server
|
||||
handler AnthropicHandler
|
||||
request *AnthropicRequest
|
||||
}
|
||||
|
||||
// NewAnthropic creates a new Anthropic test server with a handler function.
|
||||
// The handler is called for each request and should return either a streaming
|
||||
// response (via channel) or a non-streaming response.
|
||||
// Returns the base URL of the server.
|
||||
func NewAnthropic(t testing.TB, handler AnthropicHandler) string {
|
||||
t.Helper()
|
||||
|
||||
s := &anthropicServer{
|
||||
handler: handler,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /v1/messages", s.handleMessages)
|
||||
|
||||
s.server = httptest.NewServer(mux)
|
||||
|
||||
t.Cleanup(func() {
|
||||
s.server.Close()
|
||||
})
|
||||
|
||||
return s.server.URL
|
||||
}
|
||||
|
||||
func (s *anthropicServer) handleMessages(w http.ResponseWriter, r *http.Request) {
|
||||
var req AnthropicRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// Return a more detailed error for debugging
|
||||
http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Request = r // Embed the original http.Request
|
||||
|
||||
s.mu.Lock()
|
||||
s.request = &req
|
||||
s.mu.Unlock()
|
||||
|
||||
resp := s.handler(&req)
|
||||
s.writeResponse(w, &req, resp)
|
||||
}
|
||||
|
||||
func (s *anthropicServer) writeResponse(w http.ResponseWriter, req *AnthropicRequest, resp AnthropicResponse) {
|
||||
hasStreaming := resp.StreamingChunks != nil
|
||||
hasNonStreaming := resp.Response != nil
|
||||
|
||||
switch {
|
||||
case hasStreaming && hasNonStreaming:
|
||||
http.Error(w, "handler returned both streaming and non-streaming responses", http.StatusInternalServerError)
|
||||
return
|
||||
case !hasStreaming && !hasNonStreaming:
|
||||
http.Error(w, "handler returned empty response", http.StatusInternalServerError)
|
||||
return
|
||||
case req.Stream && !hasStreaming:
|
||||
http.Error(w, "handler returned non-streaming response for streaming request", http.StatusInternalServerError)
|
||||
return
|
||||
case !req.Stream && !hasNonStreaming:
|
||||
http.Error(w, "handler returned streaming response for non-streaming request", http.StatusInternalServerError)
|
||||
return
|
||||
case hasStreaming:
|
||||
s.writeStreamingResponse(w, resp.StreamingChunks)
|
||||
default:
|
||||
s.writeNonStreamingResponse(w, resp.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *anthropicServer) writeStreamingResponse(w http.ResponseWriter, chunks <-chan AnthropicChunk) {
|
||||
_ = s // receiver unused but kept for consistency
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("anthropic-version", "2023-06-01")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for chunk := range chunks {
|
||||
chunkData := make(map[string]interface{})
|
||||
chunkData["type"] = chunk.Type
|
||||
|
||||
switch chunk.Type {
|
||||
case "message_start":
|
||||
chunkData["message"] = chunk.Message
|
||||
case "content_block_start":
|
||||
chunkData["index"] = chunk.Index
|
||||
chunkData["content_block"] = chunk.ContentBlock
|
||||
case "content_block_delta":
|
||||
chunkData["index"] = chunk.Index
|
||||
chunkData["delta"] = chunk.Delta
|
||||
case "content_block_stop":
|
||||
chunkData["index"] = chunk.Index
|
||||
case "message_delta":
|
||||
chunkData["delta"] = map[string]interface{}{
|
||||
"stop_reason": chunk.StopReason,
|
||||
"stop_sequence": chunk.StopSequence,
|
||||
}
|
||||
chunkData["usage"] = chunk.Usage
|
||||
case "message_stop":
|
||||
// No additional fields
|
||||
}
|
||||
|
||||
chunkBytes, err := json.Marshal(chunkData)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Send both event and data lines to match Anthropic API format
|
||||
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", chunk.Type, chunkBytes); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *anthropicServer) writeNonStreamingResponse(w http.ResponseWriter, resp *AnthropicMessage) {
|
||||
_ = s // receiver unused but kept for consistency
|
||||
response := map[string]interface{}{
|
||||
"id": resp.ID,
|
||||
"type": resp.Type,
|
||||
"role": resp.Role,
|
||||
"model": resp.Model,
|
||||
"content": []map[string]interface{}{
|
||||
{
|
||||
"type": "text",
|
||||
"text": resp.Content,
|
||||
},
|
||||
},
|
||||
"stop_reason": resp.StopReason,
|
||||
"usage": resp.Usage,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("anthropic-version", "2023-06-01")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// AnthropicStreamingResponse creates a streaming response from chunks.
|
||||
func AnthropicStreamingResponse(chunks ...AnthropicChunk) AnthropicResponse {
|
||||
ch := make(chan AnthropicChunk, len(chunks))
|
||||
go func() {
|
||||
for _, chunk := range chunks {
|
||||
ch <- chunk
|
||||
}
|
||||
close(ch)
|
||||
}()
|
||||
return AnthropicResponse{StreamingChunks: ch}
|
||||
}
|
||||
|
||||
// AnthropicNonStreamingResponse creates a non-streaming response with the given text.
|
||||
func AnthropicNonStreamingResponse(text string) AnthropicResponse {
|
||||
return AnthropicResponse{
|
||||
Response: &AnthropicMessage{
|
||||
ID: fmt.Sprintf("msg-%s", uuid.New().String()[:8]),
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Content: text,
|
||||
Model: "claude-3-opus-20240229",
|
||||
StopReason: "end_turn",
|
||||
Usage: AnthropicUsage{
|
||||
InputTokens: 10,
|
||||
OutputTokens: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AnthropicTextChunks creates a complete streaming response with text deltas.
|
||||
// Takes text deltas and creates all required chunks (message_start,
|
||||
// content_block_start, content_block_delta for each delta,
|
||||
// content_block_stop, message_delta, message_stop).
|
||||
func AnthropicTextChunks(deltas ...string) []AnthropicChunk {
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
messageID := fmt.Sprintf("msg-%s", uuid.New().String()[:8])
|
||||
model := "claude-3-opus-20240229"
|
||||
|
||||
chunks := []AnthropicChunk{
|
||||
{
|
||||
Type: "message_start",
|
||||
Message: AnthropicChunkMessage{
|
||||
ID: messageID,
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Model: model,
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "content_block_start",
|
||||
Index: 0,
|
||||
ContentBlock: AnthropicContentBlock{
|
||||
Type: "text",
|
||||
Text: "", // According to Anthropic API spec, text should be empty in content_block_start
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add a delta chunk for each delta
|
||||
for _, delta := range deltas {
|
||||
chunks = append(chunks, AnthropicChunk{
|
||||
Type: "content_block_delta",
|
||||
Index: 0,
|
||||
Delta: AnthropicDeltaBlock{
|
||||
Type: "text_delta",
|
||||
Text: delta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
chunks = append(chunks,
|
||||
AnthropicChunk{
|
||||
Type: "content_block_stop",
|
||||
Index: 0,
|
||||
},
|
||||
AnthropicChunk{
|
||||
Type: "message_delta",
|
||||
StopReason: "end_turn",
|
||||
Usage: AnthropicUsage{
|
||||
InputTokens: 10,
|
||||
OutputTokens: 5,
|
||||
},
|
||||
},
|
||||
AnthropicChunk{
|
||||
Type: "message_stop",
|
||||
},
|
||||
)
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
// AnthropicToolCallChunks creates a complete streaming response for a tool call.
|
||||
// Input JSON can be split across multiple deltas, matching Anthropic's
|
||||
// input_json_delta streaming behavior.
|
||||
func AnthropicToolCallChunks(toolName string, inputJSONDeltas ...string) []AnthropicChunk {
|
||||
if len(inputJSONDeltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
if toolName == "" {
|
||||
toolName = "tool"
|
||||
}
|
||||
|
||||
messageID := fmt.Sprintf("msg-%s", uuid.New().String()[:8])
|
||||
model := "claude-3-opus-20240229"
|
||||
toolCallID := fmt.Sprintf("toolu_%s", uuid.New().String()[:8])
|
||||
|
||||
chunks := []AnthropicChunk{
|
||||
{
|
||||
Type: "message_start",
|
||||
Message: AnthropicChunkMessage{
|
||||
ID: messageID,
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Model: model,
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: "content_block_start",
|
||||
Index: 0,
|
||||
ContentBlock: AnthropicContentBlock{
|
||||
Type: "tool_use",
|
||||
ID: toolCallID,
|
||||
Name: toolName,
|
||||
Input: json.RawMessage("{}"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, delta := range inputJSONDeltas {
|
||||
chunks = append(chunks, AnthropicChunk{
|
||||
Type: "content_block_delta",
|
||||
Index: 0,
|
||||
Delta: AnthropicDeltaBlock{
|
||||
Type: "input_json_delta",
|
||||
PartialJSON: delta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
chunks = append(chunks,
|
||||
AnthropicChunk{
|
||||
Type: "content_block_stop",
|
||||
Index: 0,
|
||||
},
|
||||
AnthropicChunk{
|
||||
Type: "message_delta",
|
||||
StopReason: "tool_use",
|
||||
Usage: AnthropicUsage{
|
||||
InputTokens: 10,
|
||||
OutputTokens: 5,
|
||||
},
|
||||
},
|
||||
AnthropicChunk{
|
||||
Type: "message_stop",
|
||||
},
|
||||
)
|
||||
|
||||
return chunks
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package chattest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/chatd/chattest"
|
||||
)
|
||||
|
||||
func TestAnthropic_Streaming(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
||||
return chattest.AnthropicStreamingResponse(
|
||||
chattest.AnthropicTextChunks("Hello", " world", "!")...,
|
||||
)
|
||||
})
|
||||
|
||||
// Create fantasy client pointing to our test server
|
||||
client, err := fantasyanthropic.New(
|
||||
fantasyanthropic.WithAPIKey("test-key"),
|
||||
fantasyanthropic.WithBaseURL(serverURL),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
model, err := client.LanguageModel(ctx, "claude-3-opus-20240229")
|
||||
require.NoError(t, err)
|
||||
|
||||
call := fantasy.Call{
|
||||
Prompt: []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Say hello"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
stream, err := model.Stream(ctx, call)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedDeltas := []string{"Hello", " world", "!"}
|
||||
deltaIndex := 0
|
||||
|
||||
var allParts []fantasy.StreamPart
|
||||
for part := range stream {
|
||||
allParts = append(allParts, part)
|
||||
if part.Type == fantasy.StreamPartTypeTextDelta {
|
||||
require.Less(t, deltaIndex, len(expectedDeltas), "Received more deltas than expected")
|
||||
require.Equal(t, expectedDeltas[deltaIndex], part.Delta,
|
||||
"Delta at index %d should be %q, got %q", deltaIndex, expectedDeltas[deltaIndex], part.Delta)
|
||||
deltaIndex++
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, len(expectedDeltas), deltaIndex, "Expected %d deltas, got %d. Total parts received: %d", len(expectedDeltas), deltaIndex, len(allParts))
|
||||
}
|
||||
|
||||
func TestAnthropic_ToolCalls(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requestCount atomic.Int32
|
||||
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
||||
switch requestCount.Add(1) {
|
||||
case 1:
|
||||
return chattest.AnthropicStreamingResponse(
|
||||
chattest.AnthropicToolCallChunks("get_weather", `{"location":"San Francisco"}`)...,
|
||||
)
|
||||
default:
|
||||
return chattest.AnthropicStreamingResponse(
|
||||
chattest.AnthropicTextChunks("The weather in San Francisco is 72F.")...,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
client, err := fantasyanthropic.New(
|
||||
fantasyanthropic.WithAPIKey("test-key"),
|
||||
fantasyanthropic.WithBaseURL(serverURL),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
model, err := client.LanguageModel(context.Background(), "claude-3-opus-20240229")
|
||||
require.NoError(t, err)
|
||||
|
||||
type weatherInput struct {
|
||||
Location string `json:"location"`
|
||||
}
|
||||
var toolCallCount atomic.Int32
|
||||
weatherTool := fantasy.NewAgentTool(
|
||||
"get_weather",
|
||||
"Get weather for a location.",
|
||||
func(ctx context.Context, input weatherInput, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
toolCallCount.Add(1)
|
||||
require.Equal(t, "San Francisco", input.Location)
|
||||
return fantasy.NewTextResponse("72F"), nil
|
||||
},
|
||||
)
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
model,
|
||||
fantasy.WithSystemPrompt("You are a helpful assistant."),
|
||||
fantasy.WithTools(weatherTool),
|
||||
)
|
||||
|
||||
result, err := agent.Stream(context.Background(), fantasy.AgentStreamCall{
|
||||
Prompt: "What's the weather in San Francisco?",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
require.Equal(t, int32(1), toolCallCount.Load(), "expected exactly one tool execution")
|
||||
require.GreaterOrEqual(t, requestCount.Load(), int32(2), "expected follow-up model call after tool execution")
|
||||
}
|
||||
|
||||
func TestAnthropic_NonStreaming(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
||||
return chattest.AnthropicNonStreamingResponse("Response text")
|
||||
})
|
||||
|
||||
// Create fantasy client pointing to our test server
|
||||
client, err := fantasyanthropic.New(
|
||||
fantasyanthropic.WithAPIKey("test-key"),
|
||||
fantasyanthropic.WithBaseURL(serverURL),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
model, err := client.LanguageModel(ctx, "claude-3-opus-20240229")
|
||||
require.NoError(t, err)
|
||||
|
||||
call := fantasy.Call{
|
||||
Prompt: []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Test message"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
response, err := model.Generate(ctx, call)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
}
|
||||
|
||||
func TestAnthropic_Streaming_MismatchReturnsErrorPart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
||||
return chattest.AnthropicNonStreamingResponse("wrong response type")
|
||||
})
|
||||
|
||||
client, err := fantasyanthropic.New(
|
||||
fantasyanthropic.WithAPIKey("test-key"),
|
||||
fantasyanthropic.WithBaseURL(serverURL),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
model, err := client.LanguageModel(context.Background(), "claude-3-opus-20240229")
|
||||
require.NoError(t, err)
|
||||
|
||||
stream, err := model.Stream(context.Background(), fantasy.Call{
|
||||
Prompt: []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var streamErr error
|
||||
for part := range stream {
|
||||
if part.Type == fantasy.StreamPartTypeError {
|
||||
streamErr = part.Error
|
||||
break
|
||||
}
|
||||
}
|
||||
require.Error(t, streamErr)
|
||||
require.Contains(t, streamErr.Error(), "500 Internal Server Error")
|
||||
}
|
||||
|
||||
func TestAnthropic_NonStreaming_MismatchReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
||||
return chattest.AnthropicStreamingResponse(
|
||||
chattest.AnthropicTextChunks("wrong", " response")...,
|
||||
)
|
||||
})
|
||||
|
||||
client, err := fantasyanthropic.New(
|
||||
fantasyanthropic.WithAPIKey("test-key"),
|
||||
fantasyanthropic.WithBaseURL(serverURL),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
model, err := client.LanguageModel(context.Background(), "claude-3-opus-20240229")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = model.Generate(context.Background(), fantasy.Call{
|
||||
Prompt: []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "500 Internal Server Error")
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package chattest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// OpenAIHandler handles OpenAI API requests and returns a response.
|
||||
type OpenAIHandler func(req *OpenAIRequest) OpenAIResponse
|
||||
|
||||
// OpenAIResponse represents a response to an OpenAI request.
|
||||
// Either StreamingChunks or Response should be set, not both.
|
||||
type OpenAIResponse struct {
|
||||
StreamingChunks <-chan OpenAIChunk
|
||||
Response *OpenAICompletion
|
||||
}
|
||||
|
||||
// OpenAIRequest represents an OpenAI chat completion request.
|
||||
type OpenAIRequest struct {
|
||||
*http.Request
|
||||
Model string `json:"model"`
|
||||
Messages []OpenAIMessage `json:"messages"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Prompt []interface{} `json:"prompt,omitempty"` // For responses API
|
||||
// TODO: encoding/json ignores inline tags. Add custom UnmarshalJSON to capture unknown keys.
|
||||
Options map[string]interface{} `json:",inline"` //nolint:revive
|
||||
}
|
||||
|
||||
// OpenAIMessage represents a message in an OpenAI request.
|
||||
type OpenAIMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// OpenAIToolCallFunction represents the function details in a tool call.
|
||||
type OpenAIToolCallFunction struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIToolCall represents a tool call in a streaming chunk or completion.
|
||||
type OpenAIToolCall struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Function OpenAIToolCallFunction `json:"function,omitempty"`
|
||||
Index int `json:"index,omitempty"` // For streaming deltas
|
||||
}
|
||||
|
||||
// OpenAIChunkChoice represents a choice in a streaming chunk.
|
||||
type OpenAIChunkChoice struct {
|
||||
Index int `json:"index"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIChunk represents a streaming chunk from OpenAI.
|
||||
type OpenAIChunk struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []OpenAIChunkChoice `json:"choices"`
|
||||
}
|
||||
|
||||
// OpenAICompletionChoice represents a choice in a completion response.
|
||||
type OpenAICompletionChoice struct {
|
||||
Index int `json:"index"`
|
||||
Message OpenAIMessage `json:"message"`
|
||||
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// OpenAICompletionUsage represents usage information in a completion response.
|
||||
type OpenAICompletionUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
// OpenAICompletion represents a non-streaming OpenAI completion response.
|
||||
type OpenAICompletion struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []OpenAICompletionChoice `json:"choices"`
|
||||
Usage OpenAICompletionUsage `json:"usage"`
|
||||
}
|
||||
|
||||
// openAIServer is a test server that mocks the OpenAI API.
|
||||
type openAIServer struct {
|
||||
mu sync.Mutex
|
||||
server *httptest.Server
|
||||
handler OpenAIHandler
|
||||
request *OpenAIRequest
|
||||
}
|
||||
|
||||
// NewOpenAI creates a new OpenAI test server with a handler function.
|
||||
// The handler is called for each request and should return either a streaming
|
||||
// response (via channel) or a non-streaming response.
|
||||
// Returns the base URL of the server.
|
||||
func NewOpenAI(t testing.TB, handler OpenAIHandler) string {
|
||||
t.Helper()
|
||||
|
||||
s := &openAIServer{
|
||||
handler: handler,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /chat/completions", s.handleChatCompletions)
|
||||
mux.HandleFunc("POST /responses", s.handleResponses)
|
||||
|
||||
s.server = httptest.NewServer(mux)
|
||||
|
||||
t.Cleanup(func() {
|
||||
s.server.Close()
|
||||
})
|
||||
|
||||
return s.server.URL
|
||||
}
|
||||
|
||||
func (s *openAIServer) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
var req OpenAIRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Request = r
|
||||
|
||||
s.mu.Lock()
|
||||
s.request = &req
|
||||
s.mu.Unlock()
|
||||
|
||||
resp := s.handler(&req)
|
||||
s.writeChatCompletionsResponse(w, &req, resp)
|
||||
}
|
||||
|
||||
func (s *openAIServer) handleResponses(w http.ResponseWriter, r *http.Request) {
|
||||
var req OpenAIRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Request = r
|
||||
|
||||
s.mu.Lock()
|
||||
s.request = &req
|
||||
s.mu.Unlock()
|
||||
|
||||
resp := s.handler(&req)
|
||||
s.writeResponsesAPIResponse(w, &req, resp)
|
||||
}
|
||||
|
||||
func (s *openAIServer) writeChatCompletionsResponse(w http.ResponseWriter, req *OpenAIRequest, resp OpenAIResponse) {
|
||||
hasStreaming := resp.StreamingChunks != nil
|
||||
hasNonStreaming := resp.Response != nil
|
||||
|
||||
switch {
|
||||
case hasStreaming && hasNonStreaming:
|
||||
http.Error(w, "handler returned both streaming and non-streaming responses", http.StatusInternalServerError)
|
||||
return
|
||||
case !hasStreaming && !hasNonStreaming:
|
||||
http.Error(w, "handler returned empty response", http.StatusInternalServerError)
|
||||
return
|
||||
case req.Stream && !hasStreaming:
|
||||
http.Error(w, "handler returned non-streaming response for streaming request", http.StatusInternalServerError)
|
||||
return
|
||||
case !req.Stream && !hasNonStreaming:
|
||||
http.Error(w, "handler returned streaming response for non-streaming request", http.StatusInternalServerError)
|
||||
return
|
||||
case hasStreaming:
|
||||
s.writeChatCompletionsStreaming(w, resp.StreamingChunks)
|
||||
default:
|
||||
s.writeChatCompletionsNonStreaming(w, resp.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *openAIServer) writeResponsesAPIResponse(w http.ResponseWriter, req *OpenAIRequest, resp OpenAIResponse) {
|
||||
hasStreaming := resp.StreamingChunks != nil
|
||||
hasNonStreaming := resp.Response != nil
|
||||
|
||||
switch {
|
||||
case hasStreaming && hasNonStreaming:
|
||||
http.Error(w, "handler returned both streaming and non-streaming responses", http.StatusInternalServerError)
|
||||
return
|
||||
case !hasStreaming && !hasNonStreaming:
|
||||
http.Error(w, "handler returned empty response", http.StatusInternalServerError)
|
||||
return
|
||||
case req.Stream && !hasStreaming:
|
||||
http.Error(w, "handler returned non-streaming response for streaming request", http.StatusInternalServerError)
|
||||
return
|
||||
case !req.Stream && !hasNonStreaming:
|
||||
http.Error(w, "handler returned streaming response for non-streaming request", http.StatusInternalServerError)
|
||||
return
|
||||
case hasStreaming:
|
||||
s.writeResponsesAPIStreaming(w, resp.StreamingChunks)
|
||||
default:
|
||||
s.writeResponsesAPINonStreaming(w, resp.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *openAIServer) writeChatCompletionsStreaming(w http.ResponseWriter, chunks <-chan OpenAIChunk) {
|
||||
_ = s // receiver unused but kept for consistency
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for chunk := range chunks {
|
||||
choicesData := make([]map[string]interface{}, len(chunk.Choices))
|
||||
for i, choice := range chunk.Choices {
|
||||
choiceData := map[string]interface{}{
|
||||
"index": choice.Index,
|
||||
}
|
||||
if choice.Delta != "" {
|
||||
choiceData["delta"] = map[string]interface{}{
|
||||
"content": choice.Delta,
|
||||
}
|
||||
}
|
||||
if len(choice.ToolCalls) > 0 {
|
||||
// Tool calls come in the delta
|
||||
if choiceData["delta"] == nil {
|
||||
choiceData["delta"] = make(map[string]interface{})
|
||||
}
|
||||
delta, ok := choiceData["delta"].(map[string]interface{})
|
||||
if !ok {
|
||||
delta = make(map[string]interface{})
|
||||
choiceData["delta"] = delta
|
||||
}
|
||||
delta["tool_calls"] = choice.ToolCalls
|
||||
}
|
||||
if choice.FinishReason != "" {
|
||||
choiceData["finish_reason"] = choice.FinishReason
|
||||
}
|
||||
choicesData[i] = choiceData
|
||||
}
|
||||
|
||||
chunkData := map[string]interface{}{
|
||||
"id": chunk.ID,
|
||||
"object": chunk.Object,
|
||||
"created": chunk.Created,
|
||||
"model": chunk.Model,
|
||||
"choices": choicesData,
|
||||
}
|
||||
|
||||
chunkBytes, err := json.Marshal(chunkData)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", chunkBytes); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
func (s *openAIServer) writeResponsesAPIStreaming(w http.ResponseWriter, chunks <-chan OpenAIChunk) {
|
||||
_ = s // receiver unused but kept for consistency
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
itemIDs := make(map[int]string)
|
||||
|
||||
for chunk := range chunks {
|
||||
// Responses API sends one event per choice
|
||||
for outputIndex, choice := range chunk.Choices {
|
||||
if choice.Index != 0 {
|
||||
outputIndex = choice.Index
|
||||
}
|
||||
itemID, found := itemIDs[outputIndex]
|
||||
if !found {
|
||||
itemID = fmt.Sprintf("msg_%s", uuid.New().String()[:8])
|
||||
itemIDs[outputIndex] = itemID
|
||||
}
|
||||
|
||||
chunkData := map[string]interface{}{
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": itemID,
|
||||
"output_index": outputIndex,
|
||||
"created": chunk.Created,
|
||||
"model": chunk.Model,
|
||||
"content_index": 0,
|
||||
"delta": choice.Delta,
|
||||
}
|
||||
|
||||
chunkBytes, err := json.Marshal(chunkData)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n\n", chunkBytes); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
func (s *openAIServer) writeChatCompletionsNonStreaming(w http.ResponseWriter, resp *OpenAICompletion) {
|
||||
_ = s // receiver unused but kept for consistency
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *openAIServer) writeResponsesAPINonStreaming(w http.ResponseWriter, resp *OpenAICompletion) {
|
||||
_ = s // receiver unused but kept for consistency
|
||||
// Convert all choices to output format
|
||||
outputs := make([]map[string]interface{}, len(resp.Choices))
|
||||
for i, choice := range resp.Choices {
|
||||
outputs[i] = map[string]interface{}{
|
||||
"id": uuid.New().String(),
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": []map[string]interface{}{
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": choice.Message.Content,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": resp.ID,
|
||||
"object": "response",
|
||||
"created": resp.Created,
|
||||
"model": resp.Model,
|
||||
"output": outputs,
|
||||
"usage": resp.Usage,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// OpenAIStreamingResponse creates a streaming response from chunks.
|
||||
func OpenAIStreamingResponse(chunks ...OpenAIChunk) OpenAIResponse {
|
||||
ch := make(chan OpenAIChunk, len(chunks))
|
||||
go func() {
|
||||
for _, chunk := range chunks {
|
||||
ch <- chunk
|
||||
}
|
||||
close(ch)
|
||||
}()
|
||||
return OpenAIResponse{StreamingChunks: ch}
|
||||
}
|
||||
|
||||
// OpenAINonStreamingResponse creates a non-streaming response with the given text.
|
||||
func OpenAINonStreamingResponse(text string) OpenAIResponse {
|
||||
return OpenAIResponse{
|
||||
Response: &OpenAICompletion{
|
||||
ID: fmt.Sprintf("chatcmpl-%s", uuid.New().String()[:8]),
|
||||
Object: "chat.completion",
|
||||
Created: time.Now().Unix(),
|
||||
Model: "gpt-4",
|
||||
Choices: []OpenAICompletionChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Message: OpenAIMessage{
|
||||
Role: "assistant",
|
||||
Content: text,
|
||||
},
|
||||
FinishReason: "stop",
|
||||
},
|
||||
},
|
||||
Usage: OpenAICompletionUsage{
|
||||
PromptTokens: 10,
|
||||
CompletionTokens: 5,
|
||||
TotalTokens: 15,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAITextChunks creates streaming chunks with text deltas.
|
||||
// Each delta string becomes a separate chunk with a single choice.
|
||||
// Returns a slice of chunks, one per delta, with each choice having its index (0, 1, 2, ...).
|
||||
func OpenAITextChunks(deltas ...string) []OpenAIChunk {
|
||||
if len(deltas) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
chunkID := fmt.Sprintf("chatcmpl-%s", uuid.New().String()[:8])
|
||||
now := time.Now().Unix()
|
||||
chunks := make([]OpenAIChunk, len(deltas))
|
||||
|
||||
for i, delta := range deltas {
|
||||
chunks[i] = OpenAIChunk{
|
||||
ID: chunkID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: now,
|
||||
Model: "gpt-4",
|
||||
Choices: []OpenAIChunkChoice{
|
||||
{
|
||||
Index: i,
|
||||
Delta: delta,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
// OpenAIToolCallChunk creates a streaming chunk with a tool call.
|
||||
// Takes the tool name and arguments JSON string, creates a tool call for choice index 0.
|
||||
func OpenAIToolCallChunk(toolName, arguments string) OpenAIChunk {
|
||||
return OpenAIChunk{
|
||||
ID: fmt.Sprintf("chatcmpl-%s", uuid.New().String()[:8]),
|
||||
Object: "chat.completion.chunk",
|
||||
Created: time.Now().Unix(),
|
||||
Model: "gpt-4",
|
||||
Choices: []OpenAIChunkChoice{
|
||||
{
|
||||
Index: 0,
|
||||
ToolCalls: []OpenAIToolCall{
|
||||
{
|
||||
Index: 0,
|
||||
ID: fmt.Sprintf("call_%s", uuid.New().String()[:8]),
|
||||
Type: "function",
|
||||
Function: OpenAIToolCallFunction{
|
||||
Name: toolName,
|
||||
Arguments: arguments,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package chattest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
fantasyopenai "charm.land/fantasy/providers/openai"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/chatd/chattest"
|
||||
)
|
||||
|
||||
func TestOpenAI_Streaming(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
append(
|
||||
append(
|
||||
chattest.OpenAITextChunks("Hello", "Hi"),
|
||||
chattest.OpenAITextChunks(" world", " there")...,
|
||||
),
|
||||
chattest.OpenAITextChunks("!", "!")...,
|
||||
)...,
|
||||
)
|
||||
})
|
||||
|
||||
// Create fantasy client pointing to our test server
|
||||
client, err := fantasyopenai.New(
|
||||
fantasyopenai.WithAPIKey("test-key"),
|
||||
fantasyopenai.WithBaseURL(serverURL),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
model, err := client.LanguageModel(ctx, "gpt-4")
|
||||
require.NoError(t, err)
|
||||
|
||||
call := fantasy.Call{
|
||||
Prompt: []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Say hello"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
stream, err := model.Stream(ctx, call)
|
||||
require.NoError(t, err)
|
||||
|
||||
// We expect chunks in order: one choice per chunk
|
||||
// So we get: "Hello" (choice 0), "Hi" (choice 1), " world" (choice 0), " there" (choice 1), "!" (choice 0), "!" (choice 1)
|
||||
expectedDeltas := []string{"Hello", "Hi", " world", " there", "!", "!"}
|
||||
deltaIndex := 0
|
||||
|
||||
for part := range stream {
|
||||
if part.Type == fantasy.StreamPartTypeTextDelta {
|
||||
// Verify we're getting deltas in the expected order
|
||||
require.Less(t, deltaIndex, len(expectedDeltas), "Received more deltas than expected")
|
||||
require.Equal(t, expectedDeltas[deltaIndex], part.Delta,
|
||||
"Delta at index %d should be %q, got %q", deltaIndex, expectedDeltas[deltaIndex], part.Delta)
|
||||
deltaIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we received all expected deltas
|
||||
require.Equal(t, len(expectedDeltas), deltaIndex, "Expected %d deltas, got %d", len(expectedDeltas), deltaIndex)
|
||||
}
|
||||
|
||||
func TestOpenAI_Streaming_ResponsesAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
append(
|
||||
append(
|
||||
chattest.OpenAITextChunks("First", "Second"),
|
||||
chattest.OpenAITextChunks(" output", " output")...,
|
||||
),
|
||||
chattest.OpenAITextChunks("!", "!")...,
|
||||
)...,
|
||||
)
|
||||
})
|
||||
|
||||
// Create fantasy client pointing to our test server (responses API)
|
||||
client, err := fantasyopenai.New(
|
||||
fantasyopenai.WithAPIKey("test-key"),
|
||||
fantasyopenai.WithBaseURL(serverURL),
|
||||
fantasyopenai.WithUseResponsesAPI(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
model, err := client.LanguageModel(ctx, "gpt-4")
|
||||
require.NoError(t, err)
|
||||
|
||||
call := fantasy.Call{
|
||||
Prompt: []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Say hello"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
stream, err := model.Stream(ctx, call)
|
||||
require.NoError(t, err)
|
||||
|
||||
var parts []fantasy.StreamPart
|
||||
for part := range stream {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
|
||||
// Verify we received the chunks in order
|
||||
require.Greater(t, len(parts), 0)
|
||||
|
||||
// Extract text deltas from parts and verify they match expected chunks in order
|
||||
// We expect: "First", " output", "!" for choice 0, and "Second", " output", "!" for choice 1
|
||||
var allDeltas []string
|
||||
for _, part := range parts {
|
||||
if part.Type == fantasy.StreamPartTypeTextDelta {
|
||||
allDeltas = append(allDeltas, part.Delta)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we received deltas (responses API may handle multiple choices differently)
|
||||
// If we got text deltas, verify the content
|
||||
if len(allDeltas) > 0 {
|
||||
allText := ""
|
||||
for _, delta := range allDeltas {
|
||||
allText += delta
|
||||
}
|
||||
require.Contains(t, allText, "First")
|
||||
require.Contains(t, allText, "Second")
|
||||
require.Contains(t, allText, "output")
|
||||
require.Contains(t, allText, "!")
|
||||
} else {
|
||||
// If no text deltas, at least verify we got some parts (may be different format)
|
||||
require.Greater(t, len(parts), 0, "Expected at least one stream part")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAI_NonStreaming_CompletionsAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
return chattest.OpenAINonStreamingResponse("First response")
|
||||
})
|
||||
|
||||
// Create fantasy client pointing to our test server (completions API)
|
||||
client, err := fantasyopenai.New(
|
||||
fantasyopenai.WithAPIKey("test-key"),
|
||||
fantasyopenai.WithBaseURL(serverURL),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
model, err := client.LanguageModel(ctx, "gpt-4")
|
||||
require.NoError(t, err)
|
||||
|
||||
call := fantasy.Call{
|
||||
Prompt: []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Test message"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
response, err := model.Generate(ctx, call)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
}
|
||||
|
||||
func TestOpenAI_ToolCalls(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var requestCount atomic.Int32
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
switch requestCount.Add(1) {
|
||||
case 1:
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAIToolCallChunk("get_weather", `{"location":"San Francisco"}`),
|
||||
)
|
||||
default:
|
||||
return chattest.OpenAIStreamingResponse(
|
||||
chattest.OpenAITextChunks("The weather in San Francisco is 72F.")...,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Create fantasy client pointing to our test server
|
||||
client, err := fantasyopenai.New(
|
||||
fantasyopenai.WithAPIKey("test-key"),
|
||||
fantasyopenai.WithBaseURL(serverURL),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
model, err := client.LanguageModel(ctx, "gpt-4")
|
||||
require.NoError(t, err)
|
||||
|
||||
type weatherInput struct {
|
||||
Location string `json:"location"`
|
||||
}
|
||||
var toolCallCount atomic.Int32
|
||||
weatherTool := fantasy.NewAgentTool(
|
||||
"get_weather",
|
||||
"Get weather for a location.",
|
||||
func(ctx context.Context, input weatherInput, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
toolCallCount.Add(1)
|
||||
require.Equal(t, "San Francisco", input.Location)
|
||||
return fantasy.NewTextResponse("72F"), nil
|
||||
},
|
||||
)
|
||||
|
||||
agent := fantasy.NewAgent(
|
||||
model,
|
||||
fantasy.WithSystemPrompt("You are a helpful assistant."),
|
||||
fantasy.WithTools(weatherTool),
|
||||
)
|
||||
|
||||
result, err := agent.Stream(ctx, fantasy.AgentStreamCall{
|
||||
Prompt: "What's the weather in San Francisco?",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int32(1), toolCallCount.Load(), "expected exactly one tool execution")
|
||||
require.GreaterOrEqual(t, requestCount.Load(), int32(2), "expected follow-up model call after tool execution")
|
||||
}
|
||||
|
||||
func TestOpenAI_NonStreaming_ResponsesAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
return chattest.OpenAINonStreamingResponse("First output")
|
||||
})
|
||||
|
||||
// Create fantasy client pointing to our test server (responses API)
|
||||
client, err := fantasyopenai.New(
|
||||
fantasyopenai.WithAPIKey("test-key"),
|
||||
fantasyopenai.WithBaseURL(serverURL),
|
||||
fantasyopenai.WithUseResponsesAPI(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
model, err := client.LanguageModel(ctx, "gpt-4")
|
||||
require.NoError(t, err)
|
||||
|
||||
call := fantasy.Call{
|
||||
Prompt: []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "Test message"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
response, err := model.Generate(ctx, call)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
}
|
||||
|
||||
func TestOpenAI_Streaming_MismatchReturnsErrorPart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
return chattest.OpenAINonStreamingResponse("wrong response type")
|
||||
})
|
||||
|
||||
client, err := fantasyopenai.New(
|
||||
fantasyopenai.WithAPIKey("test-key"),
|
||||
fantasyopenai.WithBaseURL(serverURL),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
model, err := client.LanguageModel(context.Background(), "gpt-4")
|
||||
require.NoError(t, err)
|
||||
|
||||
stream, err := model.Stream(context.Background(), fantasy.Call{
|
||||
Prompt: []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var streamErr error
|
||||
for part := range stream {
|
||||
if part.Type == fantasy.StreamPartTypeError {
|
||||
streamErr = part.Error
|
||||
break
|
||||
}
|
||||
}
|
||||
require.Error(t, streamErr)
|
||||
require.Contains(t, streamErr.Error(), "non-streaming response for streaming request")
|
||||
}
|
||||
|
||||
func TestOpenAI_NonStreaming_MismatchReturnsError_CompletionsAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("wrong response type")...)
|
||||
})
|
||||
|
||||
client, err := fantasyopenai.New(
|
||||
fantasyopenai.WithAPIKey("test-key"),
|
||||
fantasyopenai.WithBaseURL(serverURL),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
model, err := client.LanguageModel(context.Background(), "gpt-4")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = model.Generate(context.Background(), fantasy.Call{
|
||||
Prompt: []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "streaming response for non-streaming request")
|
||||
}
|
||||
|
||||
func TestOpenAI_NonStreaming_MismatchReturnsError_ResponsesAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
||||
return chattest.OpenAIStreamingResponse(chattest.OpenAITextChunks("wrong response type")...)
|
||||
})
|
||||
|
||||
client, err := fantasyopenai.New(
|
||||
fantasyopenai.WithAPIKey("test-key"),
|
||||
fantasyopenai.WithBaseURL(serverURL),
|
||||
fantasyopenai.WithUseResponsesAPI(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
model, err := client.LanguageModel(context.Background(), "gpt-4")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = model.Generate(context.Background(), fantasy.Call{
|
||||
Prompt: []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "streaming response for non-streaming request")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/fantasy"
|
||||
)
|
||||
|
||||
// toolResponse builds a fantasy.ToolResponse from a JSON-serializable
|
||||
// result payload.
|
||||
func toolResponse(result map[string]any) fantasy.ToolResponse {
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return fantasy.NewTextResponse("{}")
|
||||
}
|
||||
return fantasy.NewTextResponse(string(data))
|
||||
}
|
||||
|
||||
func truncateRunes(value string, maxLen int) string {
|
||||
if maxLen <= 0 || value == "" {
|
||||
return ""
|
||||
}
|
||||
if utf8.RuneCountInString(value) <= maxLen {
|
||||
return value
|
||||
}
|
||||
|
||||
runes := []rune(value)
|
||||
if maxLen > len(runes) {
|
||||
maxLen = len(runes)
|
||||
}
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/util/namesgenerator"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
const (
|
||||
// buildPollInterval is how often we check if the workspace
|
||||
// build has completed.
|
||||
buildPollInterval = 2 * time.Second
|
||||
// buildTimeout is the maximum time to wait for a workspace
|
||||
// build to complete before giving up.
|
||||
buildTimeout = 10 * time.Minute
|
||||
// agentConnectTimeout is the maximum time to wait for the
|
||||
// workspace agent to become reachable after a successful build.
|
||||
agentConnectTimeout = 2 * time.Minute
|
||||
// agentRetryInterval is how often we retry connecting to the
|
||||
// workspace agent.
|
||||
agentRetryInterval = 2 * time.Second
|
||||
// agentAttemptTimeout is the timeout for a single connection
|
||||
// attempt to the workspace agent during the retry loop.
|
||||
agentAttemptTimeout = 5 * time.Second
|
||||
// agentPingTimeout is the timeout for a single agent ping
|
||||
// when checking whether an existing workspace is alive.
|
||||
agentPingTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// CreateWorkspaceFn creates a workspace for the given owner.
|
||||
type CreateWorkspaceFn func(
|
||||
ctx context.Context,
|
||||
ownerID uuid.UUID,
|
||||
req codersdk.CreateWorkspaceRequest,
|
||||
) (codersdk.Workspace, error)
|
||||
|
||||
// AgentConnFunc provides access to workspace agent connections.
|
||||
type AgentConnFunc func(
|
||||
ctx context.Context,
|
||||
agentID uuid.UUID,
|
||||
) (workspacesdk.AgentConn, func(), error)
|
||||
|
||||
// CreateWorkspaceOptions configures the create_workspace tool.
|
||||
type CreateWorkspaceOptions struct {
|
||||
DB database.Store
|
||||
OwnerID uuid.UUID
|
||||
ChatID uuid.UUID
|
||||
CreateFn CreateWorkspaceFn
|
||||
AgentConnFn AgentConnFunc
|
||||
WorkspaceMu *sync.Mutex
|
||||
}
|
||||
|
||||
type createWorkspaceArgs struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Parameters map[string]string `json:"parameters,omitempty"`
|
||||
}
|
||||
|
||||
// CreateWorkspace returns a tool that creates a new workspace from a
|
||||
// template. The tool is idempotent: if the chat already has a
|
||||
// workspace that is building or running, it returns the existing
|
||||
// workspace instead of creating a new one. A mutex prevents parallel
|
||||
// calls from creating duplicate workspaces.
|
||||
func CreateWorkspace(options CreateWorkspaceOptions) fantasy.AgentTool {
|
||||
return fantasy.NewAgentTool(
|
||||
"create_workspace",
|
||||
"Create a new workspace from a template. Requires a "+
|
||||
"template_id (from list_templates). Optionally provide "+
|
||||
"a name and parameter values (from read_template). "+
|
||||
"If no name is given, one will be generated. "+
|
||||
"This tool is idempotent — if the chat already has a "+
|
||||
"workspace that is building or running, the existing "+
|
||||
"workspace is returned.",
|
||||
func(ctx context.Context, args createWorkspaceArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if options.CreateFn == nil {
|
||||
return fantasy.NewTextErrorResponse("workspace creator is not configured"), nil
|
||||
}
|
||||
|
||||
templateIDStr := strings.TrimSpace(args.TemplateID)
|
||||
if templateIDStr == "" {
|
||||
return fantasy.NewTextErrorResponse("template_id is required; use list_templates to find one"), nil
|
||||
}
|
||||
templateID, err := uuid.Parse(templateIDStr)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
xerrors.Errorf("invalid template_id: %w", err).Error(),
|
||||
), nil
|
||||
}
|
||||
|
||||
// Serialize workspace creation to prevent parallel
|
||||
// tool calls from creating duplicate workspaces.
|
||||
if options.WorkspaceMu != nil {
|
||||
options.WorkspaceMu.Lock()
|
||||
defer options.WorkspaceMu.Unlock()
|
||||
}
|
||||
|
||||
// Check for an existing workspace on the chat.
|
||||
if options.DB != nil && options.ChatID != uuid.Nil {
|
||||
existing, done, existErr := checkExistingWorkspace(
|
||||
ctx, options.DB, options.ChatID,
|
||||
options.AgentConnFn,
|
||||
)
|
||||
if existErr != nil {
|
||||
return fantasy.NewTextErrorResponse(existErr.Error()), nil
|
||||
}
|
||||
if done {
|
||||
return toolResponse(existing), nil
|
||||
}
|
||||
}
|
||||
|
||||
ownerID := options.OwnerID
|
||||
|
||||
// Set up dbauthz context for DB lookups.
|
||||
if options.DB != nil {
|
||||
ownerCtx, ownerErr := asOwner(ctx, options.DB, ownerID)
|
||||
if ownerErr != nil {
|
||||
return fantasy.NewTextErrorResponse(ownerErr.Error()), nil
|
||||
}
|
||||
ctx = ownerCtx
|
||||
}
|
||||
|
||||
createReq := codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: templateID,
|
||||
}
|
||||
|
||||
// Resolve workspace name.
|
||||
name := strings.TrimSpace(args.Name)
|
||||
if name == "" {
|
||||
seed := "workspace"
|
||||
if options.DB != nil {
|
||||
if t, lookupErr := options.DB.GetTemplateByID(ctx, templateID); lookupErr == nil {
|
||||
seed = t.Name
|
||||
}
|
||||
}
|
||||
name = generatedWorkspaceName(seed)
|
||||
} else if err := codersdk.NameValid(name); err != nil {
|
||||
name = generatedWorkspaceName(name)
|
||||
}
|
||||
createReq.Name = name
|
||||
|
||||
// Map parameters.
|
||||
for k, v := range args.Parameters {
|
||||
createReq.RichParameterValues = append(
|
||||
createReq.RichParameterValues,
|
||||
codersdk.WorkspaceBuildParameter{Name: k, Value: v},
|
||||
)
|
||||
}
|
||||
|
||||
workspace, err := options.CreateFn(ctx, ownerID, createReq)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
// Wait for the build to complete and the agent to
|
||||
// come online so subsequent tools can use the
|
||||
// workspace immediately.
|
||||
if options.DB != nil {
|
||||
if err := waitForBuild(ctx, options.DB, workspace.ID); err != nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
xerrors.Errorf("workspace build failed: %w", err).Error(),
|
||||
), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Look up the first agent so we can link it to the chat.
|
||||
workspaceAgentID := uuid.Nil
|
||||
if options.DB != nil {
|
||||
agents, agentErr := options.DB.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID)
|
||||
if agentErr == nil && len(agents) > 0 {
|
||||
workspaceAgentID = agents[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
// Persist workspace + agent association on the chat.
|
||||
if options.DB != nil && options.ChatID != uuid.Nil {
|
||||
_, _ = options.DB.UpdateChatWorkspace(ctx, database.UpdateChatWorkspaceParams{
|
||||
ID: options.ChatID,
|
||||
WorkspaceID: uuid.NullUUID{
|
||||
UUID: workspace.ID,
|
||||
Valid: true,
|
||||
},
|
||||
WorkspaceAgentID: uuid.NullUUID{
|
||||
UUID: workspaceAgentID,
|
||||
Valid: workspaceAgentID != uuid.Nil,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for the agent to come online.
|
||||
if workspaceAgentID != uuid.Nil && options.AgentConnFn != nil {
|
||||
if err := waitForAgent(ctx, options.AgentConnFn, workspaceAgentID); err != nil {
|
||||
// Non-fatal: the workspace was created
|
||||
// successfully, the agent just isn't ready
|
||||
// yet. The model can retry.
|
||||
return toolResponse(map[string]any{
|
||||
"created": true,
|
||||
"workspace_name": workspace.FullName(),
|
||||
"agent_status": "not_ready",
|
||||
"agent_error": err.Error(),
|
||||
}), nil
|
||||
}
|
||||
}
|
||||
|
||||
return toolResponse(map[string]any{
|
||||
"created": true,
|
||||
"workspace_name": workspace.FullName(),
|
||||
}), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// checkExistingWorkspace checks whether the chat already has a usable
|
||||
// workspace. Returns the result map and true if the caller should
|
||||
// return early (workspace exists and is alive or building). Returns
|
||||
// false if the caller should proceed with creation (workspace is dead
|
||||
// or missing).
|
||||
func checkExistingWorkspace(
|
||||
ctx context.Context,
|
||||
db database.Store,
|
||||
chatID uuid.UUID,
|
||||
agentConnFn AgentConnFunc,
|
||||
) (map[string]any, bool, error) {
|
||||
chat, err := db.GetChatByID(ctx, chatID)
|
||||
if err != nil {
|
||||
return nil, false, xerrors.Errorf("load chat: %w", err)
|
||||
}
|
||||
if !chat.WorkspaceID.Valid {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// Check if workspace still exists.
|
||||
ws, err := db.GetWorkspaceByID(ctx, chat.WorkspaceID.UUID)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
// Workspace was deleted — allow creation.
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, xerrors.Errorf("load workspace: %w", err)
|
||||
}
|
||||
|
||||
// Check the latest build status.
|
||||
build, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, ws.ID)
|
||||
if err != nil {
|
||||
// Can't determine status — allow creation.
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
job, err := db.GetProvisionerJobByID(ctx, build.JobID)
|
||||
if err != nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
switch job.JobStatus {
|
||||
case database.ProvisionerJobStatusPending,
|
||||
database.ProvisionerJobStatusRunning:
|
||||
// Build is in progress — wait for it instead of
|
||||
// creating a new workspace.
|
||||
if err := waitForBuild(ctx, db, ws.ID); err != nil {
|
||||
return nil, false, xerrors.Errorf(
|
||||
"existing workspace build failed: %w", err,
|
||||
)
|
||||
}
|
||||
return map[string]any{
|
||||
"created": false,
|
||||
"workspace_name": ws.Name,
|
||||
"status": "already_exists",
|
||||
"message": "workspace was already being built and is now ready",
|
||||
}, true, nil
|
||||
|
||||
case database.ProvisionerJobStatusSucceeded:
|
||||
// Build succeeded — check if agent is reachable.
|
||||
if chat.WorkspaceAgentID.Valid && agentConnFn != nil {
|
||||
pingCtx, cancel := context.WithTimeout(
|
||||
ctx, agentPingTimeout,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
conn, release, connErr := agentConnFn(
|
||||
pingCtx, chat.WorkspaceAgentID.UUID,
|
||||
)
|
||||
if connErr == nil {
|
||||
release()
|
||||
_ = conn
|
||||
return map[string]any{
|
||||
"created": false,
|
||||
"workspace_name": ws.Name,
|
||||
"status": "already_exists",
|
||||
"message": "workspace is already running and reachable",
|
||||
}, true, nil
|
||||
}
|
||||
// Agent unreachable — workspace is dead, allow
|
||||
// creation.
|
||||
}
|
||||
// No agent ID or no conn func — allow creation.
|
||||
return nil, false, nil
|
||||
|
||||
default:
|
||||
// Failed, canceled, etc — allow creation.
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// waitForBuild polls the workspace's latest build until it
|
||||
// completes or the context expires.
|
||||
func waitForBuild(
|
||||
ctx context.Context,
|
||||
db database.Store,
|
||||
workspaceID uuid.UUID,
|
||||
) error {
|
||||
buildCtx, cancel := context.WithTimeout(ctx, buildTimeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(buildPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
build, err := db.GetLatestWorkspaceBuildByWorkspaceID(
|
||||
buildCtx, workspaceID,
|
||||
)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get latest build: %w", err)
|
||||
}
|
||||
|
||||
job, err := db.GetProvisionerJobByID(buildCtx, build.JobID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get provisioner job: %w", err)
|
||||
}
|
||||
|
||||
switch job.JobStatus {
|
||||
case database.ProvisionerJobStatusSucceeded:
|
||||
return nil
|
||||
case database.ProvisionerJobStatusFailed:
|
||||
errMsg := "build failed"
|
||||
if job.Error.Valid {
|
||||
errMsg = job.Error.String
|
||||
}
|
||||
return xerrors.New(errMsg)
|
||||
case database.ProvisionerJobStatusCanceled:
|
||||
return xerrors.New("build was canceled")
|
||||
case database.ProvisionerJobStatusPending,
|
||||
database.ProvisionerJobStatusRunning,
|
||||
database.ProvisionerJobStatusCanceling:
|
||||
// Still in progress — keep waiting.
|
||||
default:
|
||||
return xerrors.Errorf("unexpected job status: %s", job.JobStatus)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-buildCtx.Done():
|
||||
return xerrors.Errorf(
|
||||
"timed out waiting for workspace build: %w",
|
||||
buildCtx.Err(),
|
||||
)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForAgent retries connecting to the workspace agent until it
|
||||
// succeeds or the timeout expires.
|
||||
func waitForAgent(
|
||||
ctx context.Context,
|
||||
agentConnFn AgentConnFunc,
|
||||
agentID uuid.UUID,
|
||||
) error {
|
||||
agentCtx, cancel := context.WithTimeout(ctx, agentConnectTimeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(agentRetryInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
var lastErr error
|
||||
for {
|
||||
attemptCtx, attemptCancel := context.WithTimeout(agentCtx, agentAttemptTimeout)
|
||||
conn, release, err := agentConnFn(attemptCtx, agentID)
|
||||
attemptCancel()
|
||||
if err == nil {
|
||||
release()
|
||||
_ = conn
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
select {
|
||||
case <-agentCtx.Done():
|
||||
return xerrors.Errorf(
|
||||
"timed out waiting for workspace agent: %w",
|
||||
lastErr,
|
||||
)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generatedWorkspaceName(seed string) string {
|
||||
base := codersdk.UsernameFrom(strings.TrimSpace(strings.ToLower(seed)))
|
||||
if strings.TrimSpace(base) == "" {
|
||||
base = "workspace"
|
||||
}
|
||||
|
||||
suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:4]
|
||||
if len(base) > 27 {
|
||||
base = strings.Trim(base[:27], "-")
|
||||
}
|
||||
if base == "" {
|
||||
base = "workspace"
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("%s-%s", base, suffix)
|
||||
if err := codersdk.NameValid(name); err == nil {
|
||||
return name
|
||||
}
|
||||
return namesgenerator.NameDigitWith("-")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
type EditFilesOptions struct {
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
}
|
||||
|
||||
type EditFilesArgs struct {
|
||||
Files []workspacesdk.FileEdits `json:"files"`
|
||||
}
|
||||
|
||||
func EditFiles(options EditFilesOptions) fantasy.AgentTool {
|
||||
return fantasy.NewAgentTool(
|
||||
"edit_files",
|
||||
"Perform search-and-replace edits on one or more files in the workspace."+
|
||||
" Each file can have multiple edits applied atomically.",
|
||||
func(ctx context.Context, args EditFilesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if options.GetWorkspaceConn == nil {
|
||||
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
|
||||
}
|
||||
conn, err := options.GetWorkspaceConn(ctx)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return executeEditFilesTool(ctx, conn, args)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func executeEditFilesTool(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
args EditFilesArgs,
|
||||
) (fantasy.ToolResponse, error) {
|
||||
if len(args.Files) == 0 {
|
||||
return fantasy.NewTextErrorResponse("files is required"), nil
|
||||
}
|
||||
|
||||
if err := conn.EditFiles(ctx, workspacesdk.FileEditRequest{Files: args.Files}); err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return toolResponse(map[string]any{"ok": true}), nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultExecuteTimeout = 60 * time.Second
|
||||
chatAgentEnvVar = "CODER_CHAT_AGENT"
|
||||
gitAuthRequiredPrefix = "CODER_GITAUTH_REQUIRED:"
|
||||
authRequiredResultReason = "authentication_required"
|
||||
)
|
||||
|
||||
type ExecuteOptions struct {
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
DefaultTimeout time.Duration
|
||||
}
|
||||
|
||||
type ExecuteArgs struct {
|
||||
Command string `json:"command"`
|
||||
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
func Execute(options ExecuteOptions) fantasy.AgentTool {
|
||||
return fantasy.NewAgentTool(
|
||||
"execute",
|
||||
"Execute a shell command in the workspace.",
|
||||
func(ctx context.Context, args ExecuteArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if options.GetWorkspaceConn == nil {
|
||||
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
|
||||
}
|
||||
conn, err := options.GetWorkspaceConn(ctx)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return executeTool(ctx, conn, args, options.DefaultTimeout), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func executeTool(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
args ExecuteArgs,
|
||||
defaultTimeout time.Duration,
|
||||
) fantasy.ToolResponse {
|
||||
if args.Command == "" {
|
||||
return fantasy.NewTextErrorResponse("command is required")
|
||||
}
|
||||
|
||||
timeout := defaultTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultExecuteTimeout
|
||||
}
|
||||
if args.TimeoutSeconds != nil {
|
||||
timeout = time.Duration(*args.TimeoutSeconds) * time.Second
|
||||
}
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
output, exitCode, err := runCommand(cmdCtx, conn, args.Command)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error())
|
||||
}
|
||||
return toolResponse(map[string]any{
|
||||
"output": output,
|
||||
"exit_code": exitCode,
|
||||
})
|
||||
}
|
||||
|
||||
func runCommand(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
command string,
|
||||
) (string, int, error) {
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer sshClient.Close()
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer session.Close()
|
||||
if err := session.Setenv(chatAgentEnvVar, "true"); err != nil {
|
||||
return "", 0, xerrors.Errorf("set %s: %w", chatAgentEnvVar, err)
|
||||
}
|
||||
|
||||
resultCh := make(chan struct {
|
||||
output string
|
||||
exitCode int
|
||||
err error
|
||||
}, 1)
|
||||
|
||||
go func() {
|
||||
output, err := session.CombinedOutput(command)
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
var exitErr *ssh.ExitError
|
||||
if xerrors.As(err, &exitErr) {
|
||||
exitCode = exitErr.ExitStatus()
|
||||
} else {
|
||||
exitCode = 1
|
||||
}
|
||||
}
|
||||
resultCh <- struct {
|
||||
output string
|
||||
exitCode int
|
||||
err error
|
||||
}{
|
||||
output: string(output),
|
||||
exitCode: exitCode,
|
||||
err: err,
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = session.Close()
|
||||
return "", 0, ctx.Err()
|
||||
case result := <-resultCh:
|
||||
return result.output, result.exitCode, result.err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
)
|
||||
|
||||
// ListTemplatesOptions configures the list_templates tool.
|
||||
type ListTemplatesOptions struct {
|
||||
DB database.Store
|
||||
OwnerID uuid.UUID
|
||||
}
|
||||
|
||||
type listTemplatesArgs struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
}
|
||||
|
||||
// ListTemplates returns a tool that lists available workspace templates.
|
||||
// The agent uses this to discover templates before creating a workspace.
|
||||
func ListTemplates(options ListTemplatesOptions) fantasy.AgentTool {
|
||||
return fantasy.NewAgentTool(
|
||||
"list_templates",
|
||||
"List available workspace templates. Optionally filter by a "+
|
||||
"search query matching template name or description. "+
|
||||
"Use this to find a template before creating a workspace.",
|
||||
func(ctx context.Context, args listTemplatesArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if options.DB == nil {
|
||||
return fantasy.NewTextErrorResponse("database is not configured"), nil
|
||||
}
|
||||
|
||||
ctx, err := asOwner(ctx, options.DB, options.OwnerID)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
filterParams := database.GetTemplatesWithFilterParams{
|
||||
Deleted: false,
|
||||
Deprecated: sql.NullBool{
|
||||
Bool: false,
|
||||
Valid: true,
|
||||
},
|
||||
}
|
||||
query := strings.TrimSpace(args.Query)
|
||||
if query != "" {
|
||||
filterParams.FuzzyName = query
|
||||
}
|
||||
|
||||
templates, err := options.DB.GetTemplatesWithFilter(ctx, filterParams)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(templates))
|
||||
for _, t := range templates {
|
||||
item := map[string]any{
|
||||
"id": t.ID.String(),
|
||||
"name": t.Name,
|
||||
}
|
||||
if display := strings.TrimSpace(t.DisplayName); display != "" {
|
||||
item["display_name"] = display
|
||||
}
|
||||
if desc := strings.TrimSpace(t.Description); desc != "" {
|
||||
item["description"] = truncateRunes(desc, 200)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return toolResponse(map[string]any{
|
||||
"templates": items,
|
||||
"count": len(items),
|
||||
}), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// asOwner sets up a dbauthz context for the given owner so that
|
||||
// subsequent database calls are scoped to what that user can access.
|
||||
func asOwner(ctx context.Context, db database.Store, ownerID uuid.UUID) (context.Context, error) {
|
||||
actor, _, err := httpmw.UserRBACSubject(ctx, db, ownerID, rbac.ScopeAll)
|
||||
if err != nil {
|
||||
return ctx, xerrors.Errorf("load user authorization: %w", err)
|
||||
}
|
||||
return dbauthz.As(ctx, actor), nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
type ReadFileOptions struct {
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
}
|
||||
|
||||
type ReadFileArgs struct {
|
||||
Path string `json:"path"`
|
||||
Offset *int64 `json:"offset,omitempty"`
|
||||
Limit *int64 `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
func ReadFile(options ReadFileOptions) fantasy.AgentTool {
|
||||
return fantasy.NewAgentTool(
|
||||
"read_file",
|
||||
"Read a file from the workspace.",
|
||||
func(ctx context.Context, args ReadFileArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if options.GetWorkspaceConn == nil {
|
||||
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
|
||||
}
|
||||
conn, err := options.GetWorkspaceConn(ctx)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return executeReadFileTool(ctx, conn, args)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func executeReadFileTool(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
args ReadFileArgs,
|
||||
) (fantasy.ToolResponse, error) {
|
||||
if args.Path == "" {
|
||||
return fantasy.NewTextErrorResponse("path is required"), nil
|
||||
}
|
||||
|
||||
offset := int64(0)
|
||||
limit := int64(0)
|
||||
if args.Offset != nil {
|
||||
offset = *args.Offset
|
||||
}
|
||||
if args.Limit != nil {
|
||||
limit = *args.Limit
|
||||
}
|
||||
|
||||
reader, mimeType, err := conn.ReadFile(ctx, args.Path, offset, limit)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
return toolResponse(map[string]any{
|
||||
"content": string(data),
|
||||
"mime_type": mimeType,
|
||||
}), nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
// ReadTemplateOptions configures the read_template tool.
|
||||
type ReadTemplateOptions struct {
|
||||
DB database.Store
|
||||
OwnerID uuid.UUID
|
||||
}
|
||||
|
||||
type readTemplateArgs struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
}
|
||||
|
||||
// ReadTemplate returns a tool that retrieves details about a specific
|
||||
// template, including its configurable rich parameters. The agent
|
||||
// uses this after list_templates and before create_workspace.
|
||||
func ReadTemplate(options ReadTemplateOptions) fantasy.AgentTool {
|
||||
return fantasy.NewAgentTool(
|
||||
"read_template",
|
||||
"Get details about a workspace template, including its "+
|
||||
"configurable parameters. Use this after finding a "+
|
||||
"template with list_templates and before creating a "+
|
||||
"workspace with create_workspace.",
|
||||
func(ctx context.Context, args readTemplateArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if options.DB == nil {
|
||||
return fantasy.NewTextErrorResponse("database is not configured"), nil
|
||||
}
|
||||
|
||||
templateIDStr := strings.TrimSpace(args.TemplateID)
|
||||
if templateIDStr == "" {
|
||||
return fantasy.NewTextErrorResponse("template_id is required"), nil
|
||||
}
|
||||
templateID, err := uuid.Parse(templateIDStr)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
xerrors.Errorf("invalid template_id: %w", err).Error(),
|
||||
), nil
|
||||
}
|
||||
|
||||
ctx, err = asOwner(ctx, options.DB, options.OwnerID)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
template, err := options.DB.GetTemplateByID(ctx, templateID)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse("template not found"), nil
|
||||
}
|
||||
|
||||
params, err := options.DB.GetTemplateVersionParameters(ctx, template.ActiveVersionID)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(
|
||||
xerrors.Errorf("failed to get template parameters: %w", err).Error(),
|
||||
), nil
|
||||
}
|
||||
|
||||
templateInfo := map[string]any{
|
||||
"id": template.ID.String(),
|
||||
"name": template.Name,
|
||||
"active_version_id": template.ActiveVersionID.String(),
|
||||
}
|
||||
if display := strings.TrimSpace(template.DisplayName); display != "" {
|
||||
templateInfo["display_name"] = display
|
||||
}
|
||||
if desc := strings.TrimSpace(template.Description); desc != "" {
|
||||
templateInfo["description"] = desc
|
||||
}
|
||||
|
||||
paramList := make([]map[string]any, 0, len(params))
|
||||
for _, p := range params {
|
||||
param := map[string]any{
|
||||
"name": p.Name,
|
||||
"type": p.Type,
|
||||
"required": p.Required,
|
||||
}
|
||||
if display := strings.TrimSpace(p.DisplayName); display != "" {
|
||||
param["display_name"] = display
|
||||
}
|
||||
if desc := strings.TrimSpace(p.Description); desc != "" {
|
||||
param["description"] = truncateRunes(desc, 300)
|
||||
}
|
||||
if p.DefaultValue != "" {
|
||||
param["default"] = p.DefaultValue
|
||||
}
|
||||
if p.Mutable {
|
||||
param["mutable"] = true
|
||||
}
|
||||
if p.Ephemeral {
|
||||
param["ephemeral"] = true
|
||||
}
|
||||
if p.FormType != "" {
|
||||
param["form_type"] = string(p.FormType)
|
||||
}
|
||||
if len(p.Options) > 0 && string(p.Options) != "null" && string(p.Options) != "[]" {
|
||||
var opts []map[string]any
|
||||
if err := json.Unmarshal(p.Options, &opts); err == nil && len(opts) > 0 {
|
||||
param["options"] = opts
|
||||
}
|
||||
}
|
||||
if p.ValidationRegex != "" {
|
||||
param["validation_regex"] = p.ValidationRegex
|
||||
}
|
||||
if p.ValidationMin.Valid {
|
||||
param["validation_min"] = p.ValidationMin.Int32
|
||||
}
|
||||
if p.ValidationMax.Valid {
|
||||
param["validation_max"] = p.ValidationMax.Int32
|
||||
}
|
||||
|
||||
paramList = append(paramList, param)
|
||||
}
|
||||
|
||||
return toolResponse(map[string]any{
|
||||
"template": templateInfo,
|
||||
"parameters": paramList,
|
||||
}), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package chattool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"charm.land/fantasy"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
type WriteFileOptions struct {
|
||||
GetWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error)
|
||||
}
|
||||
|
||||
type WriteFileArgs struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func WriteFile(options WriteFileOptions) fantasy.AgentTool {
|
||||
return fantasy.NewAgentTool(
|
||||
"write_file",
|
||||
"Write a file to the workspace.",
|
||||
func(ctx context.Context, args WriteFileArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if options.GetWorkspaceConn == nil {
|
||||
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
|
||||
}
|
||||
conn, err := options.GetWorkspaceConn(ctx)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return executeWriteFileTool(ctx, conn, args)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func executeWriteFileTool(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
args WriteFileArgs,
|
||||
) (fantasy.ToolResponse, error) {
|
||||
if args.Path == "" {
|
||||
return fantasy.NewTextErrorResponse("path is required"), nil
|
||||
}
|
||||
|
||||
if err := conn.WriteFile(ctx, args.Path, strings.NewReader(args.Content)); err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
return toolResponse(map[string]any{"ok": true}), nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
const (
|
||||
coderHomeInstructionDir = ".coder"
|
||||
coderHomeInstructionFile = "AGENTS.md"
|
||||
maxInstructionFileBytes = 64 * 1024
|
||||
)
|
||||
|
||||
var markdownCommentPattern = regexp.MustCompile(`<!--[\s\S]*?-->`)
|
||||
|
||||
func readHomeInstructionFile(
|
||||
ctx context.Context,
|
||||
conn workspacesdk.AgentConn,
|
||||
) (content string, sourcePath string, truncated bool, err error) {
|
||||
if conn == nil {
|
||||
return "", "", false, nil
|
||||
}
|
||||
|
||||
coderDir, err := conn.LS(ctx, "", workspacesdk.LSRequest{
|
||||
Path: []string{coderHomeInstructionDir},
|
||||
Relativity: workspacesdk.LSRelativityHome,
|
||||
})
|
||||
if err != nil {
|
||||
if isCodersdkStatusCode(err, http.StatusNotFound) {
|
||||
return "", "", false, nil
|
||||
}
|
||||
return "", "", false, xerrors.Errorf("list home instruction directory: %w", err)
|
||||
}
|
||||
|
||||
var filePath string
|
||||
for _, entry := range coderDir.Contents {
|
||||
if entry.IsDir {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(entry.Name), coderHomeInstructionFile) {
|
||||
filePath = strings.TrimSpace(entry.AbsolutePathString)
|
||||
break
|
||||
}
|
||||
}
|
||||
if filePath == "" {
|
||||
return "", "", false, nil
|
||||
}
|
||||
|
||||
reader, _, err := conn.ReadFile(
|
||||
ctx,
|
||||
filePath,
|
||||
0,
|
||||
maxInstructionFileBytes+1,
|
||||
)
|
||||
if err != nil {
|
||||
if isCodersdkStatusCode(err, http.StatusNotFound) {
|
||||
return "", "", false, nil
|
||||
}
|
||||
return "", "", false, xerrors.Errorf("read home instruction file: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
raw, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", "", false, xerrors.Errorf("read home instruction bytes: %w", err)
|
||||
}
|
||||
|
||||
truncated = int64(len(raw)) > maxInstructionFileBytes
|
||||
if truncated {
|
||||
raw = raw[:maxInstructionFileBytes]
|
||||
}
|
||||
|
||||
content = sanitizeInstructionMarkdown(string(raw))
|
||||
if content == "" {
|
||||
return "", "", truncated, nil
|
||||
}
|
||||
|
||||
return content, filePath, truncated, nil
|
||||
}
|
||||
|
||||
func sanitizeInstructionMarkdown(content string) string {
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "\n")
|
||||
content = markdownCommentPattern.ReplaceAllString(content, "")
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
//nolint:revive // Boolean indicates content was truncated.
|
||||
func formatHomeInstruction(content string, sourcePath string, truncated bool) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
sourcePath = strings.TrimSpace(sourcePath)
|
||||
if sourcePath == "" {
|
||||
sourcePath = "~/.coder/AGENTS.md"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
_, _ = b.WriteString("<coder-home-instructions>\n")
|
||||
_, _ = b.WriteString("Source: ")
|
||||
_, _ = b.WriteString(sourcePath)
|
||||
if truncated {
|
||||
_, _ = b.WriteString(" (truncated to 64KiB)")
|
||||
}
|
||||
_, _ = b.WriteString("\n\n")
|
||||
_, _ = b.WriteString(content)
|
||||
_, _ = b.WriteString("\n</coder-home-instructions>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isCodersdkStatusCode(err error, statusCode int) bool {
|
||||
var sdkErr *codersdk.Error
|
||||
if !xerrors.As(err, &sdkErr) {
|
||||
return false
|
||||
}
|
||||
return sdkErr.StatusCode() == statusCode
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package chatd //nolint:testpackage // Uses internal symbols.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
|
||||
)
|
||||
|
||||
func TestSanitizeInstructionMarkdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := "line 1\r\n<!-- hidden -->\r\nline 2\r\n"
|
||||
require.Equal(t, "line 1\n\nline 2", sanitizeInstructionMarkdown(input))
|
||||
}
|
||||
|
||||
func TestReadHomeInstructionFileNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
conn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).DoAndReturn(
|
||||
func(context.Context, string, workspacesdk.LSRequest) (workspacesdk.LSResponse, error) {
|
||||
return workspacesdk.LSResponse{}, codersdk.NewTestError(404, "POST", "/api/v0/list-directory")
|
||||
},
|
||||
)
|
||||
|
||||
content, sourcePath, truncated, err := readHomeInstructionFile(context.Background(), conn)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, content)
|
||||
require.Empty(t, sourcePath)
|
||||
require.False(t, truncated)
|
||||
}
|
||||
|
||||
func TestReadHomeInstructionFileSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
conn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
|
||||
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).DoAndReturn(
|
||||
func(context.Context, string, workspacesdk.LSRequest) (workspacesdk.LSResponse, error) {
|
||||
return workspacesdk.LSResponse{
|
||||
Contents: []workspacesdk.LSFile{{
|
||||
Name: "AGENTS.md",
|
||||
AbsolutePathString: "/home/coder/.coder/AGENTS.md",
|
||||
}},
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
conn.EXPECT().ReadFile(
|
||||
gomock.Any(),
|
||||
"/home/coder/.coder/AGENTS.md",
|
||||
int64(0),
|
||||
int64(maxInstructionFileBytes+1),
|
||||
).Return(
|
||||
io.NopCloser(strings.NewReader("base\n<!-- hidden -->\nlocal")),
|
||||
"text/markdown",
|
||||
nil,
|
||||
)
|
||||
|
||||
content, sourcePath, truncated, err := readHomeInstructionFile(context.Background(), conn)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "base\n\nlocal", content)
|
||||
require.Equal(t, "/home/coder/.coder/AGENTS.md", sourcePath)
|
||||
require.False(t, truncated)
|
||||
}
|
||||
|
||||
func TestReadHomeInstructionFileTruncates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
conn := agentconnmock.NewMockAgentConn(ctrl)
|
||||
content := strings.Repeat("a", maxInstructionFileBytes+8)
|
||||
|
||||
conn.EXPECT().LS(gomock.Any(), "", gomock.Any()).Return(
|
||||
workspacesdk.LSResponse{
|
||||
Contents: []workspacesdk.LSFile{{
|
||||
Name: "AGENTS.md",
|
||||
AbsolutePathString: "/home/coder/.coder/AGENTS.md",
|
||||
}},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
conn.EXPECT().ReadFile(
|
||||
gomock.Any(),
|
||||
"/home/coder/.coder/AGENTS.md",
|
||||
int64(0),
|
||||
int64(maxInstructionFileBytes+1),
|
||||
).Return(io.NopCloser(strings.NewReader(content)), "text/markdown", nil)
|
||||
|
||||
got, _, truncated, err := readHomeInstructionFile(context.Background(), conn)
|
||||
require.NoError(t, err)
|
||||
require.True(t, truncated)
|
||||
require.Len(t, got, maxInstructionFileBytes)
|
||||
}
|
||||
|
||||
func TestInsertSystemInstructionAfterSystemMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleSystem,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "base"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: "hello"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got := chatprompt.InsertSystem(prompt, "project rules")
|
||||
require.Len(t, got, 3)
|
||||
require.Equal(t, fantasy.MessageRoleSystem, got[0].Role)
|
||||
require.Equal(t, fantasy.MessageRoleSystem, got[1].Role)
|
||||
require.Equal(t, fantasy.MessageRoleUser, got[2].Role)
|
||||
|
||||
part, ok := fantasy.AsMessagePart[fantasy.TextPart](got[1].Content[0])
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "project rules", part.Text)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package chatd
|
||||
|
||||
// DefaultSystemPrompt is used for new chats when no deployment override is
|
||||
// configured.
|
||||
const DefaultSystemPrompt = `You are the Coder agent — an interactive chat tool that helps users with software-engineering tasks inside of the Coder product.
|
||||
Use the instructions below and the tools available to you to assist User.
|
||||
|
||||
IMPORTANT — obey every rule in this prompt before anything else.
|
||||
Do EXACTLY what the User asked, never more, never less.
|
||||
|
||||
<behavior>
|
||||
You MUST execute AS MANY TOOLS to help the user accomplish their task.
|
||||
You are COMFORTABLE with vague tasks - using your tools to collect the most relevant answer possible.
|
||||
If a user asks how something works, no matter how vague, you MUST use your tools to collect the most relevant answer possible.
|
||||
DO NOT ask the user for clarification - just use your tools.
|
||||
</behavior>
|
||||
|
||||
<personality>
|
||||
Analytical — You break problems into measurable steps, relying on tool output and data rather than intuition.
|
||||
Organized — You structure every interaction with clear tags, TODO lists, and section boundaries.
|
||||
Precision-Oriented — You insist on exact formatting, package-manager choice, and rule adherence.
|
||||
Efficiency-Focused — You minimize chatter, run tasks in parallel, and favor small, complete answers.
|
||||
Clarity-Seeking — You ask for missing details instead of guessing, avoiding any ambiguity.
|
||||
</personality>
|
||||
|
||||
<communication>
|
||||
Be concise, direct, and to the point.
|
||||
NO emojis unless the User explicitly asks for them.
|
||||
If a task appears incomplete or ambiguous, **pause and ask the User** rather than guessing or marking "done".
|
||||
Prefer accuracy over reassurance; confirm facts with tool calls instead of assuming the User is right.
|
||||
If you face an architectural, tooling, or package-manager choice, **ask the User's preference first**.
|
||||
Default to the project's existing package manager / tooling; never substitute without confirmation.
|
||||
You MUST avoid text before/after your response, such as "The answer is" or "Short answer:", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
|
||||
Mimic the style of the User's messages.
|
||||
Do not remind the User you are happy to help.
|
||||
Do not inherently assume the User is correct; they may be making assumptions.
|
||||
If you are not confident in your answer, DO NOT provide an answer. Use your tools to collect more information, or ask the User for help.
|
||||
Do not act with sycophantic flattery or over-the-top enthusiasm.
|
||||
|
||||
Here are examples to demonstrate appropriate communication style and level of verbosity:
|
||||
|
||||
<example>
|
||||
user: find me a good issue to work on
|
||||
assistant: Issue [#1234](https://example) indicates a bug in the frontend, which you've contributed to in the past.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: work on this issue <url>
|
||||
...assistant does work...
|
||||
assistant: I've put up this pull request: https://github.com/example/example/pull/1824. Please let me know your thoughts!
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: what is 2+2?
|
||||
assistant: 4
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: how does X work in <popular-repository-name>?
|
||||
assistant: Let me take a look at the code...
|
||||
[tool calls to investigate the repository]
|
||||
</example>
|
||||
</communication>
|
||||
|
||||
<collaboration>
|
||||
When a user asks for help with a task or there is ambiguity on the objective, always start by asking clarifying questions to understand:
|
||||
- What specific aspect they want to focus on
|
||||
- Their goals and vision for the changes
|
||||
- Their preferences for approach or style
|
||||
- What problems they're trying to solve
|
||||
|
||||
Don't assume what needs to be done - collaborate to define the scope together.
|
||||
</collaboration>`
|
||||
@@ -0,0 +1,512 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
||||
var ErrSubagentNotDescendant = xerrors.New("target chat is not a descendant of current chat")
|
||||
|
||||
const (
|
||||
subagentAwaitPollInterval = 200 * time.Millisecond
|
||||
defaultSubagentWaitTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
type spawnAgentArgs struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
type waitAgentArgs struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
TimeoutSeconds *int `json:"timeout_seconds,omitempty"`
|
||||
}
|
||||
|
||||
type messageAgentArgs struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Message string `json:"message"`
|
||||
Interrupt bool `json:"interrupt,omitempty"`
|
||||
}
|
||||
|
||||
type closeAgentArgs struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
}
|
||||
|
||||
func (p *Server) subagentTools(currentChat func() database.Chat) []fantasy.AgentTool {
|
||||
return []fantasy.AgentTool{
|
||||
fantasy.NewAgentTool(
|
||||
"spawn_agent",
|
||||
"Spawn a delegated child agent chat from the root chat.",
|
||||
func(ctx context.Context, args spawnAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if currentChat == nil {
|
||||
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
|
||||
}
|
||||
|
||||
parent := currentChat()
|
||||
if parent.ParentChatID.Valid {
|
||||
return fantasy.NewTextErrorResponse("delegated chats cannot create child subagents"), nil
|
||||
}
|
||||
|
||||
parent, err := p.db.GetChatByID(ctx, parent.ID)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
childChat, err := p.createChildSubagentChat(
|
||||
ctx,
|
||||
parent,
|
||||
args.Prompt,
|
||||
args.Title,
|
||||
)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
return toolJSONResponse(map[string]any{
|
||||
"chat_id": childChat.ID.String(),
|
||||
"title": childChat.Title,
|
||||
"status": string(childChat.Status),
|
||||
}), nil
|
||||
},
|
||||
),
|
||||
fantasy.NewAgentTool(
|
||||
"wait_agent",
|
||||
"Wait until a delegated descendant agent reaches a non-streaming status.",
|
||||
func(ctx context.Context, args waitAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if currentChat == nil {
|
||||
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
|
||||
}
|
||||
|
||||
targetChatID, err := parseSubagentToolChatID(args.ChatID)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
timeout := defaultSubagentWaitTimeout
|
||||
if args.TimeoutSeconds != nil {
|
||||
timeout = time.Duration(*args.TimeoutSeconds) * time.Second
|
||||
}
|
||||
|
||||
parent := currentChat()
|
||||
targetChat, report, err := p.awaitSubagentCompletion(
|
||||
ctx,
|
||||
parent.ID,
|
||||
targetChatID,
|
||||
timeout,
|
||||
)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
return toolJSONResponse(map[string]any{
|
||||
"chat_id": targetChatID.String(),
|
||||
"title": targetChat.Title,
|
||||
"report": report,
|
||||
"status": string(targetChat.Status),
|
||||
}), nil
|
||||
},
|
||||
),
|
||||
fantasy.NewAgentTool(
|
||||
"message_agent",
|
||||
"Send a message to a delegated descendant agent. Use wait_agent to collect a response.",
|
||||
func(ctx context.Context, args messageAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if currentChat == nil {
|
||||
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
|
||||
}
|
||||
|
||||
targetChatID, err := parseSubagentToolChatID(args.ChatID)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
parent := currentChat()
|
||||
busyBehavior := SendMessageBusyBehaviorQueue
|
||||
if args.Interrupt {
|
||||
busyBehavior = SendMessageBusyBehaviorInterrupt
|
||||
}
|
||||
targetChat, err := p.sendSubagentMessage(
|
||||
ctx,
|
||||
parent.ID,
|
||||
targetChatID,
|
||||
args.Message,
|
||||
busyBehavior,
|
||||
)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
return toolJSONResponse(map[string]any{
|
||||
"chat_id": targetChatID.String(),
|
||||
"title": targetChat.Title,
|
||||
"status": string(targetChat.Status),
|
||||
"interrupted": args.Interrupt,
|
||||
}), nil
|
||||
},
|
||||
),
|
||||
fantasy.NewAgentTool(
|
||||
"close_agent",
|
||||
"Interrupt a delegated descendant agent immediately.",
|
||||
func(ctx context.Context, args closeAgentArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
|
||||
if currentChat == nil {
|
||||
return fantasy.NewTextErrorResponse("subagent callbacks are not configured"), nil
|
||||
}
|
||||
|
||||
targetChatID, err := parseSubagentToolChatID(args.ChatID)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
parent := currentChat()
|
||||
targetChat, err := p.closeSubagent(
|
||||
ctx,
|
||||
parent.ID,
|
||||
targetChatID,
|
||||
)
|
||||
if err != nil {
|
||||
return fantasy.NewTextErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
return toolJSONResponse(map[string]any{
|
||||
"chat_id": targetChatID.String(),
|
||||
"title": targetChat.Title,
|
||||
"terminated": true,
|
||||
"status": string(targetChat.Status),
|
||||
}), nil
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func parseSubagentToolChatID(raw string) (uuid.UUID, error) {
|
||||
chatID, err := uuid.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return uuid.Nil, xerrors.New("chat_id must be a valid UUID")
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
func (p *Server) createChildSubagentChat(
|
||||
ctx context.Context,
|
||||
parent database.Chat,
|
||||
prompt string,
|
||||
title string,
|
||||
) (database.Chat, error) {
|
||||
if parent.ParentChatID.Valid {
|
||||
return database.Chat{}, xerrors.New("delegated chats cannot create child subagents")
|
||||
}
|
||||
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
return database.Chat{}, xerrors.New("prompt is required")
|
||||
}
|
||||
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
title = subagentFallbackChatTitle(prompt)
|
||||
}
|
||||
|
||||
rootChatID := parent.ID
|
||||
if parent.RootChatID.Valid {
|
||||
rootChatID = parent.RootChatID.UUID
|
||||
}
|
||||
if parent.LastModelConfigID == uuid.Nil {
|
||||
return database.Chat{}, xerrors.New("parent chat model config id is required")
|
||||
}
|
||||
|
||||
child, err := p.CreateChat(ctx, CreateOptions{
|
||||
OwnerID: parent.OwnerID,
|
||||
WorkspaceID: parent.WorkspaceID,
|
||||
WorkspaceAgentID: parent.WorkspaceAgentID,
|
||||
ParentChatID: uuid.NullUUID{
|
||||
UUID: parent.ID,
|
||||
Valid: true,
|
||||
},
|
||||
RootChatID: uuid.NullUUID{
|
||||
UUID: rootChatID,
|
||||
Valid: true,
|
||||
},
|
||||
ModelConfigID: parent.LastModelConfigID,
|
||||
Title: title,
|
||||
InitialUserContent: []fantasy.Content{fantasy.TextContent{Text: prompt}},
|
||||
})
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("create child chat: %w", err)
|
||||
}
|
||||
|
||||
return child, nil
|
||||
}
|
||||
|
||||
func (p *Server) sendSubagentMessage(
|
||||
ctx context.Context,
|
||||
parentChatID uuid.UUID,
|
||||
targetChatID uuid.UUID,
|
||||
message string,
|
||||
busyBehavior SendMessageBusyBehavior,
|
||||
) (database.Chat, error) {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return database.Chat{}, xerrors.New("message is required")
|
||||
}
|
||||
|
||||
isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID)
|
||||
if err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
if !isDescendant {
|
||||
return database.Chat{}, ErrSubagentNotDescendant
|
||||
}
|
||||
|
||||
sendResult, err := p.SendMessage(ctx, SendMessageOptions{
|
||||
ChatID: targetChatID,
|
||||
Content: []fantasy.Content{fantasy.TextContent{Text: message}},
|
||||
BusyBehavior: busyBehavior,
|
||||
})
|
||||
if err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
|
||||
return sendResult.Chat, nil
|
||||
}
|
||||
|
||||
func (p *Server) awaitSubagentCompletion(
|
||||
ctx context.Context,
|
||||
parentChatID uuid.UUID,
|
||||
targetChatID uuid.UUID,
|
||||
timeout time.Duration,
|
||||
) (database.Chat, string, error) {
|
||||
isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID)
|
||||
if err != nil {
|
||||
return database.Chat{}, "", err
|
||||
}
|
||||
if !isDescendant {
|
||||
return database.Chat{}, "", ErrSubagentNotDescendant
|
||||
}
|
||||
|
||||
if timeout <= 0 {
|
||||
timeout = defaultSubagentWaitTimeout
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
ticker := time.NewTicker(subagentAwaitPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
targetChat, report, done, checkErr := p.checkSubagentCompletion(ctx, targetChatID)
|
||||
if checkErr != nil {
|
||||
return database.Chat{}, "", checkErr
|
||||
}
|
||||
if done {
|
||||
if targetChat.Status == database.ChatStatusError {
|
||||
reason := strings.TrimSpace(report)
|
||||
if reason == "" {
|
||||
reason = "agent reached error status"
|
||||
}
|
||||
return database.Chat{}, "", xerrors.New(reason)
|
||||
}
|
||||
return targetChat, report, nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-timer.C:
|
||||
return database.Chat{}, "", xerrors.New("timed out waiting for delegated subagent completion")
|
||||
case <-ctx.Done():
|
||||
return database.Chat{}, "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Server) closeSubagent(
|
||||
ctx context.Context,
|
||||
parentChatID uuid.UUID,
|
||||
targetChatID uuid.UUID,
|
||||
) (database.Chat, error) {
|
||||
isDescendant, err := isSubagentDescendant(ctx, p.db, parentChatID, targetChatID)
|
||||
if err != nil {
|
||||
return database.Chat{}, err
|
||||
}
|
||||
if !isDescendant {
|
||||
return database.Chat{}, ErrSubagentNotDescendant
|
||||
}
|
||||
|
||||
targetChat, err := p.db.GetChatByID(ctx, targetChatID)
|
||||
if err != nil {
|
||||
return database.Chat{}, xerrors.Errorf("get target chat: %w", err)
|
||||
}
|
||||
|
||||
if targetChat.Status == database.ChatStatusWaiting {
|
||||
return targetChat, nil
|
||||
}
|
||||
|
||||
updatedChat := p.InterruptChat(ctx, targetChat)
|
||||
if updatedChat.Status != database.ChatStatusWaiting {
|
||||
return database.Chat{}, xerrors.New("set target chat waiting")
|
||||
}
|
||||
return updatedChat, nil
|
||||
}
|
||||
|
||||
func (p *Server) checkSubagentCompletion(
|
||||
ctx context.Context,
|
||||
chatID uuid.UUID,
|
||||
) (database.Chat, string, bool, error) {
|
||||
chat, err := p.db.GetChatByID(ctx, chatID)
|
||||
if err != nil {
|
||||
return database.Chat{}, "", false, xerrors.Errorf("get chat: %w", err)
|
||||
}
|
||||
|
||||
if chat.Status == database.ChatStatusPending || chat.Status == database.ChatStatusRunning {
|
||||
return database.Chat{}, "", false, nil
|
||||
}
|
||||
|
||||
report, err := latestSubagentAssistantMessage(ctx, p.db, chatID)
|
||||
if err != nil {
|
||||
return database.Chat{}, "", false, err
|
||||
}
|
||||
|
||||
return chat, report, true, nil
|
||||
}
|
||||
|
||||
func latestSubagentAssistantMessage(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
chatID uuid.UUID,
|
||||
) (string, error) {
|
||||
messages, err := store.GetChatMessagesByChatID(ctx, chatID)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get chat messages: %w", err)
|
||||
}
|
||||
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
if messages[i].CreatedAt.Equal(messages[j].CreatedAt) {
|
||||
return messages[i].ID < messages[j].ID
|
||||
}
|
||||
return messages[i].CreatedAt.Before(messages[j].CreatedAt)
|
||||
})
|
||||
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
message := messages[i]
|
||||
if message.Role != string(fantasy.MessageRoleAssistant) ||
|
||||
message.Visibility == database.ChatMessageVisibilityModel {
|
||||
continue
|
||||
}
|
||||
|
||||
content, parseErr := chatprompt.ParseContent(message.Role, message.Content)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(contentBlocksToText(content))
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func isSubagentDescendant(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
ancestorChatID uuid.UUID,
|
||||
targetChatID uuid.UUID,
|
||||
) (bool, error) {
|
||||
if ancestorChatID == targetChatID {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
descendants, err := listSubagentDescendants(ctx, store, ancestorChatID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, descendant := range descendants {
|
||||
if descendant.ID == targetChatID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func listSubagentDescendants(
|
||||
ctx context.Context,
|
||||
store database.Store,
|
||||
chatID uuid.UUID,
|
||||
) ([]database.Chat, error) {
|
||||
queue := []uuid.UUID{chatID}
|
||||
visited := map[uuid.UUID]struct{}{chatID: {}}
|
||||
|
||||
out := make([]database.Chat, 0)
|
||||
for len(queue) > 0 {
|
||||
parentChatID := queue[0]
|
||||
queue = queue[1:]
|
||||
|
||||
children, err := store.ListChildChatsByParentID(ctx, parentChatID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("list child chats for %s: %w", parentChatID, err)
|
||||
}
|
||||
|
||||
for _, child := range children {
|
||||
if _, ok := visited[child.ID]; ok {
|
||||
continue
|
||||
}
|
||||
visited[child.ID] = struct{}{}
|
||||
out = append(out, child)
|
||||
queue = append(queue, child.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func subagentFallbackChatTitle(message string) string {
|
||||
const maxWords = 6
|
||||
const maxRunes = 80
|
||||
|
||||
words := strings.Fields(message)
|
||||
if len(words) == 0 {
|
||||
return "New Chat"
|
||||
}
|
||||
|
||||
truncated := false
|
||||
if len(words) > maxWords {
|
||||
words = words[:maxWords]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
title := strings.Join(words, " ")
|
||||
if truncated {
|
||||
title += "..."
|
||||
}
|
||||
|
||||
return subagentTruncateRunes(title, maxRunes)
|
||||
}
|
||||
|
||||
func subagentTruncateRunes(value string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxRunes {
|
||||
return value
|
||||
}
|
||||
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
|
||||
func toolJSONResponse(result map[string]any) fantasy.ToolResponse {
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return fantasy.NewTextResponse("{}")
|
||||
}
|
||||
return fantasy.NewTextResponse(string(data))
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package chatd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/fantasy"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/coderd/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
|
||||
)
|
||||
|
||||
const titleGenerationPrompt = "Generate a concise title (max 8 words, under 128 characters) for " +
|
||||
"the user's first message. Return plain text only — no quotes, no emoji, " +
|
||||
"no markdown, no special characters."
|
||||
|
||||
// maybeGenerateChatTitle generates an AI title for the chat when
|
||||
// appropriate (first user message, no assistant reply yet, and the
|
||||
// current title is either empty or still the fallback truncation).
|
||||
// It is a best-effort operation that logs and swallows errors.
|
||||
func (p *Server) maybeGenerateChatTitle(
|
||||
ctx context.Context,
|
||||
chat database.Chat,
|
||||
messages []database.ChatMessage,
|
||||
model fantasy.LanguageModel,
|
||||
logger slog.Logger,
|
||||
) {
|
||||
input, ok := titleInput(chat, messages)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
titleCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
title, err := generateTitle(titleCtx, model, input)
|
||||
if err != nil {
|
||||
logger.Debug(ctx, "failed to generate chat title",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
if title == "" || title == chat.Title {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = p.db.UpdateChatByID(ctx, database.UpdateChatByIDParams{
|
||||
ID: chat.ID,
|
||||
Title: title,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "failed to update generated chat title",
|
||||
slog.F("chat_id", chat.ID),
|
||||
slog.Error(err),
|
||||
)
|
||||
return
|
||||
}
|
||||
chat.Title = title
|
||||
p.publishChatPubsubEvent(chat, coderdpubsub.ChatEventKindTitleChange)
|
||||
}
|
||||
|
||||
// generateTitle calls the model with a title-generation system prompt
|
||||
// and returns the normalized result.
|
||||
func generateTitle(
|
||||
ctx context.Context,
|
||||
model fantasy.LanguageModel,
|
||||
input string,
|
||||
) (string, error) {
|
||||
prompt := []fantasy.Message{
|
||||
{
|
||||
Role: fantasy.MessageRoleSystem,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: titleGenerationPrompt},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: fantasy.MessageRoleUser,
|
||||
Content: []fantasy.MessagePart{
|
||||
fantasy.TextPart{Text: input},
|
||||
},
|
||||
},
|
||||
}
|
||||
toolChoice := fantasy.ToolChoiceNone
|
||||
response, err := model.Generate(ctx, fantasy.Call{
|
||||
Prompt: prompt,
|
||||
ToolChoice: &toolChoice,
|
||||
})
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("generate title text: %w", err)
|
||||
}
|
||||
|
||||
title := normalizeTitleOutput(contentBlocksToText(response.Content))
|
||||
if title == "" {
|
||||
return "", xerrors.New("generated title was empty")
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
// titleInput returns the first user message text and whether title
|
||||
// generation should proceed. It returns false when the chat already
|
||||
// has assistant/tool replies, has more than one visible user message,
|
||||
// or the current title doesn't look like a candidate for replacement.
|
||||
func titleInput(
|
||||
chat database.Chat,
|
||||
messages []database.ChatMessage,
|
||||
) (string, bool) {
|
||||
userCount := 0
|
||||
firstUserText := ""
|
||||
|
||||
for _, message := range messages {
|
||||
if message.Visibility == database.ChatMessageVisibilityModel {
|
||||
continue
|
||||
}
|
||||
|
||||
switch message.Role {
|
||||
case string(fantasy.MessageRoleAssistant), string(fantasy.MessageRoleTool):
|
||||
return "", false
|
||||
case string(fantasy.MessageRoleUser):
|
||||
userCount++
|
||||
if firstUserText == "" {
|
||||
parsed, err := chatprompt.ParseContent(
|
||||
string(fantasy.MessageRoleUser), message.Content,
|
||||
)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
firstUserText = strings.TrimSpace(
|
||||
contentBlocksToText(parsed),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if userCount != 1 || firstUserText == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
currentTitle := strings.TrimSpace(chat.Title)
|
||||
if currentTitle == "" {
|
||||
return firstUserText, true
|
||||
}
|
||||
|
||||
if currentTitle != fallbackChatTitle(firstUserText) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return firstUserText, true
|
||||
}
|
||||
|
||||
func normalizeTitleOutput(title string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
title = strings.Trim(title, "\"'`")
|
||||
title = strings.Join(strings.Fields(title), " ")
|
||||
return truncateRunes(title, 80)
|
||||
}
|
||||
|
||||
func fallbackChatTitle(message string) string {
|
||||
const maxWords = 6
|
||||
const maxRunes = 80
|
||||
|
||||
words := strings.Fields(message)
|
||||
if len(words) == 0 {
|
||||
return "New Chat"
|
||||
}
|
||||
|
||||
truncated := false
|
||||
if len(words) > maxWords {
|
||||
words = words[:maxWords]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
title := strings.Join(words, " ")
|
||||
if truncated {
|
||||
title += "…"
|
||||
}
|
||||
|
||||
return truncateRunes(title, maxRunes)
|
||||
}
|
||||
|
||||
// contentBlocksToText concatenates the text parts of content blocks
|
||||
// into a single space-separated string.
|
||||
func contentBlocksToText(content []fantasy.Content) string {
|
||||
parts := make([]string, 0, len(content))
|
||||
for _, block := range 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.Join(parts, " ")
|
||||
}
|
||||
|
||||
func truncateRunes(value string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxLen {
|
||||
return value
|
||||
}
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
Reference in New Issue
Block a user