mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
161 lines
5.0 KiB
Go
161 lines
5.0 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"sync"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
|
|
skillspkg "github.com/coder/coder/v2/coderd/x/skills"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// MaxSkillInvocationsPerMessage bounds the skill-invocation parts in one
|
|
// message, and with them the skill bodies a message stores.
|
|
const MaxSkillInvocationsPerMessage = 8
|
|
|
|
// ErrTooManySkillInvocations reports a message with more than
|
|
// MaxSkillInvocationsPerMessage skill-invocation parts.
|
|
var ErrTooManySkillInvocations = xerrors.Errorf("a message can invoke at most %d skills", MaxSkillInvocationsPerMessage)
|
|
|
|
// SkillInvocationUnavailableError reports a skill-invocation part whose
|
|
// skill the chat owner cannot use: it is missing, disabled, or not readable.
|
|
type SkillInvocationUnavailableError struct {
|
|
Source codersdk.ChatSkillSource
|
|
Name string
|
|
}
|
|
|
|
func (e *SkillInvocationUnavailableError) Error() string {
|
|
return fmt.Sprintf("Skill %s/%s is not available.", e.Source, e.Name)
|
|
}
|
|
|
|
// resolveChatSkillInvocations loads the chat and resolves the
|
|
// skill-invocation parts of a message sent to it. The chat is only read
|
|
// when the message invokes a skill.
|
|
func (p *Server) resolveChatSkillInvocations(
|
|
ctx context.Context,
|
|
chatID uuid.UUID,
|
|
parts []codersdk.ChatMessagePart,
|
|
) ([]codersdk.ChatMessagePart, error) {
|
|
if !slices.ContainsFunc(parts, isSkillInvocation) {
|
|
return parts, nil
|
|
}
|
|
chat, err := p.db.GetChatByID(ctx, chatID)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("load chat for skill invocations: %w", err)
|
|
}
|
|
return p.resolveSkillInvocations(ctx, chat, parts)
|
|
}
|
|
|
|
// resolveSkillInvocations stores the current body of every invoked skill on
|
|
// its part, read with the chat owner's permissions through the same loaders
|
|
// as read_skill. Skills load by source and name, so an invocation does not
|
|
// depend on the skill being listed in the turn's skill index. Workspace
|
|
// skills come from the chat's pinned context snapshot, so a chat without
|
|
// one cannot invoke them.
|
|
func (p *Server) resolveSkillInvocations(
|
|
ctx context.Context,
|
|
chat database.Chat,
|
|
parts []codersdk.ChatMessagePart,
|
|
) ([]codersdk.ChatMessagePart, error) {
|
|
invocations := countSkillInvocations(parts)
|
|
if invocations == 0 {
|
|
return parts, nil
|
|
}
|
|
if invocations > MaxSkillInvocationsPerMessage {
|
|
return nil, ErrTooManySkillInvocations
|
|
}
|
|
loadOwner := sync.OnceValues(func() (rbac.Subject, error) {
|
|
return p.chatOwnerSubject(ctx, chat.OwnerID)
|
|
})
|
|
loadWorkspaceSkills := sync.OnceValues(func() ([]chattool.SkillMeta, error) {
|
|
_, skills, err := p.resolveTurnWorkspaceContext(ctx, chat, database.WorkspaceAgent{})
|
|
return skills, err
|
|
})
|
|
loadBody := func(part codersdk.ChatMessagePart) (string, error) {
|
|
switch part.SkillSource {
|
|
case codersdk.ChatSkillSourcePersonal:
|
|
parsed, err := p.loadPersonalSkillBody(ctx, chat.OwnerID, part.SkillName)
|
|
return parsed.Body, err
|
|
case codersdk.ChatSkillSourceOrganization:
|
|
owner, err := loadOwner()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parsed, err := p.loadOrganizationSkillBody(ctx, owner, chat.OrganizationID, part.SkillName)
|
|
return parsed.Body, err
|
|
case codersdk.ChatSkillSourceProject:
|
|
projectID := p.resolveChatProjectID(ctx, p.logger, chat)
|
|
if !projectID.Valid {
|
|
return "", skillspkg.ErrSkillNotFound
|
|
}
|
|
owner, err := loadOwner()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parsed, err := p.loadProjectSkillBody(ctx, owner, projectID.UUID, part.SkillName)
|
|
return parsed.Body, err
|
|
case codersdk.ChatSkillSourceWorkspace:
|
|
skills, err := loadWorkspaceSkills()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, skill := range skills {
|
|
if skill.Name == part.SkillName {
|
|
return chattool.PinnedWorkspaceSkillBody(skill)
|
|
}
|
|
}
|
|
}
|
|
return "", skillspkg.ErrSkillNotFound
|
|
}
|
|
|
|
// A message that repeats a skill loads it once.
|
|
type skillKey struct {
|
|
source codersdk.ChatSkillSource
|
|
name string
|
|
}
|
|
bodies := make(map[skillKey]string, invocations)
|
|
resolved := slices.Clone(parts)
|
|
for i, part := range resolved {
|
|
if !isSkillInvocation(part) {
|
|
continue
|
|
}
|
|
key := skillKey{source: part.SkillSource, name: part.SkillName}
|
|
if body, ok := bodies[key]; ok {
|
|
resolved[i].SkillBody = body
|
|
continue
|
|
}
|
|
body, err := loadBody(part)
|
|
if errors.Is(err, skillspkg.ErrSkillNotFound) {
|
|
return nil, &SkillInvocationUnavailableError{Source: part.SkillSource, Name: part.SkillName}
|
|
}
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("load invoked skill %s/%s: %w", part.SkillSource, part.SkillName, err)
|
|
}
|
|
bodies[key] = body
|
|
resolved[i].SkillBody = body
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func countSkillInvocations(parts []codersdk.ChatMessagePart) int {
|
|
count := 0
|
|
for _, part := range parts {
|
|
if isSkillInvocation(part) {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func isSkillInvocation(part codersdk.ChatMessagePart) bool {
|
|
return part.Type == codersdk.ChatMessagePartTypeSkillInvocation
|
|
}
|