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:
Kyle Carberry
2026-04-06 13:19:05 -04:00
committed by GitHub
parent a2ce74f398
commit 4cfbf544a0
6 changed files with 178 additions and 41 deletions
+11 -30
View File
@@ -403,6 +403,16 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
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)
if inputError != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, *inputError)
@@ -483,7 +493,7 @@ func (api *API) postChats(rw http.ResponseWriter, r *http.Request) {
WorkspaceID: workspaceSelection.WorkspaceID,
Title: title,
ModelConfigID: modelConfigID,
SystemPrompt: api.resolvedChatSystemPrompt(ctx),
SystemPrompt: req.SystemPrompt,
InitialUserContent: contentBlocks,
MCPServerIDs: mcpServerIDs,
Labels: labels,
@@ -3477,35 +3487,6 @@ func (api *API) deleteUserChatCompactionThreshold(rw http.ResponseWriter, r *htt
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) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
+105
View File
@@ -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.Parallel()
+54 -6
View File
@@ -886,7 +886,8 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C
return xerrors.Errorf("insert chat: %w", err)
}
systemPrompt := strings.TrimSpace(opts.SystemPrompt)
deploymentPrompt := p.resolveDeploymentSystemPrompt(ctx)
userPrompt := SanitizePromptText(opts.SystemPrompt)
var workspaceAwareness string
if opts.WorkspaceID.Valid {
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,
}
if systemPrompt != "" {
systemContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText(systemPrompt),
if deploymentPrompt != "" {
deploymentContent, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{
codersdk.ChatMessageText(deploymentPrompt),
})
if err != nil {
return xerrors.Errorf("marshal system prompt: %w", err)
return xerrors.Errorf("marshal deployment system prompt: %w", err)
}
appendChatMessage(&msgParams, newChatMessage(
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,
opts.ModelConfigID,
chatprompt.CurrentContentVersion,
@@ -5229,6 +5246,37 @@ func (p *Server) resolveUserCompactionThreshold(ctx context.Context, userID uuid
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
// database and wraps it in <user-instructions> tags. Returns empty
// string if no prompt is set.
+6 -5
View File
@@ -1,6 +1,7 @@
package chatd_test
import (
"strings"
"testing"
"github.com/google/uuid"
@@ -110,19 +111,19 @@ func TestSpawnComputerUseAgent_SystemPromptFormat(t *testing.T) {
// The system message raw content is a JSON-encoded string.
// It should contain the system prompt with the user prompt.
var rawSystemContent string
var foundPrompt bool
for _, msg := range messages {
if msg.Role != "system" {
continue
}
if msg.Content.Valid {
rawSystemContent = string(msg.Content.RawMessage)
if msg.Content.Valid && strings.Contains(string(msg.Content.RawMessage), prompt) {
foundPrompt = true
break
}
}
assert.Contains(t, rawSystemContent, prompt,
"system prompt raw content should contain the user prompt")
assert.True(t, foundPrompt,
"at least one system message should contain the user prompt")
}
func TestSpawnComputerUseAgent_ChildIsListedUnderParent(t *testing.T) {