From 42ac783a8abdfdca10d5f0ca313764ddf72f54e7 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 15 Jan 2026 15:22:00 +0200 Subject: [PATCH 01/15] chore: refactoring for req dumps Signed-off-by: Danny Kopping --- provider/openai.go | 1 - 1 file changed, 1 deletion(-) diff --git a/provider/openai.go b/provider/openai.go index 951770d9..c321eb8e 100644 --- a/provider/openai.go +++ b/provider/openai.go @@ -44,7 +44,6 @@ func NewOpenAI(cfg config.OpenAI) *OpenAI { if cfg.APIDumpDir == "" { cfg.APIDumpDir = os.Getenv("BRIDGE_DUMP_DIR") } - if cfg.CircuitBreaker != nil { cfg.CircuitBreaker.OpenErrorResponse = openAIOpenErrorResponse } From 9d690176e5463c73ac5760e5ffc3524d17f97825 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 15 Jan 2026 15:49:29 +0200 Subject: [PATCH 02/15] feat: tool injection working Signed-off-by: Danny Kopping --- intercept/responses/base.go | 44 +++++++++++++++++++++++++++++++++ intercept/responses/blocking.go | 2 ++ 2 files changed, 46 insertions(+) diff --git a/intercept/responses/base.go b/intercept/responses/base.go index 923c7542..c8d1cd6b 100644 --- a/intercept/responses/base.go +++ b/intercept/responses/base.go @@ -24,6 +24,7 @@ import ( "github.com/coder/aibridge/tracing" "github.com/coder/quartz" "github.com/google/uuid" + "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" oaiconst "github.com/openai/openai-go/v3/shared/constant" @@ -112,6 +113,49 @@ func (i *responsesInterceptionBase) validateRequest(ctx context.Context, w http. return nil } +func (i *responsesInterceptionBase) injectTools() { + if i.req == nil || i.mcpProxy == nil { + return + } + + tools := i.mcpProxy.ListTools() + if len(tools) == 0 { + return + } + + // Inject tools. + for _, tool := range i.mcpProxy.ListTools() { + params := map[string]any{ + "type": "object", + "properties": tool.Params, + // "additionalProperties": false, // Only relevant when strict=true. + } + + // Otherwise the request fails with "None is not of type 'array'" if a nil slice is given. + if len(tool.Required) > 0 { + // Must list ALL properties when strict=true. + params["required"] = tool.Required + } + + fn := responses.ToolUnionParam{ + OfFunction: &responses.FunctionToolParam{ + Name: tool.ID, + Strict: openai.Bool(false), // TODO: configurable. + Description: openai.String(tool.Description), + Parameters: params, + }, + } + + i.req.Tools = append(i.req.Tools, fn) + } + + var err error + i.reqPayload, err = sjson.SetBytes(i.reqPayload, "tools", i.req.Tools) + if err != nil { + i.logger.Warn(context.Background(), "failed to set tools", slog.Error(err)) + } +} + // sendCustomErr sends custom responses.Error error to the client // it should only be called before any data is sent back to the client func (i *responsesInterceptionBase) sendCustomErr(ctx context.Context, w http.ResponseWriter, code int, err error) { diff --git a/intercept/responses/blocking.go b/intercept/responses/blocking.go index dd909fac..8b239790 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -49,6 +49,8 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * srv := i.newResponsesService() var respCopy responseCopier + i.injectTools() + opts := i.requestOptions(&respCopy) response, upstreamErr := srv.New(ctx, i.req.ResponseNewParams, opts...) From f3b94757797be6fca4c0d5974dfa24683e1fe83f Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 16 Jan 2026 18:16:25 +0200 Subject: [PATCH 03/15] WIP Signed-off-by: Danny Kopping --- intercept/responses/base.go | 43 +++-- intercept/responses/blocking.go | 263 +++++++++++++++++++++++++++++-- intercept/responses/streaming.go | 4 +- provider/openai.go | 4 +- 4 files changed, 284 insertions(+), 30 deletions(-) diff --git a/intercept/responses/base.go b/intercept/responses/base.go index c8d1cd6b..41d35b68 100644 --- a/intercept/responses/base.go +++ b/intercept/responses/base.go @@ -27,10 +27,11 @@ import ( "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" - oaiconst "github.com/openai/openai-go/v3/shared/constant" + "github.com/openai/openai-go/v3/shared/constant" "github.com/tidwall/gjson" "github.com/tidwall/sjson" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) const ( @@ -43,6 +44,7 @@ type responsesInterceptionBase struct { reqPayload []byte cfg config.OpenAI model string + tracer trace.Tracer recorder recorder.Recorder mcpProxy mcp.ServerProxier logger slog.Logger @@ -98,18 +100,6 @@ func (i *responsesInterceptionBase) validateRequest(ctx context.Context, w http. return err } - // keeping the same logic for 'parallel_tool_calls' as in chat-completions - // https://github.com/coder/aibridge/blob/7535a71e91a1d214a31a9b59bb810befb26141bc/intercept/chatcompletions/streaming.go#L99 - if len(i.req.Tools) > 0 { - var err error - i.reqPayload, err = sjson.SetBytes(i.reqPayload, "parallel_tool_calls", false) - if err != nil { - err = fmt.Errorf("failed set parallel_tool_calls parameter: %w", err) - i.sendCustomErr(ctx, w, http.StatusInternalServerError, err) - return err - } - } - return nil } @@ -123,6 +113,16 @@ func (i *responsesInterceptionBase) injectTools() { return } + // TODO: implement parallel tool calls. + // Disable parallel tool calls to simplify inner agentic loop; best-effort. + if len(tools) > 0 { + var err error + i.reqPayload, err = sjson.SetBytes(i.reqPayload, "parallel_tool_calls", false) + if err != nil { + i.logger.Warn(context.Background(), "failed to disable parallel_tool_calls", slog.Error(err)) + } + } + // Inject tools. for _, tool := range i.mcpProxy.ListTools() { params := map[string]any{ @@ -211,6 +211,15 @@ func (i *responsesInterceptionBase) lastUserPrompt() (string, error) { return i.req.Input.OfString.Value, nil } + // If the input list is a slice, check if the final message has a "user" role. + if count := len(i.req.Input.OfInputItemList); count > 0 { + last := i.req.Input.OfInputItemList[count-1] + if last.OfInputMessage == nil || last.OfInputMessage.Role != string(constant.ValueOf[constant.User]()) { + // The last message was not user-supplied. + return "", nil + } + } + // Fallback to parsing original bytes since golang SDK doesn't properly decode 'Input' field. // If 'type' field of input item is not set it will be omitted from 'Input.OfInputItemList' // It is an optional field according to API: https://platform.openai.com/docs/api-reference/responses/create#responses_create-input-input_item_list-input_message @@ -218,7 +227,7 @@ func (i *responsesInterceptionBase) lastUserPrompt() (string, error) { inputItems := gjson.GetBytes(i.reqPayload, "input").Array() for i := len(inputItems) - 1; i >= 0; i-- { item := inputItems[i] - if item.Get("role").Str == "user" { + if item.Get("role").Str == string(constant.ValueOf[constant.User]()) { var sb strings.Builder // content can be a string or array of objects: @@ -248,8 +257,8 @@ func (i *responsesInterceptionBase) recordUserPrompt(ctx context.Context, respon return } + // No prompt found: last request was not human-initiated. if prompt == "" { - i.logger.Warn(ctx, "got empty last prompt, skipping prompt recording") return } @@ -279,9 +288,9 @@ func (i *responsesInterceptionBase) recordToolUsage(ctx context.Context, respons // recording other function types to be considered: https://github.com/coder/aibridge/issues/121 switch item.Type { - case string(oaiconst.ValueOf[oaiconst.FunctionCall]()): + case string(constant.ValueOf[constant.FunctionCall]()): args = i.parseFunctionCallJSONArgs(ctx, item.Arguments) - case string(oaiconst.ValueOf[oaiconst.CustomToolCall]()): + case string(constant.ValueOf[constant.CustomToolCall]()): args = item.Input default: continue diff --git a/intercept/responses/blocking.go b/intercept/responses/blocking.go index 8b239790..8811c247 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -1,22 +1,33 @@ package responses import ( + "context" + "encoding/json" "errors" + "fmt" "net/http" + "strings" + "time" "cdr.dev/slog/v3" "github.com/coder/aibridge/config" "github.com/coder/aibridge/mcp" "github.com/coder/aibridge/recorder" "github.com/google/uuid" + "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/packages/param" + "github.com/openai/openai-go/v3/responses" + "github.com/openai/openai-go/v3/shared/constant" + "github.com/tidwall/sjson" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type BlockingResponsesInterceptor struct { responsesInterceptionBase } -func NewBlockingInterceptor(id uuid.UUID, req *ResponsesNewParamsWrapper, reqPayload []byte, cfg config.OpenAI, model string) *BlockingResponsesInterceptor { +func NewBlockingInterceptor(id uuid.UUID, req *ResponsesNewParamsWrapper, reqPayload []byte, cfg config.OpenAI, model string, tracer trace.Tracer) *BlockingResponsesInterceptor { return &BlockingResponsesInterceptor{ responsesInterceptionBase: responsesInterceptionBase{ id: id, @@ -24,6 +35,7 @@ func NewBlockingInterceptor(id uuid.UUID, req *ResponsesNewParamsWrapper, reqPay reqPayload: reqPayload, cfg: cfg, model: model, + tracer: tracer, }, } } @@ -46,21 +58,59 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * return err } - srv := i.newResponsesService() - var respCopy responseCopier - i.injectTools() - opts := i.requestOptions(&respCopy) - response, upstreamErr := srv.New(ctx, i.req.ResponseNewParams, opts...) + var ( + response *responses.Response + upstreamErr error + respCopy responseCopier + ) + + for { + srv := i.newResponsesService() + respCopy = responseCopier{} + + opts := i.requestOptions(&respCopy) + opts = append(opts, option.WithRequestTimeout(time.Second*600)) + response, upstreamErr = srv.New(ctx, i.req.ResponseNewParams, opts...) + + if upstreamErr != nil { + break + } - // response could be nil eg. fixtures/openai/responses/blocking/wrong_response_format.txtar - if response != nil { + // response could be nil eg. fixtures/openai/responses/blocking/wrong_response_format.txtar + if response == nil { + break + } + + // Record prompt usage on first successful response. i.recordUserPrompt(ctx, response.ID) i.recordToolUsage(ctx, response) i.recordTokenUsage(ctx, response) - } else { - i.logger.Warn(ctx, "got empty response, skipping prompt, tool usage and token usage recording") + + // Invoke any injected function calls. + // The Responses API refers to what we call "tools" as "functions", so we keep the terminology + // consistent in this package. + // See https://platform.openai.com/docs/guides/function-calling + + nextRequest, err := i.handleInjectedFunctionCalls(ctx, response) + if err != nil { + i.logger.Error(ctx, "failed to invoke injected function call", slog.Error(err)) + i.sendCustomErr(ctx, w, http.StatusInternalServerError, fmt.Errorf("failed to invoke injected function call")) + break + } + + // No next request, flow is complete. + if nextRequest == nil { + break + } + + i.reqPayload, err = sjson.SetBytes(i.reqPayload, "input", nextRequest.Input) + if err != nil { + // TODO: handle error properly. + i.logger.Error(ctx, "failure to marshal new input in inner agentic loop", slog.Error(err)) + break + } } if upstreamErr != nil && !respCopy.responseReceived.Load() { @@ -72,3 +122,196 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * return errors.Join(upstreamErr, err) } + +// handleInjectedFunctionCalls checks for function calls that we need to handle in our inner agentic loop. +// These are functions injected by the MCP proxy. +func (i *BlockingResponsesInterceptor) handleInjectedFunctionCalls(ctx context.Context, response *responses.Response) (*ResponsesNewParamsWrapper, error) { + if response == nil { + return nil, fmt.Errorf("empty response") + } + + // MCP proxy has not been configured; no way to handle injected functions. + if i.mcpProxy == nil { + return nil, nil + } + + pending := i.getPendingInjectedFunctionCalls(ctx, response) + if len(pending) == 0 { + // No injected function calls need to be invoked, flow is complete. + return nil, nil + } + + // TODO: clone? + nextRequest := i.req + + // We need to inject the output of the last response as input to the next request, in order for + // the tool call result(s) to make sense. + + // Unset the string input, we need a list now. + nextRequest.Input.OfString = param.Opt[string]{} + + // TODO: check for OutputText + for _, output := range response.Output { + // nextRequest.Input.OfInputItemList = append(nextRequest.Input.OfInputItemList, responses.ResponseInputItemParamOfOutputMessage(output.AsMessage().ToParam().Content, response.ID, responses.ResponseOutputMessageStatus(response.Status))) + // TODO: this is a pretty janky vibe-coded func by Claude; it had the args in the wrong order, needs a LOT of verification. + i.appendOutputToInput(nextRequest, output) + } + + for _, fc := range pending { + res, err := i.invokeInjectedFunc(ctx, response.ID, fc) + if err != nil { + i.logger.Error(ctx, "invoke injected tool", slog.Error(err), slog.F("id", fc.ID), slog.F("call_id", fc.CallID)) + // ALWAYS include response. + } + + nextRequest.Input.OfInputItemList = append(nextRequest.Input.OfInputItemList, res) + } + + return nextRequest, nil +} + +// getPendingInjectedFunctionCalls extracts function calls from the response that are managed by MCP proxy +func (i *BlockingResponsesInterceptor) getPendingInjectedFunctionCalls(ctx context.Context, response *responses.Response) []responses.ResponseFunctionToolCall { + var calls []responses.ResponseFunctionToolCall + + for _, item := range response.Output { + if item.Type != string(constant.ValueOf[constant.FunctionCall]()) { + continue + } + + // Injected functions are defined by MCP, and MCP tools have to have a schema + // for their inputs. The Responses API also supports "Custom Tools": + // https://platform.openai.com/docs/guides/function-calling#custom-tools + // These are like regular functions but their inputs are not schematized. + // As such, custom tools are not considered here. + fc := item.AsFunctionCall() + + // Check if this is a tool managed by our MCP proxy + if i.mcpProxy != nil && i.mcpProxy.GetTool(fc.Name) != nil { + calls = append(calls, fc) + } else { + // Record tool usage for non-managed tools + _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: response.ID, + Tool: fc.Name, + Args: fc.Arguments, + Injected: false, + }) + } + } + + return calls +} + +func (i *BlockingResponsesInterceptor) invokeInjectedFunc(ctx context.Context, responseID string, fc responses.ResponseFunctionToolCall) (responses.ResponseInputItemUnionParam, error) { + tool := i.mcpProxy.GetTool(fc.Name) + if tool == nil { + return responses.ResponseInputItemParamOfFunctionCallOutput(fc.CallID, "error: unknown injected function"), fmt.Errorf("unknown injected tool: %q", fc.Name) + } + + args := i.unmarshalArgs(fc.Arguments) + res, err := tool.Call(ctx, args, i.tracer) + _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: responseID, + ServerURL: &tool.ServerURL, + Tool: tool.Name, + Args: args, + Injected: true, + InvocationError: err, + }) + + var output string + if err != nil { + // Results have no fixed structure; if an error occurs, we can just pass back the error. + // https://platform.openai.com/docs/guides/function-calling?strict-mode=enabled#formatting-results + output = fmt.Sprintf("invocation error: %q", err.Error()) + } else { + var out strings.Builder + if encErr := json.NewEncoder(&out).Encode(res); encErr != nil { + i.logger.Warn(ctx, "failed to encode tool response", slog.Error(encErr)) + output = fmt.Sprintf("result encode error: %q", encErr.Error()) + } else { + output = out.String() + } + } + + return responses.ResponseInputItemParamOfFunctionCallOutput(fc.CallID, output), nil +} + +// appendOutputToInput converts a response output item to an input item and appends it to the +// request's input list. This is used in agentic loops where we need to feed the model's output +// back as input for the next iteration (e.g., when processing tool call results). +// +// The conversion uses the openai-go library's ToParam() methods where available, which leverage +// param.Override() with raw JSON to preserve all fields. For types without ToParam(), we use +// the ResponseInputItemParamOf* helper functions. +func (i *BlockingResponsesInterceptor) appendOutputToInput(req *ResponsesNewParamsWrapper, item responses.ResponseOutputItemUnion) { + var inputItem responses.ResponseInputItemUnionParam + + switch item.Type { + case string(constant.ValueOf[constant.Message]()): + p := item.AsMessage().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfOutputMessage: &p} + + case string(constant.ValueOf[constant.FileSearchCall]()): + p := item.AsFileSearchCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfFileSearchCall: &p} + + case string(constant.ValueOf[constant.FunctionCall]()): + p := item.AsFunctionCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfFunctionCall: &p} + + case string(constant.ValueOf[constant.WebSearchCall]()): + p := item.AsWebSearchCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfWebSearchCall: &p} + + case "computer_call": // No constant.ComputerCall type exists + p := item.AsComputerCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfComputerCall: &p} + + case string(constant.ValueOf[constant.Reasoning]()): + p := item.AsReasoning().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfReasoning: &p} + + case string(constant.ValueOf[constant.Compaction]()): + c := item.AsCompaction() + inputItem = responses.ResponseInputItemParamOfCompaction(c.EncryptedContent) + + case string(constant.ValueOf[constant.ImageGenerationCall]()): + c := item.AsImageGenerationCall() + inputItem = responses.ResponseInputItemParamOfImageGenerationCall(c.ID, c.Result, c.Status) + + case string(constant.ValueOf[constant.CodeInterpreterCall]()): + p := item.AsCodeInterpreterCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfCodeInterpreterCall: &p} + + case "custom_tool_call": // No constant.CustomToolCall type exists + p := item.AsCustomToolCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfCustomToolCall: &p} + + // Output-only types that don't have direct input equivalents or are handled separately: + // - local_shell_call, shell_call, shell_call_output: Shell tool outputs + // - apply_patch_call, apply_patch_call_output: Apply patch outputs + // - mcp_call, mcp_list_tools, mcp_approval_request: MCP-specific outputs + default: + i.logger.Debug(context.Background(), "skipping output item type for input", slog.F("type", item.Type)) + return + } + + req.Input.OfInputItemList = append(req.Input.OfInputItemList, inputItem) +} + +// unmarshalArgs unmarshals JSON arguments string into a map +func (i *BlockingResponsesInterceptor) unmarshalArgs(in string) (args recorder.ToolArgs) { + if len(strings.TrimSpace(in)) == 0 { + return args + } + + if err := json.Unmarshal([]byte(in), &args); err != nil { + i.logger.Warn(context.Background(), "failed to unmarshal tool args", slog.Error(err)) + } + + return args +} diff --git a/intercept/responses/streaming.go b/intercept/responses/streaming.go index f7fc7f1c..765f997d 100644 --- a/intercept/responses/streaming.go +++ b/intercept/responses/streaming.go @@ -16,6 +16,7 @@ import ( "github.com/openai/openai-go/v3/responses" oaiconst "github.com/openai/openai-go/v3/shared/constant" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) const ( @@ -26,7 +27,7 @@ type StreamingResponsesInterceptor struct { responsesInterceptionBase } -func NewStreamingInterceptor(id uuid.UUID, req *ResponsesNewParamsWrapper, reqPayload []byte, cfg config.OpenAI, model string) *StreamingResponsesInterceptor { +func NewStreamingInterceptor(id uuid.UUID, req *ResponsesNewParamsWrapper, reqPayload []byte, cfg config.OpenAI, model string, tracer trace.Tracer) *StreamingResponsesInterceptor { return &StreamingResponsesInterceptor{ responsesInterceptionBase: responsesInterceptionBase{ id: id, @@ -34,6 +35,7 @@ func NewStreamingInterceptor(id uuid.UUID, req *ResponsesNewParamsWrapper, reqPa reqPayload: reqPayload, cfg: cfg, model: model, + tracer: tracer, }, } } diff --git a/provider/openai.go b/provider/openai.go index c321eb8e..2e60609a 100644 --- a/provider/openai.go +++ b/provider/openai.go @@ -113,9 +113,9 @@ func (p *OpenAI) CreateInterceptor(w http.ResponseWriter, r *http.Request, trace return nil, fmt.Errorf("unmarshal request body: %w", err) } if req.Stream { - interceptor = responses.NewStreamingInterceptor(id, &req, payload, p.cfg, string(req.Model)) + interceptor = responses.NewStreamingInterceptor(id, &req, payload, p.cfg, string(req.Model), tracer) } else { - interceptor = responses.NewBlockingInterceptor(id, &req, payload, p.cfg, string(req.Model)) + interceptor = responses.NewBlockingInterceptor(id, &req, payload, p.cfg, string(req.Model), tracer) } default: From fa9c43628d69666e24193f4271638e56bd15a1fb Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Sat, 17 Jan 2026 10:40:56 +0200 Subject: [PATCH 04/15] chore: refactor for clarity Signed-off-by: Danny Kopping --- intercept/responses/base.go | 55 ------ intercept/responses/blocking.go | 203 +------------------- intercept/responses/injected_tools.go | 259 ++++++++++++++++++++++++++ 3 files changed, 261 insertions(+), 256 deletions(-) create mode 100644 intercept/responses/injected_tools.go diff --git a/intercept/responses/base.go b/intercept/responses/base.go index 41d35b68..95e14413 100644 --- a/intercept/responses/base.go +++ b/intercept/responses/base.go @@ -24,12 +24,10 @@ import ( "github.com/coder/aibridge/tracing" "github.com/coder/quartz" "github.com/google/uuid" - "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/option" "github.com/openai/openai-go/v3/responses" "github.com/openai/openai-go/v3/shared/constant" "github.com/tidwall/gjson" - "github.com/tidwall/sjson" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) @@ -103,59 +101,6 @@ func (i *responsesInterceptionBase) validateRequest(ctx context.Context, w http. return nil } -func (i *responsesInterceptionBase) injectTools() { - if i.req == nil || i.mcpProxy == nil { - return - } - - tools := i.mcpProxy.ListTools() - if len(tools) == 0 { - return - } - - // TODO: implement parallel tool calls. - // Disable parallel tool calls to simplify inner agentic loop; best-effort. - if len(tools) > 0 { - var err error - i.reqPayload, err = sjson.SetBytes(i.reqPayload, "parallel_tool_calls", false) - if err != nil { - i.logger.Warn(context.Background(), "failed to disable parallel_tool_calls", slog.Error(err)) - } - } - - // Inject tools. - for _, tool := range i.mcpProxy.ListTools() { - params := map[string]any{ - "type": "object", - "properties": tool.Params, - // "additionalProperties": false, // Only relevant when strict=true. - } - - // Otherwise the request fails with "None is not of type 'array'" if a nil slice is given. - if len(tool.Required) > 0 { - // Must list ALL properties when strict=true. - params["required"] = tool.Required - } - - fn := responses.ToolUnionParam{ - OfFunction: &responses.FunctionToolParam{ - Name: tool.ID, - Strict: openai.Bool(false), // TODO: configurable. - Description: openai.String(tool.Description), - Parameters: params, - }, - } - - i.req.Tools = append(i.req.Tools, fn) - } - - var err error - i.reqPayload, err = sjson.SetBytes(i.reqPayload, "tools", i.req.Tools) - if err != nil { - i.logger.Warn(context.Background(), "failed to set tools", slog.Error(err)) - } -} - // sendCustomErr sends custom responses.Error error to the client // it should only be called before any data is sent back to the client func (i *responsesInterceptionBase) sendCustomErr(ctx context.Context, w http.ResponseWriter, code int, err error) { diff --git a/intercept/responses/blocking.go b/intercept/responses/blocking.go index 8811c247..978b8687 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -1,12 +1,9 @@ package responses import ( - "context" - "encoding/json" "errors" "fmt" "net/http" - "strings" "time" "cdr.dev/slog/v3" @@ -15,9 +12,7 @@ import ( "github.com/coder/aibridge/recorder" "github.com/google/uuid" "github.com/openai/openai-go/v3/option" - "github.com/openai/openai-go/v3/packages/param" "github.com/openai/openai-go/v3/responses" - "github.com/openai/openai-go/v3/shared/constant" "github.com/tidwall/sjson" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -92,8 +87,7 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * // The Responses API refers to what we call "tools" as "functions", so we keep the terminology // consistent in this package. // See https://platform.openai.com/docs/guides/function-calling - - nextRequest, err := i.handleInjectedFunctionCalls(ctx, response) + nextRequest, err := i.handleInjectedToolCalls(ctx, response) if err != nil { i.logger.Error(ctx, "failed to invoke injected function call", slog.Error(err)) i.sendCustomErr(ctx, w, http.StatusInternalServerError, fmt.Errorf("failed to invoke injected function call")) @@ -107,8 +101,8 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * i.reqPayload, err = sjson.SetBytes(i.reqPayload, "input", nextRequest.Input) if err != nil { - // TODO: handle error properly. i.logger.Error(ctx, "failure to marshal new input in inner agentic loop", slog.Error(err)) + // TODO: what should be returned under this condition? break } } @@ -122,196 +116,3 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * return errors.Join(upstreamErr, err) } - -// handleInjectedFunctionCalls checks for function calls that we need to handle in our inner agentic loop. -// These are functions injected by the MCP proxy. -func (i *BlockingResponsesInterceptor) handleInjectedFunctionCalls(ctx context.Context, response *responses.Response) (*ResponsesNewParamsWrapper, error) { - if response == nil { - return nil, fmt.Errorf("empty response") - } - - // MCP proxy has not been configured; no way to handle injected functions. - if i.mcpProxy == nil { - return nil, nil - } - - pending := i.getPendingInjectedFunctionCalls(ctx, response) - if len(pending) == 0 { - // No injected function calls need to be invoked, flow is complete. - return nil, nil - } - - // TODO: clone? - nextRequest := i.req - - // We need to inject the output of the last response as input to the next request, in order for - // the tool call result(s) to make sense. - - // Unset the string input, we need a list now. - nextRequest.Input.OfString = param.Opt[string]{} - - // TODO: check for OutputText - for _, output := range response.Output { - // nextRequest.Input.OfInputItemList = append(nextRequest.Input.OfInputItemList, responses.ResponseInputItemParamOfOutputMessage(output.AsMessage().ToParam().Content, response.ID, responses.ResponseOutputMessageStatus(response.Status))) - // TODO: this is a pretty janky vibe-coded func by Claude; it had the args in the wrong order, needs a LOT of verification. - i.appendOutputToInput(nextRequest, output) - } - - for _, fc := range pending { - res, err := i.invokeInjectedFunc(ctx, response.ID, fc) - if err != nil { - i.logger.Error(ctx, "invoke injected tool", slog.Error(err), slog.F("id", fc.ID), slog.F("call_id", fc.CallID)) - // ALWAYS include response. - } - - nextRequest.Input.OfInputItemList = append(nextRequest.Input.OfInputItemList, res) - } - - return nextRequest, nil -} - -// getPendingInjectedFunctionCalls extracts function calls from the response that are managed by MCP proxy -func (i *BlockingResponsesInterceptor) getPendingInjectedFunctionCalls(ctx context.Context, response *responses.Response) []responses.ResponseFunctionToolCall { - var calls []responses.ResponseFunctionToolCall - - for _, item := range response.Output { - if item.Type != string(constant.ValueOf[constant.FunctionCall]()) { - continue - } - - // Injected functions are defined by MCP, and MCP tools have to have a schema - // for their inputs. The Responses API also supports "Custom Tools": - // https://platform.openai.com/docs/guides/function-calling#custom-tools - // These are like regular functions but their inputs are not schematized. - // As such, custom tools are not considered here. - fc := item.AsFunctionCall() - - // Check if this is a tool managed by our MCP proxy - if i.mcpProxy != nil && i.mcpProxy.GetTool(fc.Name) != nil { - calls = append(calls, fc) - } else { - // Record tool usage for non-managed tools - _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ - InterceptionID: i.ID().String(), - MsgID: response.ID, - Tool: fc.Name, - Args: fc.Arguments, - Injected: false, - }) - } - } - - return calls -} - -func (i *BlockingResponsesInterceptor) invokeInjectedFunc(ctx context.Context, responseID string, fc responses.ResponseFunctionToolCall) (responses.ResponseInputItemUnionParam, error) { - tool := i.mcpProxy.GetTool(fc.Name) - if tool == nil { - return responses.ResponseInputItemParamOfFunctionCallOutput(fc.CallID, "error: unknown injected function"), fmt.Errorf("unknown injected tool: %q", fc.Name) - } - - args := i.unmarshalArgs(fc.Arguments) - res, err := tool.Call(ctx, args, i.tracer) - _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ - InterceptionID: i.ID().String(), - MsgID: responseID, - ServerURL: &tool.ServerURL, - Tool: tool.Name, - Args: args, - Injected: true, - InvocationError: err, - }) - - var output string - if err != nil { - // Results have no fixed structure; if an error occurs, we can just pass back the error. - // https://platform.openai.com/docs/guides/function-calling?strict-mode=enabled#formatting-results - output = fmt.Sprintf("invocation error: %q", err.Error()) - } else { - var out strings.Builder - if encErr := json.NewEncoder(&out).Encode(res); encErr != nil { - i.logger.Warn(ctx, "failed to encode tool response", slog.Error(encErr)) - output = fmt.Sprintf("result encode error: %q", encErr.Error()) - } else { - output = out.String() - } - } - - return responses.ResponseInputItemParamOfFunctionCallOutput(fc.CallID, output), nil -} - -// appendOutputToInput converts a response output item to an input item and appends it to the -// request's input list. This is used in agentic loops where we need to feed the model's output -// back as input for the next iteration (e.g., when processing tool call results). -// -// The conversion uses the openai-go library's ToParam() methods where available, which leverage -// param.Override() with raw JSON to preserve all fields. For types without ToParam(), we use -// the ResponseInputItemParamOf* helper functions. -func (i *BlockingResponsesInterceptor) appendOutputToInput(req *ResponsesNewParamsWrapper, item responses.ResponseOutputItemUnion) { - var inputItem responses.ResponseInputItemUnionParam - - switch item.Type { - case string(constant.ValueOf[constant.Message]()): - p := item.AsMessage().ToParam() - inputItem = responses.ResponseInputItemUnionParam{OfOutputMessage: &p} - - case string(constant.ValueOf[constant.FileSearchCall]()): - p := item.AsFileSearchCall().ToParam() - inputItem = responses.ResponseInputItemUnionParam{OfFileSearchCall: &p} - - case string(constant.ValueOf[constant.FunctionCall]()): - p := item.AsFunctionCall().ToParam() - inputItem = responses.ResponseInputItemUnionParam{OfFunctionCall: &p} - - case string(constant.ValueOf[constant.WebSearchCall]()): - p := item.AsWebSearchCall().ToParam() - inputItem = responses.ResponseInputItemUnionParam{OfWebSearchCall: &p} - - case "computer_call": // No constant.ComputerCall type exists - p := item.AsComputerCall().ToParam() - inputItem = responses.ResponseInputItemUnionParam{OfComputerCall: &p} - - case string(constant.ValueOf[constant.Reasoning]()): - p := item.AsReasoning().ToParam() - inputItem = responses.ResponseInputItemUnionParam{OfReasoning: &p} - - case string(constant.ValueOf[constant.Compaction]()): - c := item.AsCompaction() - inputItem = responses.ResponseInputItemParamOfCompaction(c.EncryptedContent) - - case string(constant.ValueOf[constant.ImageGenerationCall]()): - c := item.AsImageGenerationCall() - inputItem = responses.ResponseInputItemParamOfImageGenerationCall(c.ID, c.Result, c.Status) - - case string(constant.ValueOf[constant.CodeInterpreterCall]()): - p := item.AsCodeInterpreterCall().ToParam() - inputItem = responses.ResponseInputItemUnionParam{OfCodeInterpreterCall: &p} - - case "custom_tool_call": // No constant.CustomToolCall type exists - p := item.AsCustomToolCall().ToParam() - inputItem = responses.ResponseInputItemUnionParam{OfCustomToolCall: &p} - - // Output-only types that don't have direct input equivalents or are handled separately: - // - local_shell_call, shell_call, shell_call_output: Shell tool outputs - // - apply_patch_call, apply_patch_call_output: Apply patch outputs - // - mcp_call, mcp_list_tools, mcp_approval_request: MCP-specific outputs - default: - i.logger.Debug(context.Background(), "skipping output item type for input", slog.F("type", item.Type)) - return - } - - req.Input.OfInputItemList = append(req.Input.OfInputItemList, inputItem) -} - -// unmarshalArgs unmarshals JSON arguments string into a map -func (i *BlockingResponsesInterceptor) unmarshalArgs(in string) (args recorder.ToolArgs) { - if len(strings.TrimSpace(in)) == 0 { - return args - } - - if err := json.Unmarshal([]byte(in), &args); err != nil { - i.logger.Warn(context.Background(), "failed to unmarshal tool args", slog.Error(err)) - } - - return args -} diff --git a/intercept/responses/injected_tools.go b/intercept/responses/injected_tools.go new file mode 100644 index 00000000..526adaac --- /dev/null +++ b/intercept/responses/injected_tools.go @@ -0,0 +1,259 @@ +package responses + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "cdr.dev/slog/v3" + "github.com/coder/aibridge/recorder" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/packages/param" + "github.com/openai/openai-go/v3/responses" + "github.com/openai/openai-go/v3/shared/constant" + "github.com/tidwall/sjson" +) + +func (i *responsesInterceptionBase) injectTools() { + if i.req == nil || i.mcpProxy == nil { + return + } + + tools := i.mcpProxy.ListTools() + if len(tools) == 0 { + return + } + + // TODO: implement parallel tool calls. + // Disable parallel tool calls to simplify inner agentic loop; best-effort. + if len(tools) > 0 { + var err error + i.reqPayload, err = sjson.SetBytes(i.reqPayload, "parallel_tool_calls", false) + if err != nil { + i.logger.Warn(context.Background(), "failed to disable parallel_tool_calls", slog.Error(err)) + } + } + + // Inject tools. + for _, tool := range i.mcpProxy.ListTools() { + params := map[string]any{ + "type": "object", + "properties": tool.Params, + // "additionalProperties": false, // Only relevant when strict=true. + } + + // Otherwise the request fails with "None is not of type 'array'" if a nil slice is given. + if len(tool.Required) > 0 { + // Must list ALL properties when strict=true. + params["required"] = tool.Required + } + + fn := responses.ToolUnionParam{ + OfFunction: &responses.FunctionToolParam{ + Name: tool.ID, + Strict: openai.Bool(false), // TODO: configurable. + Description: openai.String(tool.Description), + Parameters: params, + }, + } + + i.req.Tools = append(i.req.Tools, fn) + } + + var err error + i.reqPayload, err = sjson.SetBytes(i.reqPayload, "tools", i.req.Tools) + if err != nil { + i.logger.Warn(context.Background(), "failed to set tools", slog.Error(err)) + } +} + +// handleInjectedToolCalls checks for function calls that we need to handle in our inner agentic loop. +// These are functions injected by the MCP proxy. +func (i *BlockingResponsesInterceptor) handleInjectedToolCalls(ctx context.Context, response *responses.Response) (*ResponsesNewParamsWrapper, error) { + if response == nil { + return nil, fmt.Errorf("empty response") + } + + // MCP proxy has not been configured; no way to handle injected functions. + if i.mcpProxy == nil { + return nil, nil + } + + pending := i.getPendingInjectedToolCalls(ctx, response) + if len(pending) == 0 { + // No injected function calls need to be invoked, flow is complete. + return nil, nil + } + + // TODO: clone? + nextRequest := i.req + + i.prepareRequestForInjectedTools(nextRequest, response) + + for _, fc := range pending { + res := i.invokeInjectedTool(ctx, response.ID, fc) + nextRequest.Input.OfInputItemList = append(nextRequest.Input.OfInputItemList, res) + } + + return nextRequest, nil +} + +// prepareRequestForInjectedTools prepares the request by setting the output of the given +// response as input to the next request, in order for the tool call result(s) to make function correctly. +func (i *BlockingResponsesInterceptor) prepareRequestForInjectedTools(req *ResponsesNewParamsWrapper, response *responses.Response) { + // Unset the string input; we need a list now. + req.Input.OfString = param.Opt[string]{} + + // OutputText is also available, but by definition the trigger for a function call is not a simple + // text response from the model. + for _, output := range response.Output { + i.appendOutputToInput(req, output) + } +} + +// getPendingInjectedToolCalls extracts function calls from the response that are managed by MCP proxy +func (i *BlockingResponsesInterceptor) getPendingInjectedToolCalls(ctx context.Context, response *responses.Response) []responses.ResponseFunctionToolCall { + var calls []responses.ResponseFunctionToolCall + + for _, item := range response.Output { + if item.Type != string(constant.ValueOf[constant.FunctionCall]()) { + continue + } + + // Injected functions are defined by MCP, and MCP tools have to have a schema + // for their inputs. The Responses API also supports "Custom Tools": + // https://platform.openai.com/docs/guides/function-calling#custom-tools + // These are like regular functions but their inputs are not schematized. + // As such, custom tools are not considered here. + fc := item.AsFunctionCall() + + // Check if this is a tool managed by our MCP proxy + if i.mcpProxy != nil && i.mcpProxy.GetTool(fc.Name) != nil { + calls = append(calls, fc) + } else { + // Record tool usage for non-managed tools + _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: response.ID, + Tool: fc.Name, + Args: fc.Arguments, + Injected: false, + }) + } + } + + return calls +} + +func (i *BlockingResponsesInterceptor) invokeInjectedTool(ctx context.Context, responseID string, fc responses.ResponseFunctionToolCall) responses.ResponseInputItemUnionParam { + tool := i.mcpProxy.GetTool(fc.Name) + if tool == nil { + return responses.ResponseInputItemParamOfFunctionCallOutput(fc.CallID, fmt.Sprintf("error: unknown injected function %q", fc.ID)) + } + + args := i.unmarshalArgs(fc.Arguments) + res, err := tool.Call(ctx, args, i.tracer) + _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: responseID, + ServerURL: &tool.ServerURL, + Tool: tool.Name, + Args: args, + Injected: true, + InvocationError: err, + }) + + var output string + if err != nil { + // Results have no fixed structure; if an error occurs, we can just pass back the error. + // https://platform.openai.com/docs/guides/function-calling?strict-mode=enabled#formatting-results + output = fmt.Sprintf("invocation error: %q", err.Error()) + } else { + var out strings.Builder + if encErr := json.NewEncoder(&out).Encode(res); encErr != nil { + i.logger.Warn(ctx, "failed to encode tool response", slog.Error(encErr)) + output = fmt.Sprintf("result encode error: %q", encErr.Error()) + } else { + output = out.String() + } + } + + return responses.ResponseInputItemParamOfFunctionCallOutput(fc.CallID, output) +} + +// appendOutputToInput converts a response output item to an input item and appends it to the +// request's input list. This is used in agentic loops where we need to feed the model's output +// back as input for the next iteration (e.g., when processing tool call results). +// +// The conversion uses the openai-go library's ToParam() methods where available, which leverage +// param.Override() with raw JSON to preserve all fields. For types without ToParam(), we use +// the ResponseInputItemParamOf* helper functions. +func (i *BlockingResponsesInterceptor) appendOutputToInput(req *ResponsesNewParamsWrapper, item responses.ResponseOutputItemUnion) { + var inputItem responses.ResponseInputItemUnionParam + + switch item.Type { + case string(constant.ValueOf[constant.Message]()): + p := item.AsMessage().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfOutputMessage: &p} + + case string(constant.ValueOf[constant.FileSearchCall]()): + p := item.AsFileSearchCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfFileSearchCall: &p} + + case string(constant.ValueOf[constant.FunctionCall]()): + p := item.AsFunctionCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfFunctionCall: &p} + + case string(constant.ValueOf[constant.WebSearchCall]()): + p := item.AsWebSearchCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfWebSearchCall: &p} + + case "computer_call": // No constant.ComputerCall type exists + p := item.AsComputerCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfComputerCall: &p} + + case string(constant.ValueOf[constant.Reasoning]()): + p := item.AsReasoning().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfReasoning: &p} + + case string(constant.ValueOf[constant.Compaction]()): + c := item.AsCompaction() + inputItem = responses.ResponseInputItemParamOfCompaction(c.EncryptedContent) + + case string(constant.ValueOf[constant.ImageGenerationCall]()): + c := item.AsImageGenerationCall() + inputItem = responses.ResponseInputItemParamOfImageGenerationCall(c.ID, c.Result, c.Status) + + case string(constant.ValueOf[constant.CodeInterpreterCall]()): + p := item.AsCodeInterpreterCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfCodeInterpreterCall: &p} + + case "custom_tool_call": // No constant.CustomToolCall type exists + p := item.AsCustomToolCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfCustomToolCall: &p} + + // Output-only types that don't have direct input equivalents or are handled separately: + // - local_shell_call, shell_call, shell_call_output: Shell tool outputs + // - apply_patch_call, apply_patch_call_output: Apply patch outputs + // - mcp_call, mcp_list_tools, mcp_approval_request: MCP-specific outputs + default: + i.logger.Debug(context.Background(), "skipping output item type for input", slog.F("type", item.Type)) + return + } + + req.Input.OfInputItemList = append(req.Input.OfInputItemList, inputItem) +} + +// unmarshalArgs unmarshals JSON arguments string into a map +func (i *BlockingResponsesInterceptor) unmarshalArgs(in string) (args recorder.ToolArgs) { + if len(strings.TrimSpace(in)) == 0 { + return args + } + + if err := json.Unmarshal([]byte(in), &args); err != nil { + i.logger.Warn(context.Background(), "failed to unmarshal tool args", slog.Error(err)) + } + + return args +} From 78c2a374df8f25b9ee014f0d6d56052582e6e31b Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Sat, 17 Jan 2026 11:19:37 +0200 Subject: [PATCH 05/15] chore: more refactoring Signed-off-by: Danny Kopping --- intercept/responses/blocking.go | 62 +++++++++++++++++++-------- intercept/responses/injected_tools.go | 28 ++++-------- 2 files changed, 54 insertions(+), 36 deletions(-) diff --git a/intercept/responses/blocking.go b/intercept/responses/blocking.go index 978b8687..e904a366 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -1,6 +1,7 @@ package responses import ( + "context" "errors" "fmt" "net/http" @@ -83,26 +84,13 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * i.recordToolUsage(ctx, response) i.recordTokenUsage(ctx, response) - // Invoke any injected function calls. - // The Responses API refers to what we call "tools" as "functions", so we keep the terminology - // consistent in this package. - // See https://platform.openai.com/docs/guides/function-calling - nextRequest, err := i.handleInjectedToolCalls(ctx, response) + shouldLoop, err := i.handleInnerAgenticLoop(ctx, response) if err != nil { - i.logger.Error(ctx, "failed to invoke injected function call", slog.Error(err)) - i.sendCustomErr(ctx, w, http.StatusInternalServerError, fmt.Errorf("failed to invoke injected function call")) - break - } - - // No next request, flow is complete. - if nextRequest == nil { - break + i.sendCustomErr(ctx, w, http.StatusInternalServerError, err) + shouldLoop = false } - i.reqPayload, err = sjson.SetBytes(i.reqPayload, "input", nextRequest.Input) - if err != nil { - i.logger.Error(ctx, "failure to marshal new input in inner agentic loop", slog.Error(err)) - // TODO: what should be returned under this condition? + if !shouldLoop { break } } @@ -116,3 +104,43 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * return errors.Join(upstreamErr, err) } + +// handleInnerAgenticLoop orchestrates the inner agentic loop whereby injected tools +// are invoked and their results are sent back to the model. +// This is in contrast to regular tool calls which will be handled by the client +// in its own agentic loop. +func (i *BlockingResponsesInterceptor) handleInnerAgenticLoop(ctx context.Context, response *responses.Response) (bool, error) { + // Check if there any injected tools to invoke. + pending := i.getPendingInjectedToolCalls(ctx, response) + if len(pending) == 0 { + // No injected function calls need to be invoked, flow is complete. + return false, nil + } + + // Invoke any injected function calls. + // The Responses API refers to what we call "tools" as "functions", so we keep the terminology + // consistent in this package. + // See https://platform.openai.com/docs/guides/function-calling + results, err := i.handleInjectedToolCalls(ctx, pending, response) + if err != nil { + return false, fmt.Errorf("failed to handle injected tool calls: %w", err) + } + + // No tool results means no tools were invocable, so the flow is complete. + if len(results) == 0 { + return false, nil + } + + // We'll use the tool results to issue another request to provide the model with. + i.prepareRequestForAgenticLoop(response) + i.req.Input.OfInputItemList = append(i.req.Input.OfInputItemList, results...) + + i.reqPayload, err = sjson.SetBytes(i.reqPayload, "input", i.req.Input) + if err != nil { + i.logger.Error(ctx, "failure to marshal new input in inner agentic loop", slog.Error(err)) + // TODO: what should be returned under this condition? + return false, nil + } + + return true, nil +} diff --git a/intercept/responses/injected_tools.go b/intercept/responses/injected_tools.go index 526adaac..69b8e264 100644 --- a/intercept/responses/injected_tools.go +++ b/intercept/responses/injected_tools.go @@ -70,7 +70,8 @@ func (i *responsesInterceptionBase) injectTools() { // handleInjectedToolCalls checks for function calls that we need to handle in our inner agentic loop. // These are functions injected by the MCP proxy. -func (i *BlockingResponsesInterceptor) handleInjectedToolCalls(ctx context.Context, response *responses.Response) (*ResponsesNewParamsWrapper, error) { +// Returns a list of tool call results. +func (i *BlockingResponsesInterceptor) handleInjectedToolCalls(ctx context.Context, pending []responses.ResponseFunctionToolCall, response *responses.Response) ([]responses.ResponseInputItemUnionParam, error) { if response == nil { return nil, fmt.Errorf("empty response") } @@ -80,35 +81,24 @@ func (i *BlockingResponsesInterceptor) handleInjectedToolCalls(ctx context.Conte return nil, nil } - pending := i.getPendingInjectedToolCalls(ctx, response) - if len(pending) == 0 { - // No injected function calls need to be invoked, flow is complete. - return nil, nil - } - - // TODO: clone? - nextRequest := i.req - - i.prepareRequestForInjectedTools(nextRequest, response) - + var results []responses.ResponseInputItemUnionParam for _, fc := range pending { - res := i.invokeInjectedTool(ctx, response.ID, fc) - nextRequest.Input.OfInputItemList = append(nextRequest.Input.OfInputItemList, res) + results = append(results, i.invokeInjectedTool(ctx, response.ID, fc)) } - return nextRequest, nil + return results, nil } -// prepareRequestForInjectedTools prepares the request by setting the output of the given +// prepareRequestForAgenticLoop prepares the request by setting the output of the given // response as input to the next request, in order for the tool call result(s) to make function correctly. -func (i *BlockingResponsesInterceptor) prepareRequestForInjectedTools(req *ResponsesNewParamsWrapper, response *responses.Response) { +func (i *BlockingResponsesInterceptor) prepareRequestForAgenticLoop(response *responses.Response) { // Unset the string input; we need a list now. - req.Input.OfString = param.Opt[string]{} + i.req.Input.OfString = param.Opt[string]{} // OutputText is also available, but by definition the trigger for a function call is not a simple // text response from the model. for _, output := range response.Output { - i.appendOutputToInput(req, output) + i.appendOutputToInput(i.req, output) } } From 476eab5dec8bcac549cf491fee3e5d2b394d34da Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Sat, 17 Jan 2026 12:04:30 +0200 Subject: [PATCH 06/15] chore: add test Signed-off-by: Danny Kopping --- fixtures/fixtures.go | 7 +- ...n_tool.txtar => single_builtin_tool.txtar} | 0 .../blocking/single_injected_tool.txtar | 1522 +++++++++++++++++ intercept/responses/base_test.go | 2 +- responses_integration_test.go | 88 +- 5 files changed, 1615 insertions(+), 4 deletions(-) rename fixtures/openai/responses/blocking/{builtin_tool.txtar => single_builtin_tool.txtar} (100%) create mode 100644 fixtures/openai/responses/blocking/single_injected_tool.txtar diff --git a/fixtures/fixtures.go b/fixtures/fixtures.go index f38b2999..1bef08f3 100644 --- a/fixtures/fixtures.go +++ b/fixtures/fixtures.go @@ -51,8 +51,8 @@ var ( //go:embed openai/responses/blocking/simple.txtar OaiResponsesBlockingSimple []byte - //go:embed openai/responses/blocking/builtin_tool.txtar - OaiResponsesBlockingBuiltinTool []byte + //go:embed openai/responses/blocking/single_builtin_tool.txtar + OaiResponsesBlockingSingleBuiltinTool []byte //go:embed openai/responses/blocking/cached_input_tokens.txtar OaiResponsesBlockingCachedInputTokens []byte @@ -68,6 +68,9 @@ var ( //go:embed openai/responses/blocking/wrong_response_format.txtar OaiResponsesBlockingWrongResponseFormat []byte + + //go:embed openai/responses/blocking/single_injected_tool.txtar + OaiResponsesSingleInjectedTool []byte ) var ( diff --git a/fixtures/openai/responses/blocking/builtin_tool.txtar b/fixtures/openai/responses/blocking/single_builtin_tool.txtar similarity index 100% rename from fixtures/openai/responses/blocking/builtin_tool.txtar rename to fixtures/openai/responses/blocking/single_builtin_tool.txtar diff --git a/fixtures/openai/responses/blocking/single_injected_tool.txtar b/fixtures/openai/responses/blocking/single_injected_tool.txtar new file mode 100644 index 00000000..028377dc --- /dev/null +++ b/fixtures/openai/responses/blocking/single_injected_tool.txtar @@ -0,0 +1,1522 @@ +Coder MCP tools automatically injected. + +-- request -- +{ + "input": "list the template params for version aa4e30e4-a086-4df6-a364-1343f1458104", + "model": "gpt-5.2" +} + + +-- non-streaming -- +{ + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1768644075, + "created_at": 1768644072, + "error": null, + "frequency_penalty": 0, + "id": "resp_012db006225b0ec700696b5de8a01481a28182ea6885448f93", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "metadata": {}, + "model": "gpt-5.2-2025-12-11", + "object": "response", + "output": [ + { + "id": "rs_012db006225b0ec700696b5dea84e081a2b7777aeb4925d8f9", + "summary": [], + "type": "reasoning" + }, + { + "arguments": "{\"template_version_id\":\"aa4e30e4-a086-4df6-a364-1343f1458104\"}", + "call_id": "call_5AroFIQIK3cm3suliZdux0TB", + "id": "fc_012db006225b0ec700696b5deb0a5081a28a495f192f19e75f", + "name": "bmcp_coder_coder_template_version_parameters", + "status": "completed", + "type": "function_call" + } + ], + "parallel_tool_calls": false, + "presence_penalty": 0, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": "high", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "status": "completed", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "description": "Create a task.", + "name": "bmcp_coder_coder_create_task", + "parameters": { + "properties": { + "input": { + "description": "Input/prompt for the task.", + "type": "string" + }, + "template_version_id": { + "description": "ID of the template version to create the task from.", + "type": "string" + }, + "template_version_preset_id": { + "description": "Optional ID of the template version preset to create the task from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.", + "type": "string" + } + }, + "required": [ + "input", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template in Coder. First, you must create a template version.", + "name": "bmcp_coder_coder_create_template", + "parameters": { + "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon": { + "description": "A URL to an icon to use.", + "type": "string" + }, + "name": { + "type": "string" + }, + "version_id": { + "description": "The ID of the version to use.", + "type": "string" + } + }, + "required": [ + "name", + "display_name", + "description", + "version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\u003cterraform-spec\u003e\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"\u0026\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\u003c/terraform-spec\u003e\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\u003caws-ec2-instance\u003e\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\u003c/aws-ec2-instance\u003e\n\n\u003cgcp-vm-instance\u003e\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = \u003c\u003cEOMETA\n#!/usr/bin/env sh\nset -eux\n\n# If user does not exist, create it and set up passwordless sudo\nif ! id -u \"${local.linux_user}\" \u003e/dev/null 2\u003e\u00261; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" \u003e /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\u003c/gcp-vm-instance\u003e\n\n\u003cazure-vm-instance\u003e\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\u003c/azure-vm-instance\u003e\n\n\u003cdocker-container\u003e\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\u003c/docker-container\u003e\n\n\u003ckubernetes-pod\u003e\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\u003c/kubernetes-pod\u003e\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n", + "name": "bmcp_coder_coder_create_template_version", + "parameters": { + "properties": { + "file_id": { + "type": "string" + }, + "template_id": { + "type": "string" + } + }, + "required": [ + "file_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace", + "parameters": { + "properties": { + "name": { + "description": "Name of the workspace to create.", + "type": "string" + }, + "rich_parameters": { + "description": "Key/value pairs of rich parameters to pass to the template version to create the workspace.", + "type": "object" + }, + "template_version_id": { + "description": "ID of the template version to create the workspace from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.", + "type": "string" + } + }, + "required": [ + "user", + "template_version_id", + "name", + "rich_parameters" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace_build", + "parameters": { + "properties": { + "template_version_id": { + "description": "(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.", + "type": "string" + }, + "transition": { + "description": "The transition to perform. Must be one of: start, stop, delete", + "enum": [ + "start", + "stop", + "delete" + ], + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "transition" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a task.", + "name": "bmcp_coder_coder_delete_task", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a template. This is irreversible.", + "name": "bmcp_coder_coder_delete_template", + "parameters": { + "properties": { + "template_id": { + "type": "string" + } + }, + "required": [ + "template_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the currently authenticated user, similar to the `whoami` command.", + "name": "bmcp_coder_coder_get_authenticated_user", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a task.", + "name": "bmcp_coder_coder_get_task_logs", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the status of a task.", + "name": "bmcp_coder_coder_get_task_status", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a template version. This is useful to check whether a template version successfully imports or not.", + "name": "bmcp_coder_coder_get_template_version_logs", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.", + "name": "bmcp_coder_coder_get_workspace", + "parameters": { + "properties": { + "workspace_id": { + "description": "The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.", + "name": "bmcp_coder_coder_get_workspace_agent_logs", + "parameters": { + "properties": { + "workspace_agent_id": { + "type": "string" + } + }, + "required": [ + "workspace_agent_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.", + "name": "bmcp_coder_coder_get_workspace_build_logs", + "parameters": { + "properties": { + "workspace_build_id": { + "type": "string" + } + }, + "required": [ + "workspace_build_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List tasks.", + "name": "bmcp_coder_coder_list_tasks", + "parameters": { + "properties": { + "status": { + "description": "Optional filter by task status.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists templates for the authenticated user.", + "name": "bmcp_coder_coder_list_templates", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists workspaces for the authenticated user.", + "name": "bmcp_coder_coder_list_workspaces", + "parameters": { + "properties": { + "owner": { + "description": "The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Send input to a running task.", + "name": "bmcp_coder_coder_send_task_input", + "parameters": { + "properties": { + "input": { + "description": "The input to send to the task.", + "type": "string" + }, + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id", + "input" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.", + "name": "bmcp_coder_coder_template_version_parameters", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Update the active version of a template. This is helpful when iterating on templates.", + "name": "bmcp_coder_coder_update_template_active_version", + "parameters": { + "properties": { + "template_id": { + "type": "string" + }, + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_id", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.", + "name": "bmcp_coder_coder_upload_tar_file", + "parameters": { + "properties": { + "files": { + "description": "A map of file names to file contents.", + "type": "object" + } + }, + "required": [ + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh \u003cworkspace\u003e \u003ccommand\u003e' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"", + "name": "bmcp_coder_coder_workspace_bash", + "parameters": { + "properties": { + "background": { + "description": "Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.", + "type": "boolean" + }, + "command": { + "description": "The bash command to execute in the workspace.", + "type": "string" + }, + "timeout_ms": { + "default": 60000, + "description": "Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.", + "minimum": 1, + "type": "integer" + }, + "workspace": { + "description": "The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "command" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit a file in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_file", + "parameters": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "edits" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit one or more files in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_files", + "parameters": { + "properties": { + "files": { + "description": "An array of files to edit.", + "items": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + } + }, + "required": [ + "path", + "edits" + ], + "type": "object" + }, + "type": "array" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List the URLs of Coder apps running in a workspace for a single agent.", + "name": "bmcp_coder_coder_workspace_list_apps", + "parameters": { + "properties": { + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List directories in a workspace.", + "name": "bmcp_coder_coder_workspace_ls", + "parameters": { + "properties": { + "path": { + "description": "The absolute path of the directory in the workspace to list.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Fetch URLs that forward to the specified port.", + "name": "bmcp_coder_coder_workspace_port_forward", + "parameters": { + "properties": { + "port": { + "description": "The port to forward.", + "type": "number" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "port" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Read from a file in a workspace.", + "name": "bmcp_coder_coder_workspace_read_file", + "parameters": { + "properties": { + "limit": { + "description": "The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.", + "type": "integer" + }, + "offset": { + "description": "A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.", + "type": "integer" + }, + "path": { + "description": "The absolute path of the file to read in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n", + "name": "bmcp_coder_coder_workspace_write_file", + "parameters": { + "properties": { + "content": { + "description": "The base64-encoded bytes to write to the file.", + "type": "string" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "content" + ], + "type": "object" + }, + "strict": false, + "type": "function" + } + ], + "top_logprobs": 0, + "top_p": 0.98, + "truncation": "disabled", + "usage": { + "input_tokens": 6371, + "input_tokens_details": { + "cached_tokens": 6144 + }, + "output_tokens": 75, + "output_tokens_details": { + "reasoning_tokens": 25 + }, + "total_tokens": 6446 + }, + "user": null +} + + +-- non-streaming/tool-call -- +{ + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1768644080, + "created_at": 1768644076, + "error": null, + "frequency_penalty": 0, + "id": "resp_012db006225b0ec700696b5dec1d4c81a2a6a416e31af39b90", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "metadata": {}, + "model": "gpt-5.2-2025-12-11", + "object": "response", + "output": [ + { + "id": "rs_012db006225b0ec700696b5dec8e4c81a29eae3985d087c0b3", + "summary": [], + "type": "reasoning" + }, + { + "content": [ + { + "annotations": [], + "logprobs": [], + "text": "The template version `aa4e30e4-a086-4df6-a364-1343f1458104` defines **one** workspace parameter:\n\n### `jetbrains_ides`\n- **Display name:** JetBrains IDEs \n- **Type:** `list(string)` \n- **Form type:** `multi-select` \n- **Default:** `[]` (empty selection) \n- **Mutable after creation:** `true` \n- **Description:** Select which JetBrains IDEs to configure for use in this workspace.\n\n**Selectable options (name → value):**\n- CLion → `CL`\n- GoLand → `GO`\n- IntelliJ IDEA → `IU`\n- PhpStorm → `PS`\n- PyCharm → `PY`\n- Rider → `RD`\n- RubyMine → `RM`\n- RustRover → `RR`\n- WebStorm → `WS`", + "type": "output_text" + } + ], + "id": "msg_012db006225b0ec700696b5ded3f9881a2836e6cca7a5866e6", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": false, + "presence_penalty": 0, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": "high", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "status": "completed", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "description": "Create a task.", + "name": "bmcp_coder_coder_create_task", + "parameters": { + "properties": { + "input": { + "description": "Input/prompt for the task.", + "type": "string" + }, + "template_version_id": { + "description": "ID of the template version to create the task from.", + "type": "string" + }, + "template_version_preset_id": { + "description": "Optional ID of the template version preset to create the task from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.", + "type": "string" + } + }, + "required": [ + "input", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template in Coder. First, you must create a template version.", + "name": "bmcp_coder_coder_create_template", + "parameters": { + "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon": { + "description": "A URL to an icon to use.", + "type": "string" + }, + "name": { + "type": "string" + }, + "version_id": { + "description": "The ID of the version to use.", + "type": "string" + } + }, + "required": [ + "name", + "display_name", + "description", + "version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\u003cterraform-spec\u003e\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"\u0026\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\u003c/terraform-spec\u003e\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\u003caws-ec2-instance\u003e\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\u003c/aws-ec2-instance\u003e\n\n\u003cgcp-vm-instance\u003e\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = \u003c\u003cEOMETA\n#!/usr/bin/env sh\nset -eux\n\n# If user does not exist, create it and set up passwordless sudo\nif ! id -u \"${local.linux_user}\" \u003e/dev/null 2\u003e\u00261; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" \u003e /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\u003c/gcp-vm-instance\u003e\n\n\u003cazure-vm-instance\u003e\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\u003c/azure-vm-instance\u003e\n\n\u003cdocker-container\u003e\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\u003c/docker-container\u003e\n\n\u003ckubernetes-pod\u003e\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\u003c/kubernetes-pod\u003e\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n", + "name": "bmcp_coder_coder_create_template_version", + "parameters": { + "properties": { + "file_id": { + "type": "string" + }, + "template_id": { + "type": "string" + } + }, + "required": [ + "file_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace", + "parameters": { + "properties": { + "name": { + "description": "Name of the workspace to create.", + "type": "string" + }, + "rich_parameters": { + "description": "Key/value pairs of rich parameters to pass to the template version to create the workspace.", + "type": "object" + }, + "template_version_id": { + "description": "ID of the template version to create the workspace from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.", + "type": "string" + } + }, + "required": [ + "user", + "template_version_id", + "name", + "rich_parameters" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace_build", + "parameters": { + "properties": { + "template_version_id": { + "description": "(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.", + "type": "string" + }, + "transition": { + "description": "The transition to perform. Must be one of: start, stop, delete", + "enum": [ + "start", + "stop", + "delete" + ], + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "transition" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a task.", + "name": "bmcp_coder_coder_delete_task", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a template. This is irreversible.", + "name": "bmcp_coder_coder_delete_template", + "parameters": { + "properties": { + "template_id": { + "type": "string" + } + }, + "required": [ + "template_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the currently authenticated user, similar to the `whoami` command.", + "name": "bmcp_coder_coder_get_authenticated_user", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a task.", + "name": "bmcp_coder_coder_get_task_logs", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the status of a task.", + "name": "bmcp_coder_coder_get_task_status", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a template version. This is useful to check whether a template version successfully imports or not.", + "name": "bmcp_coder_coder_get_template_version_logs", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.", + "name": "bmcp_coder_coder_get_workspace", + "parameters": { + "properties": { + "workspace_id": { + "description": "The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.", + "name": "bmcp_coder_coder_get_workspace_agent_logs", + "parameters": { + "properties": { + "workspace_agent_id": { + "type": "string" + } + }, + "required": [ + "workspace_agent_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.", + "name": "bmcp_coder_coder_get_workspace_build_logs", + "parameters": { + "properties": { + "workspace_build_id": { + "type": "string" + } + }, + "required": [ + "workspace_build_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List tasks.", + "name": "bmcp_coder_coder_list_tasks", + "parameters": { + "properties": { + "status": { + "description": "Optional filter by task status.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists templates for the authenticated user.", + "name": "bmcp_coder_coder_list_templates", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists workspaces for the authenticated user.", + "name": "bmcp_coder_coder_list_workspaces", + "parameters": { + "properties": { + "owner": { + "description": "The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Send input to a running task.", + "name": "bmcp_coder_coder_send_task_input", + "parameters": { + "properties": { + "input": { + "description": "The input to send to the task.", + "type": "string" + }, + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id", + "input" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.", + "name": "bmcp_coder_coder_template_version_parameters", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Update the active version of a template. This is helpful when iterating on templates.", + "name": "bmcp_coder_coder_update_template_active_version", + "parameters": { + "properties": { + "template_id": { + "type": "string" + }, + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_id", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.", + "name": "bmcp_coder_coder_upload_tar_file", + "parameters": { + "properties": { + "files": { + "description": "A map of file names to file contents.", + "type": "object" + } + }, + "required": [ + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh \u003cworkspace\u003e \u003ccommand\u003e' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"", + "name": "bmcp_coder_coder_workspace_bash", + "parameters": { + "properties": { + "background": { + "description": "Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.", + "type": "boolean" + }, + "command": { + "description": "The bash command to execute in the workspace.", + "type": "string" + }, + "timeout_ms": { + "default": 60000, + "description": "Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.", + "minimum": 1, + "type": "integer" + }, + "workspace": { + "description": "The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "command" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit a file in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_file", + "parameters": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "edits" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit one or more files in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_files", + "parameters": { + "properties": { + "files": { + "description": "An array of files to edit.", + "items": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + } + }, + "required": [ + "path", + "edits" + ], + "type": "object" + }, + "type": "array" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List the URLs of Coder apps running in a workspace for a single agent.", + "name": "bmcp_coder_coder_workspace_list_apps", + "parameters": { + "properties": { + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List directories in a workspace.", + "name": "bmcp_coder_coder_workspace_ls", + "parameters": { + "properties": { + "path": { + "description": "The absolute path of the directory in the workspace to list.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Fetch URLs that forward to the specified port.", + "name": "bmcp_coder_coder_workspace_port_forward", + "parameters": { + "properties": { + "port": { + "description": "The port to forward.", + "type": "number" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "port" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Read from a file in a workspace.", + "name": "bmcp_coder_coder_workspace_read_file", + "parameters": { + "properties": { + "limit": { + "description": "The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.", + "type": "integer" + }, + "offset": { + "description": "A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.", + "type": "integer" + }, + "path": { + "description": "The absolute path of the file to read in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n", + "name": "bmcp_coder_coder_workspace_write_file", + "parameters": { + "properties": { + "content": { + "description": "The base64-encoded bytes to write to the file.", + "type": "string" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "content" + ], + "type": "object" + }, + "strict": false, + "type": "function" + } + ], + "top_logprobs": 0, + "top_p": 0.98, + "truncation": "disabled", + "usage": { + "input_tokens": 6756, + "input_tokens_details": { + "cached_tokens": 6144 + }, + "output_tokens": 231, + "output_tokens_details": { + "reasoning_tokens": 43 + }, + "total_tokens": 6987 + }, + "user": null +} + diff --git a/intercept/responses/base_test.go b/intercept/responses/base_test.go index 03746297..d45a9bf8 100644 --- a/intercept/responses/base_test.go +++ b/intercept/responses/base_test.go @@ -28,7 +28,7 @@ func TestLastUserPrompt(t *testing.T) { }, { name: "array_single_input_string", - reqPayload: fixtures.Request(t, fixtures.OaiResponsesBlockingBuiltinTool), + reqPayload: fixtures.Request(t, fixtures.OaiResponsesBlockingSingleBuiltinTool), expected: "Is 3 + 5 a prime number? Use the add function to calculate the sum.", }, { diff --git a/responses_integration_test.go b/responses_integration_test.go index acef4fd7..12bf7534 100644 --- a/responses_integration_test.go +++ b/responses_integration_test.go @@ -13,9 +13,14 @@ import ( "testing" "time" + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/aibridge" "github.com/coder/aibridge/config" aibcontext "github.com/coder/aibridge/context" "github.com/coder/aibridge/fixtures" + "github.com/coder/aibridge/internal/testutil" + "github.com/coder/aibridge/mcp" "github.com/coder/aibridge/provider" "github.com/coder/aibridge/recorder" "github.com/openai/openai-go/v3/responses" @@ -58,7 +63,7 @@ func TestResponsesOutputMatchesUpstream(t *testing.T) { }, { name: "blocking_builtin_tool", - fixture: fixtures.OaiResponsesBlockingBuiltinTool, + fixture: fixtures.OaiResponsesBlockingSingleBuiltinTool, expectModel: "gpt-4.1", expectPromptRecorded: "Is 3 + 5 a prime number? Use the add function to calculate the sum.", expectToolRecorded: &recorder.ToolUsageRecord{ @@ -746,3 +751,84 @@ func startRejectingListener(t *testing.T) (addr string) { return "http://" + ln.Addr().String() } + +// TestResponsesBlockingInjectedTool tests that injected MCP tool calls trigger the inner agentic loop, +// invoke the tool via MCP, and send the result back to the model. +func TestResponsesBlockingInjectedTool(t *testing.T) { + t.Parallel() + + files := filesMap(txtar.Parse(fixtures.OaiResponsesSingleInjectedTool)) + require.Contains(t, files, fixtureRequest) + require.Contains(t, files, fixtureNonStreamingResponse) + require.Contains(t, files, fixtureNonStreamingToolResponse) + + ctx, cancel := context.WithTimeout(t.Context(), time.Second*30) + t.Cleanup(cancel) + + // Setup mock server with response mutator for multi-turn interaction. + mockAPI := newMockServer(ctx, t, files, func(reqCount uint32, resp []byte) []byte { + if reqCount == 1 { + return resp // First request gets the normal response (with tool call). + } + // Second request gets the tool response. + return files[fixtureNonStreamingToolResponse] + }) + t.Cleanup(mockAPI.Close) + + // Setup MCP server proxies (with mock tools). + mcpProxiers, mcpCalls := setupMCPServerProxiesForTest(t, testTracer) + mcpMgr := mcp.NewServerProxyManager(mcpProxiers, testTracer) + require.NoError(t, mcpMgr.Init(ctx)) + + prov := provider.NewOpenAI(openaiCfg(mockAPI.URL, apiKey)) + mockRecorder := &testutil.MockRecorder{} + logger := slogtest.Make(t, &slogtest.Options{}).Leveled(slog.LevelDebug) + + bridge, err := aibridge.NewRequestBridge(ctx, []aibridge.Provider{prov}, mockRecorder, mcpMgr, logger, nil, testTracer) + require.NoError(t, err) + + srv := httptest.NewUnstartedServer(bridge) + srv.Config.BaseContext = func(_ net.Listener) context.Context { + return aibcontext.AsActor(ctx, userID, nil) + } + srv.Start() + t.Cleanup(srv.Close) + + req := createOpenAIResponsesReq(t, srv.URL, files[fixtureRequest]) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // Wait for both requests to be made (inner agentic loop). + require.Eventually(t, func() bool { + return mockAPI.callCount.Load() == 2 + }, time.Second*10, time.Millisecond*50) + + // Verify the injected tool was invoked via MCP. + // The fixture uses "bmcp_coder_coder_template_version_parameters" as the tool ID, which maps to + // "coder_template_version_parameters" in the MCP server. + invocations := mcpCalls.getCallsByTool("coder_template_version_parameters") + require.Len(t, invocations, 1, "expected MCP tool to be invoked once") + + // Verify the injected tool usage was recorded. + // The tool name recorded is the MCP tool name (without prefix). + toolUsages := mockRecorder.RecordedToolUsages() + require.Len(t, toolUsages, 1) + require.Equal(t, "coder_template_version_parameters", toolUsages[0].Tool) + require.Equal(t, map[string]any{ + "template_version_id": "aa4e30e4-a086-4df6-a364-1343f1458104", + }, toolUsages[0].Args) + require.True(t, toolUsages[0].Injected, "injected tool should be marked as injected") + + // Verify prompt was recorded. + prompts := mockRecorder.RecordedPromptUsages() + require.Len(t, prompts, 1) + require.Equal(t, "list the template params for version aa4e30e4-a086-4df6-a364-1343f1458104", prompts[0].Prompt) + + // Verify the response is the final tool response (after agentic loop). + require.Equal(t, string(files[fixtureNonStreamingToolResponse]), string(body)) +} From 410f004c7709c08dfd24a43efb58475532a5578c Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Sat, 17 Jan 2026 15:06:39 +0200 Subject: [PATCH 07/15] chore: fix tests, add tool invocation err case Signed-off-by: Danny Kopping --- Makefile | 2 +- bridge_integration_test.go | 26 +- fixtures/fixtures.go | 3 + .../blocking/single_injected_tool_error.txtar | 1522 +++++++++++++++++ intercept/responses/base.go | 7 +- intercept/responses/blocking.go | 1 + intercept/responses/injected_tools.go | 24 +- intercept/responses/streaming.go | 2 + responses_integration_test.go | 167 +- 9 files changed, 1670 insertions(+), 84 deletions(-) create mode 100644 fixtures/openai/responses/blocking/single_injected_tool_error.txtar diff --git a/Makefile b/Makefile index bb07b5fe..dcd4e6f8 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ test-race: CGO_ENABLED=1 go test -count=1 -race ./... coverage: - go test -coverprofile=coverage.out ./... + go test -coverprofile=coverage.out -coverpkg=./... ./... go tool cover -func=coverage.out | tail -n 1 coverage-html: diff --git a/bridge_integration_test.go b/bridge_integration_test.go index 670352d6..b32f6de8 100644 --- a/bridge_integration_test.go +++ b/bridge_integration_test.go @@ -1792,16 +1792,31 @@ const mockToolName = "coder_list_workspaces" // callAccumulator tracks all tool invocations by name and each instance's arguments. type callAccumulator struct { - calls map[string][]any - callsMu sync.Mutex + calls map[string][]any + callsMu sync.Mutex + toolErrors map[string]string } func newCallAccumulator() *callAccumulator { return &callAccumulator{ - calls: make(map[string][]any), + calls: make(map[string][]any), + toolErrors: make(map[string]string), } } +func (a *callAccumulator) setToolError(tool string, errMsg string) { + a.callsMu.Lock() + defer a.callsMu.Unlock() + a.toolErrors[tool] = errMsg +} + +func (a *callAccumulator) getToolError(tool string) (string, bool) { + a.callsMu.Lock() + defer a.callsMu.Unlock() + errMsg, ok := a.toolErrors[tool] + return errMsg, ok +} + func (a *callAccumulator) addCall(tool string, args any) { a.callsMu.Lock() defer a.callsMu.Unlock() @@ -1831,12 +1846,15 @@ func createMockMCPSrv(t *testing.T) (http.Handler, *callAccumulator) { // Accumulate tool calls & their arguments. acc := newCallAccumulator() - for _, name := range []string{mockToolName, "coder_list_templates", "coder_template_version_parameters", "coder_get_authenticated_user", "coder_create_workspace_build"} { + for _, name := range []string{mockToolName, "coder_list_templates", "coder_template_version_parameters", "coder_get_authenticated_user", "coder_create_workspace_build", "coder_delete_template"} { tool := mcplib.NewTool(name, mcplib.WithDescription(fmt.Sprintf("Mock of the %s tool", name)), ) s.AddTool(tool, func(ctx context.Context, request mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { acc.addCall(request.Params.Name, request.Params.Arguments) + if errMsg, ok := acc.getToolError(request.Params.Name); ok { + return mcplib.NewToolResultError(errMsg), nil + } return mcplib.NewToolResultText("mock"), nil }) } diff --git a/fixtures/fixtures.go b/fixtures/fixtures.go index 1bef08f3..c812a944 100644 --- a/fixtures/fixtures.go +++ b/fixtures/fixtures.go @@ -71,6 +71,9 @@ var ( //go:embed openai/responses/blocking/single_injected_tool.txtar OaiResponsesSingleInjectedTool []byte + + //go:embed openai/responses/blocking/single_injected_tool_error.txtar + OaiResponsesSingleInjectedToolError []byte ) var ( diff --git a/fixtures/openai/responses/blocking/single_injected_tool_error.txtar b/fixtures/openai/responses/blocking/single_injected_tool_error.txtar new file mode 100644 index 00000000..9e4c2716 --- /dev/null +++ b/fixtures/openai/responses/blocking/single_injected_tool_error.txtar @@ -0,0 +1,1522 @@ +Coder MCP tools automatically injected, and errors invoking them are recorded. + +-- request -- +{ + "input": "delete the template with ID 03cb4fdd-8109-4a22-8e22-bb4975171395, don't ask for confirmation", + "model": "gpt-5.2" +} + + +-- non-streaming -- +{ + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1768650575, + "created_at": 1768650573, + "error": null, + "frequency_penalty": 0, + "id": "resp_06e2afba24b6b2ad00696b774d1df0819eaf1ec802bc8a2ca9", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "metadata": {}, + "model": "gpt-5.2-2025-12-11", + "object": "response", + "output": [ + { + "id": "rs_06e2afba24b6b2ad00696b774d6894819eb9ec114d25c713e4", + "summary": [], + "type": "reasoning" + }, + { + "arguments": "{\"template_id\":\"03cb4fdd-8109-4a22-8e22-bb4975171395\"}", + "call_id": "call_ITNAVLCwsZSEAlQHq8C8bS5L", + "id": "fc_06e2afba24b6b2ad00696b774f22f8819ead7d3f3eb4e080ea", + "name": "bmcp_coder_coder_delete_template", + "status": "completed", + "type": "function_call" + } + ], + "parallel_tool_calls": false, + "presence_penalty": 0, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": "high", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "status": "completed", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "description": "Create a task.", + "name": "bmcp_coder_coder_create_task", + "parameters": { + "properties": { + "input": { + "description": "Input/prompt for the task.", + "type": "string" + }, + "template_version_id": { + "description": "ID of the template version to create the task from.", + "type": "string" + }, + "template_version_preset_id": { + "description": "Optional ID of the template version preset to create the task from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.", + "type": "string" + } + }, + "required": [ + "input", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template in Coder. First, you must create a template version.", + "name": "bmcp_coder_coder_create_template", + "parameters": { + "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon": { + "description": "A URL to an icon to use.", + "type": "string" + }, + "name": { + "type": "string" + }, + "version_id": { + "description": "The ID of the version to use.", + "type": "string" + } + }, + "required": [ + "name", + "display_name", + "description", + "version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\u003cterraform-spec\u003e\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"\u0026\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\u003c/terraform-spec\u003e\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\u003caws-ec2-instance\u003e\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\u003c/aws-ec2-instance\u003e\n\n\u003cgcp-vm-instance\u003e\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = \u003c\u003cEOMETA\n#!/usr/bin/env sh\nset -eux\n\n# If user does not exist, create it and set up passwordless sudo\nif ! id -u \"${local.linux_user}\" \u003e/dev/null 2\u003e\u00261; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" \u003e /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\u003c/gcp-vm-instance\u003e\n\n\u003cazure-vm-instance\u003e\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\u003c/azure-vm-instance\u003e\n\n\u003cdocker-container\u003e\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\u003c/docker-container\u003e\n\n\u003ckubernetes-pod\u003e\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\u003c/kubernetes-pod\u003e\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n", + "name": "bmcp_coder_coder_create_template_version", + "parameters": { + "properties": { + "file_id": { + "type": "string" + }, + "template_id": { + "type": "string" + } + }, + "required": [ + "file_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace", + "parameters": { + "properties": { + "name": { + "description": "Name of the workspace to create.", + "type": "string" + }, + "rich_parameters": { + "description": "Key/value pairs of rich parameters to pass to the template version to create the workspace.", + "type": "object" + }, + "template_version_id": { + "description": "ID of the template version to create the workspace from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.", + "type": "string" + } + }, + "required": [ + "user", + "template_version_id", + "name", + "rich_parameters" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace_build", + "parameters": { + "properties": { + "template_version_id": { + "description": "(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.", + "type": "string" + }, + "transition": { + "description": "The transition to perform. Must be one of: start, stop, delete", + "enum": [ + "start", + "stop", + "delete" + ], + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "transition" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a task.", + "name": "bmcp_coder_coder_delete_task", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a template. This is irreversible.", + "name": "bmcp_coder_coder_delete_template", + "parameters": { + "properties": { + "template_id": { + "type": "string" + } + }, + "required": [ + "template_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the currently authenticated user, similar to the `whoami` command.", + "name": "bmcp_coder_coder_get_authenticated_user", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a task.", + "name": "bmcp_coder_coder_get_task_logs", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the status of a task.", + "name": "bmcp_coder_coder_get_task_status", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a template version. This is useful to check whether a template version successfully imports or not.", + "name": "bmcp_coder_coder_get_template_version_logs", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.", + "name": "bmcp_coder_coder_get_workspace", + "parameters": { + "properties": { + "workspace_id": { + "description": "The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.", + "name": "bmcp_coder_coder_get_workspace_agent_logs", + "parameters": { + "properties": { + "workspace_agent_id": { + "type": "string" + } + }, + "required": [ + "workspace_agent_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.", + "name": "bmcp_coder_coder_get_workspace_build_logs", + "parameters": { + "properties": { + "workspace_build_id": { + "type": "string" + } + }, + "required": [ + "workspace_build_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List tasks.", + "name": "bmcp_coder_coder_list_tasks", + "parameters": { + "properties": { + "status": { + "description": "Optional filter by task status.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists templates for the authenticated user.", + "name": "bmcp_coder_coder_list_templates", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists workspaces for the authenticated user.", + "name": "bmcp_coder_coder_list_workspaces", + "parameters": { + "properties": { + "owner": { + "description": "The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Send input to a running task.", + "name": "bmcp_coder_coder_send_task_input", + "parameters": { + "properties": { + "input": { + "description": "The input to send to the task.", + "type": "string" + }, + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id", + "input" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.", + "name": "bmcp_coder_coder_template_version_parameters", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Update the active version of a template. This is helpful when iterating on templates.", + "name": "bmcp_coder_coder_update_template_active_version", + "parameters": { + "properties": { + "template_id": { + "type": "string" + }, + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_id", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.", + "name": "bmcp_coder_coder_upload_tar_file", + "parameters": { + "properties": { + "files": { + "description": "A map of file names to file contents.", + "type": "object" + } + }, + "required": [ + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh \u003cworkspace\u003e \u003ccommand\u003e' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"", + "name": "bmcp_coder_coder_workspace_bash", + "parameters": { + "properties": { + "background": { + "description": "Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.", + "type": "boolean" + }, + "command": { + "description": "The bash command to execute in the workspace.", + "type": "string" + }, + "timeout_ms": { + "default": 60000, + "description": "Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.", + "minimum": 1, + "type": "integer" + }, + "workspace": { + "description": "The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "command" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit a file in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_file", + "parameters": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "edits" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit one or more files in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_files", + "parameters": { + "properties": { + "files": { + "description": "An array of files to edit.", + "items": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + } + }, + "required": [ + "path", + "edits" + ], + "type": "object" + }, + "type": "array" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List the URLs of Coder apps running in a workspace for a single agent.", + "name": "bmcp_coder_coder_workspace_list_apps", + "parameters": { + "properties": { + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List directories in a workspace.", + "name": "bmcp_coder_coder_workspace_ls", + "parameters": { + "properties": { + "path": { + "description": "The absolute path of the directory in the workspace to list.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Fetch URLs that forward to the specified port.", + "name": "bmcp_coder_coder_workspace_port_forward", + "parameters": { + "properties": { + "port": { + "description": "The port to forward.", + "type": "number" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "port" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Read from a file in a workspace.", + "name": "bmcp_coder_coder_workspace_read_file", + "parameters": { + "properties": { + "limit": { + "description": "The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.", + "type": "integer" + }, + "offset": { + "description": "A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.", + "type": "integer" + }, + "path": { + "description": "The absolute path of the file to read in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n", + "name": "bmcp_coder_coder_workspace_write_file", + "parameters": { + "properties": { + "content": { + "description": "The base64-encoded bytes to write to the file.", + "type": "string" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "content" + ], + "type": "object" + }, + "strict": false, + "type": "function" + } + ], + "top_logprobs": 0, + "top_p": 0.98, + "truncation": "disabled", + "usage": { + "input_tokens": 6377, + "input_tokens_details": { + "cached_tokens": 6144 + }, + "output_tokens": 119, + "output_tokens_details": { + "reasoning_tokens": 70 + }, + "total_tokens": 6496 + }, + "user": null +} + + +-- non-streaming/tool-call -- +{ + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1768650579, + "created_at": 1768650576, + "error": null, + "frequency_penalty": 0, + "id": "resp_06e2afba24b6b2ad00696b775044e8819ea14840698ef966e2", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "metadata": {}, + "model": "gpt-5.2-2025-12-11", + "object": "response", + "output": [ + { + "id": "rs_06e2afba24b6b2ad00696b7750c35c819e860aa1438936bad6", + "summary": [], + "type": "reasoning" + }, + { + "content": [ + { + "annotations": [], + "logprobs": [], + "text": "I couldn’t delete template `03cb4fdd-8109-4a22-8e22-bb4975171395` because the API returned:\n\n- `500 Internal error deleting template`\n- underlying cause: `unauthorized: rbac: forbidden`\n\nThis means the authenticated account I’m using doesn’t have RBAC permission to delete that template.\n\nIf you want, tell me which user/account should perform the deletion (or have an admin grant delete permission for that template), and I can retry once I have the right access.", + "type": "output_text" + } + ], + "id": "msg_06e2afba24b6b2ad00696b77516d58819e9bfdec585db91bd6", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": false, + "presence_penalty": 0, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": "high", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "status": "completed", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "description": "Create a task.", + "name": "bmcp_coder_coder_create_task", + "parameters": { + "properties": { + "input": { + "description": "Input/prompt for the task.", + "type": "string" + }, + "template_version_id": { + "description": "ID of the template version to create the task from.", + "type": "string" + }, + "template_version_preset_id": { + "description": "Optional ID of the template version preset to create the task from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.", + "type": "string" + } + }, + "required": [ + "input", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template in Coder. First, you must create a template version.", + "name": "bmcp_coder_coder_create_template", + "parameters": { + "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon": { + "description": "A URL to an icon to use.", + "type": "string" + }, + "name": { + "type": "string" + }, + "version_id": { + "description": "The ID of the version to use.", + "type": "string" + } + }, + "required": [ + "name", + "display_name", + "description", + "version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\u003cterraform-spec\u003e\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"\u0026\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\u003c/terraform-spec\u003e\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\u003caws-ec2-instance\u003e\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\u003c/aws-ec2-instance\u003e\n\n\u003cgcp-vm-instance\u003e\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = \u003c\u003cEOMETA\n#!/usr/bin/env sh\nset -eux\n\n# If user does not exist, create it and set up passwordless sudo\nif ! id -u \"${local.linux_user}\" \u003e/dev/null 2\u003e\u00261; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" \u003e /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\u003c/gcp-vm-instance\u003e\n\n\u003cazure-vm-instance\u003e\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\u003c/azure-vm-instance\u003e\n\n\u003cdocker-container\u003e\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\u003c/docker-container\u003e\n\n\u003ckubernetes-pod\u003e\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\u003c/kubernetes-pod\u003e\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n", + "name": "bmcp_coder_coder_create_template_version", + "parameters": { + "properties": { + "file_id": { + "type": "string" + }, + "template_id": { + "type": "string" + } + }, + "required": [ + "file_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace", + "parameters": { + "properties": { + "name": { + "description": "Name of the workspace to create.", + "type": "string" + }, + "rich_parameters": { + "description": "Key/value pairs of rich parameters to pass to the template version to create the workspace.", + "type": "object" + }, + "template_version_id": { + "description": "ID of the template version to create the workspace from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.", + "type": "string" + } + }, + "required": [ + "user", + "template_version_id", + "name", + "rich_parameters" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace_build", + "parameters": { + "properties": { + "template_version_id": { + "description": "(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.", + "type": "string" + }, + "transition": { + "description": "The transition to perform. Must be one of: start, stop, delete", + "enum": [ + "start", + "stop", + "delete" + ], + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "transition" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a task.", + "name": "bmcp_coder_coder_delete_task", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a template. This is irreversible.", + "name": "bmcp_coder_coder_delete_template", + "parameters": { + "properties": { + "template_id": { + "type": "string" + } + }, + "required": [ + "template_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the currently authenticated user, similar to the `whoami` command.", + "name": "bmcp_coder_coder_get_authenticated_user", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a task.", + "name": "bmcp_coder_coder_get_task_logs", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the status of a task.", + "name": "bmcp_coder_coder_get_task_status", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a template version. This is useful to check whether a template version successfully imports or not.", + "name": "bmcp_coder_coder_get_template_version_logs", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.", + "name": "bmcp_coder_coder_get_workspace", + "parameters": { + "properties": { + "workspace_id": { + "description": "The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.", + "name": "bmcp_coder_coder_get_workspace_agent_logs", + "parameters": { + "properties": { + "workspace_agent_id": { + "type": "string" + } + }, + "required": [ + "workspace_agent_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.", + "name": "bmcp_coder_coder_get_workspace_build_logs", + "parameters": { + "properties": { + "workspace_build_id": { + "type": "string" + } + }, + "required": [ + "workspace_build_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List tasks.", + "name": "bmcp_coder_coder_list_tasks", + "parameters": { + "properties": { + "status": { + "description": "Optional filter by task status.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists templates for the authenticated user.", + "name": "bmcp_coder_coder_list_templates", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists workspaces for the authenticated user.", + "name": "bmcp_coder_coder_list_workspaces", + "parameters": { + "properties": { + "owner": { + "description": "The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Send input to a running task.", + "name": "bmcp_coder_coder_send_task_input", + "parameters": { + "properties": { + "input": { + "description": "The input to send to the task.", + "type": "string" + }, + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id", + "input" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.", + "name": "bmcp_coder_coder_template_version_parameters", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Update the active version of a template. This is helpful when iterating on templates.", + "name": "bmcp_coder_coder_update_template_active_version", + "parameters": { + "properties": { + "template_id": { + "type": "string" + }, + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_id", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.", + "name": "bmcp_coder_coder_upload_tar_file", + "parameters": { + "properties": { + "files": { + "description": "A map of file names to file contents.", + "type": "object" + } + }, + "required": [ + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh \u003cworkspace\u003e \u003ccommand\u003e' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"", + "name": "bmcp_coder_coder_workspace_bash", + "parameters": { + "properties": { + "background": { + "description": "Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.", + "type": "boolean" + }, + "command": { + "description": "The bash command to execute in the workspace.", + "type": "string" + }, + "timeout_ms": { + "default": 60000, + "description": "Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.", + "minimum": 1, + "type": "integer" + }, + "workspace": { + "description": "The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "command" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit a file in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_file", + "parameters": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "edits" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit one or more files in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_files", + "parameters": { + "properties": { + "files": { + "description": "An array of files to edit.", + "items": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + } + }, + "required": [ + "path", + "edits" + ], + "type": "object" + }, + "type": "array" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List the URLs of Coder apps running in a workspace for a single agent.", + "name": "bmcp_coder_coder_workspace_list_apps", + "parameters": { + "properties": { + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List directories in a workspace.", + "name": "bmcp_coder_coder_workspace_ls", + "parameters": { + "properties": { + "path": { + "description": "The absolute path of the directory in the workspace to list.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Fetch URLs that forward to the specified port.", + "name": "bmcp_coder_coder_workspace_port_forward", + "parameters": { + "properties": { + "port": { + "description": "The port to forward.", + "type": "number" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "port" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Read from a file in a workspace.", + "name": "bmcp_coder_coder_workspace_read_file", + "parameters": { + "properties": { + "limit": { + "description": "The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.", + "type": "integer" + }, + "offset": { + "description": "A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.", + "type": "integer" + }, + "path": { + "description": "The absolute path of the file to read in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n", + "name": "bmcp_coder_coder_workspace_write_file", + "parameters": { + "properties": { + "content": { + "description": "The base64-encoded bytes to write to the file.", + "type": "string" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "content" + ], + "type": "object" + }, + "strict": false, + "type": "function" + } + ], + "top_logprobs": 0, + "top_p": 0.98, + "truncation": "disabled", + "usage": { + "input_tokens": 6539, + "input_tokens_details": { + "cached_tokens": 6144 + }, + "output_tokens": 144, + "output_tokens_details": { + "reasoning_tokens": 28 + }, + "total_tokens": 6683 + }, + "user": null +} + diff --git a/intercept/responses/base.go b/intercept/responses/base.go index 95e14413..d330838c 100644 --- a/intercept/responses/base.go +++ b/intercept/responses/base.go @@ -156,10 +156,13 @@ func (i *responsesInterceptionBase) lastUserPrompt() (string, error) { return i.req.Input.OfString.Value, nil } - // If the input list is a slice, check if the final message has a "user" role. + // If the input list is a slice and the SDK properly decoded the last item, + // check if the final message has a "user" role. if count := len(i.req.Input.OfInputItemList); count > 0 { last := i.req.Input.OfInputItemList[count-1] - if last.OfInputMessage == nil || last.OfInputMessage.Role != string(constant.ValueOf[constant.User]()) { + // Only do this early check if OfInputMessage is populated (SDK decoded it). + // If nil, we fall through to gjson parsing which handles all cases. + if last.OfInputMessage != nil && last.OfInputMessage.Role != string(constant.ValueOf[constant.User]()) { // The last message was not user-supplied. return "", nil } diff --git a/intercept/responses/blocking.go b/intercept/responses/blocking.go index e904a366..a0bc60dc 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -55,6 +55,7 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * } i.injectTools() + i.disableParallelToolCalls() var ( response *responses.Response diff --git a/intercept/responses/injected_tools.go b/intercept/responses/injected_tools.go index 69b8e264..9200c2fa 100644 --- a/intercept/responses/injected_tools.go +++ b/intercept/responses/injected_tools.go @@ -25,16 +25,6 @@ func (i *responsesInterceptionBase) injectTools() { return } - // TODO: implement parallel tool calls. - // Disable parallel tool calls to simplify inner agentic loop; best-effort. - if len(tools) > 0 { - var err error - i.reqPayload, err = sjson.SetBytes(i.reqPayload, "parallel_tool_calls", false) - if err != nil { - i.logger.Warn(context.Background(), "failed to disable parallel_tool_calls", slog.Error(err)) - } - } - // Inject tools. for _, tool := range i.mcpProxy.ListTools() { params := map[string]any{ @@ -68,6 +58,20 @@ func (i *responsesInterceptionBase) injectTools() { } } +// disableParallelToolCalls disables parallel tool calls, to simplify the inner agentic loop. +// This is best-effort, and failing to set this flag does not fail the request. +// TODO: implement parallel tool calls. +func (i *responsesInterceptionBase) disableParallelToolCalls() { + // Disable parallel tool calls to simplify inner agentic loop; best-effort. + if len(i.req.Tools) > 0 { + var err error + i.reqPayload, err = sjson.SetBytes(i.reqPayload, "parallel_tool_calls", false) + if err != nil { + i.logger.Warn(context.Background(), "failed to disable parallel_tool_calls", slog.Error(err)) + } + } +} + // handleInjectedToolCalls checks for function calls that we need to handle in our inner agentic loop. // These are functions injected by the MCP proxy. // Returns a list of tool call results. diff --git a/intercept/responses/streaming.go b/intercept/responses/streaming.go index 765f997d..dec9e50c 100644 --- a/intercept/responses/streaming.go +++ b/intercept/responses/streaming.go @@ -61,6 +61,8 @@ func (i *StreamingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r return err } + i.disableParallelToolCalls() + events := eventstream.NewEventStream(ctx, i.logger.Named("sse-sender"), nil) go events.Start(w, r) defer func() { diff --git a/responses_integration_test.go b/responses_integration_test.go index 12bf7534..6a986745 100644 --- a/responses_integration_test.go +++ b/responses_integration_test.go @@ -757,78 +757,111 @@ func startRejectingListener(t *testing.T) (addr string) { func TestResponsesBlockingInjectedTool(t *testing.T) { t.Parallel() - files := filesMap(txtar.Parse(fixtures.OaiResponsesSingleInjectedTool)) - require.Contains(t, files, fixtureRequest) - require.Contains(t, files, fixtureNonStreamingResponse) - require.Contains(t, files, fixtureNonStreamingToolResponse) - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*30) - t.Cleanup(cancel) - - // Setup mock server with response mutator for multi-turn interaction. - mockAPI := newMockServer(ctx, t, files, func(reqCount uint32, resp []byte) []byte { - if reqCount == 1 { - return resp // First request gets the normal response (with tool call). - } - // Second request gets the tool response. - return files[fixtureNonStreamingToolResponse] - }) - t.Cleanup(mockAPI.Close) + tests := []struct { + name string + fixture []byte + mcpToolName string + expectedToolArgs map[string]any + expectedPrompt string + toolError string // If non-empty, MCP tool returns this error. + }{ + { + name: "success", + fixture: fixtures.OaiResponsesSingleInjectedTool, + mcpToolName: "coder_template_version_parameters", + expectedToolArgs: map[string]any{ + "template_version_id": "aa4e30e4-a086-4df6-a364-1343f1458104", + }, + expectedPrompt: "list the template params for version aa4e30e4-a086-4df6-a364-1343f1458104", + }, + { + name: "tool_error", + fixture: fixtures.OaiResponsesSingleInjectedToolError, + mcpToolName: "coder_delete_template", + expectedToolArgs: map[string]any{ + "template_id": "03cb4fdd-8109-4a22-8e22-bb4975171395", + }, + expectedPrompt: "delete the template with ID 03cb4fdd-8109-4a22-8e22-bb4975171395, don't ask for confirmation", + toolError: "500 Internal error deleting template: unauthorized: rbac: forbidden", + }, + } - // Setup MCP server proxies (with mock tools). - mcpProxiers, mcpCalls := setupMCPServerProxiesForTest(t, testTracer) - mcpMgr := mcp.NewServerProxyManager(mcpProxiers, testTracer) - require.NoError(t, mcpMgr.Init(ctx)) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - prov := provider.NewOpenAI(openaiCfg(mockAPI.URL, apiKey)) - mockRecorder := &testutil.MockRecorder{} - logger := slogtest.Make(t, &slogtest.Options{}).Leveled(slog.LevelDebug) + files := filesMap(txtar.Parse(tc.fixture)) + require.Contains(t, files, fixtureRequest) + require.Contains(t, files, fixtureNonStreamingResponse) + require.Contains(t, files, fixtureNonStreamingToolResponse) - bridge, err := aibridge.NewRequestBridge(ctx, []aibridge.Provider{prov}, mockRecorder, mcpMgr, logger, nil, testTracer) - require.NoError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), time.Second*30) + t.Cleanup(cancel) - srv := httptest.NewUnstartedServer(bridge) - srv.Config.BaseContext = func(_ net.Listener) context.Context { - return aibcontext.AsActor(ctx, userID, nil) - } - srv.Start() - t.Cleanup(srv.Close) + // Setup mock server with response mutator for multi-turn interaction. + mockAPI := newMockServer(ctx, t, files, func(reqCount uint32, resp []byte) []byte { + if reqCount == 1 { + return resp // First request gets the normal response (with tool call). + } + // Second request gets the tool response. + return files[fixtureNonStreamingToolResponse] + }) + t.Cleanup(mockAPI.Close) - req := createOpenAIResponsesReq(t, srv.URL, files[fixtureRequest]) - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode) + // Setup MCP server proxies (with mock tools). + mcpProxiers, mcpCalls := setupMCPServerProxiesForTest(t, testTracer) + if tc.toolError != "" { + mcpCalls.setToolError(tc.mcpToolName, tc.toolError) + } + mcpMgr := mcp.NewServerProxyManager(mcpProxiers, testTracer) + require.NoError(t, mcpMgr.Init(ctx)) - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) + prov := provider.NewOpenAI(openaiCfg(mockAPI.URL, apiKey)) + mockRecorder := &testutil.MockRecorder{} + logger := slogtest.Make(t, &slogtest.Options{}).Leveled(slog.LevelDebug) - // Wait for both requests to be made (inner agentic loop). - require.Eventually(t, func() bool { - return mockAPI.callCount.Load() == 2 - }, time.Second*10, time.Millisecond*50) - - // Verify the injected tool was invoked via MCP. - // The fixture uses "bmcp_coder_coder_template_version_parameters" as the tool ID, which maps to - // "coder_template_version_parameters" in the MCP server. - invocations := mcpCalls.getCallsByTool("coder_template_version_parameters") - require.Len(t, invocations, 1, "expected MCP tool to be invoked once") - - // Verify the injected tool usage was recorded. - // The tool name recorded is the MCP tool name (without prefix). - toolUsages := mockRecorder.RecordedToolUsages() - require.Len(t, toolUsages, 1) - require.Equal(t, "coder_template_version_parameters", toolUsages[0].Tool) - require.Equal(t, map[string]any{ - "template_version_id": "aa4e30e4-a086-4df6-a364-1343f1458104", - }, toolUsages[0].Args) - require.True(t, toolUsages[0].Injected, "injected tool should be marked as injected") - - // Verify prompt was recorded. - prompts := mockRecorder.RecordedPromptUsages() - require.Len(t, prompts, 1) - require.Equal(t, "list the template params for version aa4e30e4-a086-4df6-a364-1343f1458104", prompts[0].Prompt) - - // Verify the response is the final tool response (after agentic loop). - require.Equal(t, string(files[fixtureNonStreamingToolResponse]), string(body)) + bridge, err := aibridge.NewRequestBridge(ctx, []aibridge.Provider{prov}, mockRecorder, mcpMgr, logger, nil, testTracer) + require.NoError(t, err) + + srv := httptest.NewUnstartedServer(bridge) + srv.Config.BaseContext = func(_ net.Listener) context.Context { + return aibcontext.AsActor(ctx, userID, nil) + } + srv.Start() + t.Cleanup(srv.Close) + + req := createOpenAIResponsesReq(t, srv.URL, files[fixtureRequest]) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // Wait for both requests to be made (inner agentic loop). + require.Eventually(t, func() bool { + return mockAPI.callCount.Load() == 2 + }, time.Second*10, time.Millisecond*50) + + // Verify the injected tool was invoked via MCP. + invocations := mcpCalls.getCallsByTool(tc.mcpToolName) + require.Len(t, invocations, 1, "expected MCP tool to be invoked once") + + // Verify the injected tool usage was recorded. + toolUsages := mockRecorder.RecordedToolUsages() + require.Len(t, toolUsages, 1) + require.Equal(t, tc.mcpToolName, toolUsages[0].Tool) + require.Equal(t, tc.expectedToolArgs, toolUsages[0].Args) + require.True(t, toolUsages[0].Injected, "injected tool should be marked as injected") + + // Verify prompt was recorded. + prompts := mockRecorder.RecordedPromptUsages() + require.Len(t, prompts, 1) + require.Equal(t, tc.expectedPrompt, prompts[0].Prompt) + + // Verify the response is the final tool response (after agentic loop). + require.Equal(t, string(files[fixtureNonStreamingToolResponse]), string(body)) + }) + } } From 660e0dfe8c35f370ac188d60eaa3d2d67657a0fe Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Sat, 17 Jan 2026 16:53:48 +0200 Subject: [PATCH 08/15] chore: resolve tool recordings conflict Signed-off-by: Danny Kopping --- intercept/responses/base.go | 2 +- intercept/responses/base_test.go | 2 +- intercept/responses/blocking.go | 21 ++++++++++++--------- intercept/responses/injected_tools.go | 9 --------- intercept/responses/streaming.go | 12 ++++++------ 5 files changed, 20 insertions(+), 26 deletions(-) diff --git a/intercept/responses/base.go b/intercept/responses/base.go index d330838c..90456495 100644 --- a/intercept/responses/base.go +++ b/intercept/responses/base.go @@ -225,7 +225,7 @@ func (i *responsesInterceptionBase) recordUserPrompt(ctx context.Context, respon } } -func (i *responsesInterceptionBase) recordToolUsage(ctx context.Context, response *responses.Response) { +func (i *responsesInterceptionBase) recordNonInjectedToolUsage(ctx context.Context, response *responses.Response) { if response == nil { i.logger.Warn(ctx, "got empty response, skipping tool usage recording") return diff --git a/intercept/responses/base_test.go b/intercept/responses/base_test.go index d45a9bf8..6a8c8f94 100644 --- a/intercept/responses/base_test.go +++ b/intercept/responses/base_test.go @@ -318,7 +318,7 @@ func TestRecordToolUsage(t *testing.T) { logger: slog.Make(), } - base.recordToolUsage(t.Context(), tc.response) + base.recordNonInjectedToolUsage(t.Context(), tc.response) tools := rec.RecordedToolUsages() require.Len(t, tools, len(tc.expected)) diff --git a/intercept/responses/blocking.go b/intercept/responses/blocking.go index a0bc60dc..bb1e0537 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -85,7 +85,17 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * i.recordToolUsage(ctx, response) i.recordTokenUsage(ctx, response) - shouldLoop, err := i.handleInnerAgenticLoop(ctx, response) + // Check if there any injected tools to invoke. + pending := i.getPendingInjectedToolCalls(ctx, response) + if len(pending) == 0 { + // No injected tools, record non-injected tool usage. + i.recordNonInjectedToolUsage(ctx, response) + + // No injected function calls need to be invoked, flow is complete. + break + } + + shouldLoop, err := i.handleInnerAgenticLoop(ctx, pending, response) if err != nil { i.sendCustomErr(ctx, w, http.StatusInternalServerError, err) shouldLoop = false @@ -110,14 +120,7 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * // are invoked and their results are sent back to the model. // This is in contrast to regular tool calls which will be handled by the client // in its own agentic loop. -func (i *BlockingResponsesInterceptor) handleInnerAgenticLoop(ctx context.Context, response *responses.Response) (bool, error) { - // Check if there any injected tools to invoke. - pending := i.getPendingInjectedToolCalls(ctx, response) - if len(pending) == 0 { - // No injected function calls need to be invoked, flow is complete. - return false, nil - } - +func (i *BlockingResponsesInterceptor) handleInnerAgenticLoop(ctx context.Context, pending []responses.ResponseFunctionToolCall, response *responses.Response) (bool, error) { // Invoke any injected function calls. // The Responses API refers to what we call "tools" as "functions", so we keep the terminology // consistent in this package. diff --git a/intercept/responses/injected_tools.go b/intercept/responses/injected_tools.go index 9200c2fa..7610be18 100644 --- a/intercept/responses/injected_tools.go +++ b/intercept/responses/injected_tools.go @@ -125,15 +125,6 @@ func (i *BlockingResponsesInterceptor) getPendingInjectedToolCalls(ctx context.C // Check if this is a tool managed by our MCP proxy if i.mcpProxy != nil && i.mcpProxy.GetTool(fc.Name) != nil { calls = append(calls, fc) - } else { - // Record tool usage for non-managed tools - _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ - InterceptionID: i.ID().String(), - MsgID: response.ID, - Tool: fc.Name, - Args: fc.Arguments, - Injected: false, - }) } } diff --git a/intercept/responses/streaming.go b/intercept/responses/streaming.go index dec9e50c..49153f7f 100644 --- a/intercept/responses/streaming.go +++ b/intercept/responses/streaming.go @@ -114,18 +114,18 @@ func (i *StreamingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r completedEvent := ev.AsResponseCompleted() completedResponse = &completedEvent.Response } + + if completedResponse != nil { + i.recordNonInjectedToolUsage(ctx, completedResponse) + i.recordTokenUsage(ctx, completedResponse) + } + if err := events.Send(ctx, respCopy.buff.readDelta()); err != nil { err = fmt.Errorf("failed to relay chunk: %w", err) return err } } i.recordUserPrompt(ctx, responseID) - if completedResponse != nil { - i.recordToolUsage(ctx, completedResponse) - i.recordTokenUsage(ctx, completedResponse) - } else { - i.logger.Warn(ctx, "got empty response, skipping tool and token usage recording") - } b, err := respCopy.readAll() if err != nil { From 383fddf7565fdffa293ff70ad813442a3234aff6 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Sat, 17 Jan 2026 17:03:35 +0200 Subject: [PATCH 09/15] fix: no user prompt is valid not an error case Signed-off-by: Danny Kopping --- intercept/responses/base.go | 3 ++- intercept/responses/base_test.go | 20 ++------------------ 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/intercept/responses/base.go b/intercept/responses/base.go index 90456495..ecca955a 100644 --- a/intercept/responses/base.go +++ b/intercept/responses/base.go @@ -195,7 +195,8 @@ func (i *responsesInterceptionBase) lastUserPrompt() (string, error) { } } - return "", errors.New("failed to find last user prompt") + // Request was likely not human-initiated. + return "", nil } func (i *responsesInterceptionBase) recordUserPrompt(ctx context.Context, responseID string) { diff --git a/intercept/responses/base_test.go b/intercept/responses/base_test.go index 6a8c8f94..dfeda8d9 100644 --- a/intercept/responses/base_test.go +++ b/intercept/responses/base_test.go @@ -71,45 +71,30 @@ func TestLastUserPromptErr(t *testing.T) { require.Contains(t, "cannot get last user prompt: nil struct", err.Error()) }) - t.Run("nil_struct", func(t *testing.T) { - t.Parallel() - - base := responsesInterceptionBase{} - prompt, err := base.lastUserPrompt() - require.Error(t, err) - require.Empty(t, prompt) - require.Contains(t, "cannot get last user prompt: nil req struct", err.Error()) - }) - + // Other cases where the user prompt might be empty. tests := []struct { name string reqPayload []byte - wantErrMsg string }{ { name: "empty_input", reqPayload: []byte(`{"model": "gpt-4o", "input": []}`), - wantErrMsg: "failed to find last user prompt", }, { name: "no_user_role", reqPayload: []byte(`{"model": "gpt-4o", "input": [{"role": "assistant", "content": "hello"}]}`), - wantErrMsg: "failed to find last user prompt", }, { name: "user_with_empty_content", reqPayload: []byte(`{"model": "gpt-4o", "input": [{"role": "user", "content": ""}]}`), - wantErrMsg: "failed to find last user prompt", }, { name: "user_with_empty_content_array", reqPayload: []byte(`{"model": "gpt-4o", "input": [{"role": "user", "content": []}]}`), - wantErrMsg: "failed to find last user prompt", }, { name: "user_with_non_input_text_content", reqPayload: []byte(`{"model": "gpt-4o", "input": [{"role": "user", "content": [{"type": "input_image", "url": "http://example.com/img.png"}]}]}`), - wantErrMsg: "failed to find last user prompt", }, } @@ -127,9 +112,8 @@ func TestLastUserPromptErr(t *testing.T) { } prompt, err := base.lastUserPrompt() - require.Error(t, err) + require.NoError(t, err) require.Empty(t, prompt) - require.Contains(t, tc.wantErrMsg, err.Error()) }) } } From f9e637654481d9167cf6c22400aac5fbf2400fcc Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 19 Jan 2026 11:56:18 +0200 Subject: [PATCH 10/15] chore: tool logging Signed-off-by: Danny Kopping --- mcp/tool.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/mcp/tool.go b/mcp/tool.go index dac2da50..ff998791 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -6,6 +6,7 @@ import ( "errors" "regexp" "strings" + "time" "cdr.dev/slog/v3" "github.com/coder/aibridge/tracing" @@ -68,12 +69,29 @@ func (t *Tool) Call(ctx context.Context, input any, tracer trace.Tracer) (_ *mcp span.SetAttributes(attribute.String(tracing.MCPInput, strJson)) } - return t.Client.CallTool(ctx, mcp.CallToolRequest{ + start := time.Now() + res, err := t.Client.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: t.Name, Arguments: input, }, }) + + logFn := t.Logger.Debug + if err != nil { + logFn = t.Logger.Warn + } + + // We don't log MCP results because they could be large or contain sensitive information. + logFn(ctx, "injected tool invoked", + slog.F("name", t.Name), + slog.F("server", t.ServerName), + slog.F("input", inputJson), + slog.F("duration_sec", time.Since(start).Seconds()), + slog.Error(err), + ) + + return res, err } // EncodeToolID namespaces the given tool name with a prefix to identify tools injected by this library. From fda00f8b28e0c1c2c38bf201ef73ed70257c07ef Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 19 Jan 2026 11:56:42 +0200 Subject: [PATCH 11/15] chore: correcting tool definitions Signed-off-by: Danny Kopping --- intercept/responses/injected_tools.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/intercept/responses/injected_tools.go b/intercept/responses/injected_tools.go index 7610be18..498d92f1 100644 --- a/intercept/responses/injected_tools.go +++ b/intercept/responses/injected_tools.go @@ -27,10 +27,14 @@ func (i *responsesInterceptionBase) injectTools() { // Inject tools. for _, tool := range i.mcpProxy.ListTools() { - params := map[string]any{ - "type": "object", - "properties": tool.Params, - // "additionalProperties": false, // Only relevant when strict=true. + var params map[string]any + + if tool.Params != nil { + params = map[string]any{ + "type": "object", + "properties": tool.Params, + // "additionalProperties": false, // Only relevant when strict=true. + } } // Otherwise the request fails with "None is not of type 'array'" if a nil slice is given. From ef56b97f61bac63142776794f7b3e09a6e5956a5 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 19 Jan 2026 12:01:35 +0200 Subject: [PATCH 12/15] chore: remove vestigial block Signed-off-by: Danny Kopping --- intercept/responses/base.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/intercept/responses/base.go b/intercept/responses/base.go index ecca955a..af4f5331 100644 --- a/intercept/responses/base.go +++ b/intercept/responses/base.go @@ -156,18 +156,6 @@ func (i *responsesInterceptionBase) lastUserPrompt() (string, error) { return i.req.Input.OfString.Value, nil } - // If the input list is a slice and the SDK properly decoded the last item, - // check if the final message has a "user" role. - if count := len(i.req.Input.OfInputItemList); count > 0 { - last := i.req.Input.OfInputItemList[count-1] - // Only do this early check if OfInputMessage is populated (SDK decoded it). - // If nil, we fall through to gjson parsing which handles all cases. - if last.OfInputMessage != nil && last.OfInputMessage.Role != string(constant.ValueOf[constant.User]()) { - // The last message was not user-supplied. - return "", nil - } - } - // Fallback to parsing original bytes since golang SDK doesn't properly decode 'Input' field. // If 'type' field of input item is not set it will be omitted from 'Input.OfInputItemList' // It is an optional field according to API: https://platform.openai.com/docs/api-reference/responses/create#responses_create-input-input_item_list-input_message From cc8bfcdf9069257d631ed336cd1e45e5cebd57da Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 19 Jan 2026 13:35:54 +0200 Subject: [PATCH 13/15] chore: self-review Signed-off-by: Danny Kopping --- bridge_integration_test.go | 3 ++- intercept/responses/injected_tools.go | 2 +- mcp/tool.go | 9 +++++---- responses_integration_test.go | 3 +++ 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/bridge_integration_test.go b/bridge_integration_test.go index b32f6de8..6c75f0ee 100644 --- a/bridge_integration_test.go +++ b/bridge_integration_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net" @@ -1853,7 +1854,7 @@ func createMockMCPSrv(t *testing.T) (http.Handler, *callAccumulator) { s.AddTool(tool, func(ctx context.Context, request mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { acc.addCall(request.Params.Name, request.Params.Arguments) if errMsg, ok := acc.getToolError(request.Params.Name); ok { - return mcplib.NewToolResultError(errMsg), nil + return nil, errors.New(errMsg) } return mcplib.NewToolResultText("mock"), nil }) diff --git a/intercept/responses/injected_tools.go b/intercept/responses/injected_tools.go index 498d92f1..5e379419 100644 --- a/intercept/responses/injected_tools.go +++ b/intercept/responses/injected_tools.go @@ -26,7 +26,7 @@ func (i *responsesInterceptionBase) injectTools() { } // Inject tools. - for _, tool := range i.mcpProxy.ListTools() { + for _, tool := range tools { var params map[string]any if tool.Params != nil { diff --git a/mcp/tool.go b/mcp/tool.go index ff998791..cddf6271 100644 --- a/mcp/tool.go +++ b/mcp/tool.go @@ -70,7 +70,8 @@ func (t *Tool) Call(ctx context.Context, input any, tracer trace.Tracer) (_ *mcp } start := time.Now() - res, err := t.Client.CallTool(ctx, mcp.CallToolRequest{ + var res *mcp.CallToolResult + res, outErr = t.Client.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: t.Name, Arguments: input, @@ -78,7 +79,7 @@ func (t *Tool) Call(ctx context.Context, input any, tracer trace.Tracer) (_ *mcp }) logFn := t.Logger.Debug - if err != nil { + if outErr != nil { logFn = t.Logger.Warn } @@ -88,10 +89,10 @@ func (t *Tool) Call(ctx context.Context, input any, tracer trace.Tracer) (_ *mcp slog.F("server", t.ServerName), slog.F("input", inputJson), slog.F("duration_sec", time.Since(start).Seconds()), - slog.Error(err), + slog.Error(outErr), ) - return res, err + return res, outErr } // EncodeToolID namespaces the given tool name with a prefix to identify tools injected by this library. diff --git a/responses_integration_test.go b/responses_integration_test.go index 6a986745..6efe11ee 100644 --- a/responses_integration_test.go +++ b/responses_integration_test.go @@ -854,6 +854,9 @@ func TestResponsesBlockingInjectedTool(t *testing.T) { require.Equal(t, tc.mcpToolName, toolUsages[0].Tool) require.Equal(t, tc.expectedToolArgs, toolUsages[0].Args) require.True(t, toolUsages[0].Injected, "injected tool should be marked as injected") + if tc.toolError != "" { + require.Contains(t, toolUsages[0].InvocationError.Error(), tc.toolError) + } // Verify prompt was recorded. prompts := mockRecorder.RecordedPromptUsages() From cd10bd49660c40113842541dcc4351f73312feaf Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 19 Jan 2026 18:03:56 +0200 Subject: [PATCH 14/15] chore: review comments Signed-off-by: Danny Kopping --- intercept/responses/base_test.go | 2 +- intercept/responses/blocking.go | 1 - intercept/responses/injected_tools.go | 25 ++++++------------------- intercept/responses/streaming.go | 12 ++++++------ 4 files changed, 13 insertions(+), 27 deletions(-) diff --git a/intercept/responses/base_test.go b/intercept/responses/base_test.go index dfeda8d9..860b0a02 100644 --- a/intercept/responses/base_test.go +++ b/intercept/responses/base_test.go @@ -58,7 +58,7 @@ func TestLastUserPrompt(t *testing.T) { } } -func TestLastUserPromptErr(t *testing.T) { +func TestLastUserPromptEmptyPrompt(t *testing.T) { t.Parallel() t.Run("nil_struct", func(t *testing.T) { diff --git a/intercept/responses/blocking.go b/intercept/responses/blocking.go index bb1e0537..03d1337f 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -82,7 +82,6 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * // Record prompt usage on first successful response. i.recordUserPrompt(ctx, response.ID) - i.recordToolUsage(ctx, response) i.recordTokenUsage(ctx, response) // Check if there any injected tools to invoke. diff --git a/intercept/responses/injected_tools.go b/intercept/responses/injected_tools.go index 5e379419..e9eaedc9 100644 --- a/intercept/responses/injected_tools.go +++ b/intercept/responses/injected_tools.go @@ -79,7 +79,7 @@ func (i *responsesInterceptionBase) disableParallelToolCalls() { // handleInjectedToolCalls checks for function calls that we need to handle in our inner agentic loop. // These are functions injected by the MCP proxy. // Returns a list of tool call results. -func (i *BlockingResponsesInterceptor) handleInjectedToolCalls(ctx context.Context, pending []responses.ResponseFunctionToolCall, response *responses.Response) ([]responses.ResponseInputItemUnionParam, error) { +func (i *responsesInterceptionBase) handleInjectedToolCalls(ctx context.Context, pending []responses.ResponseFunctionToolCall, response *responses.Response) ([]responses.ResponseInputItemUnionParam, error) { if response == nil { return nil, fmt.Errorf("empty response") } @@ -99,7 +99,7 @@ func (i *BlockingResponsesInterceptor) handleInjectedToolCalls(ctx context.Conte // prepareRequestForAgenticLoop prepares the request by setting the output of the given // response as input to the next request, in order for the tool call result(s) to make function correctly. -func (i *BlockingResponsesInterceptor) prepareRequestForAgenticLoop(response *responses.Response) { +func (i *responsesInterceptionBase) prepareRequestForAgenticLoop(response *responses.Response) { // Unset the string input; we need a list now. i.req.Input.OfString = param.Opt[string]{} @@ -111,7 +111,7 @@ func (i *BlockingResponsesInterceptor) prepareRequestForAgenticLoop(response *re } // getPendingInjectedToolCalls extracts function calls from the response that are managed by MCP proxy -func (i *BlockingResponsesInterceptor) getPendingInjectedToolCalls(ctx context.Context, response *responses.Response) []responses.ResponseFunctionToolCall { +func (i *responsesInterceptionBase) getPendingInjectedToolCalls(ctx context.Context, response *responses.Response) []responses.ResponseFunctionToolCall { var calls []responses.ResponseFunctionToolCall for _, item := range response.Output { @@ -135,13 +135,13 @@ func (i *BlockingResponsesInterceptor) getPendingInjectedToolCalls(ctx context.C return calls } -func (i *BlockingResponsesInterceptor) invokeInjectedTool(ctx context.Context, responseID string, fc responses.ResponseFunctionToolCall) responses.ResponseInputItemUnionParam { +func (i *responsesInterceptionBase) invokeInjectedTool(ctx context.Context, responseID string, fc responses.ResponseFunctionToolCall) responses.ResponseInputItemUnionParam { tool := i.mcpProxy.GetTool(fc.Name) if tool == nil { return responses.ResponseInputItemParamOfFunctionCallOutput(fc.CallID, fmt.Sprintf("error: unknown injected function %q", fc.ID)) } - args := i.unmarshalArgs(fc.Arguments) + args := i.parseFunctionCallJSONArgs(ctx, fc.Arguments) res, err := tool.Call(ctx, args, i.tracer) _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ InterceptionID: i.ID().String(), @@ -178,7 +178,7 @@ func (i *BlockingResponsesInterceptor) invokeInjectedTool(ctx context.Context, r // The conversion uses the openai-go library's ToParam() methods where available, which leverage // param.Override() with raw JSON to preserve all fields. For types without ToParam(), we use // the ResponseInputItemParamOf* helper functions. -func (i *BlockingResponsesInterceptor) appendOutputToInput(req *ResponsesNewParamsWrapper, item responses.ResponseOutputItemUnion) { +func (i *responsesInterceptionBase) appendOutputToInput(req *ResponsesNewParamsWrapper, item responses.ResponseOutputItemUnion) { var inputItem responses.ResponseInputItemUnionParam switch item.Type { @@ -233,16 +233,3 @@ func (i *BlockingResponsesInterceptor) appendOutputToInput(req *ResponsesNewPara req.Input.OfInputItemList = append(req.Input.OfInputItemList, inputItem) } - -// unmarshalArgs unmarshals JSON arguments string into a map -func (i *BlockingResponsesInterceptor) unmarshalArgs(in string) (args recorder.ToolArgs) { - if len(strings.TrimSpace(in)) == 0 { - return args - } - - if err := json.Unmarshal([]byte(in), &args); err != nil { - i.logger.Warn(context.Background(), "failed to unmarshal tool args", slog.Error(err)) - } - - return args -} diff --git a/intercept/responses/streaming.go b/intercept/responses/streaming.go index 49153f7f..f1b1e02b 100644 --- a/intercept/responses/streaming.go +++ b/intercept/responses/streaming.go @@ -114,18 +114,18 @@ func (i *StreamingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r completedEvent := ev.AsResponseCompleted() completedResponse = &completedEvent.Response } - - if completedResponse != nil { - i.recordNonInjectedToolUsage(ctx, completedResponse) - i.recordTokenUsage(ctx, completedResponse) - } - if err := events.Send(ctx, respCopy.buff.readDelta()); err != nil { err = fmt.Errorf("failed to relay chunk: %w", err) return err } } i.recordUserPrompt(ctx, responseID) + if completedResponse != nil { + i.recordNonInjectedToolUsage(ctx, completedResponse) + i.recordTokenUsage(ctx, completedResponse) + } else { + i.logger.Warn(ctx, "got empty response, skipping tool and token usage recording") + } b, err := respCopy.readAll() if err != nil { From d0b8e5839a4149e3d102987385538777acce263d Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 19 Jan 2026 18:45:16 +0200 Subject: [PATCH 15/15] chore: note about re-marshaling Signed-off-by: Danny Kopping --- intercept/responses/blocking.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/intercept/responses/blocking.go b/intercept/responses/blocking.go index 03d1337f..d52e4104 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -138,6 +138,9 @@ func (i *BlockingResponsesInterceptor) handleInnerAgenticLoop(ctx context.Contex i.prepareRequestForAgenticLoop(response) i.req.Input.OfInputItemList = append(i.req.Input.OfInputItemList, results...) + // TODO: we should avoid re-marshaling Input, but since it changes from a string to + // a list in this loop, we have to. + // See responsesInterceptionBase.requestOptions for more details about marshaling issues. i.reqPayload, err = sjson.SetBytes(i.reqPayload, "input", i.req.Input) if err != nil { i.logger.Error(ctx, "failure to marshal new input in inner agentic loop", slog.Error(err))