Skip to content

Commit ddcbe2a

Browse files
committed
feat(coderd/x/chatd): inject personal skills into chats
1 parent 34da29b commit ddcbe2a

4 files changed

Lines changed: 563 additions & 53 deletions

File tree

coderd/x/chatd/chatd.go

Lines changed: 213 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import (
3434
"github.com/coder/coder/v2/coderd/database/dbauthz"
3535
"github.com/coder/coder/v2/coderd/database/pubsub"
3636
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
37+
"github.com/coder/coder/v2/coderd/rbac"
3738
"github.com/coder/coder/v2/coderd/util/ptr"
3839
"github.com/coder/coder/v2/coderd/util/xjson"
3940
"github.com/coder/coder/v2/coderd/webpush"
@@ -51,6 +52,7 @@ import (
5152
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
5253
"github.com/coder/coder/v2/coderd/x/chatd/internal/agentselect"
5354
"github.com/coder/coder/v2/coderd/x/chatd/mcpclient"
55+
skillspkg "github.com/coder/coder/v2/coderd/x/skills"
5456
"github.com/coder/coder/v2/codersdk"
5557
"github.com/coder/coder/v2/codersdk/workspacesdk"
5658
"github.com/coder/quartz"
@@ -6406,14 +6408,39 @@ type systemPromptBehaviorContext struct {
64066408
isRootChat bool
64076409
}
64086410

6411+
func workspaceSkillsForResolution(workspaceSkills []chattool.SkillMeta) []skillspkg.Skill {
6412+
if len(workspaceSkills) == 0 {
6413+
return nil
6414+
}
6415+
resolved := make([]skillspkg.Skill, 0, len(workspaceSkills))
6416+
for _, skill := range workspaceSkills {
6417+
resolved = append(resolved, skillspkg.Skill{
6418+
Name: skill.Name,
6419+
Description: skill.Description,
6420+
Source: skillspkg.SourceWorkspace,
6421+
})
6422+
}
6423+
return resolved
6424+
}
6425+
6426+
func mergeTurnSkills(
6427+
personalSkills []skillspkg.Skill,
6428+
workspaceSkills []chattool.SkillMeta,
6429+
) []skillspkg.ResolvedSkill {
6430+
return skillspkg.MergeSkills(
6431+
personalSkills,
6432+
workspaceSkillsForResolution(workspaceSkills),
6433+
)
6434+
}
6435+
64096436
// buildSystemPrompt applies system-level prompt injections in the
64106437
// canonical order. It is used by both the initial prompt assembly
64116438
// and the ReloadMessages callback to keep them in sync.
64126439
func buildSystemPrompt(
64136440
prompt []fantasy.Message,
64146441
subagentInstruction string,
64156442
instruction string,
6416-
skills []chattool.SkillMeta,
6443+
resolvedSkills []skillspkg.ResolvedSkill,
64176444
userPrompt string,
64186445
behaviorContext systemPromptBehaviorContext,
64196446
) []fantasy.Message {
@@ -6423,7 +6450,7 @@ func buildSystemPrompt(
64236450
if instruction != "" {
64246451
prompt = chatprompt.InsertSystem(prompt, instruction)
64256452
}
6426-
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
6453+
if skillIndex := chattool.FormatResolvedSkillIndex(resolvedSkills); skillIndex != "" {
64276454
prompt = chatprompt.InsertSystem(prompt, skillIndex)
64286455
}
64296456
if userPrompt != "" {
@@ -6447,6 +6474,34 @@ func buildSystemPrompt(
64476474
return prompt
64486475
}
64496476

6477+
func removeSkillIndexMessages(prompt []fantasy.Message) []fantasy.Message {
6478+
out := make([]fantasy.Message, 0, len(prompt))
6479+
removed := false
6480+
for _, message := range prompt {
6481+
if isSkillIndexMessage(message) {
6482+
removed = true
6483+
continue
6484+
}
6485+
out = append(out, message)
6486+
}
6487+
if !removed {
6488+
return prompt
6489+
}
6490+
return out
6491+
}
6492+
6493+
func isSkillIndexMessage(message fantasy.Message) bool {
6494+
if message.Role != fantasy.MessageRoleSystem || len(message.Content) != 1 {
6495+
return false
6496+
}
6497+
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](message.Content[0])
6498+
if !ok {
6499+
return false
6500+
}
6501+
text := strings.TrimSpace(textPart.Text)
6502+
return strings.HasPrefix(text, chattool.AvailableSkillsOpenTag+"\n") && strings.HasSuffix(text, chattool.AvailableSkillsCloseTag)
6503+
}
6504+
64506505
type rootChatToolsOptions struct {
64516506
chat database.Chat
64526507
modelConfigID uuid.UUID
@@ -6484,6 +6539,79 @@ func (p *Server) loadPlanModeInstructions(
64846539
return fetched
64856540
}
64866541

6542+
func userSkillContext(ctx context.Context, userID uuid.UUID) context.Context {
6543+
actor := rbac.Subject{
6544+
Type: rbac.SubjectTypeUser,
6545+
ID: userID.String(),
6546+
Roles: rbac.RoleIdentifiers{rbac.RoleMember()},
6547+
Scope: rbac.ScopeAll,
6548+
}.WithCachedASTValue()
6549+
// Chat turns run asynchronously after admission, so the original request
6550+
// actor may no longer be available when a worker loads personal skills.
6551+
// We synthesize the chat owner as a member instead of reusing that actor.
6552+
// Hardcoding RoleMember is safe because dbauthz enforces
6553+
// ResourceUserSkill.WithOwner(userID), so this actor cannot read any other
6554+
// user's skills regardless of role. Org scoping is not needed because
6555+
// personal skills are user-scoped, not org-scoped.
6556+
//nolint:gocritic // The synthetic actor is intentional for the reasons above.
6557+
return dbauthz.As(ctx, actor)
6558+
}
6559+
6560+
func (p *Server) fetchPersonalSkillMetadata(
6561+
ctx context.Context,
6562+
userID uuid.UUID,
6563+
logger slog.Logger,
6564+
) []skillspkg.Skill {
6565+
rows, err := p.db.ListUserSkillMetadataByUserID(userSkillContext(ctx, userID), userID)
6566+
// See package coderd/x/skills (doc.go) for why metadata fetch failures
6567+
// intentionally degrade to an empty personal-skill list instead of
6568+
// failing the chat turn.
6569+
if err != nil {
6570+
logger.Warn(ctx, "failed to load personal skill metadata",
6571+
slog.F("owner_id", userID),
6572+
slog.Error(err),
6573+
)
6574+
return nil
6575+
}
6576+
6577+
personalSkills := make([]skillspkg.Skill, 0, len(rows))
6578+
for _, row := range rows {
6579+
personalSkills = append(personalSkills, skillspkg.Skill{
6580+
Name: row.Name,
6581+
Description: row.Description,
6582+
Source: skillspkg.SourcePersonal,
6583+
})
6584+
}
6585+
return personalSkills
6586+
}
6587+
6588+
func (p *Server) loadPersonalSkillBody(
6589+
ctx context.Context,
6590+
userID uuid.UUID,
6591+
name string,
6592+
) (skillspkg.ParsedSkill, error) {
6593+
row, err := p.db.GetUserSkillByUserIDAndName(
6594+
userSkillContext(ctx, userID),
6595+
database.GetUserSkillByUserIDAndNameParams{
6596+
UserID: userID,
6597+
Name: name,
6598+
},
6599+
)
6600+
if err != nil {
6601+
if errors.Is(err, sql.ErrNoRows) {
6602+
return skillspkg.ParsedSkill{}, skillspkg.ErrSkillNotFound
6603+
}
6604+
p.logger.Error(ctx, "load personal skill body failed",
6605+
slog.F("user_id", userID),
6606+
slog.F("name", name),
6607+
slog.Error(err),
6608+
)
6609+
return skillspkg.ParsedSkill{}, xerrors.Errorf("load personal skill body: %w", err)
6610+
}
6611+
6612+
return skillspkg.ParsePersonalSkillMarkdown([]byte(row.Content))
6613+
}
6614+
64876615
func (p *Server) appendRootChatTools(
64886616
ctx context.Context,
64896617
tools []fantasy.AgentTool,
@@ -6907,7 +7035,8 @@ func (p *Server) runChat(
69077035
mcpTools []fantasy.AgentTool
69087036
mcpCleanup func()
69097037
workspaceMCPTools []fantasy.AgentTool
6910-
skills []chattool.SkillMeta
7038+
workspaceSkills []chattool.SkillMeta
7039+
personalSkills []skillspkg.Skill
69117040
)
69127041
// Check if instruction files need to be (re-)persisted.
69137042
// This happens when no context-file parts exist yet, or when
@@ -6964,7 +7093,7 @@ func (p *Server) runChat(
69647093
return workspaceCtx.getWorkspaceConn(instructionCtx)
69657094
},
69667095
)
6967-
skills = selectSkillMetasForInstructionRefresh(
7096+
workspaceSkills = selectSkillMetasForInstructionRefresh(
69687097
persistedSkills,
69697098
discoveredSkills,
69707099
uuid.NullUUID{UUID: currentWorkspaceAgentID, Valid: hasCurrentWorkspaceAgent},
@@ -6984,8 +7113,12 @@ func (p *Server) runChat(
69847113
// re-injected via InsertSystem after compaction drops
69857114
// those messages. No workspace dial needed.
69867115
instruction = instructionFromContextFiles(messages)
6987-
skills = persistedSkills
7116+
workspaceSkills = persistedSkills
69887117
}
7118+
g2.Go(func() error {
7119+
personalSkills = p.fetchPersonalSkillMetadata(ctx, chat.OwnerID, logger)
7120+
return nil
7121+
})
69897122
g2.Go(func() error {
69907123
resolvedUserPrompt = p.resolveUserPrompt(ctx, chat.OwnerID)
69917124
return nil
@@ -7025,11 +7158,19 @@ func (p *Server) runChat(
70257158
if !isRootChat {
70267159
subagentInstruction = defaultSubagentInstruction
70277160
}
7161+
resolvedSkillsFor := func(workspaceSkills []chattool.SkillMeta) []skillspkg.ResolvedSkill {
7162+
return mergeTurnSkills(personalSkills, workspaceSkills)
7163+
}
7164+
resolveSkillAlias := func(alias string) (skillspkg.ResolvedSkill, error) {
7165+
return skillspkg.Lookup(resolvedSkillsFor(workspaceSkills), alias)
7166+
}
7167+
initialResolvedSkills := resolvedSkillsFor(workspaceSkills)
7168+
injectedSkillIndex := chattool.FormatResolvedSkillIndex(initialResolvedSkills)
70287169
prompt = buildSystemPrompt(
70297170
prompt,
70307171
subagentInstruction,
70317172
instruction,
7032-
skills,
7173+
initialResolvedSkills,
70337174
resolvedUserPrompt,
70347175
systemPromptBehaviorContext{
70357176
planMode: currentPlanMode,
@@ -7431,26 +7572,51 @@ func (p *Server) runChat(
74317572
workspaceCtx: &workspaceCtx,
74327573
workspaceMu: &workspaceMu,
74337574
instruction: &instruction,
7434-
skills: &skills,
7575+
skills: &workspaceSkills,
74357576
resolvePlanPath: resolvePlanPathForTools,
74367577
storeFile: storeChatAttachment,
74377578
isPlanModeTurn: isPlanModeTurn,
74387579
})
74397580
}
74407581

7441-
// Append skill tools when the workspace has skills.
7442-
if len(skills) > 0 {
7443-
skillOpts := chattool.ReadSkillOptions{
7444-
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
7445-
GetSkills: func() []chattool.SkillMeta {
7446-
return skills
7447-
},
7582+
// Append skill tools when personal or workspace skills are available.
7583+
skillOpts := chattool.ReadSkillOptions{
7584+
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
7585+
GetSkills: func() []chattool.SkillMeta {
7586+
return workspaceSkills
7587+
},
7588+
ResolveAlias: resolveSkillAlias,
7589+
LoadPersonalSkillBody: func(ctx context.Context, name string) (skillspkg.ParsedSkill, error) {
7590+
return p.loadPersonalSkillBody(ctx, chat.OwnerID, name)
7591+
},
7592+
}
7593+
skillToolRegistered := false
7594+
readSkillFileToolRegistered := false
7595+
appendCurrentSkillTools := func(current []fantasy.AgentTool) ([]fantasy.AgentTool, bool) {
7596+
if len(personalSkills) == 0 && len(workspaceSkills) == 0 {
7597+
return current, false
74487598
}
7449-
tools = append(tools,
7450-
chattool.ReadSkill(skillOpts),
7451-
chattool.ReadSkillFile(skillOpts),
7452-
)
7599+
7600+
updated := current
7601+
changed := false
7602+
appendTool := func(tool fantasy.AgentTool) {
7603+
if !changed {
7604+
updated = slices.Clone(current)
7605+
changed = true
7606+
}
7607+
updated = append(updated, tool)
7608+
}
7609+
if !skillToolRegistered {
7610+
appendTool(chattool.ReadSkill(skillOpts))
7611+
skillToolRegistered = true
7612+
}
7613+
if !readSkillFileToolRegistered && len(workspaceSkills) > 0 {
7614+
appendTool(chattool.ReadSkillFile(skillOpts))
7615+
readSkillFileToolRegistered = true
7616+
}
7617+
return updated, changed
74537618
}
7619+
tools, _ = appendCurrentSkillTools(tools)
74547620
if advisorRuntime != nil {
74557621
tools = append(tools, chatadvisor.Tool(chatadvisor.ToolOptions{
74567622
Runtime: advisorRuntime,
@@ -7724,14 +7890,16 @@ func (p *Server) runChat(
77247890
}
77257891
reloadedSkills := skillsFromParts(reloadedMsgs)
77267892
if len(reloadedSkills) == 0 {
7727-
reloadedSkills = skills
7893+
reloadedSkills = workspaceSkills
77287894
}
7895+
reloadedResolvedSkills := resolvedSkillsFor(reloadedSkills)
7896+
injectedSkillIndex = chattool.FormatResolvedSkillIndex(reloadedResolvedSkills)
77297897
reloadUserPrompt := p.resolveUserPrompt(reloadCtx, chat.OwnerID)
77307898
reloadedPrompt = buildSystemPrompt(
77317899
reloadedPrompt,
77327900
subagentInstruction,
77337901
reloadedInstruction,
7734-
reloadedSkills,
7902+
reloadedResolvedSkills,
77357903
reloadUserPrompt,
77367904
systemPromptBehaviorContext{
77377905
planMode: currentPlanMode,
@@ -7765,27 +7933,38 @@ func (p *Server) runChat(
77657933
chainModeActive = false
77667934
},
77677935
PrepareTools: func(currentTools []fantasy.AgentTool) []fantasy.AgentTool {
7936+
updatedTools, toolsChanged := appendCurrentSkillTools(currentTools)
7937+
77687938
// Mid-turn workspace MCP discovery for chats that bind a
77697939
// workspace via create_workspace or start_workspace
77707940
// after the turn has already started. The top-of-turn
77717941
// discovery path is gated on chat.WorkspaceID.Valid; this
77727942
// callback bridges the gap so the LLM sees workspace MCP
77737943
// tools on the very next step instead of the turn after.
77747944
if workspaceMCPDiscovered || isExploreSubagent {
7945+
if toolsChanged {
7946+
return updatedTools
7947+
}
77757948
return nil
77767949
}
77777950
snapshot := workspaceCtx.currentChatSnapshot()
77787951
if !snapshot.WorkspaceID.Valid {
7952+
if toolsChanged {
7953+
return updatedTools
7954+
}
77797955
return nil
77807956
}
77817957
workspaceMCPDiscovered = true
77827958
discovered := p.discoverWorkspaceMCPTools(
77837959
ctx, loopLogger, chat.ID, &workspaceCtx,
77847960
)
77857961
if len(discovered) == 0 {
7962+
if toolsChanged {
7963+
return updatedTools
7964+
}
77867965
return nil
77877966
}
7788-
return append(slices.Clone(currentTools), discovered...)
7967+
return append(slices.Clone(updatedTools), discovered...)
77897968
},
77907969
PrepareMessages: func(msgs []fantasy.Message) []fantasy.Message {
77917970
// Skip the snapshot update when chain mode is active;
@@ -7796,13 +7975,21 @@ func (p *Server) runChat(
77967975
if !chainModeActive {
77977976
setAdvisorPromptSnapshot(msgs)
77987977
}
7799-
if instructionInjected || instruction == "" {
7800-
return nil
7978+
result := msgs
7979+
changed := false
7980+
if !instructionInjected && instruction != "" {
7981+
instructionInjected = true
7982+
result = chatprompt.InsertSystem(result, instruction)
7983+
changed = true
78017984
}
7802-
instructionInjected = true
7803-
result := chatprompt.InsertSystem(msgs, instruction)
7804-
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
7985+
if skillIndex := chattool.FormatResolvedSkillIndex(resolvedSkillsFor(workspaceSkills)); skillIndex != "" && skillIndex != injectedSkillIndex {
7986+
result = removeSkillIndexMessages(result)
78057987
result = chatprompt.InsertSystem(result, skillIndex)
7988+
injectedSkillIndex = skillIndex
7989+
changed = true
7990+
}
7991+
if !changed {
7992+
return nil
78067993
}
78077994
if !chainModeActive {
78087995
setAdvisorPromptSnapshot(result)

0 commit comments

Comments
 (0)