From c9df57497031762c6201beab526c2f1764ab5b7c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 2 Mar 2026 21:15:44 +0530 Subject: [PATCH 1/3] fix: allow empty input for parameterless skills/reasoners (#196) Remove binding:"required" constraint on Input field in ExecuteRequest and ExecuteReasonerRequest structs. Gin interprets required on maps as "must be present AND non-empty", which rejects the valid {"input":{}} payload that SDKs send for parameterless calls. Also remove the explicit len(req.Input)==0 check in prepareExecution and add nil-input guards in the reasoner and skill handlers to match the existing pattern in execute.go. Closes #196 --- .../internal/handlers/empty_input_test.go | 169 ++++++++++++++++++ control-plane/internal/handlers/execute.go | 7 +- control-plane/internal/handlers/reasoners.go | 10 +- 3 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 control-plane/internal/handlers/empty_input_test.go diff --git a/control-plane/internal/handlers/empty_input_test.go b/control-plane/internal/handlers/empty_input_test.go new file mode 100644 index 00000000..50792566 --- /dev/null +++ b/control-plane/internal/handlers/empty_input_test.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/control-plane/internal/services" + "github.com/Agent-Field/agentfield/control-plane/pkg/types" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// TestExecuteHandler_EmptyInput verifies that calling the execute endpoint with +// an empty input object ({"input":{}}) succeeds instead of returning 400. +// Reproduction for https://github.com/Agent-Field/agentfield/issues/196. +func TestExecuteHandler_EmptyInput(t *testing.T) { + gin.SetMode(gin.TestMode) + + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + defer r.Body.Close() + + // Agent receives the (empty) input and returns success + var payload map[string]interface{} + require.NoError(t, json.Unmarshal(body, &payload)) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer agentServer.Close() + + agent := &types.AgentNode{ + ID: "node-1", + BaseURL: agentServer.URL, + Reasoners: []types.ReasonerDefinition{{ID: "ping"}}, + } + + store := newTestExecutionStorage(agent) + payloads := services.NewFilePayloadStore(t.TempDir()) + + router := gin.New() + router.POST("/api/v1/execute/:target", ExecuteHandler(store, payloads, nil, 90*time.Second)) + + // Empty input object — should be accepted for parameterless skills + req := httptest.NewRequest(http.MethodPost, "/api/v1/execute/node-1.ping", + strings.NewReader(`{"input":{}}`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code, "empty input {} should be accepted, got: %s", resp.Body.String()) + + var envelope ExecuteResponse + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &envelope)) + require.Equal(t, types.ExecutionStatusSucceeded, envelope.Status) +} + +// TestExecuteHandler_NilInput verifies that calling the execute endpoint with +// no input field at all ({}) succeeds — the handler should default to empty map. +func TestExecuteHandler_NilInput(t *testing.T) { + gin.SetMode(gin.TestMode) + + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer agentServer.Close() + + agent := &types.AgentNode{ + ID: "node-1", + BaseURL: agentServer.URL, + Reasoners: []types.ReasonerDefinition{{ID: "ping"}}, + } + + store := newTestExecutionStorage(agent) + payloads := services.NewFilePayloadStore(t.TempDir()) + + router := gin.New() + router.POST("/api/v1/execute/:target", ExecuteHandler(store, payloads, nil, 90*time.Second)) + + // No input field at all — should default to empty map + req := httptest.NewRequest(http.MethodPost, "/api/v1/execute/node-1.ping", + strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code, "missing input field should be accepted, got: %s", resp.Body.String()) +} + +// TestExecuteRequest_BindingAcceptsEmptyInput directly tests that the ExecuteRequest +// struct's binding tags accept empty and nil input (unit-level binding test). +func TestExecuteRequest_BindingAcceptsEmptyInput(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + body string + }{ + {"empty input object", `{"input":{}}`}, + {"no input field", `{}`}, + {"input with context", `{"input":{},"context":{"session":"abc"}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/", strings.NewReader(tt.body)) + c.Request.Header.Set("Content-Type", "application/json") + + var req ExecuteRequest + err := c.ShouldBindJSON(&req) + require.NoError(t, err, "binding should accept %s", tt.name) + }) + } +} + +// TestExecuteReasonerRequest_BindingAcceptsEmptyInput directly tests that the +// ExecuteReasonerRequest struct's binding tags accept empty and nil input. +func TestExecuteReasonerRequest_BindingAcceptsEmptyInput(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + body string + }{ + {"empty input object", `{"input":{}}`}, + {"no input field", `{}`}, + {"input with context", `{"input":{},"context":{"session":"abc"}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/", strings.NewReader(tt.body)) + c.Request.Header.Set("Content-Type", "application/json") + + var req ExecuteReasonerRequest + err := c.ShouldBindJSON(&req) + require.NoError(t, err, "binding should accept %s", tt.name) + }) + } +} + +// TestExecuteRequest_BindingStillRequiresJSON verifies that completely invalid +// input is still rejected (the fix doesn't break validation entirely). +func TestExecuteRequest_BindingStillRequiresJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("POST", "/", strings.NewReader("not-json")) + c.Request.Header.Set("Content-Type", "application/json") + + var req ExecuteRequest + err := c.ShouldBindJSON(&req) + require.Error(t, err, "invalid JSON should still be rejected") +} diff --git a/control-plane/internal/handlers/execute.go b/control-plane/internal/handlers/execute.go index 0d9606f8..a892ea7e 100644 --- a/control-plane/internal/handlers/execute.go +++ b/control-plane/internal/handlers/execute.go @@ -41,7 +41,7 @@ type ExecutionStore interface { // ExecuteRequest represents an execution request from an agent client. type ExecuteRequest struct { - Input map[string]interface{} `json:"input" binding:"required"` + Input map[string]interface{} `json:"input"` Context map[string]interface{} `json:"context,omitempty"` Webhook *WebhookRequest `json:"webhook,omitempty"` } @@ -781,8 +781,9 @@ func (c *executionController) prepareExecution(ctx context.Context, ginCtx *gin. if err := ginCtx.ShouldBindJSON(&req); err != nil { return nil, fmt.Errorf("invalid request body: %w", err) } - if len(req.Input) == 0 { - return nil, errors.New("input is required") + // Allow empty input for skills/reasoners that take no parameters (issue #196). + if req.Input == nil { + req.Input = map[string]interface{}{} } var ( diff --git a/control-plane/internal/handlers/reasoners.go b/control-plane/internal/handlers/reasoners.go index 9e448fb8..04d793db 100644 --- a/control-plane/internal/handlers/reasoners.go +++ b/control-plane/internal/handlers/reasoners.go @@ -20,7 +20,7 @@ import ( // ExecuteReasonerRequest represents a request to execute a reasoner type ExecuteReasonerRequest struct { - Input map[string]interface{} `json:"input" binding:"required"` + Input map[string]interface{} `json:"input"` Context map[string]interface{} `json:"context,omitempty"` } @@ -102,6 +102,10 @@ func ExecuteReasonerHandler(storageProvider storage.StorageProvider) gin.Handler c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + // Allow empty input for reasoners that take no parameters. + if req.Input == nil { + req.Input = map[string]interface{}{} + } // Find the agent node targetNode, err := storageProvider.GetAgent(ctx, nodeID) @@ -451,6 +455,10 @@ func ExecuteSkillHandler(storageProvider storage.StorageProvider) gin.HandlerFun c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + // Allow empty input for skills that take no parameters. + if req.Input == nil { + req.Input = map[string]interface{}{} + } // Find the agent node targetNode, err := storageProvider.GetAgent(ctx, nodeID) From 1abc82232ecc3201786b9892e53ec297ec8a3d41 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Mon, 2 Mar 2026 21:25:26 +0530 Subject: [PATCH 2/3] test: strengthen empty-input handler coverage --- .../internal/handlers/empty_input_test.go | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/control-plane/internal/handlers/empty_input_test.go b/control-plane/internal/handlers/empty_input_test.go index 50792566..2aa46136 100644 --- a/control-plane/internal/handlers/empty_input_test.go +++ b/control-plane/internal/handlers/empty_input_test.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "io" "net/http" @@ -10,12 +11,34 @@ import ( "time" "github.com/Agent-Field/agentfield/control-plane/internal/services" + "github.com/Agent-Field/agentfield/control-plane/internal/storage" "github.com/Agent-Field/agentfield/control-plane/pkg/types" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +type reasonerTestStorage struct { + storage.StorageProvider + agent *types.AgentNode + executions []*types.WorkflowExecution +} + +func (s *reasonerTestStorage) GetAgent(ctx context.Context, id string) (*types.AgentNode, error) { + if s.agent != nil && s.agent.ID == id { + return s.agent, nil + } + return nil, nil +} + +func (s *reasonerTestStorage) StoreWorkflowExecution(ctx context.Context, execution *types.WorkflowExecution) error { + if execution != nil { + copy := *execution + s.executions = append(s.executions, ©) + } + return nil +} + // TestExecuteHandler_EmptyInput verifies that calling the execute endpoint with // an empty input object ({"input":{}}) succeeds instead of returning 400. // Reproduction for https://github.com/Agent-Field/agentfield/issues/196. @@ -26,6 +49,7 @@ func TestExecuteHandler_EmptyInput(t *testing.T) { body, err := io.ReadAll(r.Body) require.NoError(t, err) defer r.Body.Close() + require.JSONEq(t, `{}`, string(body)) // Agent receives the (empty) input and returns success var payload map[string]interface{} @@ -69,6 +93,11 @@ func TestExecuteHandler_NilInput(t *testing.T) { gin.SetMode(gin.TestMode) agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + defer r.Body.Close() + require.JSONEq(t, `{}`, string(body)) + w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"ok"}`)) })) @@ -167,3 +196,93 @@ func TestExecuteRequest_BindingStillRequiresJSON(t *testing.T) { err := c.ShouldBindJSON(&req) require.Error(t, err, "invalid JSON should still be rejected") } + +func TestExecuteReasonerHandler_EmptyAndNilInput(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + body string + }{ + {name: "empty input object", body: `{"input":{}}`}, + {name: "nil input object", body: `{}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/reasoners/ping", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + defer r.Body.Close() + require.JSONEq(t, `{}`, string(body)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer agentServer.Close() + + store := &reasonerTestStorage{agent: &types.AgentNode{ + ID: "node-1", + BaseURL: agentServer.URL, + Reasoners: []types.ReasonerDefinition{{ID: "ping"}}, + }} + + router := gin.New() + router.POST("/reasoners/:reasoner_id", ExecuteReasonerHandler(store)) + + req := httptest.NewRequest(http.MethodPost, "/reasoners/node-1.ping", strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + require.NotEmpty(t, store.executions) + require.JSONEq(t, `{}`, string(store.executions[0].InputData)) + }) + } +} + +func TestExecuteSkillHandler_EmptyAndNilInput(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + body string + }{ + {name: "empty input object", body: `{"input":{}}`}, + {name: "nil input object", body: `{}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/skills/list_items", r.URL.Path) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + defer r.Body.Close() + require.JSONEq(t, `{}`, string(body)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer agentServer.Close() + + store := &reasonerTestStorage{agent: &types.AgentNode{ + ID: "node-1", + BaseURL: agentServer.URL, + Skills: []types.SkillDefinition{{ID: "list_items"}}, + }} + + router := gin.New() + router.POST("/skills/:skill_id", ExecuteSkillHandler(store)) + + req := httptest.NewRequest(http.MethodPost, "/skills/node-1.list_items", strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code, resp.Body.String()) + require.NotEmpty(t, store.executions) + require.JSONEq(t, `{}`, string(store.executions[0].InputData)) + }) + } +} From a1ccd2ea54057af0661c7e01f14a67a59c6e1c09 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 2 Mar 2026 16:48:42 -0500 Subject: [PATCH 3/3] fix: update empty_input_test.go for ExecuteHandler signature change Main added an internalToken parameter to ExecuteHandler in PR #197. Update the two test call sites to pass empty string for the new param. Co-Authored-By: Claude Opus 4.6 --- control-plane/internal/handlers/empty_input_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/control-plane/internal/handlers/empty_input_test.go b/control-plane/internal/handlers/empty_input_test.go index 2aa46136..901b6877 100644 --- a/control-plane/internal/handlers/empty_input_test.go +++ b/control-plane/internal/handlers/empty_input_test.go @@ -70,7 +70,7 @@ func TestExecuteHandler_EmptyInput(t *testing.T) { payloads := services.NewFilePayloadStore(t.TempDir()) router := gin.New() - router.POST("/api/v1/execute/:target", ExecuteHandler(store, payloads, nil, 90*time.Second)) + router.POST("/api/v1/execute/:target", ExecuteHandler(store, payloads, nil, 90*time.Second, "")) // Empty input object — should be accepted for parameterless skills req := httptest.NewRequest(http.MethodPost, "/api/v1/execute/node-1.ping", @@ -113,7 +113,7 @@ func TestExecuteHandler_NilInput(t *testing.T) { payloads := services.NewFilePayloadStore(t.TempDir()) router := gin.New() - router.POST("/api/v1/execute/:target", ExecuteHandler(store, payloads, nil, 90*time.Second)) + router.POST("/api/v1/execute/:target", ExecuteHandler(store, payloads, nil, 90*time.Second, "")) // No input field at all — should default to empty map req := httptest.NewRequest(http.MethodPost, "/api/v1/execute/node-1.ping",