Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
068f932
v0.76.0
andreynering Jun 5, 2026
55f32fa
feat(subagents): add internal/subagents package with parser and valid…
brianjlandau May 28, 2026
93f9d25
feat(subagents): add discovery path config and Options fields
brianjlandau May 28, 2026
55f6806
feat(subagents): add discovery, state types, and Manager
brianjlandau May 28, 2026
ce51932
feat(subagents): wire Manager into coordinator and app
brianjlandau May 29, 2026
325edd0
feat(subagents): add dispatcher agent tool with dynamic subagent_type…
brianjlandau May 29, 2026
6c6dbb5
feat(subagents): add skill preloading and subagent system prompt temp…
brianjlandau May 29, 2026
21fccfe
feat(subagents): add TUI @-mention completions and sendMessage rewrite
brianjlandau May 29, 2026
bf678cd
refactor(subagents): tighten validation, dispatcher safety, and UI la…
brianjlandau May 29, 2026
9af6566
style(subagents): apply gofumpt and wrap long comments per AGENTS.md
brianjlandau May 29, 2026
d711b8c
feat(subagents): base subagent tool pool on coder agent and rename YA…
brianjlandau May 29, 2026
dddd667
feat(subagents): add permissionMode frontmatter option
brianjlandau May 29, 2026
9c687ba
feat(subagents): add effort frontmatter option with provider-aware tr…
brianjlandau May 29, 2026
8624428
feat(subagents): add Runtime tracker for in-flight subagent sessions
brianjlandau May 30, 2026
d0ae9c0
feat(subagents): add workspace API for running subagents and definitions
brianjlandau May 30, 2026
03dc2f8
feat(subagents): add inline status panel for running subagents
brianjlandau Jun 7, 2026
17164ff
feat(subagents): add parent breadcrumb and Ctrl+Up navigation
brianjlandau Jun 7, 2026
e83faa7
feat(subagents): add subagents modal with Running and Library tabs
brianjlandau Jun 7, 2026
3ae9378
feat(subagents): surface terminal status and cancellation in runtime …
brianjlandau Jun 7, 2026
bfcdf7f
fix(subagents): wire model selection, live discovery, task identity, …
brianjlandau Jun 8, 2026
3d09875
feat(subagents): add enable/disable toggle to the Library tab
brianjlandau Jun 8, 2026
7aecc31
perf(subagents): cache resolved LanguageModel per (modelID, provider)…
brianjlandau Jun 8, 2026
0a247d3
fix(subagents): surface subagent build failures as tool errors, not t…
brianjlandau Jun 8, 2026
dcdae5b
fix(subagents): report every tools/disallowedTools overlap in Validate
brianjlandau Jun 8, 2026
987acc9
test(agent): cover resolveModelByID model-not-found path
brianjlandau Jun 8, 2026
bccaea7
fix(ui): add ctrl+x subagents keybinding to help bar
brianjlandau Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .semgrepignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# This file intentionally uses text/template (not html/template) because it
# generates LLM prompts (plain text), not HTML for web browsers.
# The XSS rule does not apply here.
internal/agent/prompt/prompt.go
46 changes: 46 additions & 0 deletions internal/agent/agent_params_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package agent

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/require"
)

// AgentParams must decode the subagent_type field so UI renderers can label
// the row with the specific subagent name.
func TestAgentParams_DecodesSubagentType(t *testing.T) {
t.Parallel()

input := []byte(`{"subagent_type":"code-reviewer","prompt":"review this"}`)

var params AgentParams
require.NoError(t, json.Unmarshal(input, &params))
require.Equal(t, "code-reviewer", params.SubagentType)
require.Equal(t, "review this", params.Prompt)
}

func TestAgentParams_OmitsSubagentTypeWhenAbsent(t *testing.T) {
t.Parallel()

input := []byte(`{"prompt":"search for things"}`)

var params AgentParams
require.NoError(t, json.Unmarshal(input, &params))
require.Empty(t, params.SubagentType)
require.Equal(t, "search for things", params.Prompt)
}

// AgentParams and AgentDispatchParams must share a wire-compatible shape so
// historical tool-call inputs decode cleanly under both types.
func TestAgentParams_WireCompatibleWithDispatchParams(t *testing.T) {
t.Parallel()

wire, err := json.Marshal(AgentDispatchParams{SubagentType: "tester", Prompt: "x"})
require.NoError(t, err)

var ap AgentParams
require.NoError(t, json.Unmarshal(wire, &ap))
require.Equal(t, "tester", ap.SubagentType)
require.Equal(t, "x", ap.Prompt)
}
162 changes: 150 additions & 12 deletions internal/agent/agent_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,145 @@ package agent
import (
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"strings"

"charm.land/fantasy"

"github.com/charmbracelet/crush/internal/agent/prompt"
"github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/subagents"
)

//go:embed templates/agent_tool.md
var agentToolDescription string

// AgentParams is the shape consumed by UI tool-call renderers when displaying
// historical agent tool invocations. New tool-call inputs decode with
// AgentDispatchParams; AgentParams stays wire-compatible so older inputs still
// decode cleanly.
type AgentParams struct {
Prompt string `json:"prompt" description:"The task for the agent to perform"`
SubagentType string `json:"subagent_type,omitempty"`
Prompt string `json:"prompt" description:"The task for the agent to perform"`
}

// AgentDispatchParams is the input to the dispatcher agent tool.
type AgentDispatchParams struct {
SubagentType string `json:"subagent_type,omitempty"`
Prompt string `json:"prompt"`
}

const (
AgentToolName = "agent"
)

// dispatcherTool implements fantasy.AgentTool with a dynamically-built schema.
type dispatcherTool struct {
info fantasy.ToolInfo
dispatch func(ctx context.Context, params AgentDispatchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error)
providerOpts fantasy.ProviderOptions
}

func (d *dispatcherTool) Info() fantasy.ToolInfo { return d.info }
func (d *dispatcherTool) ProviderOptions() fantasy.ProviderOptions { return d.providerOpts }
func (d *dispatcherTool) SetProviderOptions(opts fantasy.ProviderOptions) { d.providerOpts = opts }
func (d *dispatcherTool) Run(ctx context.Context, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
var params AgentDispatchParams
if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
return fantasy.NewTextErrorResponse("invalid parameters: " + err.Error()), nil
}
return d.dispatch(ctx, params, call)
}

// findSubagentByName returns the active subagent with the given name, or nil
// when none matches.
func findSubagentByName(active []*subagents.Subagent, name string) *subagents.Subagent {
for _, sa := range active {
if sa.Name == name {
return sa
}
}
return nil
}

// subagentSessionSetup returns a SessionSetup callback that applies the
// subagent's permission mode to the freshly-created sub-session. Returns
// nil when no setup is needed.
func (c *coordinator) subagentSessionSetup(sa *subagents.Subagent) func(sessionID string) {
if sa.PermissionMode != subagents.PermissionModeBypassPermissions {
return nil
}
return func(sessionID string) {
c.permissions.AutoApproveSession(sessionID)
}
}

// buildAgentDispatchInfo builds the ToolInfo for the agent dispatcher tool with
// a dynamic subagent_type enum derived from the currently active subagents.
func buildAgentDispatchInfo(activeSubagents []*subagents.Subagent) fantasy.ToolInfo {
enumValues := []string{"task"}
for _, sa := range activeSubagents {
enumValues = append(enumValues, sa.Name)
}

typeDesc := `The type of agent to use. Use "task" for general search and research tasks.`
if len(activeSubagents) > 0 {
lines := make([]string, 0, len(activeSubagents))
for _, sa := range activeSubagents {
lines = append(lines, fmt.Sprintf("- %s: %s", sa.Name, sa.Description))
}
typeDesc += "\n\nAvailable specialized agents:\n" + strings.Join(lines, "\n")
}

return fantasy.ToolInfo{
Name: AgentToolName,
Description: agentToolDescription,
Parameters: map[string]any{
"subagent_type": map[string]any{
"type": "string",
"enum": enumValues,
"description": typeDesc,
},
"prompt": map[string]any{
"type": "string",
"description": "The task for the agent to perform",
},
},
Required: []string{"prompt"},
Parallel: true,
}
}

func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error) {
agentCfg, ok := c.cfg.Config().Agents[config.AgentTask]
taskCfg, ok := c.cfg.Config().Agents[config.AgentTask]
if !ok {
return nil, errors.New("task agent not configured")
}
prompt, err := taskPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
coderCfg, ok := c.cfg.Config().Agents[config.AgentCoder]
if !ok {
return nil, errors.New("coder agent not configured")
}
taskPr, err := taskPrompt(prompt.WithWorkingDir(c.cfg.WorkingDir()))
if err != nil {
return nil, err
}

agent, err := c.buildAgent(ctx, prompt, agentCfg, true)
taskAgent, err := c.buildAgent(ctx, taskPr, taskCfg, true, subagentModel{})
if err != nil {
return nil, err
}
return fantasy.NewParallelAgentTool(
AgentToolName,
agentToolDescription,
func(ctx context.Context, params AgentParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {

// The subagent_type enum is a point-in-time snapshot baked into the tool
// schema; a Library reload won't refresh it. Dispatch lookups use the live
// list (activeSubagentsList) so a since-removed name fails cleanly and a
// newly added one still resolves — the enum is advisory only.
info := buildAgentDispatchInfo(c.activeSubagentsList())

return &dispatcherTool{
info: info,
dispatch: func(ctx context.Context, params AgentDispatchParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
if params.Prompt == "" {
return fantasy.NewTextErrorResponse("prompt is required"), nil
}
Expand All @@ -49,20 +150,57 @@ func (c *coordinator) agentTool(ctx context.Context) (fantasy.AgentTool, error)
if sessionID == "" {
return fantasy.ToolResponse{}, errors.New("session id missing from context")
}

agentMessageID := tools.GetMessageFromContext(ctx)
if agentMessageID == "" {
return fantasy.ToolResponse{}, errors.New("agent message id missing from context")
}

subagentType := params.SubagentType
if subagentType == "" || subagentType == config.AgentTask {
return c.runSubAgent(ctx, subAgentParams{
Agent: taskAgent,
SessionID: sessionID,
AgentMessageID: agentMessageID,
ToolCallID: call.ID,
Prompt: params.Prompt,
SessionTitle: "New Agent Session",
AgentName: config.AgentTask,
AgentColor: subagents.AutoColor(config.AgentTask),
AgentModel: taskAgent.Model().ModelCfg.Model,
})
}

sa := findSubagentByName(c.activeSubagentsList(), subagentType)
if sa == nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("unknown subagent type: %q", subagentType)), nil
}

agentCfg := sa.ToConfigAgent(coderCfg)
// Config-driven setup failures (prompt build, model/provider that
// passed discovery but fails at build) are surfaced as tool-error
// responses so the parent agent can report them and continue; a
// bare error would abort the whole turn.
subPr, err := subagentPrompt(sa, c.activeSkills, prompt.WithWorkingDir(c.cfg.WorkingDir()))
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("build subagent prompt %q: %v", sa.Name, err)), nil
}
agent, err := c.buildAgent(ctx, subPr, agentCfg, true, subagentModel{Effort: sa.Effort, Model: sa.Model, Provider: sa.Provider})
if err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("build subagent %q: %v", sa.Name, err)), nil
}

return c.runSubAgent(ctx, subAgentParams{
Agent: agent,
SessionID: sessionID,
AgentMessageID: agentMessageID,
ToolCallID: call.ID,
Prompt: params.Prompt,
SessionTitle: "New Agent Session",
SessionTitle: sa.Name + " Agent Session",
SessionSetup: c.subagentSessionSetup(sa),
AgentName: sa.Name,
AgentColor: sa.ResolvedColor(),
AgentModel: agent.Model().ModelCfg.Model,
})
},
), nil
}, nil
}
Loading
Loading