From cdcefab445db6df6d39465edf449007a0e41d4a2 Mon Sep 17 00:00:00 2001 From: Steve Calvert Date: Sun, 19 Jul 2026 14:05:51 -0700 Subject: [PATCH] feat(agents): serve agents commands from the platform API by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list/get/schemas call /api/agents/... and fall back to the classic API (one-time warning) when the tenant gate is off. run calls POST /api/agents/{agent_id}/runs with the agent_id folded into the --json body (the platform route takes it as a path parameter); because the run body is user-authored and shaped differently on the two surfaces, run never falls back automatically — gate-closed errors with GLEAN_LEGACY_APIS guidance, under which the classic messages/fragments body is expected. agentIDRequest's canonical tag becomes agent_id (camelCase agentId still accepted via cmdutil normalization). Co-Authored-By: Claude Fable 5 --- cmd/agents.go | 156 ++++++++++++++++++++++++++++++++++++++------- cmd/agents_test.go | 129 +++++++++++++++++++++++++++++++++---- cmd/schema.go | 8 +-- 3 files changed, 254 insertions(+), 39 deletions(-) diff --git a/cmd/agents.go b/cmd/agents.go index 11e8970..5257488 100644 --- a/cmd/agents.go +++ b/cmd/agents.go @@ -2,6 +2,8 @@ package cmd import ( "context" + "encoding/json" + "fmt" "io" glean "github.com/gleanwork/api-client-go" @@ -11,10 +13,21 @@ import ( "github.com/spf13/cobra" ) -// agentIDRequest is a CLI-only request struct for commands that take an agent -// ID as their only input. Using camelCase JSON tag for CLI consistency. +// agentIDRequest is a CLI-only request struct for commands whose only input +// is an agent ID. The snake_case tag matches the platform input shape; +// cmdutil transparently normalizes camelCase agentId. type agentIDRequest struct { - AgentID string `json:"agentId"` + AgentID string `json:"agent_id"` +} + +// agentRunRequest is the CLI request for `agents run`. The platform route +// takes the agent ID as a path parameter (POST /api/agents/{agent_id}/runs), +// so the CLI folds it into the --json body alongside the run inputs. +type agentRunRequest struct { + AgentID string `json:"agent_id"` + Input map[string]any `json:"input,omitempty"` + Messages []components.PlatformMessage `json:"messages,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` } func NewCmdAgents() *cobra.Command { @@ -25,11 +38,19 @@ func NewCmdAgents() *cobra.Command { Agents are AI-powered workflows that can search, reason, and act on your company's knowledge. +Agent commands are served by the platform API (/api/agents/...) and responses +use its snake_case shape. list/get/schemas fall back to the classic API with a +warning when the platform API is not enabled; run does not fall back +automatically because its request body differs between the two surfaces. Set +GLEAN_LEGACY_APIS=1 to use the classic API directly (run then expects the +classic messages/fragments body). + Example: glean agents list - glean agents get --json '{"agentId":""}' - glean agents schemas --json '{"agentId":""}' - glean agents run --json '{"agentId":"","messages":[{"author":"USER","fragments":[{"text":"summarize Q1 results"}]}]}'`, + glean agents get --json '{"agent_id":""}' + glean agents schemas --json '{"agent_id":""}' + glean agents run --json '{"agent_id":"","input":{"query":"summarize Q1 results"}}' + glean agents run --json '{"agent_id":"","messages":[{"role":"user","content":[{"type":"text","text":"summarize Q1 results"}]}]}'`, } cmd.AddCommand( newAgentsListCmd(), @@ -40,26 +61,56 @@ Example: return cmd } +// agentsListTextFn renders whichever agents list shape the request produced: +// the platform response (default) or the classic response (legacy fallback). +func agentsListTextFn(w io.Writer, v any) error { + header := []string{"ID", "NAME", "DESCRIPTION"} + switch resp := v.(type) { + case *components.PlatformAgentsSearchResponse: + rows := make([][]string, len(resp.Agents)) + for i, a := range resp.Agents { + desc := "" + if a.Description != nil { + desc = output.Truncate(*a.Description, 60) + } + rows[i] = []string{a.AgentID, a.Name, desc} + } + return output.WriteTable(w, header, rows) + case *components.SearchAgentsResponse: + rows := make([][]string, len(resp.Agents)) + for i, a := range resp.Agents { + desc := "" + if a.Description != nil { + desc = output.Truncate(*a.Description, 60) + } + rows[i] = []string{a.AgentID, a.Name, desc} + } + return output.WriteTable(w, header, rows) + default: + return output.WriteJSON(w, v) + } +} + func newAgentsListCmd() *cobra.Command { - return cmdutil.Build(cmdutil.Spec[components.SearchAgentsRequest]{ - Use: "list", - Short: "List available agents", - TextFn: func(w io.Writer, v any) error { - resp, ok := v.(*components.SearchAgentsResponse) - if !ok { - return output.WriteJSON(w, v) + return cmdutil.Build(cmdutil.Spec[components.PlatformAgentsSearchRequest]{ + Use: "list", + Short: "List available agents", + Endpoint: "/api/agents/search", + TextFn: agentsListTextFn, + Run: func(ctx context.Context, sdk *glean.Glean, req components.PlatformAgentsSearchRequest) (any, error) { + resp, err := sdk.Agents.Search(ctx, req) + if err != nil { + return nil, err } - rows := make([][]string, len(resp.Agents)) - for i, a := range resp.Agents { - desc := "" - if a.Description != nil { - desc = output.Truncate(*a.Description, 60) + return resp.PlatformAgentsSearchResponse, nil + }, + LegacyRun: func(ctx context.Context, sdk *glean.Glean, rawJSON []byte) (any, error) { + var req components.SearchAgentsRequest + if len(rawJSON) > 0 { + if err := json.Unmarshal(rawJSON, &req); err != nil { + return nil, fmt.Errorf("invalid --json: %w", err) } - rows[i] = []string{a.AgentID, a.Name, desc} } - return output.WriteTable(w, []string{"ID", "NAME", "DESCRIPTION"}, rows) - }, - Run: func(ctx context.Context, sdk *glean.Glean, req components.SearchAgentsRequest) (any, error) { resp, err := sdk.Client.Agents.List(ctx, req) if err != nil { return nil, err @@ -69,12 +120,34 @@ func newAgentsListCmd() *cobra.Command { }) } +// parseAgentIDRequest is the shared LegacyRun payload parse for get/schemas: +// the input is a bare agent ID, identical on both surfaces. +func parseAgentIDRequest(rawJSON []byte) (agentIDRequest, error) { + var req agentIDRequest + if err := json.Unmarshal(rawJSON, &req); err != nil { + return req, fmt.Errorf("invalid --json: %w", err) + } + return req, nil +} + func newAgentsGetCmd() *cobra.Command { return cmdutil.Build(cmdutil.Spec[agentIDRequest]{ Use: "get", Short: "Get an agent by ID", JSONRequired: true, + Endpoint: "/api/agents/{agent_id}", Run: func(ctx context.Context, sdk *glean.Glean, req agentIDRequest) (any, error) { + resp, err := sdk.Agents.Get(ctx, req.AgentID) + if err != nil { + return nil, err + } + return resp.PlatformAgentGetResponse, nil + }, + LegacyRun: func(ctx context.Context, sdk *glean.Glean, rawJSON []byte) (any, error) { + req, err := parseAgentIDRequest(rawJSON) + if err != nil { + return nil, err + } resp, err := sdk.Client.Agents.Retrieve(ctx, req.AgentID, nil, nil) if err != nil { return nil, err @@ -89,7 +162,19 @@ func newAgentsSchemasCmd() *cobra.Command { Use: "schemas", Short: "Get the schemas for an agent", JSONRequired: true, + Endpoint: "/api/agents/{agent_id}/schemas", Run: func(ctx context.Context, sdk *glean.Glean, req agentIDRequest) (any, error) { + resp, err := sdk.Agents.GetSchemas(ctx, req.AgentID, nil) + if err != nil { + return nil, err + } + return resp.PlatformAgentSchemasResponse, nil + }, + LegacyRun: func(ctx context.Context, sdk *glean.Glean, rawJSON []byte) (any, error) { + req, err := parseAgentIDRequest(rawJSON) + if err != nil { + return nil, err + } resp, err := sdk.Client.Agents.RetrieveSchemas(ctx, req.AgentID, nil, nil) if err != nil { return nil, err @@ -100,11 +185,34 @@ func newAgentsSchemasCmd() *cobra.Command { } func newAgentsRunCmd() *cobra.Command { - return cmdutil.Build(cmdutil.Spec[components.AgentRunCreate]{ + return cmdutil.Build(cmdutil.Spec[agentRunRequest]{ Use: "run", Short: "Run an agent (synchronous)", JSONRequired: true, - Run: func(ctx context.Context, sdk *glean.Glean, req components.AgentRunCreate) (any, error) { + Endpoint: "/api/agents/{agent_id}/runs", + // The run body is user-authored and shaped differently on the two + // surfaces (platform input/messages vs classic messages/fragments), + // so gate-closed errors instead of silently replaying the payload. + FallbackMode: cmdutil.FallbackEnvOnly, + Run: func(ctx context.Context, sdk *glean.Glean, req agentRunRequest) (any, error) { + if req.AgentID == "" { + return nil, fmt.Errorf("agent_id is required in the --json payload") + } + resp, err := sdk.Agents.CreateRun(ctx, req.AgentID, components.PlatformAgentRunCreateRequest{ + Input: req.Input, + Messages: req.Messages, + Metadata: req.Metadata, + }) + if err != nil { + return nil, err + } + return resp.PlatformAgentRunWaitResponse, nil + }, + LegacyRun: func(ctx context.Context, sdk *glean.Glean, rawJSON []byte) (any, error) { + var req components.AgentRunCreate + if err := json.Unmarshal(rawJSON, &req); err != nil { + return nil, fmt.Errorf("invalid --json: %w", err) + } resp, err := sdk.Client.Agents.Run(ctx, req) if err != nil { return nil, err diff --git a/cmd/agents_test.go b/cmd/agents_test.go index 32c17e9..235f33f 100644 --- a/cmd/agents_test.go +++ b/cmd/agents_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/gkampitakis/go-snaps/snaps" + "github.com/gleanwork/glean-cli/internal/platform" "github.com/gleanwork/glean-cli/internal/testutils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,6 +25,7 @@ func TestAgentsHelp(t *testing.T) { // list func TestAgentsListDryRun(t *testing.T) { + usePlatformAPIs(t) // Dry-run must not require auth — SDK init is deferred until after the dry-run check. b := bytes.NewBufferString("") cmd := NewCmdAgents() @@ -36,6 +38,7 @@ func TestAgentsListDryRun(t *testing.T) { } func TestAgentsListInvalidJSON(t *testing.T) { + usePlatformAPIs(t) cmd := NewCmdAgents() cmd.SetErr(bytes.NewBufferString("")) cmd.SetArgs([]string{"list", "--json", "not valid json"}) @@ -43,8 +46,9 @@ func TestAgentsListInvalidJSON(t *testing.T) { assert.Error(t, err, "invalid JSON must return error") } -func TestAgentsListLive(t *testing.T) { - _, cleanup := testutils.SetupTestWithResponse(t, []byte(`{}`)) +func TestAgentsListLive_UsesPlatformAPI(t *testing.T) { + usePlatformAPIs(t) + mock, cleanup := testutils.SetupTestWithResponse(t, []byte(`{"agents":[],"request_id":"req-1"}`)) defer cleanup() b := bytes.NewBufferString("") cmd := NewCmdAgents() @@ -52,9 +56,49 @@ func TestAgentsListLive(t *testing.T) { cmd.SetArgs([]string{"list"}) err := cmd.Execute() require.NoError(t, err) + require.NotEmpty(t, mock.Requests) + assert.Equal(t, "/api/agents/search", mock.Requests[0].URL.Path) +} + +func TestAgentsListGateClosedFallsBack(t *testing.T) { + usePlatformAPIs(t) + platform.ResetWarnings() + mock, cleanup := testutils.SetupTestWithResponse(t, nil) + defer cleanup() + mock.Routes = map[string]testutils.MockResponse{ + "/api/agents/search": gateClosedResponse(), + "/rest/api/v1/agents/search": {Body: []byte(`{"agents":[{"agent_id":"legacy-agent","name":"Legacy Agent","capabilities":{}}]}`)}, + } + + b := bytes.NewBufferString("") + errBuf := bytes.NewBufferString("") + cmd := NewCmdAgents() + cmd.SetOut(b) + cmd.SetErr(errBuf) + cmd.SetArgs([]string{"list"}) + require.NoError(t, cmd.Execute()) + + assert.Contains(t, b.String(), "legacy-agent") + assert.Contains(t, errBuf.String(), "falling back to the legacy API") + require.Len(t, mock.Requests, 2) + assert.Equal(t, "/api/agents/search", mock.Requests[0].URL.Path) + assert.Equal(t, "/rest/api/v1/agents/search", mock.Requests[1].URL.Path) +} + +func TestAgentsListLegacyEnv(t *testing.T) { + t.Setenv(platform.EnvLegacy, "1") + mock, cleanup := testutils.SetupTestWithResponse(t, []byte(`{"agents":[]}`)) + defer cleanup() + cmd := NewCmdAgents() + cmd.SetOut(bytes.NewBufferString("")) + cmd.SetArgs([]string{"list"}) + require.NoError(t, cmd.Execute()) + require.Len(t, mock.Requests, 1) + assert.Equal(t, "/rest/api/v1/agents/search", mock.Requests[0].URL.Path) } func TestAgentsListFields(t *testing.T) { + usePlatformAPIs(t) body, _ := json.Marshal(map[string]any{ "agents": []map[string]any{ {"agent_id": "agent-1", "name": "Research Agent", "capabilities": map[string]any{}}, @@ -79,6 +123,7 @@ func TestAgentsListFields(t *testing.T) { } func TestAgentsListOutputText(t *testing.T) { + usePlatformAPIs(t) body, _ := json.Marshal(map[string]any{ "agents": []map[string]any{ {"agent_id": "agent-1", "name": "Research Agent", "description": "Finds things", "capabilities": map[string]any{}}, @@ -103,7 +148,9 @@ func TestAgentsListOutputText(t *testing.T) { // get func TestAgentsGetDryRun(t *testing.T) { + usePlatformAPIs(t) // Dry-run must not require auth — SDK init is deferred until after the dry-run check. + // camelCase agentId input is normalized to the canonical snake_case shape. b := bytes.NewBufferString("") cmd := NewCmdAgents() cmd.SetOut(b) @@ -112,9 +159,9 @@ func TestAgentsGetDryRun(t *testing.T) { require.NoError(t, err) var req map[string]any require.NoError(t, json.Unmarshal(b.Bytes(), &req), "dry-run output must be valid JSON") - assert.Equal(t, "test-agent", req["agentId"]) + assert.Equal(t, "test-agent", req["agent_id"]) snaps.MatchInlineSnapshot(t, b.String(), snaps.Inline(`{ - "agentId": "test-agent" + "agent_id": "test-agent" } `)) } @@ -129,6 +176,7 @@ func TestAgentsGetMissingJSON(t *testing.T) { } func TestAgentsGetInvalidJSON(t *testing.T) { + usePlatformAPIs(t) cmd := NewCmdAgents() cmd.SetErr(bytes.NewBufferString("")) cmd.SetArgs([]string{"get", "--json", "not valid json"}) @@ -136,8 +184,9 @@ func TestAgentsGetInvalidJSON(t *testing.T) { assert.Error(t, err, "invalid JSON must return error") } -func TestAgentsGetLive(t *testing.T) { - _, cleanup := testutils.SetupTestWithResponse(t, []byte(`{}`)) +func TestAgentsGetLive_UsesPlatformAPI(t *testing.T) { + usePlatformAPIs(t) + mock, cleanup := testutils.SetupTestWithResponse(t, []byte(`{"agent":{"agent_id":"test-agent","name":"Test","capabilities":{}},"request_id":"req-1"}`)) defer cleanup() b := bytes.NewBufferString("") cmd := NewCmdAgents() @@ -145,11 +194,14 @@ func TestAgentsGetLive(t *testing.T) { cmd.SetArgs([]string{"get", "--json", `{"agentId":"test-agent"}`}) err := cmd.Execute() require.NoError(t, err) + require.NotEmpty(t, mock.Requests) + assert.Equal(t, "/api/agents/test-agent", mock.Requests[0].URL.Path) } // schemas func TestAgentsSchemasDryRun(t *testing.T) { + usePlatformAPIs(t) // Dry-run must not require auth — SDK init is deferred until after the dry-run check. b := bytes.NewBufferString("") cmd := NewCmdAgents() @@ -158,7 +210,7 @@ func TestAgentsSchemasDryRun(t *testing.T) { err := cmd.Execute() require.NoError(t, err) snaps.MatchInlineSnapshot(t, b.String(), snaps.Inline(`{ - "agentId": "test-agent" + "agent_id": "test-agent" } `)) } @@ -173,6 +225,7 @@ func TestAgentsSchemasMissingJSON(t *testing.T) { } func TestAgentsSchemasInvalidJSON(t *testing.T) { + usePlatformAPIs(t) cmd := NewCmdAgents() cmd.SetErr(bytes.NewBufferString("")) cmd.SetArgs([]string{"schemas", "--json", "not valid json"}) @@ -180,8 +233,9 @@ func TestAgentsSchemasInvalidJSON(t *testing.T) { assert.Error(t, err, "invalid JSON must return error") } -func TestAgentsSchemasLive(t *testing.T) { - _, cleanup := testutils.SetupTestWithResponse(t, []byte(`{}`)) +func TestAgentsSchemasLive_UsesPlatformAPI(t *testing.T) { + usePlatformAPIs(t) + mock, cleanup := testutils.SetupTestWithResponse(t, []byte(`{}`)) defer cleanup() b := bytes.NewBufferString("") cmd := NewCmdAgents() @@ -189,11 +243,14 @@ func TestAgentsSchemasLive(t *testing.T) { cmd.SetArgs([]string{"schemas", "--json", `{"agentId":"test-agent"}`}) err := cmd.Execute() require.NoError(t, err) + require.NotEmpty(t, mock.Requests) + assert.Equal(t, "/api/agents/test-agent/schemas", mock.Requests[0].URL.Path) } // run func TestAgentsRunDryRun(t *testing.T) { + usePlatformAPIs(t) // Dry-run must not require auth — SDK init is deferred until after the dry-run check. b := bytes.NewBufferString("") cmd := NewCmdAgents() @@ -219,6 +276,7 @@ func TestAgentsRunMissingJSON(t *testing.T) { } func TestAgentsRunInvalidJSON(t *testing.T) { + usePlatformAPIs(t) cmd := NewCmdAgents() cmd.SetErr(bytes.NewBufferString("")) cmd.SetArgs([]string{"run", "--json", "not valid json"}) @@ -226,13 +284,62 @@ func TestAgentsRunInvalidJSON(t *testing.T) { assert.Error(t, err, "invalid JSON must return error") } -func TestAgentsRunLive(t *testing.T) { +func TestAgentsRunMissingAgentID(t *testing.T) { + usePlatformAPIs(t) _, cleanup := testutils.SetupTestWithResponse(t, []byte(`{}`)) defer cleanup() + cmd := NewCmdAgents() + cmd.SetErr(bytes.NewBufferString("")) + cmd.SetArgs([]string{"run", "--json", `{"input":{"query":"hi"}}`}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "agent_id is required") +} + +func TestAgentsRunLive_UsesPlatformAPI(t *testing.T) { + usePlatformAPIs(t) + mock, cleanup := testutils.SetupTestWithResponse(t, []byte(`{}`)) + defer cleanup() + // The platform run op negotiates streaming in its Accept header; pin the + // mock's Content-Type so the SDK parses the buffered JSON response. + mock.ContentType = "application/json" b := bytes.NewBufferString("") cmd := NewCmdAgents() cmd.SetOut(b) - cmd.SetArgs([]string{"run", "--json", `{"agent_id":"test-agent","messages":[]}`}) + cmd.SetArgs([]string{"run", "--json", `{"agent_id":"test-agent","input":{"query":"hi"}}`}) + err := cmd.Execute() + require.NoError(t, err) + require.NotEmpty(t, mock.Requests) + assert.Equal(t, "/api/agents/test-agent/runs", mock.Requests[0].URL.Path) +} + +func TestAgentsRunGateClosedErrors(t *testing.T) { + usePlatformAPIs(t) + platform.ResetWarnings() + mock, cleanup := testutils.SetupTestWithResponse(t, nil) + defer cleanup() + mock.Routes = map[string]testutils.MockResponse{ + "/api/agents/test-agent/runs": gateClosedResponse(), + } + cmd := NewCmdAgents() + cmd.SetOut(bytes.NewBufferString("")) + cmd.SetErr(bytes.NewBufferString("")) + cmd.SetArgs([]string{"run", "--json", `{"agent_id":"test-agent","input":{"query":"hi"}}`}) + err := cmd.Execute() + require.Error(t, err, "run must not silently fall back to the legacy body shape") + assert.Contains(t, err.Error(), platform.EnvLegacy) + require.Len(t, mock.Requests, 1, "no legacy retry for agents run") +} + +func TestAgentsRunLegacyEnv(t *testing.T) { + t.Setenv(platform.EnvLegacy, "1") + mock, cleanup := testutils.SetupTestWithResponse(t, []byte(`{}`)) + defer cleanup() + cmd := NewCmdAgents() + cmd.SetOut(bytes.NewBufferString("")) + cmd.SetArgs([]string{"run", "--json", `{"agent_id":"test-agent","messages":[{"author":"USER","fragments":[{"text":"hi"}]}]}`}) err := cmd.Execute() require.NoError(t, err) + require.Len(t, mock.Requests, 1) + assert.Equal(t, "/rest/api/v1/agents/runs/wait", mock.Requests[0].URL.Path) } diff --git a/cmd/schema.go b/cmd/schema.go index a28f37c..55836ee 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -86,14 +86,14 @@ glean shortcuts create --json '{"data":{"inputAlias":"test/link","destinationUrl schema.Register(schema.CommandSchema{ Command: "agents", - Description: "Manage and run Glean agents. Subcommands: list, get, schemas, run.", + Description: "Manage and run Glean agents via the platform API (/api/agents/...). list/get/schemas fall back to the classic API when the platform API is not enabled; run does not (its body shape differs per surface). GLEAN_LEGACY_APIS=1 forces the classic API. Subcommands: list, get, schemas, run.", Flags: map[string]schema.FlagSchema{ - "--json": {Type: "string", Description: "JSON request body"}, + "--json": {Type: "string", Description: "JSON request body. get/schemas: {\"agent_id\":\"\"}. run: {\"agent_id\":\"\"} plus input (map) or messages ([{role, content:[{type:\"text\",text}]}])"}, "--output": {Type: "enum", Enum: []string{"json", "ndjson", "text"}, Default: "json"}, "--dry-run": {Type: "boolean", Default: false}, }, - Example: `glean agents list | jq '.[].id' -glean agents run --json '{"agentId":"my-agent","input":{"query":"test"}}'`, + Example: `glean agents list | jq '.agents[].agent_id' +glean agents run --json '{"agent_id":"my-agent","input":{"query":"test"}}'`, }) schema.Register(schema.CommandSchema{