@@ -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.
64126439func 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+
64506505type 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+
64876615func (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