From 0bfbf6b08f03e994d6761cc7be1f96352858bf35 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 15 Jan 2026 15:22:00 +0200 Subject: [PATCH 01/13] 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 951770d..c321eb8 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 debf78fe84d8610b213ca096f9faeb749144502a Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 15 Jan 2026 15:49:29 +0200 Subject: [PATCH 02/13] 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 606e799..56de0d1 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 5125e4d..352ac37 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 230673b176bbdfc4ddef0d1062bfbbf3cbaea518 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 16 Jan 2026 18:16:25 +0200 Subject: [PATCH 03/13] WIP Signed-off-by: Danny Kopping --- intercept/responses/base.go | 43 +++-- intercept/responses/blocking.go | 262 +++++++++++++++++++++++++++++-- intercept/responses/streaming.go | 4 +- provider/openai.go | 4 +- 4 files changed, 284 insertions(+), 29 deletions(-) diff --git a/intercept/responses/base.go b/intercept/responses/base.go index 56de0d1..8d21335 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 352ac37..19f77e1 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,18 +58,57 @@ 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) + + // 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() { @@ -69,3 +120,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 1aaf9d7..3014899 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 c321eb8..2e60609 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 7af135504834cc0b98822b396adbfd38ef2763b3 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Sat, 17 Jan 2026 10:40:56 +0200 Subject: [PATCH 04/13] 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 8d21335..b7f9813 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 19f77e1..ed2f3b2 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" @@ -90,8 +85,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")) @@ -105,8 +99,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 } } @@ -120,196 +114,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 0000000..526adaa --- /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 9f91ebd3eca1ad5e5e41432ee850a8f2dfd531f4 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Sat, 17 Jan 2026 11:19:37 +0200 Subject: [PATCH 05/13] 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 ed2f3b2..bdf2757 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -1,6 +1,7 @@ package responses import ( + "context" "errors" "fmt" "net/http" @@ -81,26 +82,13 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * // Record prompt usage on first successful response. i.recordUserPrompt(ctx, response.ID) - // 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 } } @@ -114,3 +102,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 526adaa..69b8e26 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 f918916a22c6bfb4fc743ac94dfeff71b79bcf2c Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Sat, 17 Jan 2026 12:04:30 +0200 Subject: [PATCH 06/13] 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 21bff9d..af78dd5 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/custom_tool.txtar OaiResponsesBlockingCustomTool []byte @@ -65,6 +65,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 0000000..028377d --- /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 9f723dd..e830071 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 5c12576..8e1f0f2 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" @@ -47,7 +52,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{ @@ -602,3 +607,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 814e8fa69cf35e222c3c5281adb3f4b9021bf42a Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Sat, 17 Jan 2026 15:06:39 +0200 Subject: [PATCH 07/13] 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 bb07b5f..dcd4e6f 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 670352d..b32f6de 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 af78dd5..1deec5a 100644 --- a/fixtures/fixtures.go +++ b/fixtures/fixtures.go @@ -68,6 +68,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 0000000..9e4c271 --- /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 b7f9813..e5aceb3 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 bdf2757..d405c5d 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 69b8e26..9200c2f 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 3014899..8327c2c 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 8e1f0f2..3327910 100644 --- a/responses_integration_test.go +++ b/responses_integration_test.go @@ -613,78 +613,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 815ec23ac6bca098637734bee7840e84b30a856a Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Sat, 17 Jan 2026 16:53:48 +0200 Subject: [PATCH 08/13] 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 | 2 +- 5 files changed, 15 insertions(+), 21 deletions(-) diff --git a/intercept/responses/base.go b/intercept/responses/base.go index e5aceb3..ffd9863 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 e830071..43f14d7 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 d405c5d..f72dc05 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -83,7 +83,17 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * // Record prompt usage on first successful response. i.recordUserPrompt(ctx, response.ID) - 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 @@ -108,14 +118,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 9200c2f..7610be1 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 8327c2c..204e840 100644 --- a/intercept/responses/streaming.go +++ b/intercept/responses/streaming.go @@ -120,7 +120,7 @@ func (i *StreamingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r } } i.recordUserPrompt(ctx, responseID) - i.recordToolUsage(ctx, completedResponse) + i.recordNonInjectedToolUsage(ctx, completedResponse) b, err := respCopy.readAll() if err != nil { From 60c1a463513170af1cb6ee9bc90f56dd1e2d3d48 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Sat, 17 Jan 2026 17:03:35 +0200 Subject: [PATCH 09/13] 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 ffd9863..6759715 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 43f14d7..3b8530b 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 78c3c8835be702fc821a654d181379f283c24df2 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 19 Jan 2026 11:56:18 +0200 Subject: [PATCH 10/13] 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 dac2da5..ff99879 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 b98ecef6c5936864e611b2fd771945a7c80605da Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 19 Jan 2026 11:56:42 +0200 Subject: [PATCH 11/13] 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 7610be1..498d92f 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 0b4a2581718846f0c1e276bd5d520330d6b43a35 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 19 Jan 2026 12:01:35 +0200 Subject: [PATCH 12/13] 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 6759715..05352fc 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 101b7398c2cd6a2b7058e3400ab047342b7825c6 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 19 Jan 2026 13:35:54 +0200 Subject: [PATCH 13/13] 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 b32f6de..6c75f0e 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 498d92f..5e37941 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 ff99879..cddf627 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 3327910..be88d70 100644 --- a/responses_integration_test.go +++ b/responses_integration_test.go @@ -710,6 +710,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()