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
21 changes: 20 additions & 1 deletion cmd/root/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import (
type debugFlags struct {
modelOverrides []string
toolsetsJSON bool
toolJSON bool
toolAgent string
skillsJSON bool
runConfig config.RuntimeConfig
}
Expand Down Expand Up @@ -82,6 +84,23 @@ func newDebugCmd() *cobra.Command {
}
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" +
"Calls have real side effects and bypass session 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")
cmd.AddCommand(toolCmd)
skillsCmd := &cobra.Command{
Use: "skills <agent-file>|<registry-ref>",
Short: "Debug the skills of an agent",
Expand Down Expand Up @@ -174,7 +193,7 @@ func (f *debugFlags) runDebugToolsetsCommand(cmd *cobra.Command, args []string)
continue
}

agentTools, err := agent.Tools(ctx)
agentTools, err := agent.ToolsWithCatalog(ctx)
if err != nil {
slog.ErrorContext(ctx, "Failed to query tools", "name", agent.Name(), "error", err)
continue
Expand Down
10 changes: 7 additions & 3 deletions cmd/root/debug_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,8 @@ func TestDebug_VisibleInAdvancedGroup(t *testing.T) {
assert.Equal(t, "advanced", cmd.GroupID)
}

// Non-regression: `toolsets --json` and `skills --json` must not share flag
// storage, otherwise running one with --json on a reused command tree makes
// the other emit JSON too.
// Non-regression: debug subcommands must not share JSON flag storage, otherwise
// reusing a command tree makes unrelated subcommands emit JSON too.
func TestDebug_JSONFlagsAreIndependent(t *testing.T) {
t.Parallel()

Expand All @@ -54,6 +53,8 @@ func TestDebug_JSONFlagsAreIndependent(t *testing.T) {
require.NoError(t, err)
skillsCmd, _, err := cmd.Find([]string{"skills"})
require.NoError(t, err)
toolCmd, _, err := cmd.Find([]string{"tool"})
require.NoError(t, err)

require.NoError(t, toolsetsCmd.Flags().Set("json", "true"))

Expand All @@ -64,6 +65,9 @@ func TestDebug_JSONFlagsAreIndependent(t *testing.T) {

assert.True(t, toolsetsJSON)
assert.False(t, skillsJSON)
toolJSON, err := toolCmd.Flags().GetBool("json")
require.NoError(t, err)
assert.False(t, toolJSON)
}

const flavoredConfig = `
Expand Down
109 changes: 109 additions & 0 deletions cmd/root/debug_tool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package root

import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"

"github.com/spf13/cobra"

"github.com/docker/docker-agent/pkg/telemetry"
"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/tools/builtin/backgroundjobs"
)

func (f *debugFlags) runDebugToolCommand(cmd *cobra.Command, args []string) (commandErr error) {
ctx := backgroundjobs.WithoutBackgroundJobs(cmd.Context())
// Tool parameters may contain secrets; keep them out of command telemetry.
telemetry.TrackCommand(ctx, "debug", []string{"tool"})
defer func() {
if commandErr != nil {
telemetry.TrackCommandError(ctx, "debug", []string{"tool"}, errors.New("tool invocation failed"))
}
}()

arguments := "{}"
if len(args) == 3 {
arguments = args[2]
}
var params map[string]json.RawMessage
if err := json.Unmarshal([]byte(arguments), &params); err != nil {
return fmt.Errorf("parameters must be a JSON object: %w", err)
}
if params == nil {
return errors.New("parameters must be a JSON object, not null")
}

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

agent, err := t.AgentOrDefault(f.toolAgent)
if err != nil {
return err
}

// Include deferred tools: activation would otherwise be lost between CLI calls.
available, err := agent.ToolsWithCatalog(ctx)
for _, warning := range agent.DrainWarnings() {
fmt.Fprintln(cmd.ErrOrStderr(), "Warning:", warning)
}
if err != nil {
return fmt.Errorf("listing tools for agent %q: %w", agent.Name(), err)
}

index := slices.IndexFunc(available, func(tool tools.Tool) bool { return tool.Name == args[1] })
if index < 0 {
return fmt.Errorf("tool %q not found for agent %q; use 'debug toolsets --json' to list tools", args[1], agent.Name())
}

result, err := callDebugTool(ctx, available[index], arguments)
if err != nil {
return err
}

if f.toolJSON {
err = encodeJSON(cmd, result)
} else {
_, err = fmt.Fprintln(cmd.OutOrStdout(), result.Output)
}
if err != nil {
return err
}
if result.IsError {
return fmt.Errorf("tool %q returned an error for agent %q", args[1], agent.Name())
}
return nil
}

func callDebugTool(ctx context.Context, tool tools.Tool, arguments string) (*tools.ToolCallResult, error) {
if tool.RuntimeHandler != "" || tool.Handler == nil {
return nil, fmt.Errorf("tool %q requires an agent runtime and cannot be called directly", tool.Name)
}
toolCall := tools.ToolCall{
ID: "debug_" + tool.Name,
Type: "function",
Function: tools.FunctionCall{
Name: tool.Name,
Arguments: arguments,
},
}
if err := ctx.Err(); err != nil {
return nil, err
}
result, err := tool.Handler(ctx, toolCall, tools.NopRuntime{})
if err != nil {
return nil, fmt.Errorf("calling tool %q: %w", tool.Name, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
if result == nil {
return nil, fmt.Errorf("tool %q returned no result", tool.Name)
}
return result, nil
}
Loading
Loading