Compare commits

...

2 Commits

Author SHA1 Message Date
Claude a058dc287a Simplify findContainerFlexible: drop name-to-ID resolution, just search all hosts
If the direct host lookup fails (wrong host ID, host name used instead, etc.),
simply fall back to searching across all hosts instead of trying to resolve
host names to IDs.

https://claude.ai/code/session_01EoVbj5ecZzsZfroGiNALp3
2026-04-05 02:32:52 +00:00
Claude 7ac04a0715 Make host_id optional and improve tool parameter descriptions for LLMs
LLMs often pass host name instead of host ID, or omit host_id entirely.
This change:
- Makes host_id optional in all tools (fetch_container_logs, inspect,
  actions) since container IDs are unique across hosts
- Adds findContainerFlexible helper that resolves host names to IDs
  and searches across all hosts when host_id is omitted
- Improves parameter descriptions to clarify ID vs name distinction

https://claude.ai/code/session_01EoVbj5ecZzsZfroGiNALp3
2026-04-05 02:31:15 +00:00
6 changed files with 88 additions and 14 deletions
+4 -4
View File
@@ -50,12 +50,12 @@ func AvailableTools(enableActions bool) []*pb.ToolDefinition {
},
{
Name: "fetch_container_logs",
Description: "Fetch raw logs from a running Docker container. Requires container_id and host from find_containers. Optionally filter by time range, log level, text search, or regex pattern. Returns up to 100 matching log lines.",
ParametersJson: `{"type":"object","properties":{"container_id":{"type":"string","description":"The container ID (from find_containers)"},"host_id":{"type":"string","description":"The host ID where the container is running (from find_containers)"},"start":{"type":"string","description":"Optional ISO 8601 start time for log range"},"end":{"type":"string","description":"Optional ISO 8601 end time for log range"},"level":{"type":"string","description":"Optional log level filter (e.g. error, warn, info)"},"query":{"type":"string","description":"Optional text search query (case-insensitive substring match)"},"regex":{"type":"string","description":"Optional regex pattern to match against log messages"}},"required":["container_id","host_id"]}`,
Description: "Fetch raw logs from a running Docker container. Requires container_id from find_containers. Optionally filter by time range, log level, text search, or regex pattern. Returns up to 100 matching log lines.",
ParametersJson: `{"type":"object","properties":{"container_id":{"type":"string","description":"The container ID returned in the 'id' field from find_containers or list_running_containers. This is a Docker container ID (e.g. 'abc123def456'), NOT the container name."},"host_id":{"type":"string","description":"Optional. The host ID returned in the 'host_id' field from find_containers. This is a host identifier (e.g. 'localhost' or a hash), NOT the host display name. If omitted, all hosts are searched."},"start":{"type":"string","description":"Optional ISO 8601 start time for log range"},"end":{"type":"string","description":"Optional ISO 8601 end time for log range"},"level":{"type":"string","description":"Optional log level filter (e.g. error, warn, info)"},"query":{"type":"string","description":"Optional text search query (case-insensitive substring match)"},"regex":{"type":"string","description":"Optional regex pattern to match against log messages"}},"required":["container_id"]}`,
},
}
inspectParams := `{"type":"object","properties":{"container_id":{"type":"string","description":"The container ID (from find_containers)"},"host_id":{"type":"string","description":"The host ID where the container is running (from find_containers)"}},"required":["container_id","host_id"]}`
inspectParams := `{"type":"object","properties":{"container_id":{"type":"string","description":"The container ID returned in the 'id' field from find_containers or list_running_containers. This is a Docker container ID (e.g. 'abc123def456'), NOT the container name."},"host_id":{"type":"string","description":"Optional. The host ID returned in the 'host_id' field from find_containers. This is a host identifier (e.g. 'localhost' or a hash), NOT the host display name. If omitted, all hosts are searched."}},"required":["container_id"]}`
tools = append(tools, &pb.ToolDefinition{
Name: "inspect_container",
Description: "Get detailed configuration of a Docker container including environment variables, port mappings, mounts, restart policy, network mode, labels, and resource limits.",
@@ -63,7 +63,7 @@ func AvailableTools(enableActions bool) []*pb.ToolDefinition {
})
if enableActions {
actionParams := `{"type":"object","properties":{"container_id":{"type":"string","description":"The container ID (from find_containers)"},"host_id":{"type":"string","description":"The host ID where the container is running (from find_containers)"}},"required":["container_id","host_id"]}`
actionParams := `{"type":"object","properties":{"container_id":{"type":"string","description":"The container ID returned in the 'id' field from find_containers. This is a Docker container ID, NOT the container name."},"host_id":{"type":"string","description":"Optional. The host ID returned in the 'host_id' field from find_containers. This is a host identifier, NOT the host display name. If omitted, all hosts are searched."}},"required":["container_id"]}`
tools = append(tools,
&pb.ToolDefinition{
Name: "start_container",
+1 -4
View File
@@ -28,11 +28,8 @@ func executeContainerAction(ctx context.Context, name string, argsJSON string, h
if args.ContainerID == "" {
return nil, fmt.Errorf("container_id is required")
}
if args.Host == "" {
return nil, fmt.Errorf("host is required")
}
cs, err := hostService.FindContainer(args.Host, args.ContainerID, labels)
cs, err := findContainerFlexible(args.Host, args.ContainerID, hostService, labels)
if err != nil {
return nil, fmt.Errorf("container not found: %w", err)
}
+3 -3
View File
@@ -155,11 +155,11 @@ func executeInspectContainer(argsJSON string, hostService ToolHostService, label
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return nil, fmt.Errorf("failed to parse arguments: %w", err)
}
if args.ContainerID == "" || args.Host == "" {
return nil, fmt.Errorf("container_id and host are required")
if args.ContainerID == "" {
return nil, fmt.Errorf("container_id is required")
}
cs, err := hostService.FindContainer(args.Host, args.ContainerID, labels)
cs, err := findContainerFlexible(args.Host, args.ContainerID, hostService, labels)
if err != nil {
return nil, fmt.Errorf("container not found: %w", err)
}
+31
View File
@@ -1,10 +1,12 @@
package cloud
import (
"fmt"
"strings"
"time"
"github.com/amir20/dozzle/internal/container"
container_support "github.com/amir20/dozzle/internal/support/container"
pb "github.com/amir20/dozzle/proto/cloud"
"github.com/rs/zerolog/log"
)
@@ -62,3 +64,32 @@ func logHostErrors(errs []error) {
}
}
}
// findContainerFlexible finds a container by ID, optionally scoped to a specific host.
// When hostID is provided and valid, it uses direct lookup for efficiency.
// Otherwise it searches across all hosts since container IDs are unique.
func findContainerFlexible(hostID string, containerID string, hostService ToolHostService, labels container.ContainerLabels) (*container_support.ContainerService, error) {
if containerID == "" {
return nil, fmt.Errorf("container_id is required")
}
// Try direct lookup if host is provided
if hostID != "" {
cs, err := hostService.FindContainer(hostID, containerID, labels)
if err == nil {
return cs, nil
}
}
// Fall back to searching across all hosts
containers, errs := hostService.ListAllContainers(labels)
logHostErrors(errs)
for _, c := range containers {
if c.ID == containerID {
return hostService.FindContainer(c.Host, containerID, labels)
}
}
return nil, fmt.Errorf("container %s not found", containerID)
}
+3 -3
View File
@@ -27,11 +27,11 @@ func executeFetchContainerLogs(ctx context.Context, argsJSON string, hostService
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return nil, fmt.Errorf("failed to parse arguments: %w", err)
}
if args.ContainerID == "" || args.Host == "" {
return nil, fmt.Errorf("container_id and host are required")
if args.ContainerID == "" {
return nil, fmt.Errorf("container_id is required")
}
cs, err := hostService.FindContainer(args.Host, args.ContainerID, labels)
cs, err := findContainerFlexible(args.Host, args.ContainerID, hostService, labels)
if err != nil {
return nil, fmt.Errorf("container not found: %w", err)
}
+46
View File
@@ -186,6 +186,52 @@ func TestExecuteTool_RestartContainer(t *testing.T) {
mockClient.AssertCalled(t, "ContainerAction", mock.Anything, mock.Anything, container.Restart)
}
func TestExecuteTool_RestartContainer_WithoutHostID(t *testing.T) {
mockClient := &MockClientService{}
mockClient.On("ContainerAction", mock.Anything, mock.Anything, container.Restart).Return(nil)
cs := container_support.NewContainerService(mockClient, container.Container{ID: "abc123"})
mockHost := &MockHostService{}
// First call with empty host fails, then ListAllContainers finds the container on "local"
mockHost.On("ListAllContainers", container.ContainerLabels(nil)).Return([]container.Container{
{ID: "abc123", Name: "nginx", Image: "nginx:latest", State: "running", Host: "local"},
}, nil)
mockHost.On("FindContainer", "local", "abc123", container.ContainerLabels(nil)).Return(cs, nil)
argsJSON := `{"container_id": "abc123"}`
resp := ExecuteTool(context.Background(), "restart_container", argsJSON, true, mockHost, nil)
assert.True(t, resp.Success)
action := resp.GetAction()
assert.NotNil(t, action)
assert.True(t, action.Success)
assert.Equal(t, "abc123", action.ContainerId)
}
func TestExecuteTool_RestartContainer_WithWrongHost(t *testing.T) {
mockClient := &MockClientService{}
mockClient.On("ContainerAction", mock.Anything, mock.Anything, container.Restart).Return(nil)
cs := container_support.NewContainerService(mockClient, container.Container{ID: "abc123"})
mockHost := &MockHostService{}
// LLM passes wrong host value, direct lookup fails, falls back to searching all hosts
mockHost.On("FindContainer", "my-server", "abc123", container.ContainerLabels(nil)).Return(nil, fmt.Errorf("host not found"))
mockHost.On("ListAllContainers", container.ContainerLabels(nil)).Return([]container.Container{
{ID: "abc123", Name: "nginx", Image: "nginx:latest", State: "running", Host: "local"},
}, nil)
mockHost.On("FindContainer", "local", "abc123", container.ContainerLabels(nil)).Return(cs, nil)
argsJSON := `{"container_id": "abc123", "host_id": "my-server"}`
resp := ExecuteTool(context.Background(), "restart_container", argsJSON, true, mockHost, nil)
assert.True(t, resp.Success)
action := resp.GetAction()
assert.NotNil(t, action)
assert.True(t, action.Success)
}
func TestExecuteTool_RestartContainer_ActionsDisabled(t *testing.T) {
mockHost := &MockHostService{}