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..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" @@ -1792,16 +1793,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 +1847,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 nil, errors.New(errMsg) + } return mcplib.NewToolResultText("mock"), nil }) } diff --git a/fixtures/fixtures.go b/fixtures/fixtures.go index 21bff9d..1deec5a 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,12 @@ var ( //go:embed openai/responses/blocking/wrong_response_format.txtar OaiResponsesBlockingWrongResponseFormat []byte + + //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/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/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 606e799..05352fc 100644 --- a/intercept/responses/base.go +++ b/intercept/responses/base.go @@ -26,10 +26,10 @@ import ( "github.com/google/uuid" "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 ( @@ -42,6 +42,7 @@ type responsesInterceptionBase struct { reqPayload []byte cfg config.OpenAI model string + tracer trace.Tracer recorder recorder.Recorder mcpProxy mcp.ServerProxier logger slog.Logger @@ -97,18 +98,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 } @@ -174,7 +163,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: @@ -194,7 +183,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) { @@ -204,8 +194,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 } @@ -224,7 +214,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 @@ -235,9 +225,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/base_test.go b/intercept/responses/base_test.go index 9f723dd..3b8530b 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.", }, { @@ -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()) }) } } @@ -318,7 +302,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 5125e4d..f72dc05 100644 --- a/intercept/responses/blocking.go +++ b/intercept/responses/blocking.go @@ -1,22 +1,29 @@ package responses import ( + "context" "errors" + "fmt" "net/http" + "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/responses" + "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 +31,7 @@ func NewBlockingInterceptor(id uuid.UUID, req *ResponsesNewParamsWrapper, reqPay reqPayload: reqPayload, cfg: cfg, model: model, + tracer: tracer, }, } } @@ -46,16 +54,54 @@ func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r * return err } - srv := i.newResponsesService() - var respCopy responseCopier + i.injectTools() + i.disableParallelToolCalls() - opts := i.requestOptions(&respCopy) - response, upstreamErr := srv.New(ctx, i.req.ResponseNewParams, opts...) + var ( + response *responses.Response + upstreamErr error + respCopy responseCopier + ) - // response could be nil eg. fixtures/openai/responses/blocking/wrong_response_format.txtar - if response != nil { + 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 { + break + } + + // Record prompt usage on first successful response. i.recordUserPrompt(ctx, response.ID) - i.recordToolUsage(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 + } + + if !shouldLoop { + break + } } if upstreamErr != nil && !respCopy.responseReceived.Load() { @@ -67,3 +113,36 @@ 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, 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. + // 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 new file mode 100644 index 0000000..5e37941 --- /dev/null +++ b/intercept/responses/injected_tools.go @@ -0,0 +1,248 @@ +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 + } + + // Inject tools. + for _, tool := range tools { + 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. + 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)) + } +} + +// 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. +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") + } + + // MCP proxy has not been configured; no way to handle injected functions. + if i.mcpProxy == nil { + return nil, nil + } + + var results []responses.ResponseInputItemUnionParam + for _, fc := range pending { + results = append(results, i.invokeInjectedTool(ctx, response.ID, fc)) + } + + return results, nil +} + +// prepareRequestForAgenticLoop prepares the request by setting the output of the given +// response as input to the next request, in order for the tool call result(s) to make function correctly. +func (i *BlockingResponsesInterceptor) prepareRequestForAgenticLoop(response *responses.Response) { + // Unset the string input; we need a list now. + 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(i.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) + } + } + + 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 +} diff --git a/intercept/responses/streaming.go b/intercept/responses/streaming.go index 1aaf9d7..204e840 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, }, } } @@ -59,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() { @@ -116,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 { diff --git a/mcp/tool.go b/mcp/tool.go index dac2da5..cddf627 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,30 @@ 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() + var res *mcp.CallToolResult + res, outErr = t.Client.CallTool(ctx, mcp.CallToolRequest{ Params: mcp.CallToolParams{ Name: t.Name, Arguments: input, }, }) + + logFn := t.Logger.Debug + if outErr != 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(outErr), + ) + + return res, outErr } // EncodeToolID namespaces the given tool name with a prefix to identify tools injected by this library. diff --git a/provider/openai.go b/provider/openai.go index 951770d..2e60609 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 } @@ -114,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: diff --git a/responses_integration_test.go b/responses_integration_test.go index 5c12576..be88d70 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,120 @@ 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() + + 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", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + files := filesMap(txtar.Parse(tc.fixture)) + 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) + if tc.toolError != "" { + mcpCalls.setToolError(tc.mcpToolName, tc.toolError) + } + 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. + 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") + if tc.toolError != "" { + require.Contains(t, toolUsages[0].InvocationError.Error(), tc.toolError) + } + + // 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)) + }) + } +}