Files
docker-agent/cmd/root/debug.go
T
David Gageot c145f64efc feat: run tool_input_transform and post_tool_use hooks on debug tool calls
Extends the debug tool command's hook support beyond the existing
tool_response_transform integration. Input transforms can rewrite
arguments or block the call before the handler runs; post_tool_use
hooks observe the final (transformed) response and can fail the
command after the result is already printed. --no-hook continues to
skip all three. Nested integer arguments are preserved end-to-end via
json.Decoder.UseNumber, including through builtin secret redaction.

Signed-off-by: David Gageot <david.gageot@docker.com>
2026-10-05 12:15:01 +02:00

368 lines
11 KiB
Go

package root
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"slices"
"github.com/goccy/go-yaml"
"github.com/spf13/cobra"
"github.com/docker/docker-agent/pkg/cli"
"github.com/docker/docker-agent/pkg/config"
"github.com/docker/docker-agent/pkg/config/sources"
"github.com/docker/docker-agent/pkg/sessiontitle"
"github.com/docker/docker-agent/pkg/team"
"github.com/docker/docker-agent/pkg/teamloader"
loaderdefaults "github.com/docker/docker-agent/pkg/teamloader/defaults"
"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 {
modelOverrides []string
toolsetsJSON bool
toolJSON bool
toolNoHook bool
toolAgent string
skillsJSON bool
runConfig config.RuntimeConfig
}
// Explicit DTOs keep the JSON contract stable and independent of internal types.
type agentToolsInfo struct {
Agent string `json:"agent"`
Tools []toolInfo `json:"tools"`
}
type toolInfo struct {
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
Parameters any `json:"parameters"`
Annotations tools.ToolAnnotations `json:"annotations"`
OutputSchema any `json:"outputSchema"`
}
type agentSkillsInfo struct {
Agent string `json:"agent"`
Skills []skillInfo `json:"skills"`
}
type skillInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Forked bool `json:"forked"`
Path string `json:"path,omitempty"`
}
func newDebugCmd() *cobra.Command {
var flags debugFlags
cmd := &cobra.Command{
Use: "debug",
Short: "Debug tools",
GroupID: "advanced",
}
cmd.AddCommand(&cobra.Command{
Use: "config <agent-file>|<registry-ref> [flavor...]",
Short: "Print the canonical form of an agent's configuration file",
Long: "Print the canonical form of an agent's configuration file.\n\n" +
"When flavors are given (positionally or with --flavor), they are applied in order and the\n" +
"resolved config is printed without its 'flavors' section, since the patches are already baked in.",
Args: cobra.MinimumNArgs(1),
RunE: flags.runDebugConfigCommand,
})
toolsetsCmd := &cobra.Command{
Use: "toolsets <agent-file>|<registry-ref>",
Short: "Debug the toolsets of an agent",
Args: cobra.ExactArgs(1),
RunE: flags.runDebugToolsetsCommand,
}
toolsetsCmd.Flags().BoolVar(&flags.toolsetsJSON, "json", false, "Output in JSON format")
cmd.AddCommand(toolsetsCmd)
toolCmd := &cobra.Command{
Use: "tool <agent-file>|<registry-ref> <tool-name> [parameters-json]",
Short: "Call a tool of an agent directly",
Long: "Call a tool of an agent directly, without an LLM turn.\n\n" +
"Parameters must be a JSON object (defaults to {}). Use --agent to select an agent.\n" +
"Use 'debug toolsets --json' to inspect tool names and parameter schemas.\n\n" +
"Tool input transforms, response transforms, and post-tool-use hooks run by default.\n" +
"Use --no-hook to skip them.\n" +
"Calls have real side effects and bypass other hooks and approval checks.\n" +
"Tools that require an agent runtime are not supported. Built-in background jobs\n" +
"cannot be launched because toolsets are stopped when the command exits.",
Example: ` docker agent debug tool agent.yaml read_file '{"path":"README.md"}'
docker agent debug tool agent.yaml shell '{"cmd":"pwd"}' --agent root --json`,
Args: cobra.RangeArgs(2, 3),
RunE: flags.runDebugToolCommand,
}
toolCmd.Flags().StringVarP(&flags.toolAgent, "agent", "a", "", "Name of the agent (defaults to the team's default agent)")
toolCmd.Flags().BoolVar(&flags.toolJSON, "json", false, "Output the full tool result in JSON format")
toolCmd.Flags().BoolVar(&flags.toolNoHook, "no-hook", false, "Skip tool input transforms, response transforms, and post-tool-use hooks")
cmd.AddCommand(toolCmd)
skillsCmd := &cobra.Command{
Use: "skills <agent-file>|<registry-ref>",
Short: "Debug the skills of an agent",
Args: cobra.ExactArgs(1),
RunE: flags.runDebugSkillsCommand,
}
skillsCmd.Flags().BoolVar(&flags.skillsJSON, "json", false, "Output in JSON format")
cmd.AddCommand(skillsCmd)
titleCmd := &cobra.Command{
Use: "title <agent-file>|<registry-ref> <question>",
Short: "Generate a session title from a question",
Args: cobra.ExactArgs(2),
RunE: flags.runDebugTitleCommand,
}
titleCmd.Flags().StringArrayVar(&flags.modelOverrides, "model", nil, "Override agent model: [agent=]provider/model (repeatable)")
cmd.AddCommand(titleCmd)
addRuntimeConfigFlags(cmd, &flags.runConfig)
cmd.AddCommand(newDebugAuthCmd())
cmd.AddCommand(newDebugOAuthCmd())
return cmd
}
// loadTeam loads an agent team from the given agent file.
// Callers should defer stopToolSets(ctx, t) to clean up.
func (f *debugFlags) loadTeam(ctx context.Context, agentFilename string, opts ...teamloader.Opt) (*team.Team, error) {
result, err := f.loadTeamWithConfig(ctx, agentFilename, opts...)
if err != nil {
return nil, err
}
return result.Team, nil
}
func (f *debugFlags) loadTeamWithConfig(ctx context.Context, agentFilename string, opts ...teamloader.Opt) (*teamloader.LoadResult, error) {
agentSource, err := sources.Resolve(agentFilename, f.runConfig.EnvProvider())
if err != nil {
return nil, err
}
opts = append(loaderdefaults.Opts(), opts...)
t, err := teamloader.LoadWithConfig(ctx, agentSource, &f.runConfig, opts...)
if err != nil {
return nil, err
}
return t, nil
}
func (f *debugFlags) runDebugConfigCommand(cmd *cobra.Command, args []string) (commandErr error) {
telemetry.TrackCommand(cmd.Context(), "debug", append([]string{"config"}, args...))
defer func() { // do not inline this defer so that commandErr is not resolved early
telemetry.TrackCommandError(cmd.Context(), "debug", append([]string{"config"}, args...), commandErr)
}()
agentSource, err := sources.Resolve(args[0], f.runConfig.EnvProvider())
if err != nil {
return err
}
flavors := slices.Concat(f.runConfig.Flavors, args[1:])
cfg, err := config.Load(cmd.Context(), agentSource, config.WithFlavors(flavors...))
if err != nil {
return err
}
// Once resolved, re-applying a flavor to the output would double-apply
// `+`/`-` patches, so the section is dropped.
if len(flavors) > 0 {
cfg.Flavors = nil
}
return yaml.NewEncoder(cmd.OutOrStdout()).Encode(cfg)
}
func (f *debugFlags) runDebugToolsetsCommand(cmd *cobra.Command, args []string) (commandErr error) {
telemetry.TrackCommand(cmd.Context(), "debug", append([]string{"toolsets"}, args...))
defer func() { // do not inline this defer so that commandErr is not resolved early
telemetry.TrackCommandError(cmd.Context(), "debug", append([]string{"toolsets"}, args...), commandErr)
}()
ctx := cmd.Context()
t, err := f.loadTeam(ctx, args[0])
if err != nil {
return err
}
defer stopToolSets(ctx, t)
out := cli.NewPrinter(cmd.OutOrStdout())
var infos []agentToolsInfo
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
}
agentTools, err := agent.ToolsWithCatalog(ctx)
if err != nil {
slog.ErrorContext(ctx, "Failed to query tools", "name", agent.Name(), "error", err)
continue
}
info := agentToolsInfo{Agent: agent.Name(), Tools: make([]toolInfo, 0, len(agentTools))}
for _, tool := range agentTools {
info.Tools = append(info.Tools, toolInfo{
Name: tool.Name,
Category: tool.Category,
Description: tool.Description,
Parameters: tool.Parameters,
Annotations: tool.Annotations,
OutputSchema: tool.OutputSchema,
})
}
// Text mode streams per agent so slow toolsets don't hold back earlier results.
if f.toolsetsJSON {
infos = append(infos, info)
continue
}
printAgentTools(out, info)
}
if f.toolsetsJSON {
return encodeJSON(cmd, infos)
}
return nil
}
func printAgentTools(out *cli.Printer, info agentToolsInfo) {
if len(info.Tools) == 0 {
out.Printf("No tools for %s\n", info.Agent)
return
}
out.Printf("%d tool(s) for %s:\n", len(info.Tools), info.Agent)
for _, tool := range info.Tools {
out.Println(" +", tool.Name, "-", tool.Description)
}
}
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(ctx, t)
out := cli.NewPrinter(cmd.OutOrStdout())
var infos []agentSkillsInfo
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
}
info := agentSkillsInfo{Agent: agent.Name(), Skills: []skillInfo{}}
// The loader creates at most one skills toolset per agent.
for _, ts := range agent.ToolSets() {
st, ok := tools.As[*skillstool.ToolSet](ts)
if !ok {
continue
}
for _, skill := range st.Skills() {
info.Skills = append(info.Skills, skillInfo{
Name: skill.Name,
Description: skill.Description,
Forked: skill.IsFork(),
Path: skill.FilePath,
})
}
break
}
if f.skillsJSON {
infos = append(infos, info)
continue
}
printAgentSkills(out, info)
}
if f.skillsJSON {
return encodeJSON(cmd, infos)
}
return nil
}
func printAgentSkills(out *cli.Printer, info agentSkillsInfo) {
if len(info.Skills) == 0 {
out.Printf("No skills for %s\n", info.Agent)
return
}
out.Printf("%d skill(s) for %s:\n", len(info.Skills), info.Agent)
for _, skill := range info.Skills {
marker := ""
if skill.Forked {
marker = " [forked]"
}
out.Println(" +", skill.Name+marker, "-", skill.Description)
}
}
func encodeJSON(cmd *cobra.Command, v any) error {
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(v)
}
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
telemetry.TrackCommandError(cmd.Context(), "debug", append([]string{"title"}, args...), commandErr)
}()
ctx := cmd.Context()
t, err := f.loadTeam(ctx, args[0], teamloader.WithModelOverrides(f.modelOverrides))
if err != nil {
return err
}
defer stopToolSets(ctx, t)
agent, err := t.DefaultAgent()
if err != nil {
return err
}
// Use the same title generation code path as the TUI (see runTUI in new.go),
// including any dedicated title_model configured for the agent's model.
models := agent.TitleModels(ctx)
if len(models) == 0 {
return fmt.Errorf("agent %q has no model configured", agent.Name())
}
gen := sessiontitle.New(models...)
if gen == nil {
return fmt.Errorf("agent %q has no usable title model", agent.Name())
}
title, err := gen.Generate(ctx, "debug", []string{args[1]})
if err != nil {
return fmt.Errorf("generating title with agent %q: %w", agent.Name(), err)
}
fmt.Fprintln(cmd.OutOrStdout(), title)
return nil
}