mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add per-chat system prompt option (#24053)
Adds a `system_prompt` field to `CreateChatRequest` that allows API consumers to provide custom instructions when creating a chat. The per-chat prompt is stored as a separate system message (`role=system`, `visibility=model`) in the `chat_messages` table, inserted between the deployment system prompt and the workspace awareness message. Also moves deployment system prompt resolution from the HTTP handler (`resolvedChatSystemPrompt`) into `chatd.CreateChat` where it belongs. The handler no longer assembles system prompts — `CreateOptions.SystemPrompt` is now purely the per-chat user prompt, and the deployment prompt is resolved internally by chatd. No database schema changes required. **Message insertion order:** 1. Deployment system prompt (resolved by chatd, existing) 2. Per-chat user system prompt (new, from `CreateOptions.SystemPrompt`) 3. Workspace awareness (existing) 4. Initial user message (existing) 🤖 Generated with [Coder Agents](https://coder.com/agents)
This commit is contained in:
+11
-30
@@ -403,6 +403,16 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate per-chat system prompt length.
|
||||||
|
const maxSystemPromptLen = 10000
|
||||||
|
if len(req.SystemPrompt) > maxSystemPromptLen {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "System prompt exceeds maximum length.",
|
||||||
|
Detail: fmt.Sprintf("System prompt must be at most %d characters, got %d.", maxSystemPromptLen, len(req.SystemPrompt)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
contentBlocks, titleSource, inputError := createChatInputFromRequest(ctx, api.Database, req)
|
contentBlocks, titleSource, inputError := createChatInputFromRequest(ctx, api.Database, req)
|
||||||
if inputError != nil {
|
if inputError != nil {
|
||||||
httpapi.Write(ctx, rw, http.StatusBadRequest, *inputError)
|
httpapi.Write(ctx, rw, http.StatusBadRequest, *inputError)
|
||||||
@@ -483,7 +493,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
|
|||||||
WorkspaceID: workspaceSelection.WorkspaceID,
|
WorkspaceID: workspaceSelection.WorkspaceID,
|
||||||
Title: title,
|
Title: title,
|
||||||
ModelConfigID: modelConfigID,
|
ModelConfigID: modelConfigID,
|
||||||
SystemPrompt: api.resolvedChatSystemPrompt(ctx),
|
SystemPrompt: req.SystemPrompt,
|
||||||
InitialUserContent: contentBlocks,
|
InitialUserContent: contentBlocks,
|
||||||
MCPServerIDs: mcpServerIDs,
|
MCPServerIDs: mcpServerIDs,
|
||||||
Labels: labels,
|
Labels: labels,
|
||||||
@@ -3477,35 +3487,6 @@ func (api *API) deleteUserChatCompactionThreshold(rw http.ResponseWriter, r *htt
|
|||||||
rw.WriteHeader(http.StatusNoContent)
|
rw.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) resolvedChatSystemPrompt(ctx context.Context) string {
|
|
||||||
config, err := api.Database.GetChatSystemPromptConfig(ctx)
|
|
||||||
if err != nil {
|
|
||||||
// We intentionally fail open here. When the prompt configuration
|
|
||||||
// cannot be read, returning the built-in default keeps the chat
|
|
||||||
// grounded instead of sending no system guidance at all.
|
|
||||||
api.Logger.Error(ctx, "failed to fetch chat system prompt configuration, using default", slog.Error(err))
|
|
||||||
return chatd.DefaultSystemPrompt
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitizedCustom := chatd.SanitizePromptText(config.ChatSystemPrompt)
|
|
||||||
if sanitizedCustom == "" && strings.TrimSpace(config.ChatSystemPrompt) != "" {
|
|
||||||
api.Logger.Warn(ctx, "custom system prompt became empty after sanitization, omitting custom portion")
|
|
||||||
}
|
|
||||||
|
|
||||||
var parts []string
|
|
||||||
if config.IncludeDefaultSystemPrompt {
|
|
||||||
parts = append(parts, chatd.DefaultSystemPrompt)
|
|
||||||
}
|
|
||||||
if sanitizedCustom != "" {
|
|
||||||
parts = append(parts, sanitizedCustom)
|
|
||||||
}
|
|
||||||
result := strings.Join(parts, "\n\n")
|
|
||||||
if result == "" {
|
|
||||||
api.Logger.Warn(ctx, "resolved system prompt is empty, no system prompt will be injected into chats")
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
apiKey := httpmw.APIKey(r)
|
apiKey := httpmw.APIKey(r)
|
||||||
|
|||||||
@@ -313,6 +313,111 @@ func TestPostChats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("WithPerChatSystemPrompt", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
client, db := newChatClientWithDatabase(t)
|
||||||
|
_ = coderdtest.CreateFirstUser(t, client.Client)
|
||||||
|
_ = createChatModelConfig(t, client)
|
||||||
|
|
||||||
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||||
|
Content: []codersdk.ChatInputPart{
|
||||||
|
{
|
||||||
|
Type: codersdk.ChatInputPartTypeText,
|
||||||
|
Text: "hello with system prompt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SystemPrompt: "You are a Go expert.",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, uuid.Nil, chat.ID)
|
||||||
|
|
||||||
|
// Use the DB directly to see system messages, which are
|
||||||
|
// hidden from the public API.
|
||||||
|
dbMessages, err := db.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Expect: deployment system prompt, per-chat system prompt,
|
||||||
|
// workspace awareness, user message.
|
||||||
|
var systemMessages []database.ChatMessage
|
||||||
|
for _, msg := range dbMessages {
|
||||||
|
if msg.Role == database.ChatMessageRoleSystem {
|
||||||
|
systemMessages = append(systemMessages, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.GreaterOrEqual(t, len(systemMessages), 2,
|
||||||
|
"expected at least deployment + per-chat system messages")
|
||||||
|
|
||||||
|
// The per-chat system prompt should be the second system
|
||||||
|
// message and contain the user-specified text.
|
||||||
|
foundPerChat := false
|
||||||
|
for _, msg := range systemMessages {
|
||||||
|
if msg.Content.Valid {
|
||||||
|
raw := string(msg.Content.RawMessage)
|
||||||
|
if strings.Contains(raw, "You are a Go expert.") {
|
||||||
|
foundPerChat = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.True(t, foundPerChat,
|
||||||
|
"per-chat system prompt not found in system messages")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PerChatSystemPromptEmpty", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
client, db := newChatClientWithDatabase(t)
|
||||||
|
_ = coderdtest.CreateFirstUser(t, client.Client)
|
||||||
|
_ = createChatModelConfig(t, client)
|
||||||
|
|
||||||
|
chat, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||||
|
Content: []codersdk.ChatInputPart{
|
||||||
|
{
|
||||||
|
Type: codersdk.ChatInputPartTypeText,
|
||||||
|
Text: "hello without system prompt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SystemPrompt: "",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dbMessages, err := db.GetChatMessagesForPromptByChatID(dbauthz.AsSystemRestricted(ctx), chat.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// No per-chat system prompt should be present.
|
||||||
|
for _, msg := range dbMessages {
|
||||||
|
if msg.Role == database.ChatMessageRoleSystem && msg.Content.Valid {
|
||||||
|
raw := string(msg.Content.RawMessage)
|
||||||
|
require.NotContains(t, raw, "You are a Go expert.",
|
||||||
|
"unexpected per-chat system prompt in messages")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PerChatSystemPromptTooLong", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
client := newChatClient(t)
|
||||||
|
_ = coderdtest.CreateFirstUser(t, client.Client)
|
||||||
|
_ = createChatModelConfig(t, client)
|
||||||
|
|
||||||
|
longPrompt := strings.Repeat("a", 10001)
|
||||||
|
_, err := client.CreateChat(ctx, codersdk.CreateChatRequest{
|
||||||
|
Content: []codersdk.ChatInputPart{
|
||||||
|
{
|
||||||
|
Type: codersdk.ChatInputPartTypeText,
|
||||||
|
Text: "hello",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SystemPrompt: longPrompt,
|
||||||
|
})
|
||||||
|
requireSDKError(t, err, http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("WorkspaceNotAccessible", func(t *testing.T) {
|
t.Run("WorkspaceNotAccessible", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+54
-6
@@ -886,7 +886,8 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
|
|||||||
return xerrors.Errorf("insert chat: %w", err)
|
return xerrors.Errorf("insert chat: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
systemPrompt := strings.TrimSpace(opts.SystemPrompt)
|
deploymentPrompt := p.resolveDeploymentSystemPrompt(ctx)
|
||||||
|
userPrompt := SanitizePromptText(opts.SystemPrompt)
|
||||||
var workspaceAwareness string
|
var workspaceAwareness string
|
||||||
if opts.WorkspaceID.Valid {
|
if opts.WorkspaceID.Valid {
|
||||||
workspaceAwareness = "This chat is attached to a workspace. You can use workspace tools like execute, read_file, write_file, etc."
|
workspaceAwareness = "This chat is attached to a workspace. You can use workspace tools like execute, read_file, write_file, etc."
|
||||||
@@ -908,16 +909,32 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
|
|||||||
ChatID: insertedChat.ID,
|
ChatID: insertedChat.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if systemPrompt != "" {
|
if deploymentPrompt != "" {
|
||||||
systemContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
|
deploymentContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
|
||||||
codersdk.ChatMessageText(systemPrompt),
|
codersdk.ChatMessageText(deploymentPrompt),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return xerrors.Errorf("marshal system prompt: %w", err)
|
return xerrors.Errorf("marshal deployment system prompt: %w", err)
|
||||||
}
|
}
|
||||||
appendChatMessage(&msgParams, newChatMessage(
|
appendChatMessage(&msgParams, newChatMessage(
|
||||||
database.ChatMessageRoleSystem,
|
database.ChatMessageRoleSystem,
|
||||||
systemContent,
|
deploymentContent,
|
||||||
|
database.ChatMessageVisibilityModel,
|
||||||
|
opts.ModelConfigID,
|
||||||
|
chatprompt.CurrentContentVersion,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if userPrompt != "" {
|
||||||
|
userPromptContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
|
||||||
|
codersdk.ChatMessageText(userPrompt),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return xerrors.Errorf("marshal user system prompt: %w", err)
|
||||||
|
}
|
||||||
|
appendChatMessage(&msgParams, newChatMessage(
|
||||||
|
database.ChatMessageRoleSystem,
|
||||||
|
userPromptContent,
|
||||||
database.ChatMessageVisibilityModel,
|
database.ChatMessageVisibilityModel,
|
||||||
opts.ModelConfigID,
|
opts.ModelConfigID,
|
||||||
chatprompt.CurrentContentVersion,
|
chatprompt.CurrentContentVersion,
|
||||||
@@ -5229,6 +5246,37 @@ func (p *Server) resolveUserCompactionThreshold(ctx context.Context, userID uuid
|
|||||||
return int32(val), true
|
return int32(val), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveDeploymentSystemPrompt builds the deployment-level system
|
||||||
|
// prompt from the built-in default and the admin-configured custom
|
||||||
|
// prompt stored in site_configs.
|
||||||
|
func (p *Server) resolveDeploymentSystemPrompt(ctx context.Context) string {
|
||||||
|
config, err := p.db.GetChatSystemPromptConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Fail open: use the built-in default so chats always have
|
||||||
|
// some system guidance.
|
||||||
|
p.logger.Error(ctx, "failed to fetch chat system prompt configuration, using default", slog.Error(err))
|
||||||
|
return DefaultSystemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizedCustom := SanitizePromptText(config.ChatSystemPrompt)
|
||||||
|
if sanitizedCustom == "" && strings.TrimSpace(config.ChatSystemPrompt) != "" {
|
||||||
|
p.logger.Warn(ctx, "custom system prompt became empty after sanitization, omitting custom portion")
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
if config.IncludeDefaultSystemPrompt {
|
||||||
|
parts = append(parts, DefaultSystemPrompt)
|
||||||
|
}
|
||||||
|
if sanitizedCustom != "" {
|
||||||
|
parts = append(parts, sanitizedCustom)
|
||||||
|
}
|
||||||
|
result := strings.Join(parts, "\n\n")
|
||||||
|
if result == "" {
|
||||||
|
p.logger.Warn(ctx, "resolved system prompt is empty, no system prompt will be injected into chats")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// resolveUserPrompt fetches the user's custom chat prompt from the
|
// resolveUserPrompt fetches the user's custom chat prompt from the
|
||||||
// database and wraps it in <user-instructions> tags. Returns empty
|
// database and wraps it in <user-instructions> tags. Returns empty
|
||||||
// string if no prompt is set.
|
// string if no prompt is set.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package chatd_test
|
package chatd_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -110,19 +111,19 @@ func TestSpawnComputerUseAgent_SystemPromptFormat(t *testing.T) {
|
|||||||
|
|
||||||
// The system message raw content is a JSON-encoded string.
|
// The system message raw content is a JSON-encoded string.
|
||||||
// It should contain the system prompt with the user prompt.
|
// It should contain the system prompt with the user prompt.
|
||||||
var rawSystemContent string
|
var foundPrompt bool
|
||||||
for _, msg := range messages {
|
for _, msg := range messages {
|
||||||
if msg.Role != "system" {
|
if msg.Role != "system" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if msg.Content.Valid {
|
if msg.Content.Valid && strings.Contains(string(msg.Content.RawMessage), prompt) {
|
||||||
rawSystemContent = string(msg.Content.RawMessage)
|
foundPrompt = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Contains(t, rawSystemContent, prompt,
|
assert.True(t, foundPrompt,
|
||||||
"system prompt raw content should contain the user prompt")
|
"at least one system message should contain the user prompt")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSpawnComputerUseAgent_ChildIsListedUnderParent(t *testing.T) {
|
func TestSpawnComputerUseAgent_ChildIsListedUnderParent(t *testing.T) {
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ type ChatInputPart struct {
|
|||||||
// CreateChatRequest is the request to create a new chat.
|
// CreateChatRequest is the request to create a new chat.
|
||||||
type CreateChatRequest struct {
|
type CreateChatRequest struct {
|
||||||
Content []ChatInputPart `json:"content"`
|
Content []ChatInputPart `json:"content"`
|
||||||
|
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||||
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"`
|
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty" format:"uuid"`
|
||||||
ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"`
|
ModelConfigID *uuid.UUID `json:"model_config_id,omitempty" format:"uuid"`
|
||||||
MCPServerIDs []uuid.UUID `json:"mcp_server_ids,omitempty" format:"uuid"`
|
MCPServerIDs []uuid.UUID `json:"mcp_server_ids,omitempty" format:"uuid"`
|
||||||
|
|||||||
Generated
+1
@@ -2386,6 +2386,7 @@ export interface CreateChatProviderConfigRequest {
|
|||||||
*/
|
*/
|
||||||
export interface CreateChatRequest {
|
export interface CreateChatRequest {
|
||||||
readonly content: readonly ChatInputPart[];
|
readonly content: readonly ChatInputPart[];
|
||||||
|
readonly system_prompt?: string;
|
||||||
readonly workspace_id?: string;
|
readonly workspace_id?: string;
|
||||||
readonly model_config_id?: string;
|
readonly model_config_id?: string;
|
||||||
readonly mcp_server_ids?: readonly string[];
|
readonly mcp_server_ids?: readonly string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user