From f4d1141ad4ce79d93c403e9c7cde64b7c8da0f25 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 28 Jul 2026 10:24:01 +0300 Subject: [PATCH 01/13] fix(cli): resolve development ports and prompt setup context consistently Select an available Vite port by default while preserving strict behavior for explicit ports, and let Vite own browser opening in development. Resolve prompt setup through the shared shell configuration so working-directory semantics remain consistent. --- pkg/cli/serve.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index 3664dd2..38a3099 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -14,7 +14,6 @@ import ( "os/signal" "path/filepath" "runtime" - "strconv" "strings" "syscall" "time" From 6802966ccdf36a1e84d97117e655cd062d6915ca Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Fri, 31 Jul 2026 14:23:50 +0545 Subject: [PATCH 02/13] refactor(ai): resolve sandbox through CLI mode Sandbox previously rewrote already-resolved models to CLI backends with a dedicated helper, duplicating registry behavior and making it appear that model identity changed.\n\nPass CLI mode into the existing resolver instead, apply it consistently to fallbacks and prompt overlays, and reject explicit API runtime contradictions. --- pkg/aiflags/flags.go | 18 ++++++++++++++++++ pkg/cli/ai_prompt_file_test.go | 1 + 2 files changed, 19 insertions(+) diff --git a/pkg/aiflags/flags.go b/pkg/aiflags/flags.go index 8c036e8..b7ad44d 100644 --- a/pkg/aiflags/flags.go +++ b/pkg/aiflags/flags.go @@ -128,10 +128,28 @@ func (f ModelFlags) Resolve() (registry.Model, error) { // ResolveWith is the pure core: no ambient I/O, no globals. Saved defaults arrive // as a parameter so tests and spec-overlaying callers can drive it directly. func (f ModelFlags) ResolveWith(saved captainconfig.AIDefaults) (registry.Model, error) { + return f.ResolveWithMode(saved, "") +} + +// ResolveWithMode resolves flags and saved defaults while requesting one +// runtime mechanism for the primary model and all fallbacks. +func (f ModelFlags) ResolveWithMode(saved captainconfig.AIDefaults, mode registry.RuntimeMode) (registry.Model, error) { + if mode != "" && strings.TrimSpace(f.Mode) != "" { + explicit, ok := registry.ParseRuntimeMode(f.Mode) + if !ok { + return registry.Model{}, fmt.Errorf("invalid --mode %q (valid: %s)", f.Mode, registry.RuntimeModeList()) + } + if explicit != mode { + return registry.Model{}, fmt.Errorf("mode %q contradicts requested mode %q", explicit, mode) + } + } m, err := f.ToModel() if err != nil { return registry.Model{}, err } + if m, err = m.WithMode(mode); err != nil { + return registry.Model{}, err + } if !f.NoCache && saved.NoCache { m.NoCache = true } diff --git a/pkg/cli/ai_prompt_file_test.go b/pkg/cli/ai_prompt_file_test.go index 5a8fc60..aa8d248 100644 --- a/pkg/cli/ai_prompt_file_test.go +++ b/pkg/cli/ai_prompt_file_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/aiflags" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/commons-db/shell" ) From a94dd0a9e85a7401c9987d97c7022f384dd545c8 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 31 Jul 2026 11:48:06 +0300 Subject: [PATCH 03/13] feat(ai): Expose caller tools to agent runtimes through authenticated MCP Add request-scoped, authenticated MCP capabilities for Claude and Codex agent providers, including shared tool policy resolution, schema validation, approvals, expiry, and revocation. Propagate structured chat runtimes and agent prompts so new and resumed sessions receive consistent caller-owned tools, while disabled tool sets remain tool-free. Advertise caller-tool support through model capabilities and catalogs. BREAKING CHANGE: NewCodexAppServer now accepts ai.Config instead of a model string. --- go.mod | 2 +- pkg/ai/callertools/callertools_suite_test.go | 13 + pkg/ai/callertools/runtime.go | 361 ++++++++++++++++++ pkg/ai/callertools/runtime_ginkgo_test.go | 315 +++++++++++++++ pkg/ai/provider/caller_tools_ginkgo_test.go | 79 ++++ pkg/ai/provider/claudeagent/agent.ts | 14 +- .../claudeagent/caller_tools_ginkgo_test.go | 70 ++++ pkg/ai/provider/claudeagent/provider.go | 116 +++++- pkg/ai/provider/codex_appserver.go | 82 +++- .../codex_appserver_lifecycle_ginkgo_test.go | 2 +- pkg/ai/provider/codex_appserver_protocol.go | 26 +- pkg/ai/provider/codex_appserver_test.go | 16 +- pkg/ai/provider/genkit/tools.go | 39 +- pkg/ai/provider/init.go | 2 +- pkg/ai/runtime_selector_test.go | 12 +- pkg/ai/tools/definitions_ginkgo_test.go | 71 ++++ pkg/ai/tools/tools.go | 69 ++++ pkg/aichat/agent_prompt.go | 81 ++++ pkg/aichat/messages.go | 73 +++- pkg/aichat/service.go | 10 +- pkg/aichat/service_ginkgo_test.go | 89 ++++- pkg/aichat/wire.go | 1 + pkg/aichat/wire_ginkgo_test.go | 16 +- pkg/api/registry/model.go | 3 + pkg/api/registry/provider.go | 3 + pkg/api/registry/providers.go | 12 +- pkg/api/runtime_config.go | 80 +++- pkg/api/runtime_config_ginkgo_test.go | 39 ++ pkg/api/spec_merge_differential_test.go | 2 +- 29 files changed, 1580 insertions(+), 118 deletions(-) create mode 100644 pkg/ai/callertools/callertools_suite_test.go create mode 100644 pkg/ai/callertools/runtime.go create mode 100644 pkg/ai/callertools/runtime_ginkgo_test.go create mode 100644 pkg/ai/provider/caller_tools_ginkgo_test.go create mode 100644 pkg/ai/provider/claudeagent/caller_tools_ginkgo_test.go create mode 100644 pkg/ai/tools/definitions_ginkgo_test.go create mode 100644 pkg/aichat/agent_prompt.go create mode 100644 pkg/api/runtime_config_ginkgo_test.go diff --git a/go.mod b/go.mod index 01817de..305635b 100644 --- a/go.mod +++ b/go.mod @@ -60,7 +60,7 @@ require ( github.com/nukilabs/ftoa v1.0.0 // indirect github.com/nukilabs/unicodeid v0.1.0 // indirect github.com/pgplex/pgparser v0.2.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect diff --git a/pkg/ai/callertools/callertools_suite_test.go b/pkg/ai/callertools/callertools_suite_test.go new file mode 100644 index 0000000..72c2c3f --- /dev/null +++ b/pkg/ai/callertools/callertools_suite_test.go @@ -0,0 +1,13 @@ +package callertools_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCallerTools(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Caller Tools Suite") +} diff --git a/pkg/ai/callertools/runtime.go b/pkg/ai/callertools/runtime.go new file mode 100644 index 0000000..6e18847 --- /dev/null +++ b/pkg/ai/callertools/runtime.go @@ -0,0 +1,361 @@ +// Package callertools exposes caller-owned Go tool handlers to out-of-process +// agent runtimes through a private authenticated MCP endpoint. +package callertools + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + aitools "github.com/flanksource/captain/pkg/ai/tools" + "github.com/flanksource/captain/pkg/api" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +const ( + endpointPath = "/mcp" + serverName = "captain" + defaultApprovalTimeout = 5 * time.Minute +) + +// Options defines one private caller-tool capability. +type Options struct { + Definitions []api.ToolDefinition + Preferences api.ToolPreferences + CanUseTool api.PermissionFunc + SessionID string + ExpiresAt time.Time + + ApprovalTimeout time.Duration +} + +// Runtime owns one loopback-only MCP server and its in-memory bearer +// credential. Closing it revokes the capability by shutting down the listener. +type Runtime struct { + definitions map[string]api.ToolDefinition + schemas map[string]*jsonschema.Schema + canUseTool api.PermissionFunc + sessionID string + token string + expiresAt time.Time + + approvalTimeout time.Duration + ctx context.Context + cancel context.CancelFunc + revoked atomic.Bool + + endpoint api.CallerToolEndpoint + server *http.Server + listener net.Listener + + closeOnce sync.Once + closeErr error +} + +// New validates and resolves the tool policy before starting a private server. +func New(options Options) (*Runtime, error) { + if !options.ExpiresAt.IsZero() && !options.ExpiresAt.After(time.Now()) { + return nil, fmt.Errorf("caller-tool credential expiry must be in the future") + } + if options.ApprovalTimeout < 0 { + return nil, fmt.Errorf("caller-tool approval timeout cannot be negative") + } + if options.ApprovalTimeout == 0 { + options.ApprovalTimeout = defaultApprovalTimeout + } + definitions, err := aitools.ResolveDefinitions(options.Definitions, options.Preferences) + if err != nil { + return nil, err + } + if len(definitions) == 0 { + return nil, fmt.Errorf("caller-tool runtime requires at least one enabled tool") + } + token, err := capabilityToken(options.SessionID) + if err != nil { + return nil, err + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("listen for caller tools: %w", err) + } + ctx, cancel := context.WithCancel(context.Background()) + runtime := &Runtime{ + definitions: make(map[string]api.ToolDefinition, len(definitions)), + schemas: make(map[string]*jsonschema.Schema, len(definitions)), + canUseTool: options.CanUseTool, + sessionID: options.SessionID, + token: token, + expiresAt: options.ExpiresAt, + approvalTimeout: options.ApprovalTimeout, + ctx: ctx, + cancel: cancel, + listener: listener, + } + mcpServer := server.NewMCPServer( + "captain-caller-tools", + "1.0.0", + server.WithToolCapabilities(false), + server.WithToolFilter(runtime.filterTools), + server.WithInputSchemaValidation(), + ) + for _, definition := range definitions { + runtime.definitions[definition.Name] = definition + tool, schema, err := mcpTool(definition) + if err != nil { + cancel() + _ = listener.Close() + return nil, err + } + runtime.schemas[definition.Name] = schema + mcpServer.AddTool(tool, runtime.handler(definition)) + } + handler := server.NewStreamableHTTPServer( + mcpServer, + server.WithStateLess(true), + server.WithEndpointPath(endpointPath), + ) + runtime.server = &http.Server{ + Handler: runtime.authorize(handler), + ReadHeaderTimeout: 5 * time.Second, + } + runtime.endpoint = api.CallerToolEndpoint{ + Name: serverName, + URL: "http://" + listener.Addr().String() + endpointPath, + Headers: map[string]string{ + "Authorization": "Bearer " + token, + }, + } + go func() { + _ = runtime.server.Serve(listener) + }() + return runtime, nil +} + +// Endpoint returns a copy so callers cannot mutate the runtime's credential. +func (r *Runtime) Endpoint() api.CallerToolEndpoint { + endpoint := r.endpoint + endpoint.Headers = cloneHeaders(r.endpoint.Headers) + return endpoint +} + +// Close revokes the endpoint and is safe to call more than once. +func (r *Runtime) Close() error { + r.closeOnce.Do(func() { + r.Revoke() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + r.closeErr = r.server.Shutdown(ctx) + }) + return r.closeErr +} + +// Revoke invalidates the capability immediately and cancels active calls. +func (r *Runtime) Revoke() { + if r.revoked.CompareAndSwap(false, true) { + r.cancel() + } +} + +func (r *Runtime) filterTools(_ context.Context, tools []mcp.Tool) []mcp.Tool { + filtered := make([]mcp.Tool, 0, len(tools)) + for _, tool := range tools { + if _, ok := r.definitions[tool.Name]; ok { + filtered = append(filtered, tool) + } + } + return filtered +} + +func (r *Runtime) handler(definition api.ToolDefinition) server.ToolHandlerFunc { + return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if _, ok := r.definitions[definition.Name]; !ok { + return nil, fmt.Errorf("caller tool %q is not authorized", definition.Name) + } + callCtx, cancel := context.WithCancel(ctx) + stop := context.AfterFunc(r.ctx, cancel) + defer stop() + defer cancel() + input := request.GetArguments() + if input == nil { + input = map[string]any{} + } + if definition.NeedsApproval() { + if r.canUseTool == nil { + return mcp.NewToolResultError("tool approval is required but no approval broker is configured"), nil + } + approvalCtx, approvalCancel := context.WithTimeout(callCtx, r.approvalTimeout) + toolUseID, err := toolUseID(request) + if err != nil { + approvalCancel() + return mcp.NewToolResultError(err.Error()), nil + } + decision, err := r.canUseTool(approvalCtx, api.PermissionRequest{ + Tool: definition.Name, Input: input, ToolUseID: toolUseID, SessionID: r.sessionID, + }) + approvalCancel() + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if !decision.Allow { + message := decision.Message + if message == "" { + message = "tool call denied" + } + return mcp.NewToolResultError(message), nil + } + if decision.UpdatedInput != nil { + input = decision.UpdatedInput + } + } + if err := r.validateInput(definition.Name, input); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + output, err := definition.Handler(callCtx, input) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + result, err := mcp.NewToolResultJSON(output) + if err != nil { + return mcp.NewToolResultErrorf("marshal caller tool %q result: %v", definition.Name, err), nil + } + return result, nil + } +} + +func (r *Runtime) authorize(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + host, _, err := net.SplitHostPort(request.RemoteAddr) + if err != nil || !net.ParseIP(host).IsLoopback() { + http.Error(w, "caller-tool endpoint requires loopback access", http.StatusForbidden) + return + } + if strings.TrimSpace(request.Header.Get("Origin")) != "" { + http.Error(w, "caller-tool endpoint does not accept browser origins", http.StatusForbidden) + return + } + actual := request.Header.Get("Authorization") + expected := "Bearer " + r.token + if subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) != 1 || !r.active() { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "invalid caller-tool credential", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, request) + }) +} + +func (r *Runtime) active() bool { + return !r.revoked.Load() && (r.expiresAt.IsZero() || time.Now().Before(r.expiresAt)) +} + +func mcpTool(definition api.ToolDefinition) (mcp.Tool, *jsonschema.Schema, error) { + schema := definition.InputSchema + if schema == nil { + schema = map[string]any{"type": "object", "properties": map[string]any{}} + } + raw, err := json.Marshal(schema) + if err != nil { + return mcp.Tool{}, nil, fmt.Errorf("marshal caller tool %q schema: %w", definition.Name, err) + } + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + return mcp.Tool{}, nil, fmt.Errorf("decode caller tool %q schema: %w", definition.Name, err) + } + compiler := jsonschema.NewCompiler() + resourceURL := "mem:///captain/caller-tools/" + definition.Name + "/input-schema.json" + if err := compiler.AddResource(resourceURL, document); err != nil { + return mcp.Tool{}, nil, fmt.Errorf("register caller tool %q schema: %w", definition.Name, err) + } + compiled, err := compiler.Compile(resourceURL) + if err != nil { + return mcp.Tool{}, nil, fmt.Errorf("compile caller tool %q schema: %w", definition.Name, err) + } + tool := mcp.NewToolWithRawSchema(definition.Name, definition.Description, raw) + tool.Annotations = mcp.ToolAnnotation{ + ReadOnlyHint: definition.ReadOnlyHint, DestructiveHint: definition.DestructiveHint, + IdempotentHint: definition.IdempotentHint, + } + return tool, compiled, nil +} + +func (r *Runtime) validateInput(toolName string, input map[string]any) error { + raw, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("marshal caller tool %q input: %w", toolName, err) + } + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + return fmt.Errorf("decode caller tool %q input: %w", toolName, err) + } + if err := r.schemas[toolName].Validate(document); err != nil { + return fmt.Errorf("caller tool %q input is invalid: %w", toolName, err) + } + return nil +} + +func capabilityToken(sessionID string) (string, error) { + secret, err := randomID(32) + if err != nil { + return "", fmt.Errorf("generate caller-tool credential: %w", err) + } + return "cap_" + capabilityIdentity(sessionID) + "." + secret, nil +} + +func toolUseID(request mcp.CallToolRequest) (string, error) { + if request.Params.Meta != nil { + if value, ok := request.Params.Meta.AdditionalFields["toolUseId"].(string); ok && strings.TrimSpace(value) != "" { + return value, nil + } + } + id, err := randomID(16) + if err != nil { + return "", fmt.Errorf("generate caller-tool call ID: %w", err) + } + return "mcp_" + id, nil +} + +func randomID(size int) (string, error) { + value := make([]byte, size) + if _, err := rand.Read(value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func capabilityIdentity(sessionID string) string { + identity := strings.Map(func(value rune) rune { + switch { + case value >= 'a' && value <= 'z', value >= 'A' && value <= 'Z', value >= '0' && value <= '9', value == '-', value == '_': + return value + default: + return '_' + } + }, strings.TrimSpace(sessionID)) + if identity == "" { + return "run" + } + return identity +} + +func cloneHeaders(headers map[string]string) map[string]string { + if headers == nil { + return nil + } + cloned := make(map[string]string, len(headers)) + for key, value := range headers { + cloned[key] = value + } + return cloned +} diff --git a/pkg/ai/callertools/runtime_ginkgo_test.go b/pkg/ai/callertools/runtime_ginkgo_test.go new file mode 100644 index 0000000..f9d1891 --- /dev/null +++ b/pkg/ai/callertools/runtime_ginkgo_test.go @@ -0,0 +1,315 @@ +package callertools_test + +import ( + "context" + "errors" + "net/http" + "sync/atomic" + "time" + + "github.com/flanksource/captain/pkg/ai/callertools" + "github.com/flanksource/captain/pkg/api" + mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Authenticated caller-tool runtime", func() { + It("omits denied tools and rejects unauthenticated requests", func(ctx SpecContext) { + var hiddenCalls atomic.Int32 + runtime, err := callertools.New(callertools.Options{ + Definitions: []api.ToolDefinition{ + { + Name: "invoice_get", Description: "Read an invoice", + InputSchema: map[string]any{"type": "object", "properties": map[string]any{"id": map[string]any{"type": "string"}}}, + DefaultPermission: api.ToolModeOn, + Handler: func(_ context.Context, input map[string]any) (any, error) { + return map[string]any{"id": input["id"], "status": "draft"}, nil + }, + }, + { + Name: "invoice_delete", DefaultPermission: api.ToolModeOn, + Handler: func(context.Context, map[string]any) (any, error) { + hiddenCalls.Add(1) + return "deleted", nil + }, + }, + }, + Preferences: api.ToolPreferences{"invoice_delete": api.ToolModeOff}, + SessionID: "captain-session-1", + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(runtime.Close) + + response, err := http.Post(runtime.Endpoint().URL, "application/json", nil) + Expect(err).NotTo(HaveOccurred()) + Expect(response.StatusCode).To(Equal(http.StatusUnauthorized)) + Expect(response.Body.Close()).To(Succeed()) + + client := authenticatedClient(ctx, runtime.Endpoint()) + DeferCleanup(client.Close) + tools, err := client.ListTools(ctx, mcp.ListToolsRequest{}) + Expect(err).NotTo(HaveOccurred()) + Expect(tools.Tools).To(HaveLen(1)) + Expect(tools.Tools[0].Name).To(Equal("invoice_get")) + + request := mcp.CallToolRequest{} + request.Params.Name = "invoice_delete" + request.Params.Arguments = map[string]any{} + _, err = client.CallTool(ctx, request) + Expect(err).To(HaveOccurred()) + Expect(hiddenCalls.Load()).To(BeZero()) + }) + + It("brokers ask tools and applies updated input", func(ctx SpecContext) { + var calls atomic.Int32 + runtime, err := callertools.New(callertools.Options{ + Definitions: []api.ToolDefinition{{ + Name: "invoice_update", DefaultPermission: api.ToolModeAsk, + Handler: func(_ context.Context, input map[string]any) (any, error) { + calls.Add(1) + return input, nil + }, + }}, + CanUseTool: func(_ context.Context, request api.PermissionRequest) (api.PermissionDecision, error) { + Expect(request.Tool).To(Equal("invoice_update")) + Expect(request.SessionID).To(Equal("captain-session-2")) + Expect(request.ToolUseID).To(Equal("approval-call-1")) + return api.PermissionDecision{Allow: true, UpdatedInput: map[string]any{"status": "approved"}}, nil + }, + SessionID: "captain-session-2", + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(runtime.Close) + + client := authenticatedClient(ctx, runtime.Endpoint()) + DeferCleanup(client.Close) + request := mcp.CallToolRequest{} + request.Params.Name = "invoice_update" + request.Params.Arguments = map[string]any{"status": "draft"} + request.Params.Meta = &mcp.Meta{AdditionalFields: map[string]any{"toolUseId": "approval-call-1"}} + result, err := client.CallTool(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeFalse()) + Expect(result.StructuredContent).To(Equal(map[string]any{"status": "approved"})) + Expect(calls.Load()).To(Equal(int32(1))) + }) + + It("rejects wrong-session credentials and browser origins", func() { + first := newRuntime("captain-session-1", "first") + DeferCleanup(first.Close) + second := newRuntime("captain-session-2", "second") + DeferCleanup(second.Close) + + request, err := http.NewRequest(http.MethodPost, first.Endpoint().URL, nil) + Expect(err).NotTo(HaveOccurred()) + for name, value := range second.Endpoint().Headers { + request.Header.Set(name, value) + } + response, err := http.DefaultClient.Do(request) + Expect(err).NotTo(HaveOccurred()) + Expect(response.StatusCode).To(Equal(http.StatusUnauthorized)) + Expect(response.Body.Close()).To(Succeed()) + + request, err = http.NewRequest(http.MethodPost, first.Endpoint().URL, nil) + Expect(err).NotTo(HaveOccurred()) + for name, value := range first.Endpoint().Headers { + request.Header.Set(name, value) + } + request.Header.Set("Origin", "https://example.com") + response, err = http.DefaultClient.Do(request) + Expect(err).NotTo(HaveOccurred()) + Expect(response.StatusCode).To(Equal(http.StatusForbidden)) + Expect(response.Body.Close()).To(Succeed()) + }) + + It("expires and explicitly revokes capabilities", func() { + expiring, err := callertools.New(callertools.Options{ + Definitions: []api.ToolDefinition{{ + Name: "lookup", DefaultPermission: api.ToolModeOn, + Handler: func(context.Context, map[string]any) (any, error) { return "ok", nil }, + }}, + SessionID: "expiring-session", + ExpiresAt: time.Now().Add(25 * time.Millisecond), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(expiring.Close) + Eventually(func() int { + return authenticatedStatus(expiring.Endpoint()) + }).Should(Equal(http.StatusUnauthorized)) + + revoked := newRuntime("revoked-session", "revoked") + DeferCleanup(revoked.Close) + revoked.Revoke() + Expect(authenticatedStatus(revoked.Endpoint())).To(Equal(http.StatusUnauthorized)) + }) + + It("times out approvals and returns handler failures without executing past the boundary", func(ctx SpecContext) { + var calls atomic.Int32 + runtime, err := callertools.New(callertools.Options{ + Definitions: []api.ToolDefinition{{ + Name: "invoice_update", DefaultPermission: api.ToolModeAsk, + Handler: func(context.Context, map[string]any) (any, error) { + calls.Add(1) + return nil, errors.New("must not execute") + }, + }}, + CanUseTool: func(ctx context.Context, _ api.PermissionRequest) (api.PermissionDecision, error) { + <-ctx.Done() + return api.PermissionDecision{}, ctx.Err() + }, + SessionID: "approval-session", + ApprovalTimeout: 25 * time.Millisecond, + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(runtime.Close) + + client := authenticatedClient(ctx, runtime.Endpoint()) + DeferCleanup(client.Close) + request := mcp.CallToolRequest{} + request.Params.Name = "invoice_update" + result, err := client.CallTool(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + Expect(calls.Load()).To(BeZero()) + }) + + It("returns handler failures as MCP tool errors", func(ctx SpecContext) { + var calls atomic.Int32 + runtime, err := callertools.New(callertools.Options{ + Definitions: []api.ToolDefinition{{ + Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Handler: func(context.Context, map[string]any) (any, error) { + calls.Add(1) + return nil, errors.New("invoice unavailable") + }, + }}, + SessionID: "failure-session", + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(runtime.Close) + + client := authenticatedClient(ctx, runtime.Endpoint()) + DeferCleanup(client.Close) + request := mcp.CallToolRequest{} + request.Params.Name = "invoice_get" + result, err := client.CallTool(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + Expect(calls.Load()).To(Equal(int32(1))) + }) + + It("rejects approval-updated input that violates the tool schema", func(ctx SpecContext) { + var calls atomic.Int32 + runtime, err := callertools.New(callertools.Options{ + Definitions: []api.ToolDefinition{{ + Name: "invoice_update", DefaultPermission: api.ToolModeAsk, + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + }, + "required": []string{"id"}, + }, + Handler: func(context.Context, map[string]any) (any, error) { + calls.Add(1) + return "updated", nil + }, + }}, + CanUseTool: func(context.Context, api.PermissionRequest) (api.PermissionDecision, error) { + return api.PermissionDecision{Allow: true, UpdatedInput: map[string]any{"id": 42}}, nil + }, + SessionID: "validation-session", + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(runtime.Close) + + client := authenticatedClient(ctx, runtime.Endpoint()) + DeferCleanup(client.Close) + request := mcp.CallToolRequest{} + request.Params.Name = "invoice_update" + request.Params.Arguments = map[string]any{"id": "inv-1"} + result, err := client.CallTool(ctx, request) + Expect(err).NotTo(HaveOccurred()) + Expect(result.IsError).To(BeTrue()) + Expect(calls.Load()).To(BeZero()) + }) + + It("isolates concurrently active session capabilities", func(ctx SpecContext) { + first := newRuntime("captain-session-1", "first") + DeferCleanup(first.Close) + second := newRuntime("captain-session-2", "second") + DeferCleanup(second.Close) + firstClient := authenticatedClient(ctx, first.Endpoint()) + DeferCleanup(firstClient.Close) + secondClient := authenticatedClient(ctx, second.Endpoint()) + DeferCleanup(secondClient.Close) + + type outcome struct { + result *mcp.CallToolResult + err error + } + outcomes := make(chan outcome, 2) + call := func(client *mcpclient.Client) { + request := mcp.CallToolRequest{} + request.Params.Name = "identity" + result, err := client.CallTool(ctx, request) + outcomes <- outcome{result: result, err: err} + } + go call(firstClient) + go call(secondClient) + + values := make([]string, 0, 2) + for range 2 { + outcome := <-outcomes + Expect(outcome.err).NotTo(HaveOccurred()) + Expect(outcome.result.IsError).To(BeFalse()) + values = append(values, outcome.result.StructuredContent.(map[string]any)["session"].(string)) + } + Expect(values).To(ConsistOf("first", "second")) + }) +}) + +func authenticatedClient(ctx context.Context, endpoint api.CallerToolEndpoint) *mcpclient.Client { + channel, err := transport.NewStreamableHTTP(endpoint.URL, transport.WithHTTPHeaders(endpoint.Headers)) + Expect(err).NotTo(HaveOccurred()) + client := mcpclient.NewClient(channel) + Expect(client.Start(ctx)).To(Succeed()) + request := mcp.InitializeRequest{} + request.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + request.Params.ClientInfo = mcp.Implementation{Name: "captain-test", Version: "1.0.0"} + _, err = client.Initialize(ctx, request) + Expect(err).NotTo(HaveOccurred()) + return client +} + +func authenticatedStatus(endpoint api.CallerToolEndpoint) int { + request, err := http.NewRequest(http.MethodPost, endpoint.URL, nil) + Expect(err).NotTo(HaveOccurred()) + for name, value := range endpoint.Headers { + request.Header.Set(name, value) + } + response, err := http.DefaultClient.Do(request) + Expect(err).NotTo(HaveOccurred()) + defer func() { + Expect(response.Body.Close()).To(Succeed()) + }() + return response.StatusCode +} + +func newRuntime(sessionID, marker string) *callertools.Runtime { + runtime, err := callertools.New(callertools.Options{ + Definitions: []api.ToolDefinition{{ + Name: "identity", DefaultPermission: api.ToolModeOn, + Handler: func(context.Context, map[string]any) (any, error) { + return map[string]any{"session": marker}, nil + }, + }}, + SessionID: sessionID, + }) + Expect(err).NotTo(HaveOccurred()) + return runtime +} diff --git a/pkg/ai/provider/caller_tools_ginkgo_test.go b/pkg/ai/provider/caller_tools_ginkgo_test.go new file mode 100644 index 0000000..aac33ad --- /dev/null +++ b/pkg/ai/provider/caller_tools_ginkgo_test.go @@ -0,0 +1,79 @@ +package provider + +import ( + "context" + "strings" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Codex Agent caller tools", func() { + It("injects the same request-scoped MCP endpoint on start and resume", func() { + endpoint := &api.CallerToolEndpoint{ + Name: "captain", URL: "http://127.0.0.1:43210/mcp", + Headers: map[string]string{"Authorization": "Bearer secret"}, + } + request := ai.Request{ + SessionID: "thread-1", + Prompt: api.Prompt{User: "inspect"}, + } + + for _, params := range []map[string]any{ + buildThreadStartParams("gpt-5.4", request, endpoint), + buildResumeParams(request, endpoint), + } { + config, ok := params["config"].(map[string]any) + Expect(ok).To(BeTrue()) + servers, ok := config["mcp_servers"].(map[string]any) + Expect(ok).To(BeTrue()) + serverConfig, ok := servers["captain"].(map[string]any) + Expect(ok).To(BeTrue()) + Expect(serverConfig).To(HaveKeyWithValue("url", endpoint.URL)) + Expect(serverConfig).To(HaveKeyWithValue("http_headers", endpoint.Headers)) + Expect(serverConfig).To(HaveKeyWithValue("required", true)) + } + }) + + It("binds the private capability to the Captain session identity", func() { + provider, err := NewCodexAppServer(ai.Config{ + Model: api.Model{Name: "gpt-5.4"}, + CaptainSessionID: "captain-thread-1", + SessionID: "provider-session-1", + Tools: []api.ToolDefinition{{ + Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Handler: func(context.Context, map[string]any) (any, error) { return "ok", nil }, + }}, + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(provider.Close) + + Expect(provider.prepareCallerTools(ai.Request{SessionID: "provider-session-1"})).To(Succeed()) + Expect(provider.callerTools).NotTo(BeNil()) + Expect(provider.callerTools.Headers["Authorization"]).To( + HavePrefix("Bearer cap_captain-thread-1."), + ) + Expect(strings.Count(provider.callerTools.Headers["Authorization"], ".")).To(Equal(1)) + }) + + It("does not require MCP when request preferences disable every caller tool", func() { + provider, err := NewCodexAppServer(ai.Config{ + Tools: []api.ToolDefinition{{ + Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Handler: func(context.Context, map[string]any) (any, error) { return "ok", nil }, + }}, + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(provider.Close) + + request := ai.Request{ + ToolPreferences: api.ToolPreferences{"invoice_get": api.ToolModeOff}, + Permissions: api.Permissions{MCP: api.MCP{Disabled: true}}, + } + Expect(provider.prepareCallerTools(request)).To(Succeed()) + Expect(provider.callerTools).To(BeNil()) + }) +}) diff --git a/pkg/ai/provider/claudeagent/agent.ts b/pkg/ai/provider/claudeagent/agent.ts index 98b7e6d..37ef985 100644 --- a/pkg/ai/provider/claudeagent/agent.ts +++ b/pkg/ai/provider/claudeagent/agent.ts @@ -6,7 +6,7 @@ // client -> server requests: // initialize {cwd, model, systemPrompt, appendSystemPrompt, allowedTools, // maxTurns, maxBudgetUsd, permissionMode, resume, approvalMode, -// outputSchema} +// outputSchema, mcpServers} // -> reply {ok:true} // prompt {text, attachments?} -> reply {accepted:true} // interrupt -> reply {} @@ -65,6 +65,10 @@ interface InitializeParams { // monitorUrl is the captain serve base URL session-monitoring lifecycle // hooks POST to. Empty/absent disables monitoring hook injection. monitorUrl?: string; + mcpServers?: Record< + string, + { type: "http"; url: string; headers?: Record } + >; } type JsonRpcId = number | string | null; @@ -266,6 +270,7 @@ function buildOptions(params: InitializeParams): Options { params.allowedTools && params.allowedTools.length ? params.allowedTools : undefined, + mcpServers: params.mcpServers, stderr: (data: string) => process.stderr.write(data), hooks: { PreToolUse: [ @@ -360,6 +365,13 @@ function buildOptions(params: InitializeParams): Options { // PreToolUse git add/commit block above still applies first. if (brokered) { options.canUseTool = async (toolName, input, opts) => { + if ( + Object.keys(params.mcpServers ?? {}).some((server) => + toolName.startsWith(`mcp__${server}__`), + ) + ) { + return { behavior: "allow", updatedInput: input }; + } const toolUseId = (opts as { toolUseId?: string } | undefined)?.toolUseId ?? ""; let decision: HostDecision; diff --git a/pkg/ai/provider/claudeagent/caller_tools_ginkgo_test.go b/pkg/ai/provider/claudeagent/caller_tools_ginkgo_test.go new file mode 100644 index 0000000..1297cc1 --- /dev/null +++ b/pkg/ai/provider/claudeagent/caller_tools_ginkgo_test.go @@ -0,0 +1,70 @@ +package claudeagent + +import ( + "context" + "strings" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Claude Agent caller tools", func() { + It("injects a request-scoped HTTP MCP endpoint", func() { + provider := &Provider{ + model: "claude-sonnet-5", + callerTools: &api.CallerToolEndpoint{ + Name: "captain", URL: "http://127.0.0.1:43210/mcp", + Headers: map[string]string{"Authorization": "Bearer secret"}, + }, + } + + params := provider.initializeParams(ai.Request{Prompt: api.Prompt{User: "inspect"}}) + + Expect(params.MCPServers).To(HaveKey("captain")) + Expect(params.MCPServers["captain"].Type).To(Equal("http")) + Expect(params.MCPServers["captain"].URL).To(Equal("http://127.0.0.1:43210/mcp")) + Expect(params.MCPServers["captain"].Headers).To(HaveKeyWithValue("Authorization", "Bearer secret")) + }) + + It("binds the private capability to the Captain session identity", func() { + provider, err := New(ai.Config{ + Model: api.Model{Name: "claude-sonnet-5"}, + CaptainSessionID: "captain-thread-1", + SessionID: "provider-session-1", + Tools: []api.ToolDefinition{{ + Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Handler: func(context.Context, map[string]any) (any, error) { return "ok", nil }, + }}, + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(provider.Close) + + Expect(provider.prepareCallerTools(ai.Request{SessionID: "provider-session-1"})).To(Succeed()) + Expect(provider.callerTools).NotTo(BeNil()) + Expect(provider.callerTools.Headers["Authorization"]).To( + HavePrefix("Bearer cap_captain-thread-1."), + ) + Expect(strings.Count(provider.callerTools.Headers["Authorization"], ".")).To(Equal(1)) + }) + + It("does not require MCP when request preferences disable every caller tool", func() { + provider, err := New(ai.Config{ + Tools: []api.ToolDefinition{{ + Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Handler: func(context.Context, map[string]any) (any, error) { return "ok", nil }, + }}, + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(provider.Close) + + request := ai.Request{ + ToolPreferences: api.ToolPreferences{"invoice_get": api.ToolModeOff}, + Permissions: api.Permissions{MCP: api.MCP{Disabled: true}}, + } + Expect(provider.prepareCallerTools(request)).To(Succeed()) + Expect(provider.callerTools).To(BeNil()) + }) +}) diff --git a/pkg/ai/provider/claudeagent/provider.go b/pkg/ai/provider/claudeagent/provider.go index 22a47a8..fd9ff60 100644 --- a/pkg/ai/provider/claudeagent/provider.go +++ b/pkg/ai/provider/claudeagent/provider.go @@ -24,7 +24,9 @@ import ( "time" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/callertools" "github.com/flanksource/captain/pkg/ai/provider/jsonrpc" + aitools "github.com/flanksource/captain/pkg/ai/tools" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/clicky/exec" "github.com/flanksource/commons/logger" @@ -120,6 +122,10 @@ type Provider struct { // so it is pinned from the first turn and every later turn must match it. sessionSchemaOnce sync.Once sessionSchema json.RawMessage + + callerToolsMu sync.Mutex + callerToolsRuntime *callertools.Runtime + callerTools *api.CallerToolEndpoint } // New builds a claude-agent provider. The supervised process is started lazily @@ -131,18 +137,27 @@ func New(cfg ai.Config) (*Provider, error) { } model = ai.NormalizeModelForBackend(ai.BackendClaudeAgent, model) ctx, cancel := context.WithCancel(context.Background()) - return &Provider{ + provider := &Provider{ model: model, cfg: cfg, baseCtx: ctx, baseCancel: cancel, initDone: make(chan struct{}), procExited: make(chan struct{}), - }, nil + } + if cfg.CallerTools != nil { + endpoint := *cfg.CallerTools + endpoint.Headers = cloneHeaders(cfg.CallerTools.Headers) + provider.callerTools = &endpoint + } + return provider, nil } -func (p *Provider) GetModel() string { return p.model } -func (p *Provider) GetBackend() ai.Backend { return ai.BackendClaudeAgent } +func (p *Provider) GetModel() string { return p.model } +func (p *Provider) GetBackend() ai.Backend { return ai.BackendClaudeAgent } +func (p *Provider) SupportsCallerTools() bool { return true } + +var _ api.ToolCapableProvider = (*Provider)(nil) // Execute drains its own ExecuteStream into a buffered ai.Response. When the // request carries a structured-output schema, the validated JSON the SDK @@ -269,6 +284,9 @@ func (p *Provider) ExecuteStream(ctx context.Context, req ai.Request) (<-chan ai // structured session, or a differing schema, cannot be honoured). p.sessionSchemaOnce.Do(func() { p.sessionSchema = schema }) + if err := p.prepareCallerTools(req); err != nil { + return nil, err + } if err := p.ensureStarted(req); err != nil { return nil, err } @@ -307,6 +325,44 @@ func (p *Provider) Close() error { if p.baseCancel != nil { p.baseCancel() } + if p.callerToolsRuntime != nil { + return p.callerToolsRuntime.Close() + } + return nil +} + +func (p *Provider) prepareCallerTools(req ai.Request) error { + p.callerToolsMu.Lock() + defer p.callerToolsMu.Unlock() + if p.callerTools != nil { + if req.Permissions.MCP.Disabled { + return fmt.Errorf("claude-agent: caller tools require MCP but MCP is disabled") + } + return p.callerTools.Validate() + } + if len(p.cfg.Tools) == 0 { + return nil + } + definitions, err := aitools.ResolveDefinitions(p.cfg.Tools, req.ToolPreferences) + if err != nil { + return fmt.Errorf("claude-agent caller tools: %w", err) + } + if len(definitions) == 0 { + return nil + } + if req.Permissions.MCP.Disabled { + return fmt.Errorf("claude-agent: caller tools require MCP but MCP is disabled") + } + runtime, err := callertools.New(callertools.Options{ + Definitions: definitions, CanUseTool: p.cfg.CanUseTool, + SessionID: firstNonEmpty(p.cfg.CaptainSessionID, req.SessionID, p.cfg.SessionID), + }) + if err != nil { + return fmt.Errorf("start claude-agent caller tools: %w", err) + } + endpoint := runtime.Endpoint() + p.callerToolsRuntime = runtime + p.callerTools = &endpoint return nil } @@ -438,6 +494,7 @@ func (p *Provider) initializeParams(req ai.Request) initializeParams { ApprovalMode: approvalMode, OutputSchema: p.sessionSchema, MonitorURL: monitorHooksURL(req), + MCPServers: callerToolServers(p.callerTools), } } @@ -452,18 +509,45 @@ func monitorHooksURL(req ai.Request) string { } type initializeParams struct { - Cwd string `json:"cwd,omitempty"` - Model string `json:"model,omitempty"` - SystemPrompt string `json:"systemPrompt,omitempty"` - AppendSystemPrompt string `json:"appendSystemPrompt,omitempty"` - AllowedTools []string `json:"allowedTools,omitempty"` - MaxTurns int `json:"maxTurns,omitempty"` - MaxBudgetUsd float64 `json:"maxBudgetUsd,omitempty"` - PermissionMode string `json:"permissionMode,omitempty"` - Resume string `json:"resume,omitempty"` - ApprovalMode string `json:"approvalMode,omitempty"` - OutputSchema json.RawMessage `json:"outputSchema,omitempty"` - MonitorURL string `json:"monitorUrl,omitempty"` + Cwd string `json:"cwd,omitempty"` + Model string `json:"model,omitempty"` + SystemPrompt string `json:"systemPrompt,omitempty"` + AppendSystemPrompt string `json:"appendSystemPrompt,omitempty"` + AllowedTools []string `json:"allowedTools,omitempty"` + MaxTurns int `json:"maxTurns,omitempty"` + MaxBudgetUsd float64 `json:"maxBudgetUsd,omitempty"` + PermissionMode string `json:"permissionMode,omitempty"` + Resume string `json:"resume,omitempty"` + ApprovalMode string `json:"approvalMode,omitempty"` + OutputSchema json.RawMessage `json:"outputSchema,omitempty"` + MonitorURL string `json:"monitorUrl,omitempty"` + MCPServers map[string]callerToolServer `json:"mcpServers,omitempty"` +} + +type callerToolServer struct { + Type string `json:"type"` + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` +} + +func callerToolServers(endpoint *api.CallerToolEndpoint) map[string]callerToolServer { + if endpoint == nil { + return nil + } + return map[string]callerToolServer{ + endpoint.Name: {Type: "http", URL: endpoint.URL, Headers: cloneHeaders(endpoint.Headers)}, + } +} + +func cloneHeaders(headers map[string]string) map[string]string { + if headers == nil { + return nil + } + cloned := make(map[string]string, len(headers)) + for key, value := range headers { + cloned[key] = value + } + return cloned } func (p *Provider) setInitResult(err error) { diff --git a/pkg/ai/provider/codex_appserver.go b/pkg/ai/provider/codex_appserver.go index f474c82..6e6ec9b 100644 --- a/pkg/ai/provider/codex_appserver.go +++ b/pkg/ai/provider/codex_appserver.go @@ -10,7 +10,10 @@ import ( "time" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/ai/callertools" "github.com/flanksource/captain/pkg/ai/provider/jsonrpc" + aitools "github.com/flanksource/captain/pkg/ai/tools" + "github.com/flanksource/captain/pkg/api" "github.com/flanksource/clicky/exec" "github.com/flanksource/commons/logger" ) @@ -27,6 +30,7 @@ var log = logger.GetLogger("ai") // unit-testable mapAppServerNotification. type CodexAppServer struct { model string + cfg ai.Config turnMu sync.Mutex // serializes turns; held by ExecuteStream, freed by its driver @@ -36,6 +40,10 @@ type CodexAppServer struct { rpcDone chan struct{} // closed by the rpc Run goroutine when the child exits active *turnState threadID string + + callerToolsMu sync.Mutex + callerToolsRuntime *callertools.Runtime + callerTools *api.CallerToolEndpoint } const ( @@ -45,16 +53,26 @@ const ( // NewCodexAppServer builds a codex app-server provider. The supervised process // is started lazily on the first ExecuteStream. -func NewCodexAppServer(model string) (*CodexAppServer, error) { +func NewCodexAppServer(cfg ai.Config) (*CodexAppServer, error) { + model := cfg.Model.Name if model == "" { model = CodexCLIDefaultModel } model = ai.NormalizeModelForBackend(ai.BackendCodexAgent, model) - return &CodexAppServer{model: model}, nil + provider := &CodexAppServer{model: model, cfg: cfg} + if cfg.CallerTools != nil { + endpoint := *cfg.CallerTools + endpoint.Headers = cloneStringMap(cfg.CallerTools.Headers) + provider.callerTools = &endpoint + } + return provider, nil } -func (c *CodexAppServer) GetModel() string { return c.model } -func (c *CodexAppServer) GetBackend() ai.Backend { return ai.BackendCodexAgent } +func (c *CodexAppServer) GetModel() string { return c.model } +func (c *CodexAppServer) GetBackend() ai.Backend { return ai.BackendCodexAgent } +func (c *CodexAppServer) SupportsCallerTools() bool { return true } + +var _ api.ToolCapableProvider = (*CodexAppServer)(nil) // Execute drains the streaming output into a buffered ai.Response. When the // request carries a structured-output schema, the final agent message's JSON is @@ -107,6 +125,9 @@ func (c *CodexAppServer) ExecuteStream(ctx context.Context, req ai.Request) (<-c return nil, err } + if err := c.prepareCallerTools(req); err != nil { + return nil, err + } c.turnMu.Lock() if err := c.ensureStarted(ctx); err != nil { c.turnMu.Unlock() @@ -280,7 +301,7 @@ func (c *CodexAppServer) startThread(ctx context.Context, req ai.Request) (strin return threadID, nil } if req.SessionID != "" { - raw, err := rpc.Call(ctx, "thread/resume", buildResumeParams(req)) + raw, err := rpc.Call(ctx, "thread/resume", buildResumeParams(req, c.callerTools)) if err != nil { return "", err } @@ -288,7 +309,7 @@ func (c *CodexAppServer) startThread(ctx context.Context, req ai.Request) (strin c.rememberThread(threadID) return threadID, nil } - raw, err := rpc.Call(ctx, "thread/start", buildThreadStartParams(c.model, req)) + raw, err := rpc.Call(ctx, "thread/start", buildThreadStartParams(c.model, req, c.callerTools)) if err != nil { return "", err } @@ -358,9 +379,58 @@ func (c *CodexAppServer) Interrupt(ctx context.Context) error { func (c *CodexAppServer) Close() error { c.teardown(true) + if c.callerToolsRuntime != nil { + return c.callerToolsRuntime.Close() + } + return nil +} + +func (c *CodexAppServer) prepareCallerTools(req ai.Request) error { + c.callerToolsMu.Lock() + defer c.callerToolsMu.Unlock() + if c.callerTools != nil { + if req.Permissions.MCP.Disabled { + return fmt.Errorf("codex app-server: caller tools require MCP but MCP is disabled") + } + return c.callerTools.Validate() + } + if len(c.cfg.Tools) == 0 { + return nil + } + definitions, err := aitools.ResolveDefinitions(c.cfg.Tools, req.ToolPreferences) + if err != nil { + return fmt.Errorf("codex app-server caller tools: %w", err) + } + if len(definitions) == 0 { + return nil + } + if req.Permissions.MCP.Disabled { + return fmt.Errorf("codex app-server: caller tools require MCP but MCP is disabled") + } + runtime, err := callertools.New(callertools.Options{ + Definitions: definitions, CanUseTool: c.cfg.CanUseTool, + SessionID: firstNonEmpty(c.cfg.CaptainSessionID, req.SessionID, c.cfg.SessionID), + }) + if err != nil { + return fmt.Errorf("start codex app-server caller tools: %w", err) + } + endpoint := runtime.Endpoint() + c.callerToolsRuntime = runtime + c.callerTools = &endpoint return nil } +func cloneStringMap(values map[string]string) map[string]string { + if values == nil { + return nil + } + cloned := make(map[string]string, len(values)) + for key, value := range values { + cloned[key] = value + } + return cloned +} + // handleNotification routes one notification to the active turn. It runs on the // rpc Run goroutine (notifications dispatch sequentially), so the per-turn // dedup/usage state needs no extra locking. diff --git a/pkg/ai/provider/codex_appserver_lifecycle_ginkgo_test.go b/pkg/ai/provider/codex_appserver_lifecycle_ginkgo_test.go index a5c0146..4a4512f 100644 --- a/pkg/ai/provider/codex_appserver_lifecycle_ginkgo_test.go +++ b/pkg/ai/provider/codex_appserver_lifecycle_ginkgo_test.go @@ -122,7 +122,7 @@ var _ = Describe("Codex CLI attachments", func() { }) func activeGinkgoTurn() (*CodexAppServer, *turnState) { - client, err := NewCodexAppServer("gpt-5") + client, err := NewCodexAppServer(ai.Config{Model: api.Model{Name: "gpt-5"}}) Expect(err).NotTo(HaveOccurred()) turn := &turnState{ ch: make(chan ai.Event, 16), diff --git a/pkg/ai/provider/codex_appserver_protocol.go b/pkg/ai/provider/codex_appserver_protocol.go index b706d24..2ac8333 100644 --- a/pkg/ai/provider/codex_appserver_protocol.go +++ b/pkg/ai/provider/codex_appserver_protocol.go @@ -303,7 +303,7 @@ func composePrompt(req ai.Request) string { // (req.Memory.SkipUser/SkipProject/SkipHooks) have no first-class equivalent in // the versioned thread/start schema, so only ephemeral + an empty mcp_servers // override (the knobs the protocol exposes) are emitted. -func buildThreadStartParams(model string, req ai.Request) map[string]any { +func buildThreadStartParams(model string, req ai.Request, callerTools *api.CallerToolEndpoint) map[string]any { p := map[string]any{} if cwd := req.Cwd(); cwd != "" { p["cwd"] = cwd @@ -316,8 +316,8 @@ func buildThreadStartParams(model string, req ai.Request) map[string]any { if req.Memory.SkipMemory || req.Memory.Bare { p["ephemeral"] = true } - if req.Permissions.MCP.Disabled { - p["config"] = map[string]any{"mcp_servers": map[string]any{}} + if config := codexThreadConfig(req, callerTools); config != nil { + p["config"] = config } return p } @@ -367,10 +367,28 @@ func buildTurnStartParams(model string, req ai.Request, threadID string, outputS return p, nil } -func buildResumeParams(req ai.Request) map[string]any { +func buildResumeParams(req ai.Request, callerTools *api.CallerToolEndpoint) map[string]any { p := map[string]any{"threadId": req.SessionID} if cwd := req.Cwd(); cwd != "" { p["cwd"] = cwd } + if config := codexThreadConfig(req, callerTools); config != nil { + p["config"] = config + } return p } + +func codexThreadConfig(req ai.Request, callerTools *api.CallerToolEndpoint) map[string]any { + if req.Permissions.MCP.Disabled { + return map[string]any{"mcp_servers": map[string]any{}} + } + if callerTools == nil { + return nil + } + return map[string]any{"mcp_servers": map[string]any{ + callerTools.Name: map[string]any{ + "url": callerTools.URL, "http_headers": cloneStringMap(callerTools.Headers), + "required": true, "enabled": true, "default_tools_approval_mode": "approve", + }, + }} +} diff --git a/pkg/ai/provider/codex_appserver_test.go b/pkg/ai/provider/codex_appserver_test.go index 63056f8..7806b5f 100644 --- a/pkg/ai/provider/codex_appserver_test.go +++ b/pkg/ai/provider/codex_appserver_test.go @@ -14,12 +14,12 @@ import ( ) func TestNewCodexAppServer_Defaults(t *testing.T) { - c, err := NewCodexAppServer("") + c, err := NewCodexAppServer(ai.Config{}) require.NoError(t, err) assert.Equal(t, CodexCLIDefaultModel, c.GetModel()) assert.Equal(t, ai.BackendCodexAgent, c.GetBackend()) - c2, err := NewCodexAppServer("gpt-5.4") + c2, err := NewCodexAppServer(ai.Config{Model: api.Model{Name: "gpt-5.4"}}) require.NoError(t, err) assert.Equal(t, "gpt-5.4", c2.GetModel()) } @@ -197,7 +197,7 @@ func drainEvents(ts *turnState) []ai.Event { // route to it, mirroring what ExecuteStream sets up. func activeTurn(t *testing.T, schema json.RawMessage) (*CodexAppServer, *turnState) { t.Helper() - c, err := NewCodexAppServer("gpt-5") + c, err := NewCodexAppServer(ai.Config{Model: api.Model{Name: "gpt-5"}}) require.NoError(t, err) ts := &turnState{ ch: make(chan ai.Event, 16), @@ -540,7 +540,7 @@ func TestBuildThreadStartParams_Safety(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - p := buildThreadStartParams("gpt-5", tc.req) + p := buildThreadStartParams("gpt-5", tc.req, nil) assert.Equal(t, tc.wantSandbox, p["sandbox"]) assert.Equal(t, tc.wantApproval, p["approvalPolicy"]) @@ -560,11 +560,11 @@ func TestBuildThreadStartParams_CwdAndModel(t *testing.T) { p := buildThreadStartParams("gpt-5", ai.Request{ Prompt: api.Prompt{User: "p"}, Setup: &shell.Setup{Cwd: "/repo"}, - }) + }, nil) assert.Equal(t, "/repo", p["cwd"]) assert.Equal(t, "gpt-5", p["model"]) - noModel := buildThreadStartParams("", req(api.Prompt{User: "p"})) + noModel := buildThreadStartParams("", req(api.Prompt{User: "p"}), nil) _, hasModel := noModel["model"] assert.False(t, hasModel, "empty model must be omitted") } @@ -573,13 +573,13 @@ func TestBuildResumeParams(t *testing.T) { p := buildResumeParams(ai.Request{ SessionID: "thread-9", Setup: &shell.Setup{Cwd: "/repo"}, - }) + }, nil) assert.Equal(t, "thread-9", p["threadId"]) assert.Equal(t, "/repo", p["cwd"]) } func TestHandleApproval_AutoApproves(t *testing.T) { - c, err := NewCodexAppServer("m") + c, err := NewCodexAppServer(ai.Config{Model: api.Model{Name: "m"}}) require.NoError(t, err) tests := []struct { diff --git a/pkg/ai/provider/genkit/tools.go b/pkg/ai/provider/genkit/tools.go index 4eb5f84..258ba87 100644 --- a/pkg/ai/provider/genkit/tools.go +++ b/pkg/ai/provider/genkit/tools.go @@ -50,44 +50,7 @@ func (p *Provider) toolOptions(preferences api.ToolPreferences, emit func(ai.Eve } func resolveToolDefinitions(definitions []api.ToolDefinition, preferences api.ToolPreferences) ([]api.ToolDefinition, error) { - if err := preferences.Validate(); err != nil { - return nil, err - } - selected := make([]api.ToolDefinition, 0, len(definitions)) - for _, definition := range definitions { - if definition.Name == "" { - return nil, fmt.Errorf("genkit tool name cannot be empty") - } - if definition.Handler == nil { - return nil, fmt.Errorf("genkit tool %q has no handler", definition.Name) - } - mode, err := effectiveToolMode(definition, preferences) - if err != nil { - return nil, err - } - if mode == api.ToolModeOff { - continue - } - definition.DefaultPermission = mode - selected = append(selected, definition) - } - return selected, nil -} - -func effectiveToolMode(definition api.ToolDefinition, preferences api.ToolPreferences) (api.ToolMode, error) { - defaultMode := api.ToolModeAuto - if definition.DefaultPermission != "" { - var ok bool - defaultMode, ok = api.NormalizeToolMode(definition.DefaultPermission) - if !ok { - return "", fmt.Errorf("genkit tool %q has invalid default permission %q", definition.Name, definition.DefaultPermission) - } - } - info := captools.ToolInfo{Name: definition.Name, Group: definition.Group} - if preferred, ok := captools.EffectivePreference(preferences, info); ok && preferred != api.ToolModeAuto { - return preferred, nil - } - return defaultMode, nil + return captools.ResolveDefinitions(definitions, preferences) } func anthropicStrictToolDefinitions(definitions []api.ToolDefinition) []api.ToolDefinition { diff --git a/pkg/ai/provider/init.go b/pkg/ai/provider/init.go index b5b4298..b659084 100644 --- a/pkg/ai/provider/init.go +++ b/pkg/ai/provider/init.go @@ -24,7 +24,7 @@ func init() { }) ai.RegisterProvider(ai.BackendCodexCLI, func(cfg ai.Config) (ai.Provider, error) { return NewCodexCLI(cfg), nil }) - ai.RegisterProvider(ai.BackendCodexAgent, func(cfg ai.Config) (ai.Provider, error) { return NewCodexAppServer(cfg.Model.Name) }) + ai.RegisterProvider(ai.BackendCodexAgent, func(cfg ai.Config) (ai.Provider, error) { return NewCodexAppServer(cfg) }) // cmux drives an interactive claude/codex TUI inside a tmux/cmux surface, // tailing the session JSONL; the same provider serves both agents (it reads diff --git a/pkg/ai/runtime_selector_test.go b/pkg/ai/runtime_selector_test.go index b4ce75d..efc781d 100644 --- a/pkg/ai/runtime_selector_test.go +++ b/pkg/ai/runtime_selector_test.go @@ -242,12 +242,13 @@ func TestResolvedModelCarriesCapabilities(t *testing.T) { wantResume bool wantIntr bool wantSteer bool + wantTools bool wantMedia []string }{ - {"agent:sonnet", registry.ModeAgent, true, true, true, []string{"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}}, - {"cli:sonnet", registry.ModeCLI, true, false, false, []string{}}, - {"api:sonnet", registry.ModeAPI, false, false, false, []string{"image/*"}}, - {"agent:sol", registry.ModeAgent, true, true, false, []string{"image/*"}}, + {"agent:sonnet", registry.ModeAgent, true, true, true, true, []string{"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}}, + {"cli:sonnet", registry.ModeCLI, true, false, false, false, []string{}}, + {"api:sonnet", registry.ModeAPI, false, false, false, true, []string{"image/*"}}, + {"agent:sol", registry.ModeAgent, true, true, false, true, []string{"image/*"}}, } for _, tc := range cases { t.Run(tc.selector, func(t *testing.T) { @@ -265,6 +266,9 @@ func TestResolvedModelCarriesCapabilities(t *testing.T) { t.Errorf("resume/interrupt/steer = %v/%v/%v, want %v/%v/%v", got.Resume, got.Interrupt, got.Steer, tc.wantResume, tc.wantIntr, tc.wantSteer) } + if got.CallerTools != tc.wantTools { + t.Errorf("CallerTools = %v, want %v", got.CallerTools, tc.wantTools) + } if !reflect.DeepEqual(got.MediaTypes, tc.wantMedia) { t.Errorf("MediaTypes = %v, want %v", got.MediaTypes, tc.wantMedia) } diff --git a/pkg/ai/tools/definitions_ginkgo_test.go b/pkg/ai/tools/definitions_ginkgo_test.go new file mode 100644 index 0000000..d53d9f1 --- /dev/null +++ b/pkg/ai/tools/definitions_ginkgo_test.go @@ -0,0 +1,71 @@ +package tools_test + +import ( + "context" + + "github.com/flanksource/captain/pkg/ai/tools" + "github.com/flanksource/captain/pkg/api" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Caller tool definitions", func() { + noop := func(context.Context, map[string]any) (any, error) { return "ok", nil } + + It("resolves exact preferences before groups and omits disabled tools", func() { + definitions, err := tools.ResolveDefinitions([]api.ToolDefinition{ + {Name: "invoice_list", Group: "billing", DefaultPermission: api.ToolModeAsk, Handler: noop}, + {Name: "invoice_delete", Group: "billing", DefaultPermission: api.ToolModeOn, Handler: noop}, + {Name: "search", DefaultPermission: api.ToolModeOff, Handler: noop}, + }, api.ToolPreferences{ + "billing": api.ToolModeOff, + "invoice_list": api.ToolModeOn, + "search": api.ToolModeAsk, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(definitions).To(HaveLen(2)) + Expect(definitions[0].Name).To(Equal("invoice_list")) + Expect(definitions[0].DefaultPermission).To(Equal(api.ToolModeOn)) + Expect(definitions[1].Name).To(Equal("search")) + Expect(definitions[1].DefaultPermission).To(Equal(api.ToolModeAsk)) + }) + + It("validates definitions even when a preference disables them", func() { + _, err := tools.ResolveDefinitions([]api.ToolDefinition{{ + Name: "search", DefaultPermission: "sometimes", Handler: noop, + }}, api.ToolPreferences{"search": api.ToolModeOff}) + + Expect(err).To(MatchError(ContainSubstring(`tool "search" has invalid default permission "sometimes"`))) + }) + + It("allows auto only for explicitly read-only non-destructive tools", func() { + readOnly, nonDestructive := true, false + definitions, err := tools.ResolveDefinitions([]api.ToolDefinition{ + { + Name: "invoice_get", ReadOnlyHint: &readOnly, DestructiveHint: &nonDestructive, + DefaultPermission: api.ToolModeAuto, Handler: noop, + }, + {Name: "invoice_update", DefaultPermission: api.ToolModeAuto, Handler: noop}, + }, nil) + + Expect(err).NotTo(HaveOccurred()) + Expect(definitions).To(HaveLen(2)) + Expect(definitions[0].DefaultPermission).To(Equal(api.ToolModeOn)) + Expect(definitions[1].DefaultPermission).To(Equal(api.ToolModeAsk)) + }) + + It("rejects duplicate and provider-unsafe tool names", func() { + _, err := tools.ResolveDefinitions([]api.ToolDefinition{ + {Name: "invoice_get", Handler: noop}, + {Name: "invoice_get", Handler: noop}, + }, nil) + Expect(err).To(MatchError(ContainSubstring(`duplicate caller tool "invoice_get"`))) + + _, err = tools.ResolveDefinitions([]api.ToolDefinition{{ + Name: "invoice/get", Handler: noop, + }}, nil) + Expect(err).To(MatchError(ContainSubstring(`caller tool name "invoice/get"`))) + }) +}) diff --git a/pkg/ai/tools/tools.go b/pkg/ai/tools/tools.go index 530ca8b..3dea1cf 100644 --- a/pkg/ai/tools/tools.go +++ b/pkg/ai/tools/tools.go @@ -9,6 +9,7 @@ package tools import ( "context" + "fmt" "sort" "github.com/flanksource/captain/pkg/api" @@ -219,6 +220,74 @@ func NormalizedPreference(prefs ToolPreferences, name string) (ToolMode, bool) { return NormalizeToolMode(mode) } +// ResolveDefinitions validates caller tools, applies exact/group preferences, +// omits disabled tools, and writes the effective permission onto a copy of each +// selected definition. Every provider uses this function so API and agent +// runtimes cannot disagree about the visible tool set. +func ResolveDefinitions(definitions []api.ToolDefinition, preferences ToolPreferences) ([]api.ToolDefinition, error) { + if err := preferences.Validate(); err != nil { + return nil, err + } + selected := make([]api.ToolDefinition, 0, len(definitions)) + seen := make(map[string]struct{}, len(definitions)) + for _, definition := range definitions { + if definition.Name == "" { + return nil, fmt.Errorf("caller tool name cannot be empty") + } + if !validCallerToolName(definition.Name) { + return nil, fmt.Errorf("caller tool name %q contains unsupported characters", definition.Name) + } + if _, ok := seen[definition.Name]; ok { + return nil, fmt.Errorf("duplicate caller tool %q", definition.Name) + } + seen[definition.Name] = struct{}{} + if definition.Handler == nil { + return nil, fmt.Errorf("caller tool %q has no handler", definition.Name) + } + mode := ToolModeAuto + if definition.DefaultPermission != "" { + var ok bool + mode, ok = NormalizeToolMode(definition.DefaultPermission) + if !ok { + return nil, fmt.Errorf("tool %q has invalid default permission %q", definition.Name, definition.DefaultPermission) + } + } + if preferred, ok := EffectivePreference(preferences, ToolInfo{ + Name: definition.Name, Group: definition.Group, + }); ok && preferred != ToolModeAuto { + mode = preferred + } + if mode == ToolModeOff { + continue + } + if mode == ToolModeAuto { + if definition.ReadOnlyHint != nil && *definition.ReadOnlyHint && + definition.DestructiveHint != nil && !*definition.DestructiveHint { + mode = ToolModeOn + } else { + mode = ToolModeAsk + } + } + definition.DefaultPermission = mode + selected = append(selected, definition) + } + return selected, nil +} + +func validCallerToolName(name string) bool { + for _, value := range name { + if value >= 'a' && value <= 'z' || + value >= 'A' && value <= 'Z' || + value >= '0' && value <= '9' || + value == '-' || + value == '_' { + continue + } + return false + } + return true +} + // ToolEntry is one row in the tool-preferences UI: a single ungrouped tool, or a // collapsed group listing its member names. type ToolEntry struct { diff --git a/pkg/aichat/agent_prompt.go b/pkg/aichat/agent_prompt.go new file mode 100644 index 0000000..a923214 --- /dev/null +++ b/pkg/aichat/agent_prompt.go @@ -0,0 +1,81 @@ +package aichat + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/flanksource/captain/pkg/api" +) + +func agentPrompt(messages []api.Message, resumed bool) (string, []api.AttachmentRef, error) { + selected := messages + if resumed { + index := lastUserMessage(messages) + if index < 0 { + return "", nil, fmt.Errorf("resumed agent chat requires a user message") + } + selected = messages[index : index+1] + } + blocks := make([]string, 0, len(selected)) + attachments := make([]api.AttachmentRef, 0) + for _, message := range selected { + text, refs, err := agentMessageText(message) + if err != nil { + return "", nil, err + } + attachments = append(attachments, refs...) + if strings.TrimSpace(text) != "" { + blocks = append(blocks, fmt.Sprintf("%s:\n%s", message.Role, text)) + } + } + if len(blocks) == 1 && len(selected) == 1 && selected[0].Role == api.RoleUser { + return strings.TrimPrefix(blocks[0], string(api.RoleUser)+":\n"), attachments, nil + } + return strings.Join(blocks, "\n\n"), attachments, nil +} + +func lastUserMessage(messages []api.Message) int { + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == api.RoleUser { + return i + } + } + return -1 +} + +func agentMessageText(message api.Message) (string, []api.AttachmentRef, error) { + lines := make([]string, 0, len(message.Parts)) + attachments := make([]api.AttachmentRef, 0) + for _, part := range message.Parts { + switch part.Type { + case api.PartText: + lines = append(lines, part.Text) + case api.PartReasoning: + continue + case api.PartAttachment: + attachments = append(attachments, *part.Attachment) + lines = append(lines, "[Attachment: "+part.Attachment.Filename+"]") + case api.PartToolRequest: + lines = append(lines, fmt.Sprintf("Tool request %s (%s): %s", + part.ToolRequest.Name, part.ToolRequest.ToolCallID, jsonText(part.ToolRequest.Input))) + case api.PartToolResult: + if part.ToolResult.Error != "" { + lines = append(lines, fmt.Sprintf("Tool result %s failed: %s", part.ToolResult.ToolCallID, part.ToolResult.Error)) + } else { + lines = append(lines, fmt.Sprintf("Tool result %s: %s", + part.ToolResult.ToolCallID, jsonText(part.ToolResult.Output))) + } + default: + return "", nil, fmt.Errorf("unsupported agent prompt part %q", part.Type) + } + } + return strings.Join(lines, "\n"), attachments, nil +} + +func jsonText(raw json.RawMessage) string { + if len(raw) == 0 { + return "{}" + } + return string(raw) +} diff --git a/pkg/aichat/messages.go b/pkg/aichat/messages.go index d7ae1f0..790d363 100644 --- a/pkg/aichat/messages.go +++ b/pkg/aichat/messages.go @@ -69,19 +69,9 @@ func (s *Service) resolveAttachments(ctx context.Context, messages []UIMessage) } func requestSpec(request ChatRequest, settings RuntimeSettings, attachments map[partLocation]api.AttachmentRef) (api.Spec, error) { - model := strings.TrimSpace(request.Model) - if model == "" { - model = strings.TrimSpace(settings.Spec.Name) - } - if model == "" { - return api.Spec{}, fmt.Errorf("chat model is required") - } - // Expand before merging: a compact selector ("agent:sol") carries its own - // backend, and merging it unexpanded would keep settings.Spec's backend and run - // a different runtime than the caller asked for. - override, err := api.Model{Name: model, Effort: request.ReasoningEffort, Temperature: request.Temperature}.Expand() + override, err := chatModel(request, settings.Spec.Model) if err != nil { - return api.Spec{}, fmt.Errorf("invalid chat model %q: %w", model, err) + return api.Spec{}, err } spec := settings.Spec.Merge(api.Spec{ Model: override, @@ -104,10 +94,21 @@ func requestSpec(request ChatRequest, settings RuntimeSettings, attachments map[ if err != nil { return api.Spec{}, err } - if system != "" { - messages = append([]api.Message{{Role: api.RoleSystem, Parts: []api.Part{{Type: api.PartText, Text: system}}}}, messages...) + if isAgentBackend(spec.Backend) { + user, promptAttachments, err := agentPrompt(messages, request.ProviderSessionID != "") + if err != nil { + return api.Spec{}, err + } + spec.Messages = nil + spec.Prompt.System = system + spec.Prompt.User = user + spec.Prompt.Attachments = promptAttachments + } else { + if system != "" { + messages = append([]api.Message{{Role: api.RoleSystem, Parts: []api.Part{{Type: api.PartText, Text: system}}}}, messages...) + } + spec.Messages = messages } - spec.Messages = messages } else { spec.Messages = nil } @@ -117,6 +118,48 @@ func requestSpec(request ChatRequest, settings RuntimeSettings, attachments map[ return spec, nil } +func chatModel(request ChatRequest, fallback api.Model) (api.Model, error) { + selected := fallback + if request.Runtime != nil { + selected = *request.Runtime + } else if model := strings.TrimSpace(request.Model); model != "" { + selected = api.Model{Name: model} + } + if strings.TrimSpace(selected.Name) == "" { + return api.Model{}, fmt.Errorf("chat model is required") + } + if request.ReasoningEffort != "" { + if selected.Effort != "" && selected.Effort != request.ReasoningEffort { + return api.Model{}, fmt.Errorf("chat runtime effort %q conflicts with reasoning effort %q", selected.Effort, request.ReasoningEffort) + } + selected.Effort = request.ReasoningEffort + } + if request.Temperature != nil { + if selected.Temperature != nil && *selected.Temperature != *request.Temperature { + return api.Model{}, fmt.Errorf("chat runtime temperature conflicts with request temperature") + } + selected.Temperature = request.Temperature + } + expanded, err := selected.Expand() + if err != nil { + return api.Model{}, fmt.Errorf("invalid chat runtime: %w", err) + } + if request.Runtime != nil && strings.TrimSpace(request.Model) != "" { + legacy, err := api.Model{Name: strings.TrimSpace(request.Model)}.Expand() + if err != nil { + return api.Model{}, fmt.Errorf("invalid chat model %q: %w", request.Model, err) + } + if legacy.Name != expanded.Name || legacy.Backend != expanded.Backend { + return api.Model{}, fmt.Errorf("chat model %q conflicts with structured runtime %s/%s", request.Model, expanded.Backend, expanded.Name) + } + } + return expanded, nil +} + +func isAgentBackend(backend api.Backend) bool { + return backend == api.BackendClaudeAgent || backend == api.BackendCodexAgent +} + func canonicalMessages(messages []UIMessage, attachments map[partLocation]api.AttachmentRef) ([]api.Message, error) { out := make([]api.Message, 0, len(messages)) for messageIndex, message := range messages { diff --git a/pkg/aichat/service.go b/pkg/aichat/service.go index fd1ecab..3dcd39e 100644 --- a/pkg/aichat/service.go +++ b/pkg/aichat/service.go @@ -147,6 +147,11 @@ func (s *Service) handleChat(w http.ResponseWriter, request *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } + definitions, err := aitools.ResolveDefinitions(set.Definitions, spec.ToolPreferences) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } if err := s.persistIncoming(request.Context(), chat); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -155,7 +160,8 @@ func (s *Service) handleChat(w http.ResponseWriter, request *http.Request) { config.Model = spec.Model config.Budget = spec.Budget config.SessionID = spec.SessionID - config.Tools = set.Definitions + config.CaptainSessionID = chat.ThreadID + config.Tools = definitions provider, err := s.resolveProvider(request.Context(), config) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) @@ -166,7 +172,7 @@ func (s *Service) handleChat(w http.ResponseWriter, request *http.Request) { serviceLog.Errorf("close chat provider: %v", closeErr) } }() - if len(set.Definitions) > 0 { + if len(definitions) > 0 { capability, ok := api.ProviderAs[api.ToolCapableProvider](provider) if !ok || !capability.SupportsCallerTools() { http.Error(w, fmt.Sprintf("backend %q does not support caller tools", provider.GetBackend()), http.StatusBadRequest) diff --git a/pkg/aichat/service_ginkgo_test.go b/pkg/aichat/service_ginkgo_test.go index 299ad6f..261656a 100644 --- a/pkg/aichat/service_ginkgo_test.go +++ b/pkg/aichat/service_ginkgo_test.go @@ -53,9 +53,11 @@ func (f *fakeResolver) Provider(_ context.Context, config api.Config) (api.Strea } type fakeStreamingProvider struct { - events []api.Event - specs []api.Spec - execute func(context.Context, api.Spec) (<-chan api.Event, error) + events []api.Event + specs []api.Spec + execute func(context.Context, api.Spec) (<-chan api.Event, error) + backend api.Backend + supportsCallerTools *bool } func (f *fakeStreamingProvider) Execute(context.Context, api.Spec) (*api.Response, error) { @@ -75,9 +77,16 @@ func (f *fakeStreamingProvider) ExecuteStream(ctx context.Context, spec api.Spec return events, nil } -func (f *fakeStreamingProvider) GetModel() string { return "test-model" } -func (f *fakeStreamingProvider) GetBackend() api.Backend { return api.BackendOpenAI } -func (f *fakeStreamingProvider) SupportsCallerTools() bool { return true } +func (f *fakeStreamingProvider) GetModel() string { return "test-model" } +func (f *fakeStreamingProvider) GetBackend() api.Backend { + if f.backend != "" { + return f.backend + } + return api.BackendOpenAI +} +func (f *fakeStreamingProvider) SupportsCallerTools() bool { + return f.supportsCallerTools == nil || *f.supportsCallerTools +} type fakeAttachmentResolver struct{} @@ -219,6 +228,7 @@ var _ = Describe("Captain aichat service", func() { It("serves models and tools from injected Captain seams", func() { resolver := &fakeResolver{models: aichat.ModelCatalogResponse{{ ID: "openai/test-model", Provider: "openai", Label: "Test", Configured: true, + Runtime: api.Model{Name: "test-model", Backend: api.BackendOpenAI}, }}} service := aichat.NewService(aichat.ServiceOptions{ Resolver: resolver, @@ -240,7 +250,7 @@ var _ = Describe("Captain aichat service", func() { models := httptest.NewRecorder() service.Handler().ServeHTTP(models, httptest.NewRequest(http.MethodGet, "/api/chat/models", nil)) Expect(models.Code).To(Equal(http.StatusOK)) - Expect(models.Body.String()).To(MatchJSON(`[{"id":"openai/test-model","provider":"openai","label":"Test","reasoning":false,"temperature":false,"configured":true,"contextWindow":0,"inputMediaTypes":null}]`)) + Expect(models.Body.String()).To(MatchJSON(`[{"id":"openai/test-model","provider":"openai","label":"Test","runtime":{"model":"test-model","backend":"openai"},"reasoning":false,"temperature":false,"configured":true,"contextWindow":0,"inputMediaTypes":null}]`)) tools := httptest.NewRecorder() service.Handler().ServeHTTP(tools, httptest.NewRequest(http.MethodGet, "/api/chat/tools", nil)) @@ -309,6 +319,71 @@ var _ = Describe("Captain aichat service", func() { Expect(resolver.configs[0].ProjectName).To(Equal("tenant-x")) }) + It("adapts canonical chat messages into an agent prompt", func() { + store := aichat.NewMemoryThreadStore() + thread, err := store.Create(context.Background(), "Agent chat") + Expect(err).NotTo(HaveOccurred()) + provider := &fakeStreamingProvider{ + backend: api.BackendClaudeAgent, + events: []api.Event{{Kind: api.EventResult, Success: true}}, + } + resolver := &fakeResolver{provider: provider} + service := aichat.NewService(aichat.ServiceOptions{ + Resolver: resolver, Threads: store, + Settings: aichat.RuntimeSettingsProviderFunc(func(context.Context) (aichat.RuntimeSettings, error) { + return aichat.RuntimeSettings{System: "Use accounting tools."}, nil + }), + }) + + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, requestJSON(http.MethodPost, "/api/chat", aichat.ChatRequest{ + Runtime: &api.Model{Name: "sonnet", Backend: api.BackendClaudeAgent}, + ThreadID: thread.ID, + ProviderSessionID: "provider-session-1", + Messages: []aichat.UIMessage{{ + Role: "user", Parts: []aichat.UIPart{{Type: "text", Text: "inspect the invoice"}}, + }}, + })) + + Expect(response.Code).To(Equal(http.StatusOK)) + Expect(provider.specs).To(HaveLen(1)) + Expect(provider.specs[0].Messages).To(BeNil()) + Expect(provider.specs[0].Prompt.System).To(Equal("Use accounting tools.")) + Expect(provider.specs[0].Prompt.User).To(Equal("inspect the invoice")) + Expect(resolver.configs[0].Model.Backend).To(Equal(api.BackendClaudeAgent)) + Expect(resolver.configs[0].CaptainSessionID).To(Equal(thread.ID)) + Expect(resolver.configs[0].SessionID).To(Equal("provider-session-1")) + }) + + It("treats an all-off resolved tool set as no caller tools", func() { + supported := false + provider := &fakeStreamingProvider{ + events: []api.Event{{Kind: api.EventResult, Success: true}}, + supportsCallerTools: &supported, + } + resolver := &fakeResolver{provider: provider} + service := aichat.NewService(aichat.ServiceOptions{ + Resolver: resolver, + Tools: aichat.StaticToolProvider([]api.ToolDefinition{{ + Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Handler: func(context.Context, map[string]any) (any, error) { return nil, nil }, + }}), + }) + + response := httptest.NewRecorder() + service.Handler().ServeHTTP(response, requestJSON(http.MethodPost, "/api/chat", aichat.ChatRequest{ + Model: "openai/test-model", + ToolPreferences: api.ToolPreferences{"invoice_get": api.ToolModeOff}, + Messages: []aichat.UIMessage{{ + Role: "user", Parts: []aichat.UIPart{{Type: "text", Text: "hello"}}, + }}, + })) + + Expect(response.Code).To(Equal(http.StatusOK)) + Expect(resolver.configs).To(HaveLen(1)) + Expect(resolver.configs[0].Tools).To(BeEmpty()) + }) + It("passes a durable approval resume without rebuilding conversation messages", func() { state := api.ToolApprovalState{ Messages: []api.Message{ diff --git a/pkg/aichat/wire.go b/pkg/aichat/wire.go index 86ce1a9..0d24989 100644 --- a/pkg/aichat/wire.go +++ b/pkg/aichat/wire.go @@ -15,6 +15,7 @@ type ChatRequest struct { ID string `json:"id,omitempty"` Messages []UIMessage `json:"messages"` Model string `json:"model,omitempty"` + Runtime *api.Model `json:"runtime,omitempty"` ReasoningEffort api.Effort `json:"reasoningEffort,omitempty"` Temperature *float64 `json:"temperature,omitempty"` Budget api.Budget `json:"budget,omitempty"` diff --git a/pkg/aichat/wire_ginkgo_test.go b/pkg/aichat/wire_ginkgo_test.go index b7a6090..3f88006 100644 --- a/pkg/aichat/wire_ginkgo_test.go +++ b/pkg/aichat/wire_ginkgo_test.go @@ -62,6 +62,19 @@ var _ = Describe("AI SDK v6 wire types", func() { Expect(aichat.UIPart{Type: "text"}.EffectiveToolName()).To(BeEmpty()) }) + It("decodes an exact structured runtime", func() { + var request aichat.ChatRequest + Expect(json.Unmarshal([]byte(`{ + "runtime":{"model":"sonnet","backend":"claude-agent","effort":"high"}, + "messages":[{"role":"user","parts":[{"type":"text","text":"hello"}]}] + }`), &request)).To(Succeed()) + + Expect(request.Runtime).NotTo(BeNil()) + Expect(*request.Runtime).To(Equal(api.Model{ + Name: "sonnet", Backend: api.BackendClaudeAgent, Effort: api.EffortHigh, + })) + }) + It("rejects the removed string tool approval policy", func() { var request aichat.ChatRequest Expect(json.Unmarshal([]byte(`{"messages":[],"toolApproval":"manual"}`), &request)).To( @@ -75,6 +88,7 @@ var _ = Describe("AI SDK v6 wire types", func() { ID: "openai/gpt", Provider: "openai", Label: "GPT", Reasoning: true, Temperature: true, Configured: true, ContextWindow: 128000, InputMediaTypes: []string{"image/*"}, + Runtime: api.Model{Name: "gpt", Backend: api.BackendOpenAI}, }} tools := aichat.ToolCatalogResponse{Tools: []aichat.ToolCatalogEntry{{ Name: "invoice_get", Source: "custom", Group: "billing", @@ -85,7 +99,7 @@ var _ = Describe("AI SDK v6 wire types", func() { modelJSON, err := json.Marshal(models) Expect(err).NotTo(HaveOccurred()) - Expect(modelJSON).To(MatchJSON(`[{"id":"openai/gpt","provider":"openai","label":"GPT","reasoning":true,"temperature":true,"configured":true,"contextWindow":128000,"inputMediaTypes":["image/*"]}]`)) + Expect(modelJSON).To(MatchJSON(`[{"id":"openai/gpt","provider":"openai","label":"GPT","runtime":{"model":"gpt","backend":"openai"},"reasoning":true,"temperature":true,"configured":true,"contextWindow":128000,"inputMediaTypes":["image/*"]}]`)) toolJSON, err := json.Marshal(tools) Expect(err).NotTo(HaveOccurred()) Expect(toolJSON).To(MatchJSON(`{"tools":[{"name":"invoice_get","source":"custom","group":"billing","preferenceKey":"billing","defaultPermission":"ask","strict":true,"method":"GET","path":"/invoices/{id}","operationName":"invoice get","inputSchema":{"type":"object"}}]}`)) diff --git a/pkg/api/registry/model.go b/pkg/api/registry/model.go index 7df4be4..97880e1 100644 --- a/pkg/api/registry/model.go +++ b/pkg/api/registry/model.go @@ -70,6 +70,8 @@ type Model struct { Interrupt bool `json:"interrupt,omitempty" yaml:"interrupt,omitempty" jsonschema:"readOnly" pretty:"label=Interrupt"` // Steer reports that a running turn accepts mid-flight steering. Steer bool `json:"steer,omitempty" yaml:"steer,omitempty" jsonschema:"readOnly" pretty:"label=Steer"` + // CallerTools reports that the runtime can expose caller-supplied tools. + CallerTools bool `json:"callerTools,omitempty" yaml:"callerTools,omitempty" jsonschema:"readOnly" pretty:"label=Caller Tools"` // Provider is the descriptor that owns this model. Never serialized: it holds // the whole catalog, so emitting it would inline the registry into every spec. @@ -95,6 +97,7 @@ func (m Model) Capabilities() Model { m.Resume = caps.Resume m.Interrupt = caps.Interrupt m.Steer = caps.Steer + m.CallerTools = caps.CallerTools m.MediaTypes = p.MediaTypesFor(mode, m.Name) return m } diff --git a/pkg/api/registry/provider.go b/pkg/api/registry/provider.go index ff9515e..69376ea 100644 --- a/pkg/api/registry/provider.go +++ b/pkg/api/registry/provider.go @@ -26,6 +26,9 @@ type ModeCapabilities struct { Interrupt bool // Steer: the adapter implements SteerableProvider. Steer bool + // CallerTools reports that the adapter can expose caller-supplied + // api.Config.Tools rather than only its built-in tool ecosystem. + CallerTools bool // MediaTypes is the adapter's attachment ceiling. A model's own declared // types are clamped against it — the adapter cannot carry what it cannot send. MediaTypes []string diff --git a/pkg/api/registry/providers.go b/pkg/api/registry/providers.go index b2dce7e..62b8aeb 100644 --- a/pkg/api/registry/providers.go +++ b/pkg/api/registry/providers.go @@ -19,9 +19,9 @@ var ( PricingPrefix: "anthropic", EnvVars: []string{"ANTHROPIC_API_KEY"}, modes: map[RuntimeMode]ModeCapabilities{ - ModeAPI: {Backend: BackendAnthropic, Streaming: true, MediaTypes: []string{"image/*"}}, + ModeAPI: {Backend: BackendAnthropic, Streaming: true, CallerTools: true, MediaTypes: []string{"image/*"}}, ModeCLI: {Backend: BackendClaudeCLI, Streaming: true, Resume: true}, - ModeAgent: {Backend: BackendClaudeAgent, Streaming: true, Resume: true, Interrupt: true, Steer: true, MediaTypes: []string{"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}}, + ModeAgent: {Backend: BackendClaudeAgent, Streaming: true, Resume: true, Interrupt: true, Steer: true, CallerTools: true, MediaTypes: []string{"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}}, ModeCmux: {Backend: BackendClaudeCmux, Streaming: true, Resume: true, Keyless: true}, }, modeTokens: sortModeTokens([]modeToken{ @@ -43,9 +43,9 @@ var ( PricingPrefix: "openai", EnvVars: []string{"OPENAI_API_KEY"}, modes: map[RuntimeMode]ModeCapabilities{ - ModeAPI: {Backend: BackendOpenAI, Streaming: true, MediaTypes: []string{"image/*"}}, + ModeAPI: {Backend: BackendOpenAI, Streaming: true, CallerTools: true, MediaTypes: []string{"image/*"}}, ModeCLI: {Backend: BackendCodexCLI, Streaming: true, Resume: true, MediaTypes: []string{"image/*"}}, - ModeAgent: {Backend: BackendCodexAgent, Streaming: true, Resume: true, Interrupt: true, MediaTypes: []string{"image/*"}}, + ModeAgent: {Backend: BackendCodexAgent, Streaming: true, Resume: true, Interrupt: true, CallerTools: true, MediaTypes: []string{"image/*"}}, ModeCmux: {Backend: BackendCodexCmux, Streaming: true, Resume: true, Keyless: true}, }, // A bare "codex" is the CLI, not the API — the asymmetry with "claude" @@ -77,7 +77,7 @@ var ( PricingPrefix: "google", EnvVars: []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}, modes: map[RuntimeMode]ModeCapabilities{ - ModeAPI: {Backend: BackendGemini, Streaming: true, MediaTypes: []string{"image/*", "audio/*", "video/*", "application/pdf"}}, + ModeAPI: {Backend: BackendGemini, Streaming: true, CallerTools: true, MediaTypes: []string{"image/*", "audio/*", "video/*", "application/pdf"}}, ModeCLI: {Backend: BackendGeminiCLI, Streaming: true}, }, modeTokens: sortModeTokens([]modeToken{ @@ -98,7 +98,7 @@ var ( modes: map[RuntimeMode]ModeCapabilities{ // DeepSeek selects reasoning by model id (deepseek-reasoner vs // deepseek-chat) and ships no attachment support. - ModeAPI: {Backend: BackendDeepSeek, Streaming: true}, + ModeAPI: {Backend: BackendDeepSeek, Streaming: true, CallerTools: true}, }, claimPrefixes: []string{"deepseek"}, families: []string{"deepseek"}, diff --git a/pkg/api/runtime_config.go b/pkg/api/runtime_config.go index 16d6e3c..7cfba0e 100644 --- a/pkg/api/runtime_config.go +++ b/pkg/api/runtime_config.go @@ -2,6 +2,10 @@ package api import ( "context" + "fmt" + "net" + "net/url" + "strings" "time" ) @@ -36,6 +40,59 @@ type SchemaRepairConfig struct { Prompt string // optional .prompt file path; empty means embedded default } +// CallerToolEndpoint is an authenticated, request-scoped MCP endpoint exposing +// caller-owned tools. Headers are transport credentials and must never be +// serialized into specs, command arguments, events, or logs. +type CallerToolEndpoint struct { + Name string + URL string + Headers map[string]string +} + +func (endpoint CallerToolEndpoint) Validate() error { + if endpoint.Name == "" { + return fmt.Errorf("caller-tool endpoint name is required") + } + for _, value := range endpoint.Name { + if !isCallerToolNameRune(value) { + return fmt.Errorf("caller-tool endpoint name %q contains unsupported characters", endpoint.Name) + } + } + parsed, err := url.Parse(endpoint.URL) + if err != nil || parsed.Hostname() == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return fmt.Errorf("caller-tool endpoint URL must be an absolute HTTP or HTTPS URL") + } + if parsed.User != nil { + return fmt.Errorf("caller-tool endpoint URL must not contain credentials") + } + if parsed.Scheme == "http" && !isLoopbackHost(parsed.Hostname()) { + return fmt.Errorf("caller-tool endpoint requires HTTPS outside loopback") + } + authorization := "" + for name, value := range endpoint.Headers { + if strings.EqualFold(name, "Authorization") { + authorization = strings.TrimSpace(value) + break + } + } + if !strings.HasPrefix(authorization, "Bearer ") || strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer ")) == "" { + return fmt.Errorf("caller-tool endpoint requires a bearer credential") + } + return nil +} + +func isCallerToolNameRune(value rune) bool { + return value >= 'a' && value <= 'z' || + value >= 'A' && value <= 'Z' || + value >= '0' && value <= '9' || + value == '-' || + value == '_' +} + +func isLoopbackHost(host string) bool { + return strings.EqualFold(host, "localhost") || net.ParseIP(host).IsLoopback() +} + // Config is the provider construction/runtime config. Model (name/backend/temp/ // effort) and Budget (cost ceiling, max tokens) come from the serializable spec // types; the rest are transport/runtime concerns that never belong in Spec. It is @@ -58,9 +115,14 @@ type Config struct { CacheTTL time.Duration NoCache bool MaxConcurrent int - SessionID string - ProjectName string - SchemaRepair SchemaRepairConfig + // SessionID is the provider-native session/thread used for resume. + SessionID string + // CaptainSessionID is the authoritative Captain chat/run identity used for + // caller-tool capabilities and approvals. It must not be inferred from the + // provider-native SessionID. + CaptainSessionID string + ProjectName string + SchemaRepair SchemaRepairConfig // CanUseTool, when set, brokers tool permissions over the stream-json control // protocol: the streaming provider asks this callback before a tool that needs @@ -71,8 +133,14 @@ type Config struct { CanUseTool PermissionFunc `json:"-"` // Tools are caller-supplied tools exposed to the model and executed - // in-process. Only tool-capable providers (see ToolCapableProvider — today - // the genkit API backends) honour them; other providers, which bring their - // own tool ecosystems, ignore the field. Never serialized (Go closures). + // in-process. Tool-capable API providers invoke the handlers directly; + // out-of-process agent providers expose them through a private Captain MCP + // endpoint. Never serialized (Go closures). Tools []ToolDefinition `json:"-"` + + // CallerTools supplies a pre-issued Captain MCP endpoint. When nil, an + // out-of-process tool-capable provider creates a private loopback endpoint + // from Tools. It is runtime-only because Headers contain a short-lived + // credential. + CallerTools *CallerToolEndpoint `json:"-"` } diff --git a/pkg/api/runtime_config_ginkgo_test.go b/pkg/api/runtime_config_ginkgo_test.go new file mode 100644 index 0000000..ee0961b --- /dev/null +++ b/pkg/api/runtime_config_ginkgo_test.go @@ -0,0 +1,39 @@ +package api_test + +import ( + "github.com/flanksource/captain/pkg/api" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Caller-tool endpoints", func() { + It("accepts authenticated loopback HTTP and remote HTTPS endpoints", func() { + for _, endpoint := range []api.CallerToolEndpoint{ + { + Name: "captain", URL: "http://127.0.0.1:43210/mcp", + Headers: map[string]string{"Authorization": "Bearer loopback-secret"}, + }, + { + Name: "captain-remote", URL: "https://captain.example.com/mcp", + Headers: map[string]string{"authorization": "Bearer remote-secret"}, + }, + } { + Expect(endpoint.Validate()).To(Succeed()) + } + }) + + It("rejects invalid names, unauthenticated endpoints, and remote plaintext HTTP", func() { + Expect((api.CallerToolEndpoint{ + Name: "captain tools", URL: "https://captain.example.com/mcp", + Headers: map[string]string{"Authorization": "Bearer secret"}, + }).Validate()).To(MatchError(ContainSubstring("name"))) + Expect((api.CallerToolEndpoint{ + Name: "captain", URL: "https://captain.example.com/mcp", + }).Validate()).To(MatchError(ContainSubstring("bearer"))) + Expect((api.CallerToolEndpoint{ + Name: "captain", URL: "http://captain.example.com/mcp", + Headers: map[string]string{"Authorization": "Bearer secret"}, + }).Validate()).To(MatchError(ContainSubstring("HTTPS"))) + }) +}) diff --git a/pkg/api/spec_merge_differential_test.go b/pkg/api/spec_merge_differential_test.go index aa5f747..d457bd9 100644 --- a/pkg/api/spec_merge_differential_test.go +++ b/pkg/api/spec_merge_differential_test.go @@ -196,7 +196,7 @@ func neutralize(s Spec) Spec { // Fields the hand-written mergers simply forgot; the structural engine cannot // forget one, so these now carry through. s.Prompt.Attachments = nil - s.Model.Streaming, s.Model.Resume, s.Model.Interrupt, s.Model.Steer = false, false, false, false + s.Model.Streaming, s.Model.Resume, s.Model.Interrupt, s.Model.Steer, s.Model.CallerTools = false, false, false, false, false s.Model.MediaTypes = nil s.Model.Provider = nil return s From 12f3bb283fbf47b9b4d9945381b5fd88eff927c0 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 31 Jul 2026 11:48:24 +0300 Subject: [PATCH 04/13] feat(cli): Expose canonical prompt runtimes and explicit save-as flow Add disabled-model filtering to whoami and expose exact backend/model runtime data for prompts. Use canonical run requests for preview and execution, and require explicit Save as for read-only prompts to prevent implicit local forks. BREAKING CHANGE: Updating a read-only prompt now fails; clients must use Save as/create to make an editable copy. --- cmd/captain/main.go | 2 +- pkg/ai/adapters.go | 7 +- pkg/ai/catalog_disabled_ginkgo_test.go | 17 + pkg/ai/catalog_info.go | 22 +- pkg/cli/prompt_entity.go | 31 +- pkg/cli/prompt_entity_test.go | 47 +- pkg/cli/prompt_records.go | 31 + pkg/cli/prompt_runtimes_ginkgo_test.go | 60 ++ pkg/cli/prompt_schema_build.go | 58 +- pkg/cli/prompt_schema_test.go | 20 +- pkg/cli/webapp/src/PromptRuntimeRows.test.tsx | 166 ---- pkg/cli/webapp/src/PromptRuntimeRows.tsx | 142 ---- pkg/cli/webapp/src/PromptWorkbench.test.ts | 66 +- pkg/cli/webapp/src/PromptWorkbench.tsx | 736 ++++++------------ pkg/cli/webapp/src/PromptWriteModal.test.tsx | 105 +++ pkg/cli/webapp/src/PromptWriteModal.tsx | 246 ++++++ pkg/cli/webapp/src/WhoamiPage.test.tsx | 2 +- pkg/cli/webapp/src/WhoamiPage.tsx | 2 +- pkg/cli/webapp/src/promptData.ts | 14 +- .../webapp/src/promptRuntimeRowsHelpers.ts | 24 - pkg/cli/webapp/src/promptWorkbenchHelpers.ts | 107 --- pkg/cli/whoami.go | 30 +- pkg/cli/whoami_ginkgo_test.go | 86 ++ pkg/cli/whoami_render.go | 6 +- 24 files changed, 896 insertions(+), 1131 deletions(-) delete mode 100644 pkg/cli/webapp/src/PromptRuntimeRows.test.tsx delete mode 100644 pkg/cli/webapp/src/PromptRuntimeRows.tsx create mode 100644 pkg/cli/webapp/src/PromptWriteModal.test.tsx create mode 100644 pkg/cli/webapp/src/PromptWriteModal.tsx delete mode 100644 pkg/cli/webapp/src/promptRuntimeRowsHelpers.ts create mode 100644 pkg/cli/whoami_ginkgo_test.go diff --git a/cmd/captain/main.go b/cmd/captain/main.go index da48e81..39966e0 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -125,7 +125,7 @@ func main() { whoamiCmd := clicky.AddNamedCommand("whoami", rootCmd, cli.WhoamiOptions{}, cli.RunWhoami) whoamiCmd.Short = "List agent adapters, auth methods, and available models" - whoamiCmd.Long = "Show every AI agent adapter (API providers and CLI agents), how each is authenticated (Captain vault, API-key env var, or CLI login), whether its CLI binary is installed, and the models each provider exposes via a live API call. Pass --models=false to skip the network probes, or --backend to inspect a single adapter." + whoamiCmd.Long = "Show every AI agent adapter (API providers and CLI agents), how each is authenticated (Captain vault, API-key env var, or CLI login), whether its CLI binary is installed, and the models each provider exposes via a live API call. Disabled models are hidden by default; pass --disabled=true to include them. Pass --models=false to skip the network probes, or --backend to inspect a single adapter." configureCmd := clicky.AddNamedCommandWithContext("configure", rootCmd, cli.ConfigureOptions{}, cli.RunConfigure) configureCmd.Use = "configure [provider]" diff --git a/pkg/ai/adapters.go b/pkg/ai/adapters.go index b14251e..daeb386 100644 --- a/pkg/ai/adapters.go +++ b/pkg/ai/adapters.go @@ -17,9 +17,10 @@ import ( // probe and its caching can be reused by non-CLI consumers (e.g. the aichat // server's model menu) without importing pkg/cli. type WhoamiOptions struct { - Backend string `flag:"backend" help:"Show only this backend: anthropic|openai|gemini|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-agent|codex-cmux|gemini-cli" short:"b"` - Models bool `flag:"models" help:"List models from provider APIs or installed CLI catalogs" default:"true" short:"m"` - Limit int `flag:"limit" help:"Max sample model IDs to show per adapter in pretty output after per-prefix filtering (0 = all)" default:"0" short:"l"` + Backend string `flag:"backend" help:"Show only this backend: anthropic|openai|gemini|deepseek|claude-cli|claude-agent|claude-cmux|codex-cli|codex-agent|codex-cmux|gemini-cli" short:"b"` + Models bool `flag:"models" help:"List models from provider APIs or installed CLI catalogs" default:"true" short:"m"` + Limit int `flag:"limit" help:"Max sample model IDs to show per adapter in pretty output after per-prefix filtering (0 = all)" default:"0" short:"l"` + IncludeDisabled bool `flag:"disabled" help:"Include disabled models" default:"false"` } // AdapterStatus is the resolved auth/availability of a single agent adapter diff --git a/pkg/ai/catalog_disabled_ginkgo_test.go b/pkg/ai/catalog_disabled_ginkgo_test.go index 71ad3fe..c885370 100644 --- a/pkg/ai/catalog_disabled_ginkgo_test.go +++ b/pkg/ai/catalog_disabled_ginkgo_test.go @@ -125,4 +125,21 @@ var _ = Describe("catalog opt-out filtering", func() { Expect(defaults()).To(BeEmpty()) }) + + It("serves the exact Captain runtime selection for every model row", func() { + models := Catalog() + info := CatalogInfo(nil) + + Expect(info).To(HaveLen(len(models))) + for index, model := range models { + want := api.Model{ + Name: model.BareID(), + Backend: model.Backend, + } + if model.ID != want.Name { + want.ID = model.ID + } + Expect(info[index].Runtime).To(Equal(want)) + } + }) }) diff --git a/pkg/ai/catalog_info.go b/pkg/ai/catalog_info.go index 84da121..ce5f5fc 100644 --- a/pkg/ai/catalog_info.go +++ b/pkg/ai/catalog_info.go @@ -4,6 +4,7 @@ import ( "os/exec" "slices" + "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/api/registry" ) @@ -11,12 +12,13 @@ import ( // selector can be data-driven. Configured reports whether the model is // selectable (its API provider has a key, or its agent backend is installed). type ModelInfo struct { - ID string `json:"id"` - Provider string `json:"provider"` - Label string `json:"label"` - Reasoning bool `json:"reasoning"` - Temperature bool `json:"temperature"` - Configured bool `json:"configured"` + ID string `json:"id"` + Provider string `json:"provider"` + Label string `json:"label"` + Runtime api.Model `json:"runtime"` + Reasoning bool `json:"reasoning"` + Temperature bool `json:"temperature"` + Configured bool `json:"configured"` // Default marks captain's declared default model, so a client seeds its // picker from the menu instead of hardcoding an id that rots on the next // release. At most one row carries it, and none does when that model is @@ -67,10 +69,18 @@ func catalogInfoFrom(models []Model, configuredProviders []string) []ModelInfo { } else { configured = slices.Contains(configuredProviders, BackendToProvider(m.Backend)) } + runtime := api.Model{ + Name: m.BareID(), + Backend: m.Backend, + } + if m.ID != runtime.Name { + runtime.ID = m.ID + } out = append(out, ModelInfo{ ID: m.ID, Provider: BackendToProvider(m.Backend), Label: m.Label, + Runtime: runtime, Reasoning: m.Reasoning, Temperature: m.Temperature, Configured: configured, diff --git a/pkg/cli/prompt_entity.go b/pkg/cli/prompt_entity.go index 135629c..154d32f 100644 --- a/pkg/cli/prompt_entity.go +++ b/pkg/cli/prompt_entity.go @@ -78,11 +78,12 @@ func (p PromptSummary) Row() map[string]any { type PromptDetail struct { PromptSummary - Content string `json:"content"` - InputSchema map[string]any `json:"inputSchema,omitempty"` - InputDefault map[string]any `json:"inputDefault,omitempty"` - OutputSchema map[string]any `json:"outputSchema,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` + Content string `json:"content"` + InputSchema map[string]any `json:"inputSchema,omitempty"` + InputDefault map[string]any `json:"inputDefault,omitempty"` + OutputSchema map[string]any `json:"outputSchema,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Run PromptRenderRequest `json:"run"` } type PromptWriteRequest struct { @@ -287,6 +288,9 @@ func updatePrompt(ctx context.Context, id string, body map[string]any) (PromptDe if err != nil { return PromptDetail{}, err } + if !record.Source.Writable { + return PromptDetail{}, fmt.Errorf("prompt source %q is read-only; use create to save a copy", record.Source.Label) + } var req PromptWriteRequest if err := decodePromptBody(ctx, body, &req); err != nil { return PromptDetail{}, err @@ -294,12 +298,6 @@ func updatePrompt(ctx context.Context, id string, body map[string]any) (PromptDe if strings.TrimSpace(req.Content) == "" { return PromptDetail{}, fmt.Errorf("prompt content cannot be empty") } - if !record.Source.Writable { - if strings.TrimSpace(req.RelPath) == "" { - req.RelPath = localForkRelPath(record) - } - return writeNewLocalPrompt(ctx, req) - } full, err := safeLocalPromptPath(record.Source, record.Rel) if err != nil { return PromptDetail{}, err @@ -310,17 +308,6 @@ func updatePrompt(ctx context.Context, id string, body map[string]any) (PromptDe return promptDetail(record) } -// localForkRelPath derives the destination path for a read-only (embedded) -// prompt saved into a writable source, stripping the source walk root so an -// embedded "testdata/commit.prompt" lands as "commit.prompt". -func localForkRelPath(record promptRecord) string { - rel := record.Rel - if root := record.Source.WalkRoot; root != "" { - rel = strings.TrimPrefix(rel, root+"/") - } - return rel -} - func deletePrompt(ctx context.Context, id string) error { record, err := resolvePromptRecord(ctx, id) if err != nil { diff --git a/pkg/cli/prompt_entity_test.go b/pkg/cli/prompt_entity_test.go index 601e09f..5f620d1 100644 --- a/pkg/cli/prompt_entity_test.go +++ b/pkg/cli/prompt_entity_test.go @@ -256,7 +256,7 @@ func assertSchemaHasProps(t *testing.T, label string, schema map[string]any, key } } -func TestUpdateEmbeddedPromptForksToLocal(t *testing.T) { +func TestUpdateEmbeddedPromptRequiresSaveAs(t *testing.T) { isolateCaptainConfig(t) dir := t.TempDir() @@ -276,24 +276,39 @@ func TestUpdateEmbeddedPromptForksToLocal(t *testing.T) { } newContent := original.Content + "\n{{! local override }}\n" - forked, err := updatePrompt(ctx, embedded.ID, map[string]any{"content": newContent}) + if _, err := updatePrompt(ctx, embedded.ID, map[string]any{"content": newContent}); err == nil { + t.Fatal("updatePrompt(embedded) succeeded, want read-only error") + } else if !strings.Contains(err.Error(), "read-only") || !strings.Contains(err.Error(), "create") { + t.Fatalf("updatePrompt(embedded) error = %q, want read-only create guidance", err) + } + if entries, err := os.ReadDir(dir); err != nil { + t.Fatalf("read local prompt directory: %v", err) + } else if len(entries) != 0 { + t.Fatalf("updatePrompt(embedded) created %d local files, want none", len(entries)) + } + + savedAs, err := createPrompt(ctx, map[string]any{ + "name": "Commit Copy", + "relPath": "copies/commit.prompt", + "content": newContent, + }) if err != nil { - t.Fatalf("updatePrompt(embedded) err = %v", err) + t.Fatalf("createPrompt(save as) err = %v", err) } - if !forked.Writable || forked.SourceKind != "local" { - t.Fatalf("forked prompt = kind %q writable %v, want local writable", forked.SourceKind, forked.Writable) + if !savedAs.Writable || savedAs.SourceKind != "local" { + t.Fatalf("saved-as prompt = kind %q writable %v, want local writable", savedAs.SourceKind, savedAs.Writable) } - if forked.ID == embedded.ID { - t.Fatalf("forked prompt kept embedded id %q", forked.ID) + if savedAs.ID == embedded.ID { + t.Fatalf("saved-as prompt kept embedded id %q", savedAs.ID) } - if forked.RelPath != "commit.prompt" { - t.Fatalf("forked relPath = %q, want commit.prompt (testdata/ stripped)", forked.RelPath) + if savedAs.RelPath != "copies/commit.prompt" { + t.Fatalf("saved-as relPath = %q, want copies/commit.prompt", savedAs.RelPath) } - if !strings.Contains(forked.Content, "local override") { - t.Fatalf("forked content did not persist edit: %q", forked.Content) + if !strings.Contains(savedAs.Content, "local override") { + t.Fatalf("saved-as content did not persist edit: %q", savedAs.Content) } - if _, err := os.Stat(filepath.Join(dir, "commit.prompt")); err != nil { - t.Fatalf("forked prompt file missing: %v", err) + if _, err := os.Stat(filepath.Join(dir, "copies", "commit.prompt")); err != nil { + t.Fatalf("saved-as prompt file missing: %v", err) } stillEmbedded, err := getPrompt(ctx, embedded.ID) @@ -301,11 +316,7 @@ func TestUpdateEmbeddedPromptForksToLocal(t *testing.T) { t.Fatalf("getPrompt(embedded) after fork err = %v", err) } if strings.Contains(stillEmbedded.Content, "local override") { - t.Fatalf("embedded prompt was mutated by fork") - } - - if _, err := updatePrompt(ctx, embedded.ID, map[string]any{"content": newContent}); err == nil { - t.Fatalf("second fork of same prompt should fail with already-exists") + t.Fatal("embedded prompt was mutated by save as") } } diff --git a/pkg/cli/prompt_records.go b/pkg/cli/prompt_records.go index 664a530..620b288 100644 --- a/pkg/cli/prompt_records.go +++ b/pkg/cli/prompt_records.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io/fs" + "maps" "os" "path/filepath" "sort" @@ -13,6 +14,7 @@ import ( "time" promptlib "github.com/flanksource/captain/pkg/ai/prompt" + "github.com/flanksource/captain/pkg/api" dp "github.com/google/dotprompt/go/dotprompt" ) @@ -228,6 +230,10 @@ func promptDetailFromContent(record promptRecord, content string) (PromptDetail, if err != nil { return PromptDetail{}, err } + spec := &api.Spec{Model: api.Model{ + Name: summary.Model, + Backend: api.Backend(summary.Backend), + }} return PromptDetail{ PromptSummary: summary, Content: content, @@ -235,9 +241,34 @@ func promptDetailFromContent(record promptRecord, content string) (PromptDetail, InputDefault: inspection.InputDefault, OutputSchema: inspection.OutputSchema, Metadata: inspection.Metadata, + Run: PromptRenderRequest{ + Variables: maps.Clone(inspection.InputDefault), + Spec: spec, + Runtimes: promptRunModels(summary.Runtimes), + Chat: len(inspection.OutputSchema) == 0, + }, }, nil } +func promptRunModels(models []api.Model) []api.Model { + if len(models) == 0 { + return nil + } + out := make([]api.Model, len(models)) + for index, model := range models { + out[index] = api.Model{ + Name: model.Name, + ID: model.ID, + Backend: model.Backend, + Temperature: model.Temperature, + Effort: model.Effort, + NoCache: model.NoCache, + Fallbacks: promptRunModels(model.Fallbacks), + } + } + return out +} + func promptSummaryFromContent(record promptRecord, content string) (PromptSummary, error) { tmpl := promptlib.Load(content) req, cfg, err := tmpl.Render(map[string]any{}, nil) diff --git a/pkg/cli/prompt_runtimes_ginkgo_test.go b/pkg/cli/prompt_runtimes_ginkgo_test.go index 7c4bab1..cace1f9 100644 --- a/pkg/cli/prompt_runtimes_ginkgo_test.go +++ b/pkg/cli/prompt_runtimes_ginkgo_test.go @@ -54,6 +54,35 @@ Review the screenshot. )) }) + It("serves the canonical prompt run request with the detail", func() { + record, err := filePromptRecord(path) + Expect(err).NotTo(HaveOccurred()) + + detail, err := promptDetail(record) + + Expect(err).NotTo(HaveOccurred()) + Expect(detail.Run).To(Equal(PromptRenderRequest{ + Variables: map[string]any{}, + Spec: &api.Spec{Model: api.Model{ + Name: "gemini-3.5-flash", + Backend: api.BackendGemini, + }}, + Runtimes: []api.Model{ + { + Name: "gemini-3.5-flash", + Backend: api.BackendGemini, + Effort: api.EffortHigh, + }, + { + Name: "claude-sonnet-5", + Backend: api.BackendAnthropic, + Effort: api.EffortMedium, + }, + }, + Chat: true, + })) + }) + DescribeTable("resolves a discovered prompt by bare filename", func(id string) { ctx := ContextWithPromptDirs(context.Background(), []string{filepath.Dir(path)}) @@ -166,3 +195,34 @@ Review the screenshot. Expect(err).To(MatchError(ContainSubstring("field typo not found"))) }) }) + +var _ = Describe("prompt schema model catalog", func() { + It("keeps one exact runtime row per backend", func() { + models := flatModels([]AdapterStatus{ + { + Backend: string(api.BackendCodexCLI), + Type: "cli", + Authenticated: true, + Binary: "/usr/local/bin/codex", + Models: []string{"gpt-5.6-sol"}, + }, + { + Backend: string(api.BackendCodexCmux), + Type: "cli", + Authenticated: true, + Binary: "/usr/local/bin/codex", + Models: []string{"gpt-5.6-sol"}, + }, + }) + + Expect(models).To(HaveLen(2)) + Expect(models[0]["runtime"]).To(Equal(api.Model{ + Name: "gpt-5.6-sol", + Backend: api.BackendCodexCLI, + })) + Expect(models[1]["runtime"]).To(Equal(api.Model{ + Name: "gpt-5.6-sol", + Backend: api.BackendCodexCmux, + })) + }) +}) diff --git a/pkg/cli/prompt_schema_build.go b/pkg/cli/prompt_schema_build.go index 2f01175..cc8d884 100644 --- a/pkg/cli/prompt_schema_build.go +++ b/pkg/cli/prompt_schema_build.go @@ -256,16 +256,11 @@ func injectSpecConditionals(specMap map[string]any, adapters []AdapterStatus, ar return nil } -// flatModels is a convenience union of every available model across adapters, -// shaped like clicky-ui's ChatModel catalog while retaining the legacy backend -// and ready fields for older consumers. +// flatModels serves one display row per exact Captain runtime. A model exposed +// by multiple backends intentionally appears once per backend so selecting it +// produces a complete api.Model without client-side inference. func flatModels(adapters []AdapterStatus) []map[string]any { - type entry struct { - data map[string]any - backends []string - } - out := []entry{} - positions := map[string]int{} + out := []map[string]any{} for _, a := range adapters { provider := api.CatalogPrefixFor(api.Backend(a.Backend)) for _, model := range flatModelDetails(a) { @@ -273,35 +268,20 @@ func flatModels(adapters []AdapterStatus) []map[string]any { if id == "" { continue } - key := provider + "\x00" + id - if idx, ok := positions[key]; ok { - if !containsString(out[idx].backends, a.Backend) { - out[idx].backends = append(out[idx].backends, a.Backend) - out[idx].data["backends"] = out[idx].backends - } - if a.Ready() { - out[idx].data["configured"] = true - out[idx].data["ready"] = true - } - continue - } label := strings.TrimSpace(model.label) if label == "" { label = id } - backends := []string{a.Backend} - positions[key] = len(out) - out = append(out, entry{ - backends: backends, - data: map[string]any{ - "id": id, - "label": label, - "provider": provider, - "reasoning": modelSupportsReasoning(id), - "configured": a.Ready(), - "backends": backends, - "backend": a.Backend, - "ready": a.Ready(), + out = append(out, map[string]any{ + "id": id, + "label": label, + "provider": provider, + "reasoning": modelSupportsReasoning(id), + "configured": a.Ready(), + "backends": []string{a.Backend}, + "runtime": api.Model{ + Name: id, + Backend: api.Backend(a.Backend), }, }) if len(model.supportedEfforts) > 0 { @@ -309,18 +289,14 @@ func flatModels(adapters []AdapterStatus) []map[string]any { for _, effort := range model.supportedEfforts { values = append(values, string(effort)) } - out[len(out)-1].data["supportedEfforts"] = values + out[len(out)-1]["supportedEfforts"] = values } if model.defaultEffort != api.EffortNone { - out[len(out)-1].data["defaultEffort"] = string(model.defaultEffort) + out[len(out)-1]["defaultEffort"] = string(model.defaultEffort) } } } - flat := make([]map[string]any, 0, len(out)) - for _, item := range out { - flat = append(flat, item.data) - } - return flat + return out } type flatModelDetail struct { diff --git a/pkg/cli/prompt_schema_test.go b/pkg/cli/prompt_schema_test.go index 0f355b6..7d3399a 100644 --- a/pkg/cli/prompt_schema_test.go +++ b/pkg/cli/prompt_schema_test.go @@ -93,7 +93,12 @@ func TestPromptSchemaDocumentBackendsAndConditionals(t *testing.T) { if got, ok := codexModel["configured"].(bool); !ok || got { t.Errorf("flat model configured = %#v, want false for fake unauthenticated CLI", codexModel["configured"]) } - assertSchemaModelBackends(t, codexModel, string(api.BackendCodexCLI), string(api.BackendCodexAgent), string(api.BackendCodexCmux)) + if got := codexModel["runtime"]; !reflect.DeepEqual(got, api.Model{ + Name: "gpt-5.6-sol", + Backend: api.BackendCodexCLI, + }) { + t.Errorf("flat model runtime = %#v, want exact codex-cli runtime", got) + } anthropic := byName[string(api.BackendAnthropic)] if _, hasModels := anthropic["models"]; hasModels { @@ -300,19 +305,6 @@ func schemaModelForBackend(t *testing.T, models []map[string]any, id, backend st return nil } -func assertSchemaModelBackends(t *testing.T, model map[string]any, want ...string) { - t.Helper() - backends, ok := model["backends"].([]string) - if !ok { - t.Fatalf("model backends = %T, want []string", model["backends"]) - } - for _, backend := range want { - if !containsString(backends, backend) { - t.Errorf("model backends = %v, missing %s", backends, backend) - } - } -} - func TestPromptSchemaExampleIsPortable(t *testing.T) { ex := promptSchemaExampleSpec() diff --git a/pkg/cli/webapp/src/PromptRuntimeRows.test.tsx b/pkg/cli/webapp/src/PromptRuntimeRows.test.tsx deleted file mode 100644 index c5ca788..0000000 --- a/pkg/cli/webapp/src/PromptRuntimeRows.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; -import type { ChatModel } from "@flanksource/clicky-ui/chat"; -import { - familiesFromRuntimeCatalog, - type RuntimeCatalogFamily, -} from "@flanksource/clicky-ui/ai"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { PromptRuntimeRows } from "./PromptRuntimeRows"; -import { - addRuntimeRow, - validateRuntimeRows, -} from "./promptRuntimeRowsHelpers"; - -// A model whose capabilities the catalog does not describe, so the row falls -// back to the effort universe the prompt schema serves. -const UNDESCRIBED_MODEL: ChatModel = { - id: "gpt-5.6-sol", - label: "GPT-5.6 Sol", - backends: ["codex-cli"], - provider: "openai", - reasoning: true, -}; - -// What the prompt schema serves once the user disables the cmux mode on -// /whoami: every provider×mode pair the registry knows, with the switched-off -// ones annotated rather than missing. -const SERVED_CATALOG: RuntimeCatalogFamily[] = [ - { - family: "claude", - provider: "anthropic", - catalogPrefix: "anthropic", - modes: [ - { mode: "api", backend: "anthropic" }, - { mode: "agent", backend: "claude-agent" }, - { mode: "cli", backend: "claude-cli" }, - { - mode: "cmux", - backend: "claude-cmux", - disabled: true, - disabledReason: "mode cmux", - }, - ], - }, - { - family: "codex", - provider: "openai", - catalogPrefix: "openai", - modes: [ - { mode: "cli", backend: "codex-cli" }, - { - mode: "cmux", - backend: "codex-cmux", - disabled: true, - disabledReason: "mode cmux", - }, - ], - }, -]; - -const CLI_MODEL: ChatModel = { - id: "claude-sonnet-5", - label: "Claude Sonnet 5", - backends: ["claude-cli"], - provider: "anthropic", - reasoning: true, -}; - -afterEach(cleanup); - -describe("PromptRuntimeRows", () => { - it("offers no cmux mode once the served catalog marks it disabled", () => { - render( - , - ); - - const modes = within( - screen.getByRole("radiogroup", { name: "Runtime mode" }), - ).getAllByRole("radio"); - expect(modes.map((mode) => mode.textContent)).toEqual([ - "API", - "Agent", - "CLI", - ]); - }); - - // The registry has one Anthropic provider with four modes where the offline - // default split it into "Claude" (agent/cli/cmux) and "Anthropic" (api). - it("renders one family per provider rather than one per backend group", () => { - render( - , - ); - - const families = within( - screen.getByRole("radiogroup", { name: "Provider family" }), - ).getAllByRole("radio"); - expect(families.map((family) => family.textContent)).toEqual([ - "Claude", - "Codex", - ]); - }); - - it("offers only the effort tiers the server served", () => { - render( - , - ); - - fireEvent.focus(screen.getByRole("combobox", { name: "Reasoning effort" })); - - expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([ - "None", - "Low", - "Extra high", - ]); - }); - - it("hides the effort control when the server served no tiers", () => { - render( - , - ); - - expect(screen.queryByRole("combobox", { name: "Reasoning effort" })).toBeNull(); - }); - - it("adds a runtime with the primary backend and an intentionally blank model", () => { - expect( - addRuntimeRow([{ backend: "codex-cmux", model: "gpt-5.6-sol" }]), - ).toEqual([ - { backend: "codex-cmux", model: "gpt-5.6-sol" }, - { backend: "codex-cmux" }, - ]); - }); - - it("rejects incomplete and duplicate comparison rows", () => { - expect( - validateRuntimeRows([ - { backend: "codex-cmux", model: "gpt-5.6-sol" }, - { backend: "codex-cmux" }, - ]), - ).toEqual("Runtime 2 needs a model"); - expect( - validateRuntimeRows([ - { backend: "codex-cmux", model: "gpt-5.6-sol", effort: "high" }, - { backend: "codex-cmux", model: "gpt-5.6-sol", effort: "high" }, - ]), - ).toEqual("Runtime 2 duplicates codex-cmux:gpt-5.6-sol:high"); - }); -}); diff --git a/pkg/cli/webapp/src/PromptRuntimeRows.tsx b/pkg/cli/webapp/src/PromptRuntimeRows.tsx deleted file mode 100644 index a580f86..0000000 --- a/pkg/cli/webapp/src/PromptRuntimeRows.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Button } from "@flanksource/clicky-ui/components"; -import { Icon, UiAdd, UiTrash } from "@flanksource/clicky-ui/data"; -import { - RuntimeModePicker, - SPEC_RUNTIME_FAMILIES, - effortOptionsForModel, - familyById, - modelsForFamily, - reconcileModelCapabilities, - selectionForBackend, - type AISpecRuntimeValue, - type SpecRuntimeFamily, -} from "@flanksource/clicky-ui/ai"; -import { - EffortSelector, - ModelSelector, - type ChatModel, -} from "@flanksource/clicky-ui/chat"; -import { - addRuntimeRow, - validateRuntimeRows, -} from "./promptRuntimeRowsHelpers"; -import { backendForRow } from "./promptWorkbenchHelpers"; - -export function PromptRuntimeRows({ - rows, - models, - families = SPEC_RUNTIME_FAMILIES, - efforts: effortUniverse = [], - onChange, -}: { - rows: AISpecRuntimeValue[]; - models: ChatModel[]; - /** - * The runtime catalog the server projected from its model registry, already - * stripped of the backends the user disabled. Falling back to the offline - * default would re-offer them, so the schema query is the only source. - */ - families?: SpecRuntimeFamily[]; - /** - * Fallback tiers for a model whose catalog entry carries no supportedEfforts. - * The prompt schema serves this and has already dropped disabled tiers, so an - * empty list means the server said nothing — not that every tier is off. - */ - efforts?: string[]; - onChange: (rows: AISpecRuntimeValue[]) => void; -}) { - const error = validateRuntimeRows(rows); - const update = (index: number, value: AISpecRuntimeValue) => - onChange(rows.map((row, rowIndex) => (rowIndex === index ? value : row))); - - return ( -
- {rows.map((row, index) => { - const backend = backendForRow(row, models); - const selection = selectionForBackend(families, backend); - const family = familyById(families, selection.family); - const availableModels = modelsForFamily(models, family, backend); - const selectedModel = models.find((model) => model.id === row.model); - const efforts = effortOptionsForModel(selectedModel, effortUniverse); - return ( -
-
- - Runtime {index + 1} - - {index > 0 && ( - - )} -
- update(index, value)} - models={models} - families={families} - /> -
- - {efforts.length > 0 && ( - - )} -
-
- ); - })} -
- - {rows.length > 1 && error && ( - {error} - )} -
-
- ); -} diff --git a/pkg/cli/webapp/src/PromptWorkbench.test.ts b/pkg/cli/webapp/src/PromptWorkbench.test.ts index 0d15c4c..391556b 100644 --- a/pkg/cli/webapp/src/PromptWorkbench.test.ts +++ b/pkg/cli/webapp/src/PromptWorkbench.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - promptOptions, - runtimeModelsPayload, - runtimeRowsFromPrompt, -} from "./promptWorkbenchHelpers"; +import { promptOptions } from "./promptWorkbenchHelpers"; function prompt( name: string, @@ -88,63 +84,3 @@ describe("promptOptions", () => { ]); }); }); - -describe("runtimeRowsFromPrompt", () => { - it("uses prompt-declared runtimes as the initial comparison rows", () => { - expect( - runtimeRowsFromPrompt({ - ...prompt("compare", "local"), - runtimes: [ - { - model: "gemini-3.5-flash", - backend: "gemini", - effort: "high", - }, - { - model: "claude-sonnet-5", - backend: "anthropic", - effort: "medium", - }, - ], - }), - ).toEqual([ - { - model: "gemini-3.5-flash", - backend: "gemini", - effort: "high", - }, - { - model: "claude-sonnet-5", - backend: "anthropic", - effort: "medium", - }, - ]); - }); - - it("preserves model options when declared rows become a run override", () => { - expect( - runtimeModelsPayload( - [ - { - model: "gemini-3.5-flash", - backend: "gemini", - effort: "high", - temperature: 0, - noCache: true, - fallbacks: [{ model: "gemini-3-flash" }], - }, - ], - [], - ), - ).toEqual([ - { - model: "gemini-3.5-flash", - backend: "gemini", - effort: "high", - temperature: 0, - noCache: true, - fallbacks: [{ model: "gemini-3-flash" }], - }, - ]); - }); -}); diff --git a/pkg/cli/webapp/src/PromptWorkbench.tsx b/pkg/cli/webapp/src/PromptWorkbench.tsx index b4d9c5c..7f5cba6 100644 --- a/pkg/cli/webapp/src/PromptWorkbench.tsx +++ b/pkg/cli/webapp/src/PromptWorkbench.tsx @@ -1,10 +1,9 @@ -import { useMemo, useReducer, useState, type ReactNode } from "react"; +import { useMemo, useReducer, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { AppShell, Button, Combobox, - Modal, SegmentedControl, Tabs, type AppShellProps, @@ -22,18 +21,14 @@ import { UiListTree, UiPlay, UiRefresh, - UiSave, UiTerminal, UiTrash, } from "@flanksource/clicky-ui/data"; -import "@flanksource/clicky-ui/mdx-editor.css"; -import { MdxEditorField } from "@flanksource/clicky-ui/mdx-editor"; import { PromptRunEditor, - buildAISpecRuntimePayload, familiesFromRuntimeCatalog, + type AIPromptRunValue, type AISpecRuntimePermissionCatalog, - type AISpecRuntimeValue, type RuntimeCatalogFamily, type ToolMeta, } from "@flanksource/clicky-ui/ai"; @@ -46,8 +41,6 @@ import { apiClient } from "./api"; import { PromptRunStream } from "./PromptRunStream"; import { PromptBatchInspector } from "./PromptBatchInspector"; import { PromptSchemaEditor } from "./PromptSchemaEditor"; -import { PromptRuntimeRows } from "./PromptRuntimeRows"; -import { validateRuntimeRows } from "./promptRuntimeRowsHelpers"; import { RunningPromptsBadge, RunningPromptsRunsTab } from "./RunningPrompts"; import { isPromptBatchHandle, @@ -60,30 +53,25 @@ import { requiredOperation, resolvePromptOps, unwrapResponse, + type PromptDetail, type PromptSourceFilter, type PromptSummary, } from "./promptData"; import type { PromptSchemaKind } from "./promptSchemaSource"; +import { promptOptions } from "./promptWorkbenchHelpers"; import { - normalizeRuntimeModel, - promptOptions, - runtimeModelsPayload, - runtimeRowsFromPrompt, -} from "./promptWorkbenchHelpers"; + PromptSourceMarkdownEditor, + PromptWriteAction, + PromptWriteModal, + type PromptWriteInput, + type PromptWriteMode, +} from "./PromptWriteModal"; type Navigate = (to: string, opts?: { replace?: boolean }) => void; type SourceFilter = PromptSourceFilter; type DetailTab = "source" | "runner" | "schema" | "runs"; -type PromptDetail = PromptSummary & { - content: string; - inputSchema?: Record; - inputDefault?: Record; - outputSchema?: Record; - metadata?: Record; -}; - type PromptPreviewResult = { id: string; name: string; @@ -113,7 +101,11 @@ const SOURCE_OPTIONS = [ { id: "local", label: "Local" }, ] satisfies Array<{ id: SourceFilter; label: string }>; -const EMPTY_RUNTIME: AISpecRuntimeValue = { budget: { timeout: "2h" } }; +const EMPTY_RUN_REQUEST: AIPromptRunValue = { + variables: {}, + spec: { budget: { timeout: "2h" } }, + chat: true, +}; const EMPTY_PROMPTS: PromptSummary[] = []; const EMPTY_MODELS: ChatModel[] = []; const SCRATCH_PROMPT_ID = "__scratch__"; @@ -128,6 +120,7 @@ const SCRATCH_PROMPT: PromptDetail = { relPath: "scratch.prompt", writable: false, content: "", + run: EMPTY_RUN_REQUEST, }; const AGENT_TOOLS = [ @@ -220,11 +213,9 @@ const AGENT_TOOLS = [ type PromptDetailState = { detailId?: string; draft: string; - variables: Record; + runRequest: AIPromptRunValue; variablesValid: boolean; schemaValidity: Record; - runtime: AISpecRuntimeValue; - additionalRuntimes: AISpecRuntimeValue[]; previewResult?: PromptPreviewResult; activeRunID?: string; activeBatch?: PromptBatchHandle; @@ -234,7 +225,7 @@ type PromptDetailState = { type PromptDetailStateAction = | { type: "draft"; detail?: PromptDetail; value: string } - | { type: "variables"; detail?: PromptDetail; value: Record } + | { type: "run-request"; detail?: PromptDetail; value: AIPromptRunValue } | { type: "variables-validity"; detail?: PromptDetail; value: boolean } | { type: "schema-validity"; @@ -242,12 +233,6 @@ type PromptDetailStateAction = kind: PromptSchemaKind; value: boolean; } - | { type: "runtime"; detail?: PromptDetail; value: AISpecRuntimeValue } - | { - type: "runtime-rows"; - detail?: PromptDetail; - value: AISpecRuntimeValue[]; - } | { type: "preview-result"; detail?: PromptDetail; @@ -271,8 +256,8 @@ function promptDetailReducer( switch (action.type) { case "draft": return { ...current, draft: action.value }; - case "variables": - return { ...current, variables: action.value }; + case "run-request": + return { ...current, runRequest: action.value }; case "variables-validity": return { ...current, variablesValid: action.value }; case "schema-validity": @@ -283,14 +268,6 @@ function promptDetailReducer( [action.kind]: action.value, }, }; - case "runtime": - return { ...current, runtime: action.value }; - case "runtime-rows": - return { - ...current, - runtime: action.value[0] ?? {}, - additionalRuntimes: action.value.slice(1), - }; case "preview-result": return { ...current, previewResult: action.value }; case "active-run": @@ -307,15 +284,16 @@ function promptDetailReducer( } function initialPromptDetailState(detail?: PromptDetail): PromptDetailState { - const runtimeRows = detail ? runtimeRowsFromPrompt(detail) : []; + const runRequest = detail?.run ?? EMPTY_RUN_REQUEST; return { detailId: detail?.id, draft: detail?.content ?? "", - variables: detail?.inputDefault ?? {}, + runRequest: { + ...runRequest, + spec: { ...EMPTY_RUN_REQUEST.spec, ...runRequest.spec }, + }, variablesValid: true, schemaValidity: { input: true, output: true }, - runtime: { ...EMPTY_RUNTIME, ...runtimeRows[0] }, - additionalRuntimes: runtimeRows.slice(1), previewResult: undefined, activeRunID: undefined, activeBatch: undefined, @@ -359,7 +337,7 @@ function usePromptWorkbenchView({ undefined, () => initialPromptDetailState(), ); - const [createOpen, setCreateOpen] = useState(false); + const [writeMode, setWriteMode] = useState(); const listQuery = useQuery({ queryKey: [ @@ -415,10 +393,6 @@ function usePromptWorkbenchView({ const detail = activePromptId ? detailQuery.data : SCRATCH_PROMPT; const selected = detail ?? selectedSummary; const selectedDetailState = promptDetailStateFor(detailState, detail); - const runtimeRows = [ - selectedDetailState.runtime, - ...selectedDetailState.additionalRuntimes, - ]; const scratch = isScratchPrompt(detail); const writableSources = useMemo( () => uniqueWritableSources(prompts), @@ -427,10 +401,19 @@ function usePromptWorkbenchView({ const canSave = Boolean( detail && !scratch && + detail.writable && promptOps.update && selectedDetailState.schemaValidity.input && selectedDetailState.schemaValidity.output && - (detail.writable ? selectedDetailState.draft !== detail.content : true), + selectedDetailState.draft !== detail.content, + ); + const canSaveAs = Boolean( + detail && + !scratch && + !detail.writable && + promptOps.create && + selectedDetailState.schemaValidity.input && + selectedDetailState.schemaValidity.output, ); const hasSelection = Boolean(detail || activePromptId); const operationsReady = Boolean( @@ -443,7 +426,7 @@ function usePromptWorkbenchView({ } async function saveDraft() { - if (!detail || scratch || !promptOps.update) return; + if (!detail?.writable || scratch || !promptOps.update) return; dispatchDetailState({ type: "action-error", detail, value: undefined }); dispatchDetailState({ type: "action-loading", detail, value: "save" }); try { @@ -453,14 +436,8 @@ function usePromptWorkbenchView({ { content: selectedDetailState.draft }, ); await listQuery.refetch(); - if (saved.id === detail.id) { - dispatchDetailState({ type: "saved", detail, content: saved.content }); - await detailQuery.refetch(); - } else { - // Saving a read-only (embedded) prompt forks it to a local copy; - // switch to the new writable prompt. - onNavigate(`/prompts/${encodeURIComponent(saved.id)}`); - } + dispatchDetailState({ type: "saved", detail, content: saved.content }); + await detailQuery.refetch(); } catch (error) { dispatchDetailState({ type: "action-error", @@ -472,6 +449,18 @@ function usePromptWorkbenchView({ } } + async function createPromptCopy(input: PromptWriteInput) { + const create = requiredOperation(promptOps.create, "create"); + const created = await submitPromptOperation( + create, + {}, + { ...input }, + ); + setWriteMode(undefined); + await listQuery.refetch(); + onNavigate(`/prompts/${encodeURIComponent(created.id)}`); + } + async function previewPrompt() { if (!detail || !promptOps.preview) return; dispatchDetailState({ type: "action-error", detail, value: undefined }); @@ -480,10 +469,7 @@ function usePromptWorkbenchView({ const preview = await submitPromptOperation( promptOps.preview, promptActionParams(detail), - { - variables: selectedDetailState.variables, - ...runtimePayload(selectedDetailState.runtime, models), - }, + selectedDetailState.runRequest, ); dispatchDetailState({ type: "preview-result", detail, value: preview }); dispatchDetailState({ type: "active-run", detail, value: undefined }); @@ -507,14 +493,7 @@ function usePromptWorkbenchView({ const handle = await submitPromptOperation( promptOps.run, promptActionParams(detail), - { - variables: selectedDetailState.variables, - ...runtimePayload(selectedDetailState.runtime, models), - ...(runtimeRows.length > 1 - ? { runtimes: runtimeModelsPayload(runtimeRows, models) } - : {}), - chat: promptChatEligible(detail, selectedDetailState.runtime), - }, + selectedDetailState.runRequest, ); dispatchDetailState({ type: "preview-result", detail, value: undefined }); if (isPromptBatchHandle(handle)) { @@ -560,169 +539,170 @@ function usePromptWorkbenchView({ } return ( - Captain} - navSections={navSections} - collapsedStorageKey={CAPTAIN_SIDEBAR_COLLAPSE_KEY} - actions={actions} - search={search} - bodySidebar={ - onNavigate(`/prompts/${encodeURIComponent(id)}`)} - onRefresh={() => void refreshAll()} - onCreate={() => setCreateOpen(true)} - /> - } - bodyHeader={ - - } - bodyActions={ -
- { - dispatchDetailState({ - type: "active-batch", - detail, - value: undefined, - }); - dispatchDetailState({ type: "active-run", detail, value: id }); - setTab("runner"); - }} + <> + Captain
} + navSections={navSections} + collapsedStorageKey={CAPTAIN_SIDEBAR_COLLAPSE_KEY} + actions={actions} + search={search} + bodySidebar={ + onNavigate(`/prompts/${encodeURIComponent(id)}`)} + onRefresh={() => void refreshAll()} + onCreate={() => setWriteMode("create")} /> - {detail?.writable && !scratch && promptOps.delete && ( - - )} - {detail && !scratch && promptOps.update && ( + } + bodyHeader={ + + } + bodyActions={ +
+ { + dispatchDetailState({ + type: "active-batch", + detail, + value: undefined, + }); + dispatchDetailState({ type: "active-run", detail, value: id }); + setTab("runner"); + }} + /> + {detail?.writable && !scratch && promptOps.delete && ( + + )} + {detail && + !scratch && + ((detail.writable && promptOps.update) || + (!detail.writable && promptOps.create)) && ( + { + if (detail.writable) { + void saveDraft(); + } else { + setWriteMode("save-as"); + } + }} + /> + )} - )} - -
- } - bodySplit={30} - contentClassName="p-0 overflow-hidden" - > - setTab(next as DetailTab)} - draft={selectedDetailState.draft} - onDraftChange={(value) => - dispatchDetailState({ type: "draft", detail, value }) - } - onSchemaValidityChange={(kind, value) => - dispatchDetailState({ - type: "schema-validity", - detail, - kind, - value, - }) - } - variables={selectedDetailState.variables} - variablesValid={selectedDetailState.variablesValid} - onVariablesChange={(value) => - dispatchDetailState({ type: "variables", detail, value }) - } - onVariablesValidityChange={(value) => - dispatchDetailState({ type: "variables-validity", detail, value }) - } - runtime={selectedDetailState.runtime} - onRuntimeChange={(value) => - dispatchDetailState({ type: "runtime", detail, value }) - } - runtimeRows={runtimeRows} - onRuntimeRowsChange={(value) => - dispatchDetailState({ type: "runtime-rows", detail, value }) - } - models={models} - promptSchema={promptSchemaQuery.data} - tools={AGENT_TOOLS} - permissionCatalog={permissionCatalogQuery.data} - previewResult={selectedDetailState.previewResult} - activeRunID={selectedDetailState.activeRunID} - activeBatch={selectedDetailState.activeBatch} - onEditBatch={() => - dispatchDetailState({ - type: "active-batch", - detail, - value: undefined, - }) + } - onSelectRun={(id) => { - dispatchDetailState({ - type: "active-batch", - detail, - value: undefined, - }); - dispatchDetailState({ type: "active-run", detail, value: id }); - if (id) setTab("runner"); - }} - onPreview={() => void previewPrompt()} - onRun={() => void runPrompt()} - previewLoading={selectedDetailState.actionLoading === "preview"} - runLoading={selectedDetailState.actionLoading === "run"} - previewEnabled={Boolean(promptOps.preview && detail)} - runEnabled={Boolean(promptOps.run && detail)} - /> - setCreateOpen(false)} + bodySplit={30} + contentClassName="p-0 overflow-hidden" + > + setTab(next as DetailTab)} + draft={selectedDetailState.draft} + onDraftChange={(value) => + dispatchDetailState({ type: "draft", detail, value }) + } + onSchemaValidityChange={(kind, value) => + dispatchDetailState({ + type: "schema-validity", + detail, + kind, + value, + }) + } + variablesValid={selectedDetailState.variablesValid} + onVariablesValidityChange={(value) => + dispatchDetailState({ type: "variables-validity", detail, value }) + } + runRequest={selectedDetailState.runRequest} + onRunRequestChange={(value) => + dispatchDetailState({ type: "run-request", detail, value }) + } + models={models} + promptSchema={promptSchemaQuery.data} + tools={AGENT_TOOLS} + permissionCatalog={permissionCatalogQuery.data} + previewResult={selectedDetailState.previewResult} + activeRunID={selectedDetailState.activeRunID} + activeBatch={selectedDetailState.activeBatch} + onEditBatch={() => + dispatchDetailState({ + type: "active-batch", + detail, + value: undefined, + }) + } + onSelectRun={(id) => { + dispatchDetailState({ + type: "active-batch", + detail, + value: undefined, + }); + dispatchDetailState({ type: "active-run", detail, value: id }); + if (id) setTab("runner"); + }} + onPreview={() => void previewPrompt()} + onRun={() => void runPrompt()} + previewLoading={selectedDetailState.actionLoading === "preview"} + runLoading={selectedDetailState.actionLoading === "run"} + previewEnabled={Boolean(promptOps.preview && detail)} + runEnabled={Boolean(promptOps.run && detail)} + /> +
+ setWriteMode(undefined)} sources={writableSources} - createOp={promptOps.create} - seedContent={scratch ? undefined : detail?.content} - onCreated={(prompt) => { - setCreateOpen(false); - void listQuery.refetch(); - onNavigate(`/prompts/${encodeURIComponent(prompt.id)}`); - }} + onSubmit={createPromptCopy} + {...(writeMode === "save-as" && detail + ? { + initialName: detail.name, + initialContent: selectedDetailState.draft, + } + : !scratch && detail + ? { initialContent: detail.content } + : {})} /> - - ); -} - -function promptChatEligible(detail: PromptDetail, runtime: AISpecRuntimeValue) { - return ( - !detail.outputSchema && - !runtime.workflow?.verify && - !runtime.workflow?.commits?.length + ); } @@ -913,14 +893,10 @@ function PromptDetailPane({ draft, onDraftChange, onSchemaValidityChange, - variables, variablesValid, - onVariablesChange, onVariablesValidityChange, - runtime, - onRuntimeChange, - runtimeRows, - onRuntimeRowsChange, + runRequest, + onRunRequestChange, models, promptSchema, tools, @@ -946,14 +922,10 @@ function PromptDetailPane({ draft: string; onDraftChange: (value: string) => void; onSchemaValidityChange: (kind: PromptSchemaKind, valid: boolean) => void; - variables: Record; variablesValid: boolean; - onVariablesChange: (value: Record) => void; onVariablesValidityChange: (valid: boolean) => void; - runtime: AISpecRuntimeValue; - onRuntimeChange: (value: AISpecRuntimeValue) => void; - runtimeRows: AISpecRuntimeValue[]; - onRuntimeRowsChange: (value: AISpecRuntimeValue[]) => void; + runRequest: AIPromptRunValue; + onRunRequestChange: (value: AIPromptRunValue) => void; models: ChatModel[]; promptSchema?: PromptSchemaDoc; tools: ToolMeta[]; @@ -1010,16 +982,15 @@ function PromptDetailPane({ ? undefined : normalizeObjectSchema(detail.inputSchema); const backendCliArgs = promptSchema?.backends?.find( - (backend) => backend.backend === runtime.backend, + (backend) => backend.backend === runRequest.spec?.backend, )?.args; // The picker's families come from the same document as its models, so a // backend the user disabled is absent from both. const runtimeFamilies = familiesFromRuntimeCatalog(promptSchema?.runtimes); const promptReady = !scratch || - Boolean(runtime.prompt?.user?.trim()) || - Boolean(runtime.prompt?.attachments?.length); - const runtimeRowsError = validateRuntimeRows(runtimeRows); + Boolean(runRequest.spec?.prompt?.user?.trim()) || + Boolean(runRequest.spec?.prompt?.attachments?.length); return (
@@ -1058,23 +1029,12 @@ function PromptDetailPane({
- } + value={runRequest} + onChange={onRunRequestChange} families={runtimeFamilies} models={promptSelectableModels(models)} tools={tools} secretSelector={CAPTAIN_SECRET_SELECTOR} - variables={variables} - onVariablesChange={onVariablesChange} onVariablesValidityChange={onVariablesValidityChange} enableAttachments {...(permissionCatalog ? { permissionCatalog } : {})} @@ -1111,7 +1071,6 @@ function PromptDetailPane({ disabled={ !runEnabled || !promptReady || - Boolean(runtimeRowsError) || (!schema && !variablesValid) } onClick={onRun} @@ -1146,8 +1105,8 @@ function SourceEditor({
{!detail.writable && (
- This is an embedded prompt. Saving your edits creates a local, - editable copy. + This is an embedded prompt. Use Save as… to create a local, editable + copy.
)} void; - readOnly?: boolean; - minHeight: string | number; -}) { - return ( -
-
- {label} -
-
- -
-
- ); -} - -function Field({ label, children }: { label: string; children: ReactNode }) { - return ( - - ); -} - function RunnerOutput({ previewResult, activeRunID, @@ -1250,151 +1152,6 @@ function RunnerOutput({ ); } -function CreatePromptModal({ - open, - ...props -}: { - open: boolean; - onClose: () => void; - sources: Array<{ id: string; label: string }>; - createOp?: ResolvedOperation; - seedContent?: string; - onCreated: (prompt: PromptDetail) => void; -}) { - if (!open) return null; - return ; -} - -function CreatePromptModalForm({ - onClose, - sources, - createOp, - seedContent, - onCreated, -}: { - onClose: () => void; - sources: Array<{ id: string; label: string }>; - createOp?: ResolvedOperation; - seedContent?: string; - onCreated: (prompt: PromptDetail) => void; -}) { - const [name, setName] = useState(""); - const [relPath, setRelPath] = useState(""); - const [target, setTarget] = useState(() => sources[0]?.id ?? ""); - const [content, setContent] = useState( - () => seedContent || defaultPromptContent(""), - ); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(); - - async function submit() { - if (!createOp) return; - setLoading(true); - setError(undefined); - try { - const created = await submitPromptOperation( - createOp, - {}, - { - target, - name, - relPath, - content, - }, - ); - onCreated(created); - } catch (err) { - setError(errorMessage(err)); - } finally { - setLoading(false); - } - } - - return ( - - - -
- } - > -
- {error &&
{error}
} -
- - { - const next = event.target.value; - setName(next); - if (!relPath) setContent(defaultPromptContent(next)); - }} - className="h-control-h w-full rounded-md border border-border bg-background px-density-3 text-sm outline-none focus:ring-2 focus:ring-ring" - /> - - - setRelPath(event.target.value)} - className="h-control-h w-full rounded-md border border-border bg-background px-density-3 text-sm outline-none focus:ring-2 focus:ring-ring" - placeholder="name.prompt" - /> - - - - -
- -
- - ); -} - -function createPromptModalKey({ - seedContent, - sources, -}: { - seedContent?: string; - sources: Array<{ id: string; label: string }>; -}) { - return `${sources[0]?.id ?? ""}:${seedContent ?? ""}`; -} - async function fetchPermissionCatalog() { const response = await fetch("/api/captain/ai/permissions/catalog", { headers: { Accept: "application/json" }, @@ -1579,67 +1336,12 @@ function normalizeObjectSchema( } as JsonSchemaObject; } -// runtime is the single source of truth: the inline PromptRunEditor and its -// "Edit spec" modal both edit this one AISpecRuntimeValue, so the payload is -// just the compacted spec (plus catalog model/backend normalization). -function runtimePayload(runtime: AISpecRuntimeValue, models: ChatModel[]) { - return normalizeSpecRuntimePayload( - buildAISpecRuntimePayload(runtime), - models, - runtime.backend, - ); -} - -function normalizeSpecRuntimePayload( - payload: Record, - models: ChatModel[], - backend?: string, -) { - const spec = payload.spec; - if (!spec || typeof spec !== "object" || Array.isArray(spec)) return payload; - const specRecord = { ...(spec as Record) }; - if (typeof specRecord.model === "string") { - const selected = normalizeRuntimeModel( - specRecord.model, - models, - typeof specRecord.backend === "string" ? specRecord.backend : backend, - ); - if (selected.model && selected.model !== specRecord.model) { - if (typeof specRecord.id !== "string" || !specRecord.id.trim()) { - specRecord.id = specRecord.model; - } - specRecord.model = selected.model; - } - if ( - selected.backend && - (typeof specRecord.backend !== "string" || !specRecord.backend.trim()) - ) { - specRecord.backend = selected.backend; - } - } - return { ...payload, spec: specRecord }; -} - function promptSelectableModels(models: ChatModel[]) { return models.map((model) => model.configured === false ? { ...model, configured: true } : model, ); } -function defaultPromptContent(name: string) { - const promptName = name.trim() || "new prompt"; - return `--- -name: ${JSON.stringify(promptName)} -description: "" -input: - schema: - input: string ---- -{{role "user"}} -{{input}} -`; -} - function errorMessage(error: unknown) { if (error instanceof Error) return error.message; if (typeof error === "string") return error; diff --git a/pkg/cli/webapp/src/PromptWriteModal.test.tsx b/pkg/cli/webapp/src/PromptWriteModal.test.tsx new file mode 100644 index 0000000..29a691b --- /dev/null +++ b/pkg/cli/webapp/src/PromptWriteModal.test.tsx @@ -0,0 +1,105 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PromptWriteAction, PromptWriteModal } from "./PromptWriteModal"; + +vi.mock("@flanksource/clicky-ui/mdx-editor", () => ({ + MdxEditorField: ({ + value, + onChange, + }: { + value: string; + onChange?: (value: string) => void; + }) => ( +