Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
66 changes: 62 additions & 4 deletions cmd/root/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"github.com/docker/docker-agent/pkg/team"
"github.com/docker/docker-agent/pkg/teamloader"
"github.com/docker/docker-agent/pkg/telemetry"
"github.com/docker/docker-agent/pkg/tools"
skillstool "github.com/docker/docker-agent/pkg/tools/builtin/skills"
)

type debugFlags struct {
Expand Down Expand Up @@ -43,6 +45,12 @@ func newDebugCmd() *cobra.Command {
Args: cobra.ExactArgs(1),
RunE: flags.runDebugToolsetsCommand,
})
cmd.AddCommand(&cobra.Command{
Use: "skills <agent-file>|<registry-ref>",
Short: "Debug the skills of an agent",
Args: cobra.ExactArgs(1),
RunE: flags.runDebugSkillsCommand,
})
titleCmd := &cobra.Command{
Use: "title <agent-file>|<registry-ref> <question>",
Short: "Generate a session title from a question",
Expand Down Expand Up @@ -118,26 +126,76 @@ func (f *debugFlags) runDebugToolsetsCommand(cmd *cobra.Command, args []string)
continue
}

tools, err := agent.Tools(ctx)
agentTools, err := agent.Tools(ctx)
if err != nil {
slog.ErrorContext(ctx, "Failed to query tools", "name", agent.Name(), "error", err)
continue
}

if len(tools) == 0 {
if len(agentTools) == 0 {
out.Printf("No tools for %s\n", agent.Name())
continue
}

out.Printf("%d tool(s) for %s:\n", len(tools), agent.Name())
for _, tool := range tools {
out.Printf("%d tool(s) for %s:\n", len(agentTools), agent.Name())
for _, tool := range agentTools {
out.Println(" +", tool.Name, "-", tool.Description)
}
}

return nil
}

func (f *debugFlags) runDebugSkillsCommand(cmd *cobra.Command, args []string) (commandErr error) {
telemetry.TrackCommand(cmd.Context(), "debug", append([]string{"skills"}, args...))
defer func() { // do not inline this defer so that commandErr is not resolved early
telemetry.TrackCommandError(cmd.Context(), "debug", append([]string{"skills"}, args...), commandErr)
}()

ctx := cmd.Context()

t, err := f.loadTeam(ctx, args[0])
if err != nil {
return err
}
defer stopToolSets(t)

out := cli.NewPrinter(cmd.OutOrStdout())

for _, name := range t.AgentNames() {
agent, err := t.Agent(name)
if err != nil {
slog.ErrorContext(ctx, "Failed to get agent", "name", name, "error", err)
continue
}

var skillsToolset *skillstool.ToolSet
for _, ts := range agent.ToolSets() {
if st, ok := tools.As[*skillstool.ToolSet](ts); ok {
skillsToolset = st
break
}
}

if skillsToolset == nil || len(skillsToolset.Skills()) == 0 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test

out.Printf("No skills for %s\n", agent.Name())
continue
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test comment on Skills() guard line

}

loadedSkills := skillsToolset.Skills()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills() is called twice: once in the nil || len(...) guard on the line above, and again here to bind loadedSkills. The method returns the struct's internal slice so both calls are safe, but it's redundant. Capturing the result once makes intent clearer and removes the double call:

var loadedSkills []skills.Skill
if skillsToolset != nil {
    loadedSkills = skillsToolset.Skills()
}
if len(loadedSkills) == 0 {
    out.Printf("No skills for %s\n", agent.Name())
    continue
}

out.Printf("%d skill(s) for %s:\n", len(loadedSkills), agent.Name())
for _, skill := range loadedSkills {
marker := ""
if skill.IsFork() {
marker = " [forked]"
}
out.Println(" +", skill.Name+marker, "-", skill.Description)
}
}

return nil
}

func (f *debugFlags) runDebugTitleCommand(cmd *cobra.Command, args []string) (commandErr error) {
telemetry.TrackCommand(cmd.Context(), "debug", append([]string{"title"}, args...))
defer func() { // do not inline this defer so that commandErr is not resolved early
Expand Down
40 changes: 40 additions & 0 deletions e2e/debug_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package e2e_test

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/skills"
)

func TestDebug_Toolsets_None(t *testing.T) {
Expand All @@ -21,3 +25,39 @@ func TestDebug_Toolsets_Todo(t *testing.T) {

require.Equal(t, "2 tool(s) for root:\n + create_todo - Create a new todo item with a description\n + list_todos - List all current todos with their status\n", output)
}

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

output := runCLI(t, "debug", "skills", "testdata/no_tools.yaml")

require.Equal(t, "No skills for root\n", output)
}

// TestDebug_Skills_Local stages two skills (one regular, one forked) in an
// isolated kit directory and asserts that `debug skills` lists each one with
// its name, description, and the [forked] marker for fork-context skills.
func TestDebug_Skills_Local(t *testing.T) {
kit := t.TempDir()
writeSkill(t, filepath.Join(kit, skills.KitSkillsSubdir, "plain"),
"---\nname: plain\ndescription: A plain skill\n---\nbody\n")
writeSkill(t, filepath.Join(kit, skills.KitSkillsSubdir, "forky"),
"---\nname: forky\ndescription: A forked skill\ncontext: fork\n---\nbody\n")

t.Setenv(skills.KitDirEnv, kit)

output := runCLI(t, "debug", "skills", "testdata/skills_local.yaml")

require.Equal(t,
"2 skill(s) for root:\n"+
" + forky [forked] - A forked skill\n"+
" + plain - A plain skill\n",
output,
)
}

func writeSkill(t *testing.T, dir, content string) {
t.Helper()
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644))
}
7 changes: 7 additions & 0 deletions e2e/testdata/skills_local.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
agents:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

root:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

model: openai/gpt-3.5-turbo
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

description: A helpful AI assistant
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

instruction: |
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

You are a knowledgeable assistant.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

skills: true
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test comment on line 7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every other fixture in e2e/testdata/ includes a version: field (e.g. version: "2"). The loader defaults gracefully via cmp.Or, so this works, but the omission is inconsistent and may surprise future contributors. Consider adding version: "9" (current latest) to match the rest.

Loading