Files
coder/coderd/x/chatd/internal/agentselect/agentselect.go
T
Michael Suchacz 73f6cd8169 feat: suffix-based chat agent selection (#23741)
Adds suffix-based agent selection for chatd. Template authors can direct
chat traffic to a specific root workspace agent by naming it with the
`-coderd-chat` suffix (for example, `coder_agent "dev-coderd-chat"`).
When no suffix match exists, chatd falls back to the first root agent by
`DisplayOrder`, then `Name`. Multiple suffix matches return an error.

The selection logic lives in `coderd/x/chatd/internal/agentselect` and
is shared by chatd core plus the workspace chat tools so all chat entry
points pick the same agent deterministically.

No database migrations, API contract changes, or provider changes. The
experimental sandbox template was split out to #23777.
2026-03-30 11:43:59 +00:00

87 lines
2.6 KiB
Go

package agentselect
import (
"cmp"
"slices"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
)
// Suffix marks chat-designated agents during the current PoC. This naming
// convention is an implementation detail, not a stable contract.
const Suffix = "-coderd-chat"
// IsChatAgent reports whether name uses the chat-agent suffix convention.
func IsChatAgent(name string) bool {
return strings.HasSuffix(strings.ToLower(name), Suffix)
}
// FindChatAgent picks the best workspace agent for a chat session from the
// provided candidates. It applies these rules in order:
// 1. Filter to root agents only (ParentID is null).
// 2. Sort stably and deterministically by DisplayOrder ASC, then Name ASC
// (case-insensitive), then Name ASC, then ID ASC.
// 3. If exactly one root agent name ends with Suffix (case-insensitive),
// return it.
// 4. If zero root agents match the suffix, return the first root agent after
// sorting (deterministic fallback).
// 5. If more than one root agent matches the suffix, return an error with an
// actionable message.
// 6. If no root agents exist at all, return an error.
func FindChatAgent(
agents []database.WorkspaceAgent,
) (database.WorkspaceAgent, error) {
rootAgents := make([]database.WorkspaceAgent, 0, len(agents))
matchingAgents := make([]database.WorkspaceAgent, 0, 1)
for _, agent := range agents {
if agent.ParentID.Valid {
continue
}
rootAgents = append(rootAgents, agent)
if IsChatAgent(agent.Name) {
matchingAgents = append(matchingAgents, agent)
}
}
if len(rootAgents) == 0 {
return database.WorkspaceAgent{}, xerrors.New(
"no eligible workspace agents found",
)
}
compareAgents := func(a, b database.WorkspaceAgent) int {
if order := cmp.Compare(a.DisplayOrder, b.DisplayOrder); order != 0 {
return order
}
if order := cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)); order != 0 {
return order
}
if order := cmp.Compare(a.Name, b.Name); order != 0 {
return order
}
return cmp.Compare(a.ID.String(), b.ID.String())
}
slices.SortStableFunc(rootAgents, compareAgents)
slices.SortStableFunc(matchingAgents, compareAgents)
switch len(matchingAgents) {
case 0:
return rootAgents[0], nil
case 1:
return matchingAgents[0], nil
default:
names := make([]string, 0, len(matchingAgents))
for _, agent := range matchingAgents {
names = append(names, agent.Name)
}
return database.WorkspaceAgent{}, xerrors.Errorf(
"multiple agents match the chat suffix %q: %s; only one agent should use this suffix",
Suffix,
strings.Join(names, ", "),
)
}
}