[
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: 🐛 Bug Report\nabout: Report a reproducible bug or issue\ntitle: \"[Bug]: <describe bug>\"\nlabels: bug\n---\n\n**Environment (please complete the following):**\n- OS: [e.g. Ubuntu 22.04]\n- kubectl-ai version (run `kubectl-ai version`): [e.g. 0.3.0]\n- LLM provider: [e.g. gemini, openai, grok...]\n- LLM model: [e.g. gemini-2.5-pro]\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Run command '...'\n3. See error\n\n**Expected behavior**\nWhat you expected to happen.\n\n**Additional context**\nAdd any other context or logs here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: 🚀 Feature Request\nabout: Suggest an idea for a new feature or improvement\ntitle: \"[Feature]: <describe your idea>\"\nlabels: enhancement\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear description of the problem you're trying to solve.\n\n**Describe the solution you'd like**\nA clear description of what you want to happen.\n\n**Describe alternatives you've considered**\nAny alternative solutions or features you’ve thought of.\n\n**Additional context**\nAdd any other context, links, or screenshots here.\n"
  },
  {
    "path": ".github/actions/kind-cluster-setup/action.yaml",
    "content": "name: Kind Cluster Setup\ndescription: \"Sets up a Kind Kubernetes cluster and authenticates with GCP\"\ninputs:\n  cluster_name:\n    description: \"The name of the Kind cluster\"\n    required: false\n    default: \"periodic-eval-cluster\"\nruns:\n  using: \"composite\"\n  steps:\n    - uses: actions/checkout@v4\n    - name: Create k8s Kind Cluster\n      uses: helm/kind-action@v1.12.0\n      with:\n        cluster_name: ${{ inputs.cluster_name }}\n        wait: 300s\n\n    - uses: \"google-github-actions/auth@v2\"\n      with:\n        project_id: \"sunilarora-fp\"\n        workload_identity_provider: \"projects/512195022720/locations/global/workloadIdentityPools/github/providers/kubectl-ai\"\n"
  },
  {
    "path": ".github/kubectl-ai.cast",
    "content": "{\"version\": 2, \"width\": 190, \"height\": 49, \"timestamp\": 1744912218, \"env\": {\"SHELL\": \"/bin/bash\", \"TERM\": \"screen-256color\"}}\n[0.017789, \"o\", \"bash-3.2$ \"]\n[8.320293, \"o\", \".\"]\n[8.513894, \"o\", \"/\"]\n[8.769729, \"o\", \"k\"]\n[8.993115, \"o\", \"u\"]\n[9.054592, \"o\", \"b\"]\n[9.209666, \"o\", \"e\"]\n[9.861315, \"o\", \"c\"]\n[10.073644, \"o\", \"t\"]\n[10.307934, \"o\", \"\\u0007\"]\n[10.308033, \"o\", \"l-\"]\n[10.853505, \"o\", \"a\"]\n[10.969856, \"o\", \"i\"]\n[11.170596, \"o\", \" \"]\n[14.210904, \"o\", \"\\r\\n\"]\n[14.351719, \"o\", \"\\r\\n\\u001b[38;5;252m\\u001b[0m\\u001b[38;5;252m\\u001b[0m  \\u001b[38;5;252mHey there, what can I help you with\\u001b[0m\\u001b[38;5;252m today?\\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\u001b[38;5;252m \\u001b[0m\\r\\n\\r\\n\\r\\n\\r\\n>>> \"]\n[18.72908, \"o\", \"h\"]\n[18.977149, \"o\", \"o\"]\n[19.130497, \"o\", \"w\"]\n[19.379084, \"o\", \"'\"]\n[19.442264, \"o\", \"s\"]\n[19.535301, \"o\", \" \"]\n[19.720667, \"o\", \"n\"]\n[19.945368, \"o\", \"g\"]\n[20.007533, \"o\", \"i\"]\n[20.078888, \"o\", \"n\"]\n[20.191491, \"o\", \"x\"]\n[20.318561, \"o\", \" \"]\n[20.414191, \"o\", \"a\"]\n[20.509116, \"o\", \"p\"]\n[20.640961, \"o\", \"p\"]\n[21.018349, \"o\", \" \"]\n[21.121216, \"o\", \"d\"]\n[21.224431, \"o\", \"o\"]\n[21.42611, \"o\", \"i\"]\n[21.484012, \"o\", \"n\"]\n[21.554566, \"o\", \"g\"]\n[21.595759, \"o\", \" \"]\n[21.692772, \"o\", \"i\"]\n[21.743452, \"o\", \"n\"]\n[21.831695, \"o\", \" \"]\n[21.953349, \"o\", \"m\"]\n[22.153589, \"o\", \"y\"]\n[22.188041, \"o\", \" \"]\n[22.286241, \"o\", \"c\"]\n[22.390762, \"o\", \"l\"]\n[22.571458, \"o\", \"u\"]\n[22.625392, \"o\", \"s\"]\n[22.834233, \"o\", \"t\"]\n[23.01689, \"o\", \"e\"]\n[23.15806, \"o\", \"r\"]\n[23.247809, \"o\", \" \"]\n[23.442082, \"o\", \"?\"]\n[23.657884, \"o\", \"\\r\\n\"]\n[32.871756, \"o\", \"\\u001b[31mError: reading streaming LLM response: iterateResponseStream: invalid stream chunk: {\\r\\n  \\\"error\\\": {\\r\\n    \\\"code\\\": 500,\\r\\n    \\\"message\\\": \\\"An internal error has occurred. Please retry or report in https://developers.generativeai.google/guide/troubleshooting\\\",\\r\\n    \\\"status\\\": \\\"INTERNAL\\\",\\r\\n    \\\"details\\\": [\\r\\n      {\\r\\n        \\\"@type\\\": \\\"type.googleapis.com/google.rpc.DebugInfo\\\",\\r\\n        \\\"detail\\\": \\\"syntax error in the model generated tool call.\\\"\\r\\n      }\\r\\n    ]\\r\\n  }\\r\\n}\\r\\n\\r\\n\\u001b[0m\\r\\n\\r\\n>>> \"]\n[52.693406, \"o\", \"^C\"]\n[52.69373, \"o\", \"Received signal, shutting down... interrupt\\r\\n\"]\n[52.702021, \"o\", \"bash-3.2$ \"]\n[53.929981, \"o\", \"w\"]\n[54.073289, \"o\", \"h\"]\n[54.105594, \"o\", \"i\"]\n[54.199234, \"o\", \"c\"]\n[54.278376, \"o\", \"h\"]\n[54.391889, \"o\", \" \"]\n[55.888282, \"o\", \"k\"]\n[56.095038, \"o\", \"u\"]\n[56.172749, \"o\", \"e\"]\n[56.18066, \"o\", \"b\"]\n[56.361241, \"o\", \"c\"]\n[56.569981, \"o\", \"t\"]\n[56.6488, \"o\", \"l\"]\n[56.847693, \"o\", \"-\"]\n[57.289273, \"o\", \"\\b\\u001b[K\"]\n[57.429839, \"o\", \"\\b\\u001b[K\"]\n[57.585486, \"o\", \"\\b\\u001b[K\"]\n[57.717702, \"o\", \"\\b\\u001b[K\"]\n[57.874424, \"o\", \"\\b\\u001b[K\"]\n[58.002212, \"o\", \"\\b\\u001b[K\"]\n[60.177642, \"o\", \"\\b\\u001b[K\"]\n[60.429335, \"o\", \"\\b\\u001b[K\"]\n[60.461237, \"o\", \"\\b\\u001b[K\"]\n[60.494139, \"o\", \"\\b\\u001b[K\"]\n[60.527807, \"o\", \"\\b\\u001b[K\"]\n[60.561504, \"o\", \"\\b\\u001b[K\"]\n[60.599378, \"o\", \"\\b\\u001b[K\"]\n[60.63316, \"o\", \"\\b\\u001b[K\"]\n[60.661988, \"o\", \"\\u0007\"]\n[60.694941, \"o\", \"\\u0007\"]\n[60.72875, \"o\", \"\\u0007\"]\n[60.766251, \"o\", \"\\u0007\"]\n[61.063912, \"o\", \"g\"]\n[61.136886, \"o\", \"o\"]\n[61.25609, \"o\", \" \"]\n[61.420433, \"o\", \"b\"]\n[61.458215, \"o\", \"u\"]\n[61.664819, \"o\", \"i\"]\n[61.858026, \"o\", \"l\"]\n[61.951196, \"o\", \"d\"]\n[62.030982, \"o\", \"\\r\\n\"]\n[64.101433, \"o\", \"bash-3.2$ \"]\n[65.730822, \"o\", \"exit\\r\\n\"]\n"
  },
  {
    "path": ".github/workflows/ci-periodic.yaml",
    "content": "# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Generated by dev/tasks/generate-github-actions\n\nname: Run evals periodically\non:\n  schedule:\n    # Run every 15 minutes\n    - cron: \"*/15 * * * *\"\n  workflow_dispatch:\n    # This allows you to manually trigger the workflow from the GitHub UI\n    inputs:\n      reason:\n        description: \"Reason for manual trigger\"\n        required: false\n        default: \"Manual run via UI\"\njobs:\n  run-eval:\n    if: github.repository == 'GoogleCloudPlatform/kubectl-ai'\n    runs-on: ubuntu-latest\n    timeout-minutes: 12\n    # Add \"id-token\" with the intended permissions.\n    permissions:\n      contents: \"read\"\n      id-token: \"write\"\n    steps:\n      - uses: actions/checkout@v4\n      - name: Kind Cluster Setup\n        uses: ./.github/actions/kind-cluster-setup\n        with:\n          cluster_name: periodic-eval-cluster\n        continue-on-error: false\n        timeout-minutes: 3\n      - name: Run an easy eval\n        run: |\n          for attempt in 1 2; do\n            echo \"=== Evaluation attempt $attempt/2 ===\"\n            if timeout 4m bash -c 'TEST_ARGS=\"--llm-provider vertexai --models gemini-2.5-pro --concurrency=1 --task-pattern=scale-\" ./dev/ci/periodics/run-evals.sh'; then\n              echo \"Evaluation completed successfully on attempt $attempt\"\n              break\n            else\n              echo \"Attempt $attempt failed or timed out\"\n              \n              # Cleanup any hanging processes\n              pkill -f k8s-ai-bench || true\n              pkill -f kubectl-ai || true\n              \n              if [ $attempt -eq 2 ]; then\n                echo \"❌ Both attempts failed\"\n                exit 1\n              else\n                echo \"Waiting 10 seconds before retry...\"\n                sleep 10\n              fi\n            fi\n          done\n      - name: Analyse results\n        run: |\n          ./dev/ci/periodics/analyze-evals.sh\n          cat ${{ github.workspace }}/.build/k8s-ai-bench.md >> ${GITHUB_STEP_SUMMARY}\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}\n  cancel-in-progress: false\n"
  },
  {
    "path": ".github/workflows/ci-presubmit.yaml",
    "content": "# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Generated by dev/tasks/generate-github-actions\n\nname: ci-presubmit\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened]\n  push:\n    branches: [\"main\"]\n\njobs:\n\n  go-build:\n    runs-on: ubuntu-latest\n    timeout-minutes: 60\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-go@v5\n        with:\n          go-version-file: 'go.mod'\n      - name: \"Run dev/ci/presubmits/go-build.sh\"\n        run: |\n          ./dev/ci/presubmits/go-build.sh\n\n\n  go-vet:\n    runs-on: ubuntu-latest\n    timeout-minutes: 60\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-go@v5\n        with:\n          go-version-file: 'go.mod'\n      - name: \"Run dev/ci/presubmits/go-vet.sh\"\n        run: |\n          ./dev/ci/presubmits/go-vet.sh\n\n\n  verify-autogen:\n    runs-on: ubuntu-latest\n    timeout-minutes: 60\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-go@v5\n        with:\n          go-version-file: 'go.mod'\n      - name: \"Run dev/ci/presubmits/verify-autogen.sh\"\n        run: |\n          ./dev/ci/presubmits/verify-autogen.sh\n\n\n  verify-format:\n    runs-on: ubuntu-latest\n    timeout-minutes: 60\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-go@v5\n        with:\n          go-version-file: 'go.mod'\n      - name: \"Run dev/ci/presubmits/verify-format.sh\"\n        run: |\n          ./dev/ci/presubmits/verify-format.sh\n\n\n  verify-gomod:\n    runs-on: ubuntu-latest\n    timeout-minutes: 60\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-go@v5\n        with:\n          go-version-file: 'go.mod'\n      - name: \"Run dev/ci/presubmits/verify-gomod.sh\"\n        run: |\n          ./dev/ci/presubmits/verify-gomod.sh\n\n\n  verify-mocks:\n    runs-on: ubuntu-latest\n    timeout-minutes: 60\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-go@v5\n        with:\n          go-version-file: 'go.mod'\n      - name: \"Run dev/ci/presubmits/verify-mocks.sh\"\n        run: |\n          ./dev/ci/presubmits/verify-mocks.sh\n\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}\n  cancel-in-progress: true\n"
  },
  {
    "path": ".github/workflows/k8s-bench-evals.yaml",
    "content": "# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# This workflow allows PR owners who add new eval tasks to manually run tests on their changes using the GitHub Actions UI.\n#It is intended for self-service validation of new or modified evals before merging.\nname: On-Demand k8s-ai-bench Eval Test\n\non:\n  workflow_dispatch:\n    inputs:\n      task_pattern:\n        description: \"Task name or glob pattern to test (must not be '*' or empty; e.g. scale-my-task, scale-foo)\"\n        required: true\n\njobs:\n  run-eval:\n    runs-on: ubuntu-latest\n    timeout-minutes: 30\n    permissions:\n      contents: \"read\"\n      id-token: \"write\"\n    steps:\n      - name: Validate task_pattern input\n        run: |\n          if [[ -z \"${{ github.event.inputs.task_pattern }}\" || \"${{ github.event.inputs.task_pattern }}\" == \"*\" || \"${{ github.event.inputs.task_pattern }}\" == \"all\" ]]; then\n            echo \"Error: You must provide a specific task name or pattern. Wildcards or empty values are not allowed.\"\n            exit 1\n          fi\n      - uses: actions/checkout@v4\n      - name: Kind Cluster Setup\n        uses: ./.github/actions/kind-cluster-setup\n        with:\n          cluster_name: ${{ github.head_ref || github.ref_name }}\n      - name: Run evals\n        # In the future, more options (provider/model/tool-use-shim) may be user-selectable.\n        # For now, these are fixed for CI safety and consistency.\n        env:\n          TEST_ARGS: >-\n            --llm-provider ${{ github.event.inputs.llm_provider || 'vertexai' }} \\\n            --models ${{ github.event.inputs.model || 'gemini-2.5-pro' }} \\\n            --enable-tool-use-shim=${{ github.event.inputs.enable_tool_use_shim || 'false' }} \\\n            --task-pattern=${{ github.event.inputs.task_pattern || 'scale-' }}\n        run: |\n          ./dev/ci/periodics/run-evals.sh\n      - name: Analyse results\n        run: |\n          ./dev/ci/periodics/analyze-evals.sh\n          cat ${{ github.workspace }}/.build/k8s-ai-bench.md >> ${GITHUB_STEP_SUMMARY}\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}\n  cancel-in-progress: false\n"
  },
  {
    "path": ".github/workflows/release.yaml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - 'v*'\n\npermissions:\n  contents: write\n  packages: write\n\njobs:\n  goreleaser:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Set up Go\n        uses: actions/setup-go@v5\n        with:\n          check-latest: true\n\n      - name: Install mockgen\n        run: go install go.uber.org/mock/mockgen@latest\n\n      - name: Run GoReleaser\n        uses: goreleaser/goreleaser-action@v6\n        with:\n          distribution: goreleaser\n          version: latest\n          args: release --clean\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \n\n      - name: Update new version in krew-index\n        uses: rajatjindal/krew-release-bot@v0.0.46"
  },
  {
    "path": ".gitignore",
    "content": "# Binary\n./kubectl-ai\nbin/\n.build/\n\n# Log files\n*.log\ntrace.log\nprompt.log\napp.log\n\n# OS specific files\n.DS_Store\n.env\n\n# IDE specific files\n.idea/\n.vscode/\n*.swp\n*.swo\n\n# Go specific\n*.exe\n*.test\n*.prof\n*.out\n.aider*\n# Added by goreleaser init:\ndist/\n\n# Ignore generated credentials from google-github-actions/auth\ngha-creds-*.json\n\n# air config\n.air.toml"
  },
  {
    "path": ".goreleaser.yaml",
    "content": "# This is an example .goreleaser.yml file with some sensible defaults.\n# Make sure to check the documentation at https://goreleaser.com\n\n# The lines below are called `modelines`. See `:help modeline`\n# Feel free to remove those if you don't want/need to use them.\n# yaml-language-server: $schema=https://goreleaser.com/static/schema.json\n# vim: set ts=2 sw=2 tw=0 fo=cnqoj\n\nversion: 2\n\nbefore:\n  hooks:\n    # You may remove this if you don't use go modules.\n    - go mod tidy\n    # you may remove this if you don't need go generate\n    - go generate ./...\n\nbuilds:\n  - env:\n      - CGO_ENABLED=0\n    goos:\n      - linux\n      - windows\n      - darwin\n    main: ./cmd\n    ldflags:\n      - -s -w\n      - -X main.version={{.Version}}\n      - -X main.commit={{.Commit}}\n      - -X main.date={{.Date}}\n\narchives:\n  - formats: [tar.gz]\n    # this name template makes the OS and Arch compatible with the results of `uname`.\n    name_template: >-\n      {{ .ProjectName }}_\n      {{- title .Os }}_\n      {{- if eq .Arch \"amd64\" }}x86_64\n      {{- else if eq .Arch \"386\" }}i386\n      {{- else }}{{ .Arch }}{{ end }}\n      {{- if .Arm }}v{{ .Arm }}{{ end }}\n    # use zip for windows archives\n    format_overrides:\n      - goos: windows\n        formats: [zip]\n\nchangelog:\n  sort: asc\n  filters:\n    exclude:\n      - \"^docs:\"\n      - \"^test:\"\n\nrelease:\n  footer: >-\n\n    ---\n\n    Released by [GoReleaser](https://github.com/goreleaser/goreleaser).\n"
  },
  {
    "path": ".krew.yaml",
    "content": "apiVersion: krew.googlecontainertools.github.com/v1alpha2\nkind: Plugin\nmetadata:\n  name: ai\nspec:\n  version: {{ .TagName }}\n  homepage: https://github.com/GoogleCloudPlatform/kubectl-ai\n  shortDescription: AI-powered Kubernetes assistant\n  description: |\n    This plugin provides a natural language interface to carry out kubernetes\n    related tasks. The plugin can plan and execute multiple steps given a high\n    level description of a task.\n    It's important to note that this plugin does not replace kubectl. Instead,\n    it makes kubectl accessible to non-kubernetes users and makes kubectl users\n    more productive because now they don't have to remember all the syntax and\n    commands to perform common tasks.\n  caveats: |\n    This plugin uses AI models (LLM) to plan and execute tasks. It supports\n    multiple LLM providers such as Gemini, Azure-OpenAI, Ollama, llamacpp.\n    You can get the API key for the default provider (Gemini) from\n    https://aistudio.google.com/app/apikey.\n  platforms:\n  - selector:\n      matchLabels:\n        os: linux\n        arch: amd64\n    {{addURIAndSha \"https://github.com/GoogleCloudPlatform/kubectl-ai/releases/download/{{ .TagName }}/kubectl-ai_Linux_x86_64.tar.gz\" .TagName }}\n    bin: kubectl-ai\n  - selector:\n      matchLabels:\n        os: linux\n        arch: arm64\n    {{addURIAndSha \"https://github.com/GoogleCloudPlatform/kubectl-ai/releases/download/{{ .TagName }}/kubectl-ai_Linux_arm64.tar.gz\" .TagName }}\n    bin: kubectl-ai\n  - selector:\n      matchLabels:\n        os: darwin\n        arch: amd64\n    {{addURIAndSha \"https://github.com/GoogleCloudPlatform/kubectl-ai/releases/download/{{ .TagName }}/kubectl-ai_Darwin_x86_64.tar.gz\" .TagName }}\n    bin: kubectl-ai\n  - selector:\n      matchLabels:\n        os: darwin\n        arch: arm64\n    {{addURIAndSha \"https://github.com/GoogleCloudPlatform/kubectl-ai/releases/download/{{ .TagName }}/kubectl-ai_Darwin_arm64.tar.gz\" .TagName }}\n    bin: kubectl-ai\n  - selector:\n      matchLabels:\n        os: windows\n        arch: amd64\n    {{addURIAndSha \"https://github.com/GoogleCloudPlatform/kubectl-ai/releases/download/{{ .TagName }}/kubectl-ai_Windows_x86_64.zip\" .TagName }}\n    bin: kubectl-ai.exe\n  - selector:\n      matchLabels:\n        os: windows\n        arch: arm64\n    {{addURIAndSha \"https://github.com/GoogleCloudPlatform/kubectl-ai/releases/download/{{ .TagName }}/kubectl-ai_Windows_arm64.zip\" .TagName }}\n    bin: kubectl-ai.exe"
  },
  {
    "path": "CONTAINER.md",
    "content": "# Running kubectl-ai in a Docker Container\n\n## 1. Build the Docker Image\n\nFirst, clone the `kubectl-ai` repository and build the Docker image from the\nsource code.\n\n```bash\ngit clone https://github.com/GoogleCloudPlatform/kubectl-ai.git\ncd kubectl-ai\ndocker build -t kubectl-ai:latest -f images/kubectl-ai/Dockerfile .\n```\n\n## 2. Running against a GKE cluster\n\nTo access a GKE cluster, `kubectl-ai` needs two configurations from your local\nmachine: **Google Cloud credentials** and a **Kubernetes config file**.\n\n### Create Google Cloud Credentials\n\nFirst, create Application Default Credentials\n[(ADC)](https://cloud.google.com/docs/authentication/application-default-credentials).\n`kubectl` uses these credentials to authenticate with your GKE cluster.\n\n```bash\ngcloud auth application-default login\n```\n\nThis command saves your credentials into the `~/.config/gcloud` directory.\n\n### Configure `kubectl`\n\nNext, generate the `kubeconfig` file. This file tells `kubectl` which cluster\nto connect to and to use your ADC credentials for authentication.\n\n```bash\ngcloud container clusters get-credentials <cluster-name> --location <location>\n```\n\nThis updates the configuration file at `~/.kube/config`.\n\n## 3. Running the Container\n\nFinally, mount both configuration directories into the `kubectl-ai` container\nwhen you run it. This example shows how to run `kubectl-ai` with a web\ninterface, mounting all necessary credentials and providing a Gemini API key.\n\n```bash\nexport GEMINI_API_KEY=\"your_api_key_here\"\ndocker run --rm -it -p 8080:8080 \\\n  -v ~/.kube:/root/.kube \\\n  -v ~/.config/gcloud:/root/.config/gcloud \\\n  -e GEMINI_API_KEY \\\n  kubectl-ai:latest \\\n  --ui-listen-address 0.0.0.0:8080 \\\n  --ui-type web\n```\n\nAlternatively with the default terminal ui:\n\n```bash\nexport GEMINI_API_KEY=\"your_api_key_here\"\ndocker run --rm -it \\\n  -v ~/.kube:/root/.kube \\\n  -v ~/.config/gcloud:/root/.config/gcloud \\\n  -e GEMINI_API_KEY \\\n  kubectl-ai:latest\n```\n"
  },
  {
    "path": "LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# kubectl-ai\n\n[![Go Report Card](https://goreportcard.com/badge/github.com/GoogleCloudPlatform/kubectl-ai)](https://goreportcard.com/report/github.com/GoogleCloudPlatform/kubectl-ai)\n![GitHub License](https://img.shields.io/github/license/GoogleCloudPlatform/kubectl-ai)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/GoogleCloudPlatform/kubectl-ai)\n[![GitHub stars](https://img.shields.io/github/stars/GoogleCloudPlatform/kubectl-ai.svg)](https://github.com/GoogleCloudPlatform/kubectl-ai/stargazers)\n\n`kubectl-ai` acts as an intelligent interface, translating user intent into\nprecise Kubernetes operations, making Kubernetes management more accessible and\nefficient.\n\n![kubectl-ai demo GIF using: kubectl-ai \"how's nginx app doing in my cluster\"](./.github/kubectl-ai.gif)\n\n## Table of Contents\n\n- [Quick Start](#quick-start)\n  - [Installation](#installation)\n  - [Usage](#usage)\n- [Configuration](#configuration)\n- [Tools](#tools)\n- [Docker Quick Start](#docker-quick-start)\n- [MCP Client Mode](#mcp-client-mode)\n- [Extras](#extras)\n- [MCP Server Mode](#mcp-server-mode)\n- [Start Contributing](#start-contributing)\n- [Learning Resources](#learning-resources)\n\n## Quick Start\n\nFirst, ensure that kubectl is installed and configured.\n\n### Installation\n\n#### Quick Install (Linux & MacOS only)\n\n```shell\ncurl -sSL https://raw.githubusercontent.com/GoogleCloudPlatform/kubectl-ai/main/install.sh | bash\n```\n\n<details>\n<summary>Other Installation Methods</summary>\n\n#### Manual Installation (Linux, MacOS and Windows)\n\n1. Download the latest release from the [releases page](https://github.com/GoogleCloudPlatform/kubectl-ai/releases/latest) for your target machine.\n\n2. Untar the release, make the binary executable and move it to a directory in your $PATH (as shown below).\n\n```shell\ntar -zxvf kubectl-ai_Darwin_arm64.tar.gz\nchmod a+x kubectl-ai\nsudo mv kubectl-ai /usr/local/bin/\n```\n\n#### Install with Krew (Linux/macOS/Windows)\n\nFirst of all, you need to have krew installed, refer to [krew document](https://krew.sigs.k8s.io/docs/user-guide/setup/install/) for more details\nThen you can install with krew\n\n```shell\nkubectl krew install ai\n```\n\nNow you can invoke `kubectl-ai` as a kubectl plugin like this: `kubectl ai`.\n\n#### Install on NixOS\n\nThere are multiple ways to install `kubectl-ai` on NixOS. For a permanent installation add the following to your NixOS-Configuration:\n\n```nix\n  environment.systemPackages = with pkgs; [\n    kubectl-ai\n  ];\n```\n\nFor a temporary installation, you can use the following command:\n\n```shell\nnix-shell -p kubectl-ai\n```\n\n</details>\n\n### Usage\n\n`kubectl-ai` supports AI models from `gemini`, `vertexai`, `azopenai`, `openai`, `grok`, `bedrock` and local LLM providers such as `ollama` and `llama.cpp`.\n\n#### Using Gemini (Default)\n\nSet your Gemini API key as an environment variable. If you don't have a key, get one from [Google AI Studio](https://aistudio.google.com).\n\n```bash\nexport GEMINI_API_KEY=your_api_key_here\nkubectl-ai\n\n# Use different gemini model\nkubectl-ai --model gemini-2.5-pro-exp-03-25\n\n# Use 2.5 flash (faster) model\nkubectl-ai --quiet --model gemini-2.5-flash-preview-04-17 \"check logs for nginx app in hello namespace\"\n```\n\n<details>\n<summary>Use other AI models</summary>\n\n#### Using AI models running locally (ollama or llama.cpp)\n\nYou can use `kubectl-ai` with AI models running locally. `kubectl-ai` supports [ollama](https://ollama.com/) and [llama.cpp](https://github.com/ggml-org/llama.cpp) to use the AI models running locally.\n\nAdditionally, the [`modelserving`](modelserving) directory provides tools and instructions for deploying your own `llama.cpp`-based LLM serving endpoints locally or on a Kubernetes cluster. This allows you to host models like Gemma directly in your environment.\n\nAn example of using Google's `gemma3` model with `ollama`:\n\n```shell\n# assuming ollama is already running and you have pulled one of the gemma models\n# ollama pull gemma3:12b-it-qat\n\n# if your ollama server is at remote, use OLLAMA_HOST variable to specify the host\n# export OLLAMA_HOST=http://192.168.1.3:11434/\n\n# enable-tool-use-shim because models require special prompting to enable tool calling\nkubectl-ai --llm-provider ollama --model gemma3:12b-it-qat --enable-tool-use-shim\n\n# you can use `models` command to discover the locally available models\n>> models\n```\n\n#### Using Grok\n\nYou can use X.AI's Grok model by setting your X.AI API key:\n\n```bash\nexport GROK_API_KEY=your_xai_api_key_here\nkubectl-ai --llm-provider=grok --model=grok-3-beta\n```\n\n#### Using AWS Bedrock\n\nYou can use AWS Bedrock Claude models with your AWS credentials:\n\n```bash\n# Configure AWS credentials using AWS SSO\naws sso login --profile your-profile-name\n# Or use other AWS credential methods (IAM roles, environment variables, etc.)\n\n# Use Claude 4 Sonnet (default)\nkubectl-ai --llm-provider=bedrock --model=us.anthropic.claude-sonnet-4-20250514-v1:0\n\n# Use Claude 3.7 Sonnet\nkubectl-ai --llm-provider=bedrock --model=us.anthropic.claude-3-7-sonnet-20250219-v1:0\n\n# Override model via environment variable\nexport BEDROCK_MODEL=us.anthropic.claude-sonnet-4-20250514-v1:0\nkubectl-ai --llm-provider=bedrock\n```\n\nAWS Bedrock uses the standard AWS SDK credential chain, supporting:\n\n- AWS SSO profiles\n- IAM roles (for EC2/ECS/Lambda)\n- Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)\n- AWS CLI configuration files\n\n#### Using Azure OpenAI\n\nYou can also use Azure OpenAI deployment by setting your OpenAI API key and specifying the provider:\n\n```bash\nexport AZURE_OPENAI_API_KEY=your_azure_openai_api_key_here\nexport AZURE_OPENAI_ENDPOINT=https://your_azure_openai_endpoint_here\nkubectl-ai --llm-provider=azopenai --model=your_azure_openai_deployment_name_here\n# or\naz login\nkubectl-ai --llm-provider=openai://your_azure_openai_endpoint_here --model=your_azure_openai_deployment_name_here\n```\n\n#### Using OpenAI\n\nYou can also use OpenAI models by setting your OpenAI API key and specifying the provider:\n\n```bash\nexport OPENAI_API_KEY=your_openai_api_key_here\nkubectl-ai --llm-provider=openai --model=gpt-4.1\n```\n\n#### Using OpenAI Compatible API\n\nFor example, you can use aliyun qwen-xxx models as follows.\n\n```bash\nexport OPENAI_API_KEY=your_openai_api_key_here\nexport OPENAI_ENDPOINT=https://dashscope.aliyuncs.com/compatible-mode/v1\nkubectl-ai --llm-provider=openai --model=qwen-plus\n```\n\n</details>\n\nRun interactively:\n\n```shell\nkubectl-ai\n```\n\nThe interactive mode allows you to have a chat with `kubectl-ai`, asking multiple questions in sequence while maintaining context from previous interactions. Simply type your queries and press Enter to receive responses. To exit the interactive shell, type `exit` or press Ctrl+C.\n\nOr, run with a task as input:\n\n```shell\nkubectl-ai --quiet \"fetch logs for nginx app in hello namespace\"\n```\n\nCombine it with other unix commands:\n\n```shell\nkubectl-ai < query.txt\n# OR\necho \"list pods in the default namespace\" | kubectl-ai\n```\n\nYou can even combine a positional argument with stdin input. The positional argument will be used as a prefix to the stdin content:\n\n```shell\ncat error.log | kubectl-ai \"explain the error\"\n```\n\nWe also support persistence between runs with an opt-in. This lets you save a session to the local filesystem, and resume it to maintain previous context. It even works between different interfaces!\n\n```shell\nkubectl-ai --new-session # start a new session\nkubectl-ai --list-sessions # list all saved sessions\nkubectl-ai --resume-session 20250807-510872 # resume session 20250807-510872\nkubectl-ai --delete-session 20250807-510872 # delete session 20250807-510872\n```\n\n## Configuration\n\nYou can also configure `kubectl-ai` using a YAML configuration file at `~/.config/kubectl-ai/config.yaml`:\n\n```shell\nmkdir -p ~/.config/kubectl-ai/\ncat <<EOF > ~/.config/kubectl-ai/config.yaml\nmodel: gemini-2.5-flash-preview-04-17\nllmProvider: gemini\ntoolConfigPaths: ~/.config/kubectl-ai/tools.yaml\nEOF\n```\n\nVerify your configuration:\n\n```shell\nkubectl-ai --quiet model\n```\n\n<details>\n<summary>More configuration Options</summary>\n\nHere's a complete configuration file with all available options and their default values:\n\n```yaml\n# LLM provider configuration\nllmProvider: \"gemini\"               # Default LLM provider\nmodel: \"gemini-2.5-pro-preview-06-05\" # Default model\nskipVerifySSL: false              # Skip SSL verification for LLM API calls\n\n# Tool and permission settings\ntoolConfigPaths: [\"~/.config/kubectl-ai/tools.yaml\"]  # Custom tools configuration paths\nskipPermissions: false             # Skip confirmation for resource-modifying commands\nenableToolUseShim: false        # Enable tool use shim for certain models\n\n# MCP configuration\nmcpServer: false                  # Run in MCP server mode\nmcpClient: false                  # Enable MCP client mode\nexternalTools: false             # Discover external MCP tools (requires mcp-server)\n\n# Runtime settings\nmaxIterations: 20                 # Maximum iterations for the agent\nquiet: false                       # Run in non-interactive mode\nremoveWorkdir: false             # Remove temporary working directory after execution\n\n# Kubernetes configuration\nkubeconfig: \"~/.kube/config\"      # Path to kubeconfig file\n\n# UI configuration\nuiType: \"terminal\"                # UI mode: \"terminal\" or \"web\"\nuiListenAddress: \"localhost:8888\" # Address for HTML UI server\n\n# Prompt configuration\npromptTemplateFilePath: \"\"      # Custom prompt template file\nextraPromptPaths: []            # Additional prompt template paths\n\n# Debug and trace settings\ntracePath: \"/tmp/kubectl-ai-trace.txt\" # Path to trace file\n```\n\n</details>\n\nAll these settings can be configured through either:\n\n1. Command line flags (e.g., `--model=gemini-2.5-pro`)\n2. Configuration file (`~/.config/kubectl-ai/config.yaml`)\n3. Environment variables (e.g., `GEMINI_API_KEY`)\n\nCommand line flags take precedence over configuration file settings.\n\n## Tools\n\n`kubectl-ai` leverages LLMs to suggest and execute Kubernetes operations using a set of powerful tools. It comes with built-in tools like `kubectl` and `bash`.\n\nYou can also extend its capabilities by defining your own custom tools. By default, `kubectl-ai` looks for your tool configurations in `~/.config/kubectl-ai/tools.yaml`.\n\nTo specify tools configuration files or directories containing tools configuration files, use:\n\n```sh\n./kubectl-ai --custom-tools-config=<path-to-tools-directory> \"your prompt here\"\n```\n\nFor further details on how to configure your own tools, [go here](docs/tools.md).\n\n## Docker Quick Start\n\nThis project provides a Docker image that gives you a standalone environment for running kubectl-ai, including against a GKE cluster.\n\n### Running the container against GKE\n\n#### Step 1: Build the Image\n\nClone the repository and build the image with the following command\n\n```bash\ngit clone https://github.com/GoogleCloudPlatform/kubectl-ai.git\ncd kubectl-ai\ndocker build -t kubectl-ai:latest -f images/kubectl-ai/Dockerfile .\n```\n\n#### Step 2: Connect to Your GKE Cluster\n\nSet up application default credentials and connect to your GKE cluster.\n\n```bash\ngcloud auth application-default login # If in a gcloud shell this is not necessary\ngcloud container clusters get-credentials <cluster-name> --zone <zone>\n```\n\n#### Step 3: Run the kubectl-ai container\n\nBelow is a sample command that can be used to launch the container with a locally hosted web-ui. Be sure to replace the placeholder values with your specific Google Cloud project ID and location. Note you do not need to mount the gcloud config directory if you're on a cloudshell machine.\n\n```bash\ndocker run --rm -it -p 8080:8080 -v ~/.kube:/root/.kube -v ~/.config/gcloud:/root/.config/gcloud -e GOOGLE_CLOUD_LOCATION=us-central1 -e GOOGLE_CLOUD_PROJECT=my-gcp-project kubectl-ai:latest --llm-provider vertexai --ui-listen-address 0.0.0.0:8080 --ui-type web\n```\n\nFor more info about running from the container image see [CONTAINER.md](CONTAINER.md)\n\n## MCP Client Mode\n\n> **Note:** MCP Client Mode is available in `kubectl-ai` version v0.0.12 and onwards.\n\n`kubectl-ai` can connect to external [MCP](https://modelcontextprotocol.io/examples) Servers to access additional tools in addition to built-in tools.\n\n### Quick Start with MCP Client\n\nEnable MCP client mode:\n\n```bash\nkubectl-ai --mcp-client\n```\n\n### MCP Client Configuration\n\nCreate or edit `~/.config/kubectl-ai/mcp.yaml` to customize MCP servers:\n\n```yaml\nservers:\n  # Local MCP server (stdio-based)\n  # sequential-thinking: Advanced reasoning and step-by-step analysis\n  - name: sequential-thinking\n    command: npx\n    args:\n      - -y\n      - \"@modelcontextprotocol/server-sequential-thinking\"\n  \n  # Remote MCP server (HTTP-based)\n  - name: cloudflare-documentation\n    url: https://docs.mcp.cloudflare.com/mcp\n    \n  # Optional: Remote MCP server with authentication\n  - name: custom-api\n    url: https://api.example.com/mcp\n    auth:\n      type: \"bearer\"\n      token: \"${MCP_TOKEN}\"\n```\n\nThe system automatically:\n\n- Converts parameter names (snake_case → camelCase)\n- Handles type conversion (strings → numbers/booleans when appropriate)\n- Provides fallback behavior for unknown servers\n\nNo additional setup required - just use the `--mcp-client` flag and the AI will have access to all configured MCP tools.\n\n📖 **For detailed configuration options, troubleshooting, and advanced features for MCP Client mode, see the [MCP Client Documentation](docs/mcp-client.md).**\n\n📖 **For multi-server orchestration and security automation examples, see the [MCP Client Integration Guide](docs/mcp-client.md).**\n\n## Extras\n\nYou can use the following special keywords for specific actions:\n\n- `model`: Display the currently selected model.\n- `models`: List all available models.\n- `tools`: List all available tools.\n- `version`: Display the `kubectl-ai` version.\n- `reset`: Clear the conversational context.\n- `clear`: Clear the terminal screen.\n- `exit` or `quit`: Terminate the interactive shell (Ctrl+C also works).\n\n### Invoking as kubectl plugin\n\nYou can also run `kubectl ai`. `kubectl` finds any executable file in your `PATH` whose name begins with `kubectl-` as a [plugin](https://kubernetes.io/docs/tasks/extend-kubectl/kubectl-plugins/).\n\n## MCP Server Mode\n\n`kubectl-ai` can act as an MCP server that exposes kubectl tools to other MCP clients (like Claude, Cursor, or VS Code). The server can run in two modes:\n\n### Basic MCP Server (Built-in tools only)\n\nExpose only kubectl-ai's native Kubernetes tools:\n\n```bash\nkubectl-ai --mcp-server\n```\n\n### Enhanced MCP Server (With external tool discovery)\n\nAdditionally discover and expose tools from other MCP servers as a unified interface:\n\n```bash\nkubectl-ai --mcp-server --external-tools\n```\n\nThis creates a powerful **tool aggregation hub** where kubectl-ai acts as both:\n\n- **MCP Server**: Exposing kubectl tools to clients\n- **MCP Client**: Consuming tools from other MCP servers\n\nTo serve clients over HTTP using the streamable transport, run:\n\n```bash\nkubectl-ai --mcp-server --mcp-server-mode streamable-http --http-port 9080\n```\n\nThis starts an MCP endpoint at `http://localhost:9080/mcp`.\n\nThe enhanced mode provides AI clients with access to both Kubernetes operations and general-purpose tools (filesystem, web search, databases, etc.) through a single MCP endpoint.\n\n📖 **For detailed configuration, examples, and troubleshooting, see the [MCP Server Documentation](docs/mcp-server.md).**\n\n## Start Contributing\n\nWe welcome contributions to `kubectl-ai` from the community. Take a look at our\n[contribution guide](contributing.md) to get started.\n\n## Learning Resources\n\n### Talks and Presentations\n\n- [From Natural Language to K8s Operations: The MCP Architecture and Practice of kubectl-ai](https://blog.wu-boy.com/2025/10/from-natural-language-to-k8s-operations-the-mcp-architecture-and-practice-of-kubectl-ai-en) - A comprehensive presentation covering the architecture and practical usage of kubectl-ai with MCP (Model Context Protocol).\n\n---\n\n*Note: This is not an officially supported Google product. This project is not\neligible for the [Google Open Source Software Vulnerability Rewards\nProgram](https://bughunters.google.com/open-source-security).*\n"
  },
  {
    "path": "cmd/main.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"flag\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\t\"syscall\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/agent\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/journal\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sessions\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/tools\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/ui\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/ui/html\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/pflag\"\n\n\t\"k8s.io/klog/v2\"\n\t\"sigs.k8s.io/yaml\"\n)\n\n// Using the defaults from goreleaser as per https://goreleaser.com/cookbooks/using-main.version/\nvar (\n\tversion = \"dev\"\n\tcommit  = \"none\"\n\tdate    = \"unknown\"\n)\n\nfunc BuildRootCommand(opt *Options) (*cobra.Command, error) {\n\trootCmd := &cobra.Command{\n\t\tUse:   \"kubectl-ai\",\n\t\tShort: \"A CLI tool to interact with Kubernetes using natural language\",\n\t\tLong:  \"kubectl-ai is a command-line tool that allows you to interact with your Kubernetes cluster using natural language queries. It leverages large language models to understand your intent and translate it into kubectl\",\n\t\tArgs:  cobra.MaximumNArgs(1), // Only one positional arg is allowed.\n\t\tRunE: func(cmd *cobra.Command, args []string) error {\n\t\t\treturn RunRootCommand(cmd.Context(), *opt, args)\n\t\t},\n\t}\n\n\trootCmd.AddCommand(&cobra.Command{\n\t\tUse:   \"version\",\n\t\tShort: \"Print the version number of kubectl-ai\",\n\t\tRun: func(cmd *cobra.Command, args []string) {\n\t\t\tfmt.Printf(\"version: %s\\ncommit: %s\\ndate: %s\\n\", version, commit, date)\n\t\t\tos.Exit(0)\n\t\t},\n\t})\n\n\tif err := opt.bindCLIFlags(rootCmd.Flags()); err != nil {\n\t\treturn nil, err\n\t}\n\treturn rootCmd, nil\n}\n\ntype Options struct {\n\tProviderID string `json:\"llmProvider,omitempty\"`\n\tModelID    string `json:\"model,omitempty\"`\n\t// SkipPermissions is a flag to skip asking for confirmation before executing kubectl commands\n\t// that modifies resources in the cluster.\n\tSkipPermissions bool `json:\"skipPermissions,omitempty\"`\n\t// EnableToolUseShim is a flag to enable tool use shim.\n\t// TODO(droot): figure out a better way to discover if the model supports tool use\n\t// and set this automatically.\n\tEnableToolUseShim bool `json:\"enableToolUseShim,omitempty\"`\n\t// Quiet flag indicates if the agent should run in non-interactive mode.\n\t// It requires a query to be provided as a positional argument.\n\tQuiet     bool `json:\"quiet,omitempty\"`\n\tMCPServer bool `json:\"mcpServer,omitempty\"`\n\tMCPClient bool `json:\"mcpClient,omitempty\"`\n\t// ExternalTools enables discovery and exposure of external MCP tools (only works with --mcp-server)\n\tExternalTools bool `json:\"externalTools,omitempty\"`\n\tMaxIterations int  `json:\"maxIterations,omitempty\"`\n\t// MCPServerMode is the mode of the MCP server. only works with --mcp-server.\n\tMCPServerMode string `json:\"mcpServerMode,omitempty\"`\n\t// Set the HTTP endpoint port for the MCP server when using HTTP transports like streamable-http.\n\tHTTPPort int `json:\"httpPort,omitempty\"`\n\t// KubeConfigPath is the path to the kubeconfig file.\n\t// If not provided, the default kubeconfig path will be used.\n\tKubeConfigPath string `json:\"kubeConfigPath,omitempty\"`\n\n\tPromptTemplateFilePath string   `json:\"promptTemplateFilePath,omitempty\"`\n\tExtraPromptPaths       []string `json:\"extraPromptPaths,omitempty\"`\n\tTracePath              string   `json:\"tracePath,omitempty\"`\n\tRemoveWorkDir          bool     `json:\"removeWorkDir,omitempty\"`\n\tToolConfigPaths        []string `json:\"toolConfigPaths,omitempty\"`\n\n\t// UIType is the type of user interface to use.\n\tUIType ui.Type `json:\"uiType,omitempty\"`\n\t// UIListenAddress is the address to listen for the web UI.\n\tUIListenAddress string `json:\"uiListenAddress,omitempty\"`\n\n\t// SkipVerifySSL is a flag to skip verifying the SSL certificate of the LLM provider.\n\tSkipVerifySSL bool `json:\"skipVerifySSL,omitempty\"`\n\n\t// Session management options\n\tResumeSession  string `json:\"resumeSession,omitempty\"`\n\tNewSession     bool   `json:\"newSession,omitempty\"`\n\tListSessions   bool   `json:\"listSessions,omitempty\"`\n\tDeleteSession  string `json:\"deleteSession,omitempty\"`\n\tSessionBackend string `json:\"sessionBackend,omitempty\"`\n\n\t// ShowToolOutput is a flag to disable truncation of tool output in the terminal UI.\n\tShowToolOutput bool `json:\"showToolOutput,omitempty\"`\n\n\t// Sandbox enables execution of tools in a sandbox environment.\n\t// Supported values: \"k8s\", \"seatbelt\".\n\t// If empty, tools are executed locally.\n\tSandbox string `json:\"sandbox,omitempty\"`\n\n\t// SandboxImage is the container image to use for the sandbox\n\tSandboxImage string `json:\"sandboxImage,omitempty\"`\n}\n\nvar defaultToolConfigPaths = []string{\n\tfilepath.Join(\"{CONFIG}\", \"kubectl-ai\", \"tools.yaml\"),\n\tfilepath.Join(\"{HOME}\", \".config\", \"kubectl-ai\", \"tools.yaml\"),\n}\n\nvar defaultConfigPaths = []string{\n\tfilepath.Join(\"{CONFIG}\", \"kubectl-ai\", \"config.yaml\"),\n\tfilepath.Join(\"{HOME}\", \".config\", \"kubectl-ai\", \"config.yaml\"),\n}\n\nfunc (o *Options) InitDefaults() {\n\to.ProviderID = \"gemini\"\n\to.ModelID = \"gemini-2.5-pro\"\n\t// by default, confirm before executing kubectl commands that modify resources in the cluster.\n\to.SkipPermissions = false\n\to.MCPServer = false\n\to.MCPClient = false\n\t// by default, external tools are disabled (only works with --mcp-server)\n\to.ExternalTools = false\n\t// We now default to our strongest model (gemini-2.5-pro-exp-03-25) which supports tool use natively.\n\t// so we don't need shim.\n\to.EnableToolUseShim = false\n\to.Quiet = false\n\to.MCPServer = false\n\to.MaxIterations = 20\n\to.KubeConfigPath = \"\"\n\to.PromptTemplateFilePath = \"\"\n\to.ExtraPromptPaths = []string{}\n\to.TracePath = filepath.Join(os.TempDir(), \"kubectl-ai-trace.txt\")\n\to.RemoveWorkDir = false\n\to.ToolConfigPaths = defaultToolConfigPaths\n\t// Default to terminal UI\n\to.UIType = ui.UITypeTerminal\n\t// Default UI listen address for HTML UI\n\to.UIListenAddress = \"localhost:8888\"\n\t// Default to not skipping SSL verification\n\to.SkipVerifySSL = false\n\t// Default MCP server mode is stdio\n\to.MCPServerMode = \"stdio\"\n\t// Default port for HTTP endpoint when using streamable-http mode\n\to.HTTPPort = 9080\n\n\t// Session management options\n\to.ResumeSession = \"\"\n\to.ListSessions = false\n\to.DeleteSession = \"\"\n\to.SessionBackend = \"memory\"\n\n\t// By default, hide tool outputs\n\to.ShowToolOutput = false\n\n\to.Sandbox = \"\"\n\to.SandboxImage = \"bitnami/kubectl:latest\"\n}\n\nfunc (o *Options) LoadConfiguration(b []byte) error {\n\tif err := yaml.Unmarshal(b, &o); err != nil {\n\t\treturn fmt.Errorf(\"parsing configuration: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc (o *Options) LoadConfigurationFile() error {\n\tconfigPaths := defaultConfigPaths\n\tfor _, configPath := range configPaths {\n\t\tpathWithPlaceholdersExpanded := configPath\n\n\t\tif strings.Contains(pathWithPlaceholdersExpanded, \"{CONFIG}\") {\n\t\t\tconfigDir, err := os.UserConfigDir()\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"getting user config directory (for config file path %q): %w\", configPath, err)\n\t\t\t}\n\t\t\tpathWithPlaceholdersExpanded = strings.ReplaceAll(pathWithPlaceholdersExpanded, \"{CONFIG}\", configDir)\n\t\t}\n\n\t\tif strings.Contains(pathWithPlaceholdersExpanded, \"{HOME}\") {\n\t\t\thomeDir, err := os.UserHomeDir()\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"getting user home directory (for config file path %q): %w\", configPath, err)\n\t\t\t}\n\t\t\tpathWithPlaceholdersExpanded = strings.ReplaceAll(pathWithPlaceholdersExpanded, \"{HOME}\", homeDir)\n\t\t}\n\n\t\tconfigPath = filepath.Clean(pathWithPlaceholdersExpanded)\n\t\tconfigBytes, err := os.ReadFile(configPath)\n\t\tif err != nil {\n\t\t\tif os.IsNotExist(err) {\n\t\t\t\t// ignore missing config files, they are optional\n\t\t\t} else {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"warning: could not load defaults from %q: %v\\n\", configPath, err)\n\t\t\t}\n\t\t} else if len(configBytes) > 0 {\n\t\t\tif err := o.LoadConfiguration(configBytes); err != nil {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"warning: error loading configuration from %q: %v\\n\", configPath, err)\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc main() {\n\tctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)\n\tdefer cancel()\n\n\tgo func() {\n\t\t<-ctx.Done()\n\t\t// restore default behavior for a second signal\n\t\tsignal.Stop(make(chan os.Signal))\n\t\tcancel()\n\t\tklog.Flush()\n\t\tfmt.Fprintf(os.Stderr, \"\\nReceived signal, shutting down gracefully... (press Ctrl+C again to force)\\n\")\n\t}()\n\n\tif err := run(ctx); err != nil {\n\t\t// Don't print error if it's a context cancellation\n\t\tif !errors.Is(err, context.Canceled) {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t}\n\t\t// Exit with non-zero status code on error, unless it's a graceful shutdown.\n\t\tif errors.Is(err, context.Canceled) {\n\t\t\tos.Exit(0)\n\t\t}\n\t\tos.Exit(1)\n\t}\n}\n\nfunc run(ctx context.Context) error {\n\t// klog setup must happen before Cobra parses any flags\n\n\t// add commandline flags for logging\n\tklogFlags := flag.NewFlagSet(\"klog\", flag.ExitOnError)\n\tklog.InitFlags(klogFlags)\n\n\tklogFlags.Set(\"logtostderr\", \"false\")\n\tklogFlags.Set(\"log_file\", filepath.Join(os.TempDir(), \"kubectl-ai.log\"))\n\n\tdefer klog.Flush()\n\n\tvar opt Options\n\n\topt.InitDefaults()\n\n\t// load YAML config values\n\tif err := opt.LoadConfigurationFile(); err != nil {\n\t\treturn fmt.Errorf(\"failed to load config file: %w\", err)\n\t}\n\n\trootCmd, err := BuildRootCommand(&opt)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// cobra has to know that we pass pass flags with flag lib, otherwise it creates conflict with flags.parse() method\n\t// We add just the klog flags we want, not all the klog flags (there are a lot, most of them are very niche)\n\trootCmd.PersistentFlags().AddGoFlag(klogFlags.Lookup(\"v\"))\n\trootCmd.PersistentFlags().AddGoFlag(klogFlags.Lookup(\"alsologtostderr\"))\n\n\t// do this early, before the third-party code logs anything.\n\tredirectStdLogToKlog()\n\n\tif err := rootCmd.ExecuteContext(ctx); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (opt *Options) bindCLIFlags(f *pflag.FlagSet) error {\n\tf.IntVar(&opt.MaxIterations, \"max-iterations\", opt.MaxIterations, \"maximum number of iterations agent will try before giving up\")\n\tf.StringVar(&opt.KubeConfigPath, \"kubeconfig\", opt.KubeConfigPath, \"path to kubeconfig file\")\n\tf.StringVar(&opt.PromptTemplateFilePath, \"prompt-template-file-path\", opt.PromptTemplateFilePath, \"path to custom prompt template file\")\n\tf.StringArrayVar(&opt.ExtraPromptPaths, \"extra-prompt-paths\", opt.ExtraPromptPaths, \"extra prompt template paths\")\n\tf.StringVar(&opt.TracePath, \"trace-path\", opt.TracePath, \"path to the trace file\")\n\tf.BoolVar(&opt.RemoveWorkDir, \"remove-workdir\", opt.RemoveWorkDir, \"remove the temporary working directory after execution\")\n\n\tf.StringVar(&opt.ProviderID, \"llm-provider\", opt.ProviderID, \"language model provider\")\n\tf.StringVar(&opt.ModelID, \"model\", opt.ModelID, \"language model e.g. gemini-2.0-flash-thinking-exp-01-21, gemini-2.0-flash\")\n\tf.BoolVar(&opt.SkipPermissions, \"skip-permissions\", opt.SkipPermissions, \"(dangerous) skip asking for confirmation before executing kubectl commands that modify resources\")\n\tf.BoolVar(&opt.MCPServer, \"mcp-server\", opt.MCPServer, \"run in MCP server mode\")\n\tf.BoolVar(&opt.ExternalTools, \"external-tools\", opt.ExternalTools, \"in MCP server mode, discover and expose external MCP tools\")\n\tf.StringArrayVar(&opt.ToolConfigPaths, \"custom-tools-config\", opt.ToolConfigPaths, \"path to custom tools config file or directory\")\n\tf.BoolVar(&opt.MCPClient, \"mcp-client\", opt.MCPClient, \"enable MCP client mode to connect to external MCP servers\")\n\tf.StringVar(&opt.MCPServerMode, \"mcp-server-mode\", opt.MCPServerMode, \"mode of the MCP server. Supported values: stdio, streamable-http\")\n\tf.IntVar(&opt.HTTPPort, \"http-port\", opt.HTTPPort, \"port for the HTTP endpoint in MCP server mode (used with --mcp-server when --mcp-server-mode is streamable-http)\")\n\tf.BoolVar(&opt.EnableToolUseShim, \"enable-tool-use-shim\", opt.EnableToolUseShim, \"enable tool use shim\")\n\tf.BoolVar(&opt.Quiet, \"quiet\", opt.Quiet, \"run in non-interactive mode, requires a query to be provided as a positional argument\")\n\n\tf.Var(&opt.UIType, \"ui-type\", \"user interface type to use. Supported values: terminal, web, tui.\")\n\tf.StringVar(&opt.UIListenAddress, \"ui-listen-address\", opt.UIListenAddress, \"address to listen for the HTML UI.\")\n\tf.BoolVar(&opt.SkipVerifySSL, \"skip-verify-ssl\", opt.SkipVerifySSL, \"skip verifying the SSL certificate of the LLM provider\")\n\tf.BoolVar(&opt.ShowToolOutput, \"show-tool-output\", opt.ShowToolOutput, \"show tool output in the terminal UI\")\n\n\tf.StringVar(&opt.Sandbox, \"sandbox\", opt.Sandbox, \"execute tools in a sandbox environment (k8s, seatbelt)\")\n\tf.StringVar(&opt.SandboxImage, \"sandbox-image\", opt.SandboxImage, \"container image to use for the sandbox\")\n\n\tf.StringVar(&opt.ResumeSession, \"resume-session\", opt.ResumeSession, \"ID of session to resume (use 'latest' for the most recent session)\")\n\tf.BoolVar(&opt.ListSessions, \"list-sessions\", opt.ListSessions, \"list all available sessions\")\n\tf.StringVar(&opt.DeleteSession, \"delete-session\", opt.DeleteSession, \"delete a session by ID\")\n\tf.BoolVar(&opt.NewSession, \"new-session\", opt.NewSession, \"start a new persistent session\")\n\tf.StringVar(&opt.SessionBackend, \"session-backend\", opt.SessionBackend,\n\t\t\"session backend to use (memory or filesystem)\")\n\n\treturn nil\n}\n\nfunc RunRootCommand(ctx context.Context, opt Options, args []string) error {\n\tvar err error\n\n\t// Automatically upgrade backend to filesystem if session persistence flags are requested explicitly\n\tif (opt.NewSession || opt.ResumeSession != \"\" || opt.ListSessions || opt.DeleteSession != \"\") && opt.SessionBackend == \"memory\" {\n\t\tklog.Infof(\"Upgrading session-backend to 'filesystem' based on provided flags\")\n\t\topt.SessionBackend = \"filesystem\"\n\t}\n\n\t// Validate flag combinations\n\tif opt.ExternalTools && !opt.MCPServer {\n\t\treturn fmt.Errorf(\"--external-tools can only be used with --mcp-server\")\n\t}\n\n\t// resolve kubeconfig path with priority: flag/env > KUBECONFIG > default path\n\tif err = resolveKubeConfigPath(&opt); err != nil {\n\t\treturn fmt.Errorf(\"failed to resolve kubeconfig path: %w\", err)\n\t}\n\n\tif opt.MCPServer {\n\t\tif err = startMCPServer(ctx, opt); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to start MCP server: %w\", err)\n\t\t}\n\t\treturn nil // MCP server mode blocks, so we return here\n\t}\n\n\tif opt.ListSessions {\n\t\treturn handleListSessions(opt)\n\t}\n\n\tif opt.DeleteSession != \"\" {\n\t\treturn handleDeleteSession(opt)\n\t}\n\n\tif err := handleCustomTools(opt.ToolConfigPaths); err != nil {\n\t\treturn fmt.Errorf(\"failed to process custom tools: %w\", err)\n\t}\n\n\t// After reading stdin, it is consumed\n\tvar hasInputData bool\n\thasInputData, err = hasStdInData()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to check if stdin has data: %w\", err)\n\t}\n\n\t// Handles positional args or stdin\n\tvar queryFromCmd string\n\tqueryFromCmd, err = resolveQueryInput(hasInputData, args)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to resolve query input %w\", err)\n\t}\n\n\tklog.Info(\"Application started\", \"pid\", os.Getpid())\n\n\tvar recorder journal.Recorder\n\tif opt.TracePath != \"\" {\n\t\tvar fileRecorder journal.Recorder\n\t\tfileRecorder, err = journal.NewFileRecorder(opt.TracePath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"creating trace recorder: %w\", err)\n\t\t}\n\t\tdefer fileRecorder.Close()\n\t\trecorder = fileRecorder\n\t} else {\n\t\t// Ensure we always have a recorder, to avoid nil checks\n\t\trecorder = &journal.LogRecorder{}\n\t\tdefer recorder.Close()\n\t}\n\n\t// Initialize session management\n\tvar session *api.Session\n\tvar sessionManager *sessions.SessionManager\n\n\tsessionManager, err = sessions.NewSessionManager(opt.SessionBackend)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create session manager: %w\", err)\n\t}\n\n\t// Build agentFactory for new agents\n\tagentFactory := func(ctx context.Context) (*agent.Agent, error) {\n\t\tvar client gollm.Client\n\t\tvar err error\n\t\tif opt.SkipVerifySSL {\n\t\t\tclient, err = gollm.NewClient(ctx, opt.ProviderID, gollm.WithSkipVerifySSL())\n\t\t} else {\n\t\t\tclient, err = gollm.NewClient(ctx, opt.ProviderID)\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"creating llm client: %w\", err)\n\t\t}\n\n\t\treturn &agent.Agent{\n\t\t\tModel:              opt.ModelID,\n\t\t\tProvider:           opt.ProviderID,\n\t\t\tKubeconfig:         opt.KubeConfigPath,\n\t\t\tLLM:                client,\n\t\t\tMaxIterations:      opt.MaxIterations,\n\t\t\tPromptTemplateFile: opt.PromptTemplateFilePath,\n\t\t\tExtraPromptPaths:   opt.ExtraPromptPaths,\n\t\t\tTools:              tools.Default(),\n\t\t\tRecorder:           recorder,\n\t\t\tRemoveWorkDir:      opt.RemoveWorkDir,\n\t\t\tSkipPermissions:    opt.SkipPermissions,\n\t\t\tEnableToolUseShim:  opt.EnableToolUseShim,\n\t\t\tMCPClientEnabled:   opt.MCPClient,\n\t\t\tSandbox:            opt.Sandbox,\n\t\t\tSandboxImage:       opt.SandboxImage,\n\t\t\tSessionBackend:     opt.SessionBackend,\n\t\t\tRunOnce:            opt.Quiet,\n\t\t\tInitialQuery:       queryFromCmd,\n\t\t}, nil\n\t}\n\n\tagentManager := agent.NewAgentManager(agentFactory, sessionManager)\n\n\t// Register cleanup for all sessions and agents\n\tdefer agentManager.Close()\n\n\tif opt.ResumeSession != \"\" {\n\t\tif opt.ResumeSession == \"latest\" {\n\t\t\tsession, err = sessionManager.GetLatestSession()\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to get latest session: %w\", err)\n\t\t\t}\n\t\t\tif session == nil {\n\t\t\t\t// No latest session found, create a new one\n\t\t\t\tklog.Info(\"No previous session found to resume. Creating new session.\")\n\t\t\t}\n\t\t} else {\n\t\t\tsession, err = sessionManager.FindSessionByID(opt.ResumeSession)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"session %s not found: %w\", opt.ResumeSession, err)\n\t\t\t}\n\t\t}\n\t}\n\n\tvar defaultAgent *agent.Agent\n\n\t// If no session loaded (or resume failed/not requested), create a new one\n\tif session == nil {\n\t\tmeta := sessions.Metadata{\n\t\t\tModelID:    opt.ModelID,\n\t\t\tProviderID: opt.ProviderID,\n\t\t}\n\t\tsession, err = sessionManager.NewSession(meta)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create a new session: %w\", err)\n\t\t}\n\n\t\tdefaultAgent, err = agentManager.GetAgent(ctx, session.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get agent for new session: %w\", err)\n\t\t}\n\t\tklog.Infof(\"Created new session: %s\\n\", session.ID)\n\t} else {\n\t\t// Update last accessed for resumed session\n\t\tif err := sessionManager.UpdateLastAccessed(session); err != nil {\n\t\t\tklog.Warningf(\"Failed to update session last accessed time: %v\", err)\n\t\t}\n\t\tklog.Infof(\"Resuming session: %s\\n\", session.ID)\n\n\t\tdefaultAgent, err = agentManager.GetAgent(ctx, session.ID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get agent for session: %w\", err)\n\t\t}\n\t}\n\n\tvar userInterface ui.UI\n\tswitch opt.UIType {\n\tcase ui.UITypeTerminal:\n\t\t// since stdin is already consumed, we use TTY for taking input from user\n\t\tuseTTYForInput := hasInputData\n\t\tuserInterface, err = ui.NewTerminalUI(defaultAgent, useTTYForInput, opt.ShowToolOutput, recorder)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"creating terminal UI: %w\", err)\n\t\t}\n\tcase ui.UITypeWeb:\n\t\tuserInterface, err = html.NewHTMLUserInterface(agentManager, sessionManager, opt.ModelID, opt.ProviderID, opt.UIListenAddress, recorder)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"creating web UI: %w\", err)\n\t\t}\n\tcase ui.UITypeTUI:\n\t\tuserInterface = ui.NewTUI(defaultAgent)\n\tdefault:\n\t\treturn fmt.Errorf(\"ui-type mode %q is not known\", opt.UIType)\n\t}\n\n\terr = userInterface.Run(ctx)\n\tif err != nil && !errors.Is(err, context.Canceled) {\n\t\treturn fmt.Errorf(\"running UI: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc handleCustomTools(toolConfigPaths []string) error {\n\t// resolve tool config paths, and then load and register custom tools from config files and dirs\n\tfor _, path := range toolConfigPaths {\n\t\tpathWithPlaceholdersExpanded := path\n\n\t\tif strings.Contains(pathWithPlaceholdersExpanded, \"{CONFIG}\") {\n\t\t\tconfigDir, err := os.UserConfigDir()\n\t\t\tif err != nil {\n\t\t\t\tklog.Warningf(\"Failed to get user config directory for tools path %q: %v\", path, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpathWithPlaceholdersExpanded = strings.ReplaceAll(pathWithPlaceholdersExpanded, \"{CONFIG}\", configDir)\n\t\t}\n\n\t\tif strings.Contains(pathWithPlaceholdersExpanded, \"{HOME}\") {\n\t\t\thomeDir, err := os.UserHomeDir()\n\t\t\tif err != nil {\n\t\t\t\tklog.Warningf(\"Failed to get user home directory for tools path %q: %v\", path, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tpathWithPlaceholdersExpanded = strings.ReplaceAll(pathWithPlaceholdersExpanded, \"{HOME}\", homeDir)\n\t\t}\n\n\t\tcleanedPath := filepath.Clean(pathWithPlaceholdersExpanded)\n\n\t\tklog.Infof(\"Attempting to load custom tools from processed path: %q (original value from config: %q)\", cleanedPath, path)\n\n\t\tif err := tools.LoadAndRegisterCustomTools(cleanedPath); err != nil {\n\t\t\tif errors.Is(err, os.ErrNotExist) && !slices.Contains(defaultToolConfigPaths, path) {\n\t\t\t\t// user specified a directory that does not exist, we must error out\n\t\t\t\treturn fmt.Errorf(\"custom tools directory not found (original value: %q, processed path: %q)\", path, cleanedPath)\n\t\t\t} else {\n\t\t\t\tklog.Warningf(\"Failed to load or register custom tools (original value: %q, processed path: %q): %v\", path, cleanedPath, err)\n\t\t\t}\n\t\t}\n\t}\n\treturn nil\n}\n\n// Redirect standard log output to our custom klog writer\n// This is primarily to suppress warning messages from\n// genai library https://github.com/googleapis/go-genai/blob/6ac4afc0168762dc3b7a4d940fc463cc1854f366/types.go#L1633\nfunc redirectStdLogToKlog() {\n\tlog.SetOutput(klogWriter{})\n\n\t// Disable standard log's prefixes (date, time, file info)\n\t// because klog will add its own more detailed prefix.\n\tlog.SetFlags(0)\n}\n\n// Define a custom writer that forwards messages to klog.Warning\ntype klogWriter struct{}\n\nfunc (writer klogWriter) Write(data []byte) (n int, err error) {\n\t// We trim the trailing newline because klog adds its own.\n\tmessage := string(bytes.TrimSuffix(data, []byte(\"\\n\")))\n\tklog.Warning(message)\n\treturn len(data), nil\n}\n\nfunc hasStdInData() (bool, error) {\n\thasData := false\n\n\tstat, err := os.Stdin.Stat()\n\tif err != nil {\n\t\treturn hasData, fmt.Errorf(\"checking stdin: %w\", err)\n\t}\n\thasData = (stat.Mode() & os.ModeCharDevice) == 0\n\n\treturn hasData, nil\n}\n\n// resolveQueryInput determines the query input from positional args and/or stdin.\n// It supports:\n// - 1 positional arg only -> kubectl-ai \"get pods\"\n// - stdin only -> echo \"get pods\" | kubectl-ai\n// - 1 positional arg + stdin (combined) -> kubectl-ai get <<< \"pods\" or kubectl-ai \"get\" <<< \"pods\"\n// As default no positional arg nor stdin\nfunc resolveQueryInput(hasStdInData bool, args []string) (string, error) {\n\tswitch {\n\tcase len(args) == 1 && !hasStdInData:\n\t\t// Use argument directly\n\t\treturn args[0], nil\n\n\tcase len(args) == 1 && hasStdInData:\n\t\t// Combine arg + stdin\n\t\tvar b strings.Builder\n\t\tb.WriteString(args[0])\n\t\tb.WriteString(\"\\n\")\n\n\t\tscanner := bufio.NewScanner(os.Stdin)\n\t\tfor scanner.Scan() {\n\t\t\tb.WriteString(scanner.Text())\n\t\t\tb.WriteString(\"\\n\")\n\t\t}\n\t\tif err := scanner.Err(); err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"reading stdin: %w\", err)\n\t\t}\n\t\tquery := strings.TrimSpace(b.String())\n\t\tif query == \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"no query provided from stdin\")\n\t\t}\n\t\treturn query, nil\n\n\tcase len(args) == 0 && hasStdInData:\n\t\t// Read stdin only\n\t\tb, err := io.ReadAll(os.Stdin)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"reading stdin: %w\", err)\n\t\t}\n\t\tquery := strings.TrimSpace(string(b))\n\t\tif query == \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"no query provided from stdin\")\n\t\t}\n\t\treturn query, nil\n\n\tdefault:\n\t\t// Case: No input at all — return empty string, no error\n\t\treturn \"\", nil\n\t}\n}\n\nfunc resolveKubeConfigPath(opt *Options) error {\n\tswitch {\n\tcase opt.KubeConfigPath != \"\":\n\t\t// Already set from flag or viper env\n\tcase os.Getenv(\"KUBECONFIG\") != \"\":\n\t\topt.KubeConfigPath = os.Getenv(\"KUBECONFIG\")\n\tdefault:\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get user home directory: %w\", err)\n\t\t}\n\t\tdefaultPath := filepath.Join(home, \".kube\", \"config\")\n\t\t// Only use the default path if it exists\n\t\tif _, err := os.Stat(defaultPath); err == nil {\n\t\t\topt.KubeConfigPath = defaultPath\n\t\t}\n\t}\n\n\t// We resolve the kubeconfig path to an absolute path, so we can run kubectl from any working directory.\n\tif opt.KubeConfigPath != \"\" {\n\t\tp, err := filepath.Abs(opt.KubeConfigPath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get absolute path for kubeconfig file %q: %w\", opt.KubeConfigPath, err)\n\t\t}\n\t\topt.KubeConfigPath = p\n\t}\n\n\treturn nil\n}\n\nfunc startMCPServer(ctx context.Context, opt Options) error {\n\tworkDir := filepath.Join(os.TempDir(), \"kubectl-ai-mcp\")\n\tif err := os.MkdirAll(workDir, 0o755); err != nil {\n\t\treturn fmt.Errorf(\"error creating work directory: %w\", err)\n\t}\n\tmcpServer, err := newKubectlMCPServer(ctx, opt.KubeConfigPath, tools.Default(), workDir, opt.ExternalTools, opt.MCPServerMode, opt.HTTPPort)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating mcp server: %w\", err)\n\t}\n\treturn mcpServer.Serve(ctx)\n}\n\n// handleListSessions lists all available sessions with their metadata.\nfunc handleListSessions(opt Options) error {\n\tmanager, err := sessions.NewSessionManager(opt.SessionBackend)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create session manager: %w\", err)\n\t}\n\n\tsessionList, err := manager.ListSessions()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to list sessions: %w\", err)\n\t}\n\n\tif len(sessionList) == 0 {\n\t\tfmt.Println(\"No sessions found.\")\n\t\treturn nil\n\t}\n\n\tfmt.Println(\"Available sessions:\")\n\tfmt.Println(\"ID\\t\\tCreated\\t\\t\\tLast Accessed\\t\\tModel\\t\\tProvider\")\n\tfmt.Println(\"--\\t\\t-------\\t\\t\\t-------------\\t\\t-----\\t\\t--------\")\n\n\tfor _, session := range sessionList {\n\t\tfmt.Printf(\"%s\\t%s\\t%s\\t%s\\t%s\\n\",\n\t\t\tsession.ID,\n\t\t\tsession.CreatedAt.Format(\"2006-01-02 15:04:05\"),\n\t\t\tsession.LastModified.Format(\"2006-01-02 15:04:05\"),\n\t\t\tsession.ModelID,\n\t\t\tsession.ProviderID)\n\t}\n\n\treturn nil\n}\n\n// handleDeleteSession deletes a session by ID.\nfunc handleDeleteSession(opt Options) error {\n\tmanager, err := sessions.NewSessionManager(opt.SessionBackend)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create session manager: %w\", err)\n\t}\n\n\tsession, err := manager.FindSessionByID(opt.DeleteSession)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"session %s not found: %w\", opt.DeleteSession, err)\n\t}\n\n\tfmt.Printf(\"Deleting session %s:\\n\", opt.DeleteSession)\n\tfmt.Printf(\"  Model: %s\\n\", session.ModelID)\n\tfmt.Printf(\"  Provider: %s\\n\", session.ProviderID)\n\tfmt.Printf(\"  Created: %s\\n\", session.CreatedAt.Format(\"2006-01-02 15:04:05\"))\n\n\tfmt.Print(\"Are you sure you want to delete this session? (y/N): \")\n\tvar response string\n\tfmt.Scanln(&response)\n\n\tif response != \"y\" && response != \"Y\" {\n\t\tfmt.Println(\"Deletion cancelled.\")\n\t\treturn nil\n\t}\n\n\tif err := manager.DeleteSession(opt.DeleteSession); err != nil {\n\t\treturn fmt.Errorf(\"failed to delete session: %w\", err)\n\t}\n\n\tfmt.Printf(\"Session %s deleted successfully.\\n\", opt.DeleteSession)\n\treturn nil\n}\n"
  },
  {
    "path": "cmd/mcp.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/mcp\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/tools\"\n\tmcpgo \"github.com/mark3labs/mcp-go/mcp\"\n\t\"github.com/mark3labs/mcp-go/server\"\n\t\"k8s.io/klog/v2\"\n)\n\ntype kubectlMCPServer struct {\n\tkubectlConfig string\n\tserver        *server.MCPServer\n\ttools         tools.Tools\n\tworkDir       string\n\tmcpManager    *mcp.Manager // Add MCP manager for external tool calls\n\tmcpServerMode string       // Server mode (e.g., \"streamable-http\", \"stdio\")\n\thttpPort      int          // Port for HTTP-based server modes\n}\n\nfunc newKubectlMCPServer(ctx context.Context, kubectlConfig string, tools tools.Tools, workDir string, exposeExternalTools bool, serverMode string, httpPort int) (*kubectlMCPServer, error) {\n\ts := &kubectlMCPServer{\n\t\tkubectlConfig: kubectlConfig,\n\t\tworkDir:       workDir,\n\t\tserver: server.NewMCPServer(\n\t\t\t\"kubectl-ai\",\n\t\t\t\"0.0.1\",\n\t\t\tserver.WithToolCapabilities(true),\n\t\t),\n\t\ttools:         tools,\n\t\tmcpServerMode: serverMode,\n\t\thttpPort:      httpPort,\n\t}\n\n\t// Add built-in tools\n\tfor _, tool := range s.tools.AllTools() {\n\t\ttoolDefn := tool.FunctionDefinition()\n\t\ttoolInputSchema, err := toolDefn.Parameters.ToRawSchema()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting tool schema to json.RawMessage: %w\", err)\n\t\t}\n\t\ts.server.AddTool(mcpgo.NewToolWithRawSchema(\n\t\t\ttoolDefn.Name,\n\t\t\ttoolDefn.Description,\n\t\t\ttoolInputSchema,\n\t\t), s.handleToolCall)\n\t}\n\n\t// Only discover external MCP tools if explicitly enabled\n\tif exposeExternalTools {\n\t\t// Initialize MCP manager to get client tools\n\t\tmanager, err := mcp.InitializeManager()\n\t\tif err != nil {\n\t\t\tklog.Warningf(\"Failed to initialize MCP manager: %v\", err)\n\t\t\treturn s, nil // Return server with just built-in tools\n\t\t}\n\n\t\t// Store the manager for later use in tool calls\n\t\ts.mcpManager = manager\n\n\t\t// Connect to MCP servers and get their tools\n\t\tif err := manager.DiscoverAndConnectServers(ctx); err != nil {\n\t\t\tklog.Warningf(\"Failed to connect to MCP servers: %v\", err)\n\t\t\treturn s, nil // Return server with just built-in tools\n\t\t}\n\n\t\t// Get tools from all connected MCP servers\n\t\tserverTools, err := manager.ListAvailableTools(ctx)\n\t\tif err != nil {\n\t\t\tklog.Warningf(\"Failed to list tools from MCP servers: %v\", err)\n\t\t\treturn s, nil // Return server with just built-in tools\n\t\t}\n\n\t\t// Add tools from MCP servers\n\t\ttotalToolsRegistered := 0\n\t\tfor serverName, tools := range serverTools {\n\t\t\tklog.V(2).Infof(\"Processing tools from MCP server %s: %d tools found\", serverName, len(tools))\n\n\t\t\tfor _, tool := range tools {\n\t\t\t\t// Create unique tool name to avoid conflicts with built-in tools or from other servers\n\t\t\t\tuniqueToolName := fmt.Sprintf(\"%s_%s\", serverName, tool.Name)\n\n\t\t\t\t// Use the actual tool schema instead of creating a generic wrapper\n\t\t\t\tvar schema *gollm.FunctionDefinition\n\t\t\t\tif tool.InputSchema != nil {\n\t\t\t\t\t// Use the real schema from the external tool\n\t\t\t\t\tschema = &gollm.FunctionDefinition{\n\t\t\t\t\t\tName:        uniqueToolName,\n\t\t\t\t\t\tDescription: fmt.Sprintf(\"%s (from %s)\", tool.Description, serverName),\n\t\t\t\t\t\tParameters:  tool.InputSchema,\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Fallback to generic schema if no schema provided\n\t\t\t\t\tklog.V(2).Infof(\"External tool %s from %s has no schema, using generic wrapper\", tool.Name, serverName)\n\t\t\t\t\tschema = &gollm.FunctionDefinition{\n\t\t\t\t\t\tName:        uniqueToolName,\n\t\t\t\t\t\tDescription: fmt.Sprintf(\"%s (from %s)\", tool.Description, serverName),\n\t\t\t\t\t\tParameters: &gollm.Schema{\n\t\t\t\t\t\t\tType: gollm.TypeObject,\n\t\t\t\t\t\t\tProperties: map[string]*gollm.Schema{\n\t\t\t\t\t\t\t\t\"args\": {\n\t\t\t\t\t\t\t\t\tType:        gollm.TypeObject,\n\t\t\t\t\t\t\t\t\tDescription: \"Tool arguments\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\ttoolInputSchema, err := schema.Parameters.ToRawSchema()\n\t\t\t\tif err != nil {\n\t\t\t\t\tklog.Errorf(\"Failed to convert tool schema for %s from %s: %v - skipping tool\", tool.Name, serverName, err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Add the tool to the server\n\t\t\t\ts.server.AddTool(mcpgo.NewToolWithRawSchema(\n\t\t\t\t\tuniqueToolName,\n\t\t\t\t\tschema.Description,\n\t\t\t\t\ttoolInputSchema,\n\t\t\t\t), s.handleToolCall)\n\n\t\t\t\ttotalToolsRegistered++\n\t\t\t\tklog.V(3).Infof(\"Registered tool: %s from server %s\", uniqueToolName, serverName)\n\t\t\t}\n\t\t}\n\n\t\tklog.Infof(\"MCP server initialized with external tool discovery enabled - registered %d tools from %d servers\", totalToolsRegistered, len(serverTools))\n\t} else {\n\t\tklog.Infof(\"MCP server initialized with external tool discovery disabled\")\n\t}\n\n\treturn s, nil\n}\n\nfunc (s *kubectlMCPServer) Serve(ctx context.Context) error {\n\t// Ensure proper cleanup of MCP manager on shutdown\n\tif s.mcpManager != nil {\n\t\tdefer func() {\n\t\t\tif err := s.mcpManager.Close(); err != nil {\n\t\t\t\tklog.Warningf(\"Failed to close MCP manager: %v\", err)\n\t\t\t}\n\t\t}()\n\t}\n\n\tklog.Info(\"Starting kubectl-ai MCP server\")\n\n\tswitch s.mcpServerMode {\n\tcase \"streamable-http\":\n\t\t// Start the server in streamable HTTP mode\n\t\tklog.Infof(\"Starting MCP server in streamable HTTP mode on port %d\", s.httpPort)\n\t\thttpServer := server.NewStreamableHTTPServer(s.server)\n\t\tendpoint := fmt.Sprintf(\":%d\", s.httpPort)\n\t\tklog.Infof(\"Listening for streamable HTTP connections on port %d\", s.httpPort)\n\t\treturn httpServer.Start(endpoint)\n\tdefault:\n\t\treturn server.ServeStdio(s.server)\n\t}\n}\n\nfunc (s *kubectlMCPServer) handleToolCall(ctx context.Context, request mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {\n\ttoolName := request.Params.Name\n\n\t// First, try to find the tool in our built-in tools collection\n\tbuiltinTool := s.tools.Lookup(toolName)\n\tif builtinTool != nil {\n\t\treturn s.handleBuiltinToolCall(ctx, request, builtinTool)\n\t}\n\n\t// If not a built-in tool, try to handle as external MCP tool\n\tif s.mcpManager != nil {\n\t\treturn s.handleExternalMCPToolCall(ctx, request)\n\t}\n\n\t// Tool not found\n\treturn &mcpgo.CallToolResult{\n\t\tIsError: true,\n\t\tContent: []mcpgo.Content{\n\t\t\tmcpgo.TextContent{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: fmt.Sprintf(\"tool %q not found\", toolName),\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\n// handleBuiltinToolCall handles calls to built-in kubectl-ai tools\nfunc (s *kubectlMCPServer) handleBuiltinToolCall(ctx context.Context, request mcpgo.CallToolRequest, tool tools.Tool) (*mcpgo.CallToolResult, error) {\n\t// Set up context for built-in tools\n\tctx = context.WithValue(ctx, tools.KubeconfigKey, s.kubectlConfig)\n\tctx = context.WithValue(ctx, tools.WorkDirKey, s.workDir)\n\n\t// Convert arguments to the expected type\n\targs, ok := request.Params.Arguments.(map[string]any)\n\tif !ok {\n\t\treturn &mcpgo.CallToolResult{\n\t\t\tIsError: true,\n\t\t\tContent: []mcpgo.Content{\n\t\t\t\tmcpgo.TextContent{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: fmt.Sprintf(\"invalid arguments type: expected map[string]any, got %T\", request.Params.Arguments),\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t}\n\n\t// Execute the built-in tool\n\tresult, err := tool.Run(ctx, args)\n\tif err != nil {\n\t\treturn &mcpgo.CallToolResult{\n\t\t\tIsError: true,\n\t\t\tContent: []mcpgo.Content{\n\t\t\t\tmcpgo.TextContent{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: err.Error(),\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t}\n\n\t// Convert result to string\n\tvar resultStr string\n\tswitch v := result.(type) {\n\tcase string:\n\t\tresultStr = v\n\tdefault:\n\t\tresultStr = fmt.Sprintf(\"%v\", v)\n\t}\n\n\treturn &mcpgo.CallToolResult{\n\t\tContent: []mcpgo.Content{\n\t\t\tmcpgo.TextContent{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: resultStr,\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\n// handleExternalMCPToolCall handles calls to external MCP tools\nfunc (s *kubectlMCPServer) handleExternalMCPToolCall(ctx context.Context, request mcpgo.CallToolRequest) (*mcpgo.CallToolResult, error) {\n\ttoolName := request.Params.Name\n\n\t// Find which server provides this tool\n\tserverTools, err := s.mcpManager.ListAvailableTools(ctx)\n\tif err != nil {\n\t\treturn &mcpgo.CallToolResult{\n\t\t\tIsError: true,\n\t\t\tContent: []mcpgo.Content{\n\t\t\t\tmcpgo.TextContent{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: fmt.Sprintf(\"failed to list available tools: %v\", err),\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t}\n\n\tvar targetServerName string\n\tvar originalToolName string\n\n\t// Look for the tool by checking both original name and server-prefixed name\n\tfor serverName, tools := range serverTools {\n\t\tfor _, tool := range tools {\n\t\t\tuniqueToolName := fmt.Sprintf(\"%s_%s\", serverName, tool.Name)\n\t\t\tif uniqueToolName == toolName {\n\t\t\t\ttargetServerName = serverName\n\t\t\t\toriginalToolName = tool.Name // Use the original tool name for the MCP call\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif targetServerName != \"\" {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif targetServerName == \"\" {\n\t\treturn &mcpgo.CallToolResult{\n\t\t\tIsError: true,\n\t\t\tContent: []mcpgo.Content{\n\t\t\t\tmcpgo.TextContent{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: fmt.Sprintf(\"external MCP tool %q not found\", toolName),\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t}\n\n\t// Get the client for the target server\n\tclient, exists := s.mcpManager.GetClient(targetServerName)\n\tif !exists {\n\t\treturn &mcpgo.CallToolResult{\n\t\t\tIsError: true,\n\t\t\tContent: []mcpgo.Content{\n\t\t\t\tmcpgo.TextContent{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: fmt.Sprintf(\"MCP client for server %q not found\", targetServerName),\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t}\n\n\t// Extract arguments - handle the args wrapper for external tools and empty/nil input\n\tvar toolArgs map[string]any\n\tif request.Params.Arguments == nil {\n\t\t// Handle nil arguments as empty map\n\t\ttoolArgs = make(map[string]any)\n\t} else if args, ok := request.Params.Arguments.(map[string]any); ok {\n\t\tif argsValue, hasArgs := args[\"args\"]; hasArgs {\n\t\t\tif argsMap, ok := argsValue.(map[string]any); ok {\n\t\t\t\ttoolArgs = argsMap\n\t\t\t} else {\n\t\t\t\ttoolArgs = args // Fallback to using args directly\n\t\t\t}\n\t\t} else {\n\t\t\ttoolArgs = args // Use arguments directly if no \"args\" wrapper\n\t\t}\n\t} else {\n\t\treturn &mcpgo.CallToolResult{\n\t\t\tIsError: true,\n\t\t\tContent: []mcpgo.Content{\n\t\t\t\tmcpgo.TextContent{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: fmt.Sprintf(\"invalid arguments type: expected map[string]any, got %T\", request.Params.Arguments),\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t}\n\n\t// Call the external MCP tool using the original tool name\n\tresult, err := client.CallTool(ctx, originalToolName, toolArgs)\n\tif err != nil {\n\t\treturn &mcpgo.CallToolResult{\n\t\t\tIsError: true,\n\t\t\tContent: []mcpgo.Content{\n\t\t\t\tmcpgo.TextContent{\n\t\t\t\t\tType: \"text\",\n\t\t\t\t\tText: fmt.Sprintf(\"error calling external MCP tool %q on server %q: %v\", originalToolName, targetServerName, err),\n\t\t\t\t},\n\t\t\t},\n\t\t}, nil\n\t}\n\n\t// Return successful result\n\treturn &mcpgo.CallToolResult{\n\t\tContent: []mcpgo.Content{\n\t\t\tmcpgo.TextContent{\n\t\t\t\tType: \"text\",\n\t\t\t\tText: result,\n\t\t\t},\n\t\t},\n\t}, nil\n}\n"
  },
  {
    "path": "cmd/mcp_test.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/mcp\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/tools\"\n)\n\nfunc TestKubectlMCPServerHTTPClientIntegration(t *testing.T) {\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n\n\ttoolset := tools.Tools{}\n\ttoolset.Init()\n\ttoolset.RegisterTool(&stubTool{})\n\n\tport := getFreePort(t)\n\n\tworkDir := t.TempDir()\n\n\tserver, err := newKubectlMCPServer(ctx, \"\", toolset, workDir, false, \"streamable-http\", port)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create MCP server: %v\", err)\n\t}\n\n\tserverErr := make(chan error, 1)\n\tgo func() {\n\t\tserverErr <- server.Serve(ctx)\n\t}()\n\n\twaitForHTTPServer(t, port)\n\n\tselect {\n\tcase err := <-serverErr:\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"server exited early: %v\", err)\n\t\t}\n\tdefault:\n\t}\n\n\tclientConfig := mcp.ClientConfig{\n\t\tName:         \"test-client\",\n\t\tURL:          fmt.Sprintf(\"http://127.0.0.1:%d/mcp\", port),\n\t\tUseStreaming: true,\n\t\tTimeout:      5,\n\t}\n\n\tclient := mcp.NewClient(clientConfig)\n\n\tconnectCtx, connectCancel := context.WithTimeout(ctx, 5*time.Second)\n\tdefer connectCancel()\n\n\tt.Log(\"connecting client\")\n\n\tif err := client.Connect(connectCtx); err != nil {\n\t\tt.Fatalf(\"failed to connect client to MCP server: %v\", err)\n\t}\n\tdefer func() {\n\t\tif err := client.Close(); err != nil {\n\t\t\tt.Errorf(\"failed to close MCP client: %v\", err)\n\t\t}\n\t}()\n\n\ttoolsCtx, toolsCancel := context.WithTimeout(ctx, 5*time.Second)\n\tdefer toolsCancel()\n\n\tt.Log(\"listing tools\")\n\n\tavailableTools, err := client.ListTools(toolsCtx)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to list tools from MCP server: %v\", err)\n\t}\n\n\tt.Logf(\"retrieved %d tool(s)\", len(availableTools))\n\n\tif !toolExists(\"stub\", availableTools) {\n\t\tt.Fatalf(\"expected to find stub tool, got %v\", availableTools)\n\t}\n\n\tcancel()\n\n\tselect {\n\tcase <-serverErr:\n\tcase <-time.After(500 * time.Millisecond):\n\t}\n}\n\nfunc waitForHTTPServer(t *testing.T, port int) {\n\tt.Helper()\n\n\tdeadline := time.Now().Add(5 * time.Second)\n\taddress := fmt.Sprintf(\"127.0.0.1:%d\", port)\n\n\tfor {\n\t\tconn, err := net.DialTimeout(\"tcp\", address, 100*time.Millisecond)\n\t\tif err == nil {\n\t\t\tconn.Close()\n\t\t\treturn\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"server did not start listening on %s: %v\", address, err)\n\t\t}\n\t\ttime.Sleep(50 * time.Millisecond)\n\t}\n}\n\nfunc getFreePort(t *testing.T) int {\n\tt.Helper()\n\n\tl, err := net.Listen(\"tcp\", \"127.0.0.1:0\")\n\tif err != nil {\n\t\tt.Fatalf(\"failed to acquire free port: %v\", err)\n\t}\n\tdefer l.Close()\n\n\treturn l.Addr().(*net.TCPAddr).Port\n}\n\nfunc toolExists(name string, tools []mcp.Tool) bool {\n\tfor _, tool := range tools {\n\t\tif tool.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\ntype stubTool struct{}\n\nfunc (stubTool) Name() string {\n\treturn \"stub\"\n}\n\nfunc (stubTool) Description() string {\n\treturn \"stub tool\"\n}\n\nfunc (stubTool) FunctionDefinition() *gollm.FunctionDefinition {\n\treturn &gollm.FunctionDefinition{\n\t\tName:        \"stub\",\n\t\tDescription: \"stub tool\",\n\t\tParameters: &gollm.Schema{\n\t\t\tType: gollm.TypeObject,\n\t\t},\n\t}\n}\n\nfunc (stubTool) Run(context.Context, map[string]any) (any, error) {\n\treturn \"ok\", nil\n}\n\nfunc (stubTool) IsInteractive(map[string]any) (bool, error) {\n\treturn false, nil\n}\n\nfunc (stubTool) CheckModifiesResource(map[string]any) string {\n\treturn \"no\"\n}\n"
  },
  {
    "path": "contributing.md",
    "content": "# How to Contribute\n\nWe would love to accept your patches and contributions to this project.\n\n## Before you begin\n\n### Sign our Contributor License Agreement\n\nContributions to this project must be accompanied by a\n[Contributor License Agreement](https://cla.developers.google.com/about) (CLA).\nYou (or your employer) retain the copyright to your contribution; this simply\ngives us permission to use and redistribute your contributions as part of the\nproject.\n\nIf you or your current employer have already signed the Google CLA (even if it\nwas for a different project), you probably don't need to do it again.\n\nVisit <https://cla.developers.google.com/> to see your current agreements or to\nsign a new one.\n\n### Review our Community Guidelines\n\nThis project follows [Google's Open Source Community\nGuidelines](https://opensource.google/conduct/).\n\n## Contribution process\n\n### Code Reviews\n\nAll submissions, including submissions by project members, require review. We\nuse [GitHub pull requests](https://docs.github.com/articles/about-pull-requests)\nfor this purpose.\n\n## Understand the repo\n\nAn AI-generated overview of the system architecture for this repository is\navailable [here](https://deepwiki.com/GoogleCloudPlatform/kubectl-ai/). This can\nprovide an interactive way to explore the codebase.\n\nQuick notes about the various directories:\n- Source code for `kubectl-ai` CLI lives under `cmd/` and `pkg/` directories.\n- gollm directory is an independent Go module that implements LLM clients for\ndifferent LLM providers.\n- `modelserving` directory contains utilities and configuration to build and run\nopen source AI models locally or in a kubernetes cluster.\n- `kubectl-utils` is an independent Go package/binary to help with the benchmarks tasks\nthat evaluates various conditions involving properties of kubernetes resources.\n- User guides/design docs/proposals live under `docs` directory.\n- `dev` directory scripts for project related tasks (adhoc/CI).\n"
  },
  {
    "path": "dev/ci/periodics/analyze-evals.sh",
    "content": "#!/bin/bash\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nset -x\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\ncd ${REPO_ROOT}\n\nif [[ -z \"${OUTPUT_DIR:-}\" ]]; then\n    OUTPUT_DIR=\"${REPO_ROOT}/.build/k8s-ai-bench\"\n    mkdir -p \"${OUTPUT_DIR}\"\nfi\n\nBINDIR=\"${REPO_ROOT}/.build/bin\"\nmkdir -p \"${BINDIR}\"\n\nK8S_AI_BENCH_SRC=\"${REPO_ROOT}/.build/k8s-ai-bench-src\"\nrm -rf \"${K8S_AI_BENCH_SRC}\"\ngit clone https://github.com/gke-labs/k8s-ai-bench \"${K8S_AI_BENCH_SRC}\"\ncd \"${K8S_AI_BENCH_SRC}\"\nGOWORK=off go build -o \"${BINDIR}/k8s-ai-bench\" .\n\ncd \"${REPO_ROOT}\"\n\n# Pass --show-failures flag to the analyze command if it's set\nANALYZE_ARGS=\"\"\nif [[ \"$*\" == *\"--show-failures\"* ]]; then\n    ANALYZE_ARGS=\"--show-failures\"\nfi\n\n\"${BINDIR}/k8s-ai-bench\" analyze --input-dir \"${OUTPUT_DIR}\" ${TEST_ARGS:-} -results-filepath ${REPO_ROOT}/.build/k8s-ai-bench.md --output-format markdown ${ANALYZE_ARGS}\n\"${BINDIR}/k8s-ai-bench\" analyze --input-dir \"${OUTPUT_DIR}\" ${TEST_ARGS:-} -results-filepath ${REPO_ROOT}/.build/k8s-ai-bench.json --output-format json\n"
  },
  {
    "path": "dev/ci/periodics/run-evals.sh",
    "content": "#!/bin/bash\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nset -x\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\ncd ${REPO_ROOT}\n\nif [[ -z \"${OUTPUT_DIR:-}\" ]]; then\n    OUTPUT_DIR=\"${REPO_ROOT}/.build/k8s-ai-bench\"\n    mkdir -p \"${OUTPUT_DIR}\"\nfi\necho \"Writing results to ${OUTPUT_DIR}\"\n\nBINDIR=\"${REPO_ROOT}/.build/bin\"\nmkdir -p \"${BINDIR}\"\n\ncurl -sSL https://raw.githubusercontent.com/GoogleCloudPlatform/kubectl-ai/main/install.sh | bash\n\nK8S_AI_BENCH_SRC=\"${REPO_ROOT}/.build/k8s-ai-bench-src\"\nrm -rf \"${K8S_AI_BENCH_SRC}\"\ngit clone https://github.com/gke-labs/k8s-ai-bench \"${K8S_AI_BENCH_SRC}\"\ncd \"${K8S_AI_BENCH_SRC}\"\nGOWORK=off go build -o \"${BINDIR}/k8s-ai-bench\" .\n\n\"${BINDIR}/k8s-ai-bench\" run --agent-bin kubectl-ai --kubeconfig \"${KUBECONFIG:-~/.kube/config}\" --output-dir \"${OUTPUT_DIR}\" ${TEST_ARGS:-}\n"
  },
  {
    "path": "dev/ci/presubmits/go-build.sh",
    "content": "#!/usr/bin/env bash\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\ncd ${REPO_ROOT}\n\nfor f in $(find ${REPO_ROOT} -name go.mod); do\n  cd $(dirname ${f})\n  go build ./...\ndone\n"
  },
  {
    "path": "dev/ci/presubmits/go-vet.sh",
    "content": "#!/usr/bin/env bash\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\ncd ${REPO_ROOT}\n\nfor f in $(find ${REPO_ROOT} -name go.mod); do\n  cd $(dirname ${f})\n  go vet ./...\ndone\n"
  },
  {
    "path": "dev/ci/presubmits/verify-autogen.sh",
    "content": "#!/usr/bin/env bash\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\ncd ${REPO_ROOT}\n\ndev/tasks/generate-github-actions.sh\n\nchanges=$(git status --porcelain)\nif [[ -n \"${changes}\" ]]; then\n  echo \"FAIL: Changes detected from dev/tasks/generate-github-actions.sh:\"\n  git diff | head -n60\n  echo \"${changes}\"\n  exit 1\nfi\n"
  },
  {
    "path": "dev/ci/presubmits/verify-format.sh",
    "content": "#!/usr/bin/env bash\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\ncd ${REPO_ROOT}\n\ndev/tasks/format.sh\n\nchanges=$(git status --porcelain)\nif [[ -n \"${changes}\" ]]; then\n  echo \"FAIL: Changes detected from dev/tasks/format.sh:\"\n  git diff | head -n60\n  echo \"${changes}\"\n  exit 1\nfi\n"
  },
  {
    "path": "dev/ci/presubmits/verify-gomod.sh",
    "content": "#!/usr/bin/env bash\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\ncd ${REPO_ROOT}\n\ndev/tasks/gomod.sh\n\nchanges=$(git status --porcelain)\nif [[ -n \"${changes}\" ]]; then\n  echo \"FAIL: Changes detected from dev/tasks/gomod.sh:\"\n  git diff | head -n60\n  echo \"${changes}\"\n  exit 1\nfi\n"
  },
  {
    "path": "dev/ci/presubmits/verify-mocks.sh",
    "content": "#!/usr/bin/env bash\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\ncd ${REPO_ROOT}\n\nif ! command -v mockgen &> /dev/null; then\n  echo \"mockgen not found, installing...\"\n  go install go.uber.org/mock/mockgen@latest\nfi\n\n# We run generate to see if it creates any diffs.\ngo generate ./internal/mocks\n\nif ! git diff --quiet --exit-code -- internal/mocks; then\n  echo \"Mocks are stale. Commit the changes to the generated files.\"\n  exit 1\nfi\n\necho \"Mocks are up to date.\""
  },
  {
    "path": "dev/tasks/build-images",
    "content": "#!/usr/bin/env bash\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nSRC_DIR=${REPO_ROOT}/\ncd \"${SRC_DIR}\"\n\nif [[ -z \"${IMAGE_PREFIX:-}\" ]]; then\n  IMAGE_PREFIX=\"\"\nfi\necho \"Building images with prefix ${IMAGE_PREFIX}\"\n\nif [[ -z \"${TAG:-}\" ]]; then\n  TAG=latest\nfi\n\nif [[ -z \"${BUILDX_ARGS:-}\" ]]; then\n  BUILDX_ARGS=\"--load\"\nfi\nif [[ -z \"${DOCKER:-}\" ]]; then\n  DOCKER=\"docker\"\nfi\necho \"Using container tool: ${DOCKER}\"\n\nif [[ \"${DOCKER}\" == \"podman\" ]]; then\n  # Podman doesn't support buildx, use regular build command\n  if [[ \"${BUILDX_ARGS}\" == \"--push\" ]]; then\n    # For podman, build and then push separately\n    echo \"Building with podman...\"\n    ${DOCKER} build \\\n        --platform linux/amd64 \\\n        -f images/kubectl-ai/Dockerfile \\\n        -t ${IMAGE_PREFIX}kubectl-ai:${TAG} \\\n        .\n    echo \"Pushing with podman...\"\n    ${DOCKER} push ${IMAGE_PREFIX}kubectl-ai:${TAG}\n  else\n    # For --load or other args, just build\n    ${DOCKER} build \\\n        --platform linux/amd64 \\\n        -f images/kubectl-ai/Dockerfile \\\n        -t ${IMAGE_PREFIX}kubectl-ai:${TAG} \\\n        .\n  fi\nelse\n  # Use docker buildx for docker\n  # Specify platform to avoid multi-arch build requirements\n  ${DOCKER} buildx build ${BUILDX_ARGS} \\\n      --platform linux/amd64 \\\n      -f images/kubectl-ai/Dockerfile \\\n      -t ${IMAGE_PREFIX}kubectl-ai:${TAG} \\\n      --progress=plain .\nfi\n"
  },
  {
    "path": "dev/tasks/demo.md",
    "content": "# steps to produce the demo gif\n\n1. Use [asciinema](asciinema.org) to record a screencast.\n\n2. Use agg to produce the gif\n\n```shell\nagg .github/kubectl-ai.cast --speed 1.3 --idle-time-limit 1 --theme monokai --font-size 24 --cols 100 --rows 25 .github/kubectl-ai.gif\n```"
  },
  {
    "path": "dev/tasks/deploy-to-gke",
    "content": "#!/usr/bin/env bash\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nSRC_DIR=${REPO_ROOT}\ncd \"${SRC_DIR}\"\n\n# Get GCP project ID\nif [[ -z \"${GCP_PROJECT_ID:-}\" ]]; then\n  GCP_PROJECT_ID=$(gcloud config get project)\nfi\necho \"Using GCP_PROJECT_ID=${GCP_PROJECT_ID}\"\n\n# Build kubectl command args based on available configuration\nKUBECTL_ARGS=\"\"\n\nif [[ -n \"${KUBECONFIG:-}\" ]]; then\n  echo \"Using KUBECONFIG: ${KUBECONFIG}\"\nelse\n  echo \"Using default kubeconfig (~/.kube/config)\"\nfi\n\nif [[ -n \"${KUBE_CONTEXT:-}\" ]]; then\n  KUBECTL_ARGS=\"--context=${KUBE_CONTEXT}\"\n  echo \"Using kube context: ${KUBE_CONTEXT}\"\nelif [[ -z \"${KUBECONFIG:-}\" ]]; then\n  # Only require KUBE_CONTEXT if KUBECONFIG is not specified\n  echo \"Listing GKE clusters in project ${GCP_PROJECT_ID}:\"\n  gcloud container clusters list --project=${GCP_PROJECT_ID}\n  echo \"\"\n  echo \"Please set KUBE_CONTEXT to kubectl context to use, or set KUBECONFIG to use a specific kubeconfig file\"\n  exit 1\nelse\n  echo \"Using current context from KUBECONFIG\"\nfi\n\nif [[ -z \"${NAMESPACE:-}\" ]]; then\n  NAMESPACE=kubectl-ai\n  echo \"Defaulting to namespace: ${NAMESPACE}\"\nfi\n\n# Pick a probably-unique tag\nexport TAG=`date +%Y%m%d%H%M%S`\n\n# Set up image registry - default to GCR, but allow override\nif [[ -z \"${IMAGE_REGISTRY:-}\" ]]; then\n  IMAGE_REGISTRY=gcr.io/${GCP_PROJECT_ID}\nfi\necho \"Using image registry: ${IMAGE_REGISTRY}\"\n\n# Configure authentication for the container registry before building\necho \"Configuring authentication for ${IMAGE_REGISTRY}\"\nif [[ \"${IMAGE_REGISTRY}\" == gcr.io/* ]]; then\n  # Configure GCR authentication\n  gcloud auth configure-docker --quiet\nelif [[ \"${IMAGE_REGISTRY}\" == *-docker.pkg.dev/* ]]; then\n  # Configure Artifact Registry authentication\n  gcloud auth configure-docker ${IMAGE_REGISTRY%%/*} --quiet\nfi\n\n# Configure podman authentication before building if needed\nif [[ \"${DOCKER:-docker}\" == \"podman\" ]]; then\n  echo \"Using podman: configuring registry authentication\"\n  # For podman, we need to configure the auth helper\n  if [[ \"${IMAGE_REGISTRY}\" == gcr.io/* ]]; then\n    echo \"$(gcloud auth print-access-token)\" | podman login -u oauth2accesstoken --password-stdin gcr.io\n  elif [[ \"${IMAGE_REGISTRY}\" == *-docker.pkg.dev/* ]]; then\n    echo \"$(gcloud auth print-access-token)\" | podman login -u oauth2accesstoken --password-stdin ${IMAGE_REGISTRY%%/*}\n  fi\nfi\n\n# Build the image\necho \"Building images\"\nexport IMAGE_PREFIX=${IMAGE_REGISTRY}/\nif [[ -n \"${DOCKER:-}\" ]]; then\n  echo \"Using container tool: ${DOCKER}\"\n  export DOCKER\nfi\n\n# For GKE, we need to push images, so use --push instead of --load\nBUILDX_ARGS=--push dev/tasks/build-images\n\nKUBECTL_AI_IMAGE=\"${IMAGE_PREFIX}kubectl-ai:${TAG}\"\n\necho \"Built and pushed image: ${KUBECTL_AI_IMAGE}\"\n\n# Create the namespace if it doesn't exist\necho \"Creating namespace: ${NAMESPACE}\"\nkubectl create namespace ${NAMESPACE} ${KUBECTL_ARGS} --dry-run=client -oyaml | kubectl apply ${KUBECTL_ARGS} --server-side -f -\n\n# Note: No secret needed for Vertex AI - uses Workload Identity for authentication\n\n# Create a cluster role binding so kubectl can \"see\" the current cluster\n# For production GKE, consider using more restrictive permissions\necho \"Creating cluster role binding as view\"\ncat <<EOF | kubectl apply ${KUBECTL_ARGS} --namespace=${NAMESPACE} --server-side -f -\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: ${NAMESPACE}:kubectl-ai:view\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: view\nsubjects:\n- kind: ServiceAccount\n  name: kubectl-ai\n  namespace: ${NAMESPACE}\nEOF\n\n# Deploy manifests\necho \"Deploying manifests\"\ncat k8s/kubectl-ai-gke.yaml | \\\n  sed s@kubectl-ai:latest@${KUBECTL_AI_IMAGE}@g | \\\n  sed s@PROJECT_ID@${GCP_PROJECT_ID}@g | \\\n  kubectl apply ${KUBECTL_ARGS} --namespace=${NAMESPACE} --server-side -f -\n\necho \"\"\necho \"Deployment completed successfully!\"\necho \"Image: ${KUBECTL_AI_IMAGE}\"\necho \"Namespace: ${NAMESPACE}\"\necho \"\"\necho \"Using GKE Workload Identity Federation for Vertex AI access.\"\necho \"Make sure your GKE cluster has Workload Identity enabled and configured for Vertex AI.\"\necho \"\"\necho \"To access the service:\"\necho \"  kubectl port-forward ${KUBECTL_ARGS} -n ${NAMESPACE} service/kubectl-ai 8080:80\"\necho \"  Then open http://localhost:8080 in your browser\" "
  },
  {
    "path": "dev/tasks/deploy-to-kind",
    "content": "#!/usr/bin/env bash\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nSRC_DIR=${REPO_ROOT}\ncd \"${SRC_DIR}\"\n\n# Pick a probably-unique tag\nexport TAG=`date +%Y%m%d%H%M%S`\n\n# Build kubectl command args based on available configuration\nKUBECTL_ARGS=\"\"\n\nif [[ -n \"${KUBECONFIG:-}\" ]]; then\n  echo \"Using KUBECONFIG: ${KUBECONFIG}\"\nelse\n  echo \"Using default kubeconfig (~/.kube/config)\"\nfi\n\nif [[ -n \"${KUBE_CONTEXT:-}\" ]]; then\n  KUBECTL_ARGS=\"--context=${KUBE_CONTEXT}\"\n  echo \"Using kube context: ${KUBE_CONTEXT}\"\nelif [[ -z \"${KUBECONFIG:-}\" ]]; then\n  # Only set default context if KUBECONFIG is not specified\n  KUBE_CONTEXT=kind-kind\n  KUBECTL_ARGS=\"--context=${KUBE_CONTEXT}\"\n  echo \"Defaulting to kube context: ${KUBE_CONTEXT}\"\nfi\n\nif [[ -z \"${NAMESPACE:-}\" ]]; then\n  NAMESPACE=kubectl-ai\n  echo \"Defaulting to namespace: ${NAMESPACE}\"\nfi\n\n# Build the image\necho \"Building images\"\nexport IMAGE_PREFIX=fake.registry/\nif [[ -n \"${DOCKER:-}\" ]]; then\n  echo \"Using container tool: ${DOCKER}\"\n  export DOCKER\nfi\nBUILDX_ARGS=--load dev/tasks/build-images\n\nKUBECTL_AI_IMAGE=\"${IMAGE_PREFIX:-}kubectl-ai:${TAG}\"\n\n# Determine the kind cluster name\nKIND_CLUSTER_NAME=\"\"\nif [[ -n \"${KUBE_CONTEXT:-}\" ]] && [[ \"${KUBE_CONTEXT}\" == kind-* ]]; then\n  # Extract cluster name from context (kind-clustername -> clustername)\n  KIND_CLUSTER_NAME=\"${KUBE_CONTEXT#kind-}\"\nelse\n  # Try to find any available kind cluster\n  AVAILABLE_CLUSTERS=$(kind get clusters 2>/dev/null || echo \"\")\n  if [[ -n \"${AVAILABLE_CLUSTERS}\" ]]; then\n    KIND_CLUSTER_NAME=$(echo \"${AVAILABLE_CLUSTERS}\" | head -n1)\n    echo \"Auto-detected kind cluster: ${KIND_CLUSTER_NAME}\"\n  else\n    echo \"ERROR: No kind clusters found. Please create one with 'kind create cluster'\"\n    exit 1\n  fi\nfi\n\n# Load the image into kind\necho \"Loading images into kind cluster '${KIND_CLUSTER_NAME}': ${KUBECTL_AI_IMAGE}\"\nif [[ \"${DOCKER:-docker}\" == \"podman\" ]]; then\n  # For podman, we need to save and load the image via archive\n  echo \"Using podman: saving image to archive first\"\n  podman save ${KUBECTL_AI_IMAGE} -o /tmp/kubectl-ai-image.tar\n  kind load image-archive /tmp/kubectl-ai-image.tar --name ${KIND_CLUSTER_NAME}\n  rm -f /tmp/kubectl-ai-image.tar\nelse\n  # For docker, use the standard approach\n  kind load docker-image ${KUBECTL_AI_IMAGE} --name ${KIND_CLUSTER_NAME}\nfi\n\n# Create the namespace if it doesn't exist\necho \"Creating namespace: ${NAMESPACE}\"\nkubectl create namespace ${NAMESPACE} ${KUBECTL_ARGS} --dry-run=client -oyaml | kubectl apply ${KUBECTL_ARGS} --server-side -f -\n\n# Create the secret if it doesn't exist,\n# including the GEMINI_API_KEY environment variable if set.\n# (This is for kind, on a GKE cluster, we probably want to use Workload Identity instead)\necho \"Creating secret: kubectl-ai\"\ncat <<EOF | kubectl apply ${KUBECTL_ARGS} --namespace=${NAMESPACE} --server-side -f -\nkind: Secret\napiVersion: v1\nmetadata:\n  name: kubectl-ai\n  labels:\n    app: kubectl-ai\ntype: Opaque\nstringData:\n  GEMINI_API_KEY: ${GEMINI_API_KEY}\nEOF\n\n\n# Create a role binding so kubectl can \"see\" the current cluster\n# Again, this makes sense for kind but we will probably have a different approach for GKE\necho \"Creating cluster role binding as view\"\ncat <<EOF | kubectl apply ${KUBECTL_ARGS} --namespace=${NAMESPACE} --server-side -f -\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: ${NAMESPACE}:kubectl-ai:view\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: view\nsubjects:\n- kind: ServiceAccount\n  name: kubectl-ai\n  namespace: ${NAMESPACE}\nEOF\n\n# Deploy manifests\necho \"Deploying manifests\"\ncat k8s/kubectl-ai.yaml | sed s@kubectl-ai:latest@${KUBECTL_AI_IMAGE}@g | \\\n  kubectl apply ${KUBECTL_ARGS} --namespace=${NAMESPACE} --server-side -f -\n"
  },
  {
    "path": "dev/tasks/format.sh",
    "content": "#!/usr/bin/env bash\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\ncd ${REPO_ROOT}\n\nfind . -name \"*.go\" | xargs go run github.com/google/addlicense@master -c \"Google LLC\" -l apache\n\nfor f in $(find ${REPO_ROOT} -name go.mod); do\n  cd $(dirname ${f})\n  gofmt -w .\ndone\n"
  },
  {
    "path": "dev/tasks/generate-github-actions.sh",
    "content": "#!/usr/bin/env bash\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nset -x\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\ncd ${REPO_ROOT}\n\n\ncat > ${REPO_ROOT}/.github/workflows/ci-presubmit.yaml <<EOF\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\n# Generated by dev/tasks/generate-github-actions\n\nname: ci-presubmit\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened]\n  push:\n    branches: [\"main\"]\n\njobs:\nEOF\n\n\nfor f in $(find dev/ci/presubmits -type f | sort ); do\n    filename=$(basename ${f})\n    name=\"${filename%.*}\"\n\ncat >> ${REPO_ROOT}/.github/workflows/ci-presubmit.yaml <<EOF\n\n  ${name}:\n    runs-on: ubuntu-latest\n    timeout-minutes: 60\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/setup-go@v5\n        with:\n          go-version-file: 'go.mod'\n      - name: \"Run ${f}\"\n        run: |\n          ./${f}\n\nEOF\n\ndone\n\n\ncat >> ${REPO_ROOT}/.github/workflows/ci-presubmit.yaml <<'EOF'\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}\n  cancel-in-progress: true\nEOF\n"
  },
  {
    "path": "dev/tasks/gomod.sh",
    "content": "#!/usr/bin/env bash\n# Copyright 2025 Google LLC\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\ncd ${REPO_ROOT}\n\n# retry <max_attempts> <delay_seconds> <command...>\n# Retries a command on failure with a fixed delay between attempts.\n# Useful for transient Go proxy / network errors (e.g. HTTP/2 INTERNAL_ERROR).\nretry() {\n  local max=$1; shift\n  local delay=$1; shift\n  local attempt=1\n  until \"$@\"; do\n    if (( attempt >= max )); then\n      echo \"ERROR: command failed after ${max} attempts: $*\" >&2\n      return 1\n    fi\n    echo \"WARNING: attempt ${attempt}/${max} failed, retrying in ${delay}s: $*\" >&2\n    sleep \"${delay}\"\n    (( attempt++ ))\n  done\n}\n\nfor f in $(find ${REPO_ROOT} -name go.mod); do\n  cd $(dirname ${f})\n  rm go.sum\n  retry 3 5 go mod tidy\ndone\n"
  },
  {
    "path": "docs/bedrock.md",
    "content": "# AWS Bedrock Provider\n\nkubectl-ai supports AWS Bedrock models including Claude Sonnet 4 and Claude 3.7.\n\n## Setup\n\n### AWS Credentials\n\nConfigure AWS credentials using standard AWS SDK methods:\n\n```bash\n# Option 1: Environment variables\nexport AWS_ACCESS_KEY_ID=\"your-access-key\"\nexport AWS_SECRET_ACCESS_KEY=\"your-secret-key\"\nexport AWS_REGION=\"us-east-1\"\n\n# Option 2: AWS Profile (recommended)\nexport AWS_PROFILE=\"your-profile-name\"\nexport AWS_REGION=\"us-east-1\"\n\n# Option 3: Use IAM roles (on EC2/ECS/Lambda)\nexport AWS_REGION=\"us-east-1\"\n```\n\n### Model Configuration\n\n```bash\n# Optional: Set default model\nexport BEDROCK_MODEL=\"us.anthropic.claude-3-7-sonnet-20250219-v1:0\"\n```\n\n## Supported Models\n\nSee [AWS Bedrock documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html) for current model availability and regional support.\n\nCurrently supported:\n\n- Claude Sonnet 4: `us.anthropic.claude-sonnet-4-20250514-v1:0` (default)\n- Claude 3.7 Sonnet: `us.anthropic.claude-3-7-sonnet-20250219-v1:0`\n\n## Usage\n\n```bash\n# Use default model (Claude Sonnet 4)\nkubectl-ai --provider bedrock \"explain this deployment\"\n\n# Specify model explicitly\nkubectl-ai --provider bedrock --model us.anthropic.claude-3-7-sonnet-20250219-v1:0 \"help me debug this pod\"\n```\n\n## Authentication\n\nkubectl-ai uses the standard AWS SDK credential provider chain:\n\n1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)\n2. AWS credentials file (~/.aws/credentials)\n3. AWS config file (~/.aws/config)\n4. IAM roles for EC2 instances\n5. IAM roles for ECS tasks\n6. IAM roles for Lambda functions\n\nFor more details, see [AWS SDK Go Configuration](https://aws.github.io/aws-sdk-go-v2/docs/configuring-sdk/).\n\n## Region Configuration\n\nBedrock is available in specific AWS regions. Set your region using:\n\n```bash\nexport AWS_REGION=\"us-east-1\"  # Primary Bedrock region\n```\n\nAlternatively, configure region in `~/.aws/config`:\n\n```ini\n[default]\nregion = us-east-1\n```\n"
  },
  {
    "path": "docs/gke-deployment.md",
    "content": "# Deploying k8-kate to Google Kubernetes Engine\n\n## Prerequisites\n\n- A GKE cluster (Standard or Autopilot).\n- `gcloud` CLI authenticated with `gcloud auth login`.\n- Local Docker environment (or Cloud Build) capable of building and pushing container images.\n- [`kubectl` configured](https://cloud.google.com/kubernetes-engine/docs/how-to/cluster-access-for-kubectl) to talk to your target cluster.\n- A Gemini API key with access to the model you plan to demo.\n\n## 1. Set your Google Cloud context\n\n```bash\nexport PROJECT_ID=\"my-gcp-project\"\nexport REGION=\"us-central1\"\nexport CLUSTER_NAME=\"kubectl-ai-demo\"\n\ngcloud config set project \"${PROJECT_ID}\"\ngcloud container clusters get-credentials \"${CLUSTER_NAME}\" --region \"${REGION}\"\n```\n\nThese commands configure both `gcloud` and `kubectl` to operate on the cluster that will host `kubectl-ai`.\n\n## 2. Build and push the kubectl-ai image\n\nPick an Artifact Registry repository or another container registry that your cluster can pull from. The snippet below creates an Artifact Registry repo (if needed), builds the image locally, and pushes it.\n\n```bash\n# Create an Artifact Registry (skip if you already have one)\ngcloud artifacts repositories create kubectl-ai \\\n  --location=\"${REGION}\" \\\n  --repository-format=DOCKER \\\n  --description=\"kubectl-ai demo images\"\n\n# Configure Docker to authenticate to Artifact Registry\ngcloud auth configure-docker \"${REGION}\"-docker.pkg.dev\n\n# Build and push the container\nIMAGE=\"${REGION}-docker.pkg.dev/${PROJECT_ID}/kubectl-ai/kubectl-ai:latest\"\ndocker build -t \"${IMAGE}\" -f images/kubectl-ai/Dockerfile .\ndocker push \"${IMAGE}\"\n```\n## 3. Prepare cluster namespaces and RBAC\n\nCreate the namespaces and RBAC that the hosted agent requires:\n\n```bash\n# Sandbox namespace + RBAC (creates `computer` namespace, service account, and reader roles)\nkubectl apply -f k8s/sandbox/all-in-one.yaml\n```\n\nThe sandbox manifest provisions the `computer` namespace and the `normal-user` service account used for sandbox pods. The `kubectl-ai-gke.yaml` manifest will create the `kubectl-ai` namespace automatically.\n\n## 4. Configure the deployment manifest\n\nCopy `k8s/kubectl-ai-gke.yaml` to a working file and edit the following sections:\n\n1. **Container image** – replace the `REPLACE_WITH_YOUR_IMAGE` \n2. **Gemini API key** – change `REPLACE_WITH_YOUR_GEMINI_API_KEY` to the key you obtained from Google AI Studio\n\nReview the RBAC objects in the manifest and adjust them if your security posture requires tighter permissions.\n\n## 5. Deploy kubectl-ai\n\nApply the updated manifest to your cluster:\n\n```bash\nkubectl apply -f kubectl-ai-gke.yaml\n```\n\nKubernetes creates the Deployment, ServiceAccount, RBAC bindings, and Service for the hosted agent. You can watch the rollout with:\n\n```bash\nkubectl get pods -n kubectl-ai\nkubectl describe pod -n kubectl-ai -l app=kubectl-ai | grep -i image\n```\n\n## 6. Access the hosted web UI\n\nPort-forward the Service locally to interact with the hosted UI:\n\n```bash\nkubectl port-forward svc/kubectl-ai -n kubectl-ai 8080:80\n```\n\nThen open [http://localhost:8080](http://localhost:8080) in your browser. Each browser session can create, rename, and delete conversations, and messages stream in real time via Server-Sent Events.\n\nIf you prefer to expose the UI via an external Load Balancer, replace the Service type in the manifest with `LoadBalancer` and configure the appropriate firewall rules.\n\n## 7. Verify sandboxed tool execution\n\nWhen the UI creates a conversation, the agent launches a sandbox pod in the `computer` namespace. You can confirm sandbox activity with:\n\n```bash\nkubectl get pods -n computer\n```\n\nPods named `kubectl-ai-sandbox-*` indicate that commands are running inside isolated helper containers. Asking the agent to execute commands such as `uname -a` should produce output that matches the sandbox image (for example `bitnami/kubectl`).\n\n## 8. Cleanup\n\nRemove the deployment and sandbox resources when you are done:\n\n```bash\nkubectl delete -f kubectl-ai-gke.yaml\nkubectl delete namespace kubectl-ai\nkubectl delete -f k8s/sandbox/all-in-one.yaml\n```\n\nIf you no longer need the Artifact Registry repository or pushed image, delete them using `gcloud artifacts repositories delete` and `gcloud artifacts docker images delete`."
  },
  {
    "path": "docs/mcp-client.md",
    "content": "# kubectl-ai MCP Client Integration\n\n## Multi-Server Orchestration for Security Automation\n\nThe MCP (Model Context Protocol) Client feature enables kubectl-ai to coordinate multiple specialized tools through natural language commands. This integration demonstrates automated security workflows that combine RBAC scanning with email reporting.\n\n**Problem**: Traditional security audits require manual execution of multiple tools, data correlation, and report distribution—a time-consuming process prone to human error.\n\n**Solution**: Single command orchestration across multiple MCP servers:\n\n```bash\nkubectl-ai --mcp-client --quiet \"scan rbac and send urgent report to incident-team@company.com from sender@company.com\"\n```\n\n**Architecture Components:**\n\n- **kubectl-ai**: Central orchestrator interpreting natural language commands\n- **Permiflow**: RBAC security scanning and analysis\n- **Resend**: Automated email delivery service\n- **Additional servers**: Documentation, reasoning, and extensible integrations\n\n## Workflow Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant User\n    participant kubectl-ai as kubectl-ai<br/>(MCP Client)\n    participant Permiflow as Permiflow<br/>(MCP Server)\n    participant K8s as Kubernetes<br/>Cluster\n    participant Resend as Resend<br/>(MCP Server)\n    participant Email as Email<br/>Recipient\n\n    User->>kubectl-ai: \"scan rbac and send report to admin@company.com\"\n    kubectl-ai->>Permiflow: scan_rbac()\n    Permiflow->>K8s: Query RBAC policies\n    K8s-->>Permiflow: Return roles, bindings, permissions\n    Permiflow->>Permiflow: Analyze security risks\n    Permiflow-->>kubectl-ai: Security findings report\n    kubectl-ai->>kubectl-ai: Format report for email\n    kubectl-ai->>Resend: send_email(to, from, subject, content)\n    Resend->>Email: Deliver formatted security report\n    Email-->>User: Email confirmation\n    kubectl-ai-->>User: \"✅ RBAC scan completed and report sent\"\n```\n\n## Execution Flow\n\nThe command execution follows this sequence:\n\n1. **kubectl-ai** parses the natural language request\n2. [**Permiflow**](https://github.com/tutran-se/permiflow) performs comprehensive RBAC analysis across cluster resources\n3. [**Resend**](https://github.com/resend/mcp-send-email) formats and delivers the security report via email\n\n**Extensibility**: The architecture supports additional MCP servers for Slack notifications, Jira ticket creation, compliance databases, and custom integrations.\n\n## Configuration and Setup\n\n### MCP Server Configuration\n\nConfigure the MCP servers in `~/.config/kubectl-ai/mcp.yaml`:\n\n```yaml\nservers:\n  - name: resend\n    command: node\n    args:\n      - \"~/mcp-send-email/build/index.js\"\n    env:\n      RESEND_API_KEY: \"api-key-here\"\n  - name: permiflow\n    url: http://localhost:8080/mcp\n```\n\n### Quick Start\n\n```bash\n# 1. Start the Permiflow MCP server\npermiflow mcp --transport http --http-port 8080\n\n# 2. Execute kubectl-ai with MCP client enabled\nkubectl-ai --mcp-client --quiet \"scan rbac and send report to admin@company.com from sec@company.com\"\n```\n\n## Automation Use Cases\n\n### Scheduled Security Monitoring\n\nImplement automated daily security scans using cron:\n\n```bash\n# Daily RBAC audit at 9 AM\n0 9 * * * kubectl-ai --mcp-client --quiet \"scan rbac and send daily report to admin@company.com from sec@company.com\"\n```\n\n### Incident Response\n\nExecute immediate security assessments during incidents:\n\n```bash\nkubectl-ai --mcp-client --quiet \"scan rbac for production namespace and send urgent report to incident-team@company.com from sec@company.com\"\n```\n\n## Usage Examples\n\n### Interactive Mode\n\nLaunch kubectl-ai in interactive mode for exploratory analysis:\n\n```bash\nkubectl-ai --mcp-client\n>>> \"scan rbac and send report to admin@company.com\"\n>>> \"analyze RBAC for kubeflow namespace\"\n>>> \"show me the most dangerous permissions in production\"\n>>> \"which service accounts can access secrets across namespaces?\"\n```\n\n### Direct Commands\n\nExecute specific security queries directly:\n\n```bash\nkubectl-ai --mcp-client \"show wildcard permissions and suggest fixes\"\n```\n\n## Extended Integration\n\n### Additional MCP Servers\n\nExpand the automation capabilities by adding specialized servers:\n\n```yaml\nservers:\n  - name: slack-notifier\n    url: \"https://slack-mcp.company.com/mcp\"\n  - name: jira-tickets\n    url: \"https://jira-mcp.company.com/mcp\"\n  - name: trivy-scanner\n    command: npx\n    args: [\"-y\", \"@aquasecurity/trivy-mcp\"]\n```\n\n### Advanced Workflows\n\n**Multi-Channel Incident Response:**\n\n```bash\n\"scan rbac, create jira ticket, email security team, post to slack\"\n```\n\n**Compliance Automation:**\n\n```bash\n\"scan vulnerabilities, update compliance database, email leadership\"\n```\n\n## Benefits\n\n- **Unified Interface**: Single natural language interface for multiple tools\n- **Automation**: Reduces manual security audit processes\n- **Consistency**: Standardized security scanning and reporting\n- **Extensibility**: Modular architecture supports additional integrations\n- **Efficiency**: Rapid security assessment and stakeholder notification\n"
  },
  {
    "path": "docs/mcp-server.md",
    "content": "# kubectl-ai MCP Server\n\nkubectl-ai can run as an MCP (Model Context Protocol) server, exposing kubectl-ai tools to other MCP clients. The server can run in two modes:\n\n1. **Built-in tools only**: Exposes only kubectl-ai's native tools\n2. **External tool discovery**: Additionally discovers and exposes tools from other MCP servers\n\n## Quick Start\n\n### Basic MCP Server (Built-in tools only)\n\nStart the MCP server with only kubectl-ai's built-in tools:\n\n```bash\nkubectl-ai --mcp-server\n```\n\n### Enhanced MCP Server (With external tool discovery)\n\nStart the MCP server with external MCP tool discovery enabled:\n\n```bash\nkubectl-ai --mcp-server --external-tools\n```\n\n### Expose an HTTP Endpoint for MCP Clients\n\nRun the server with the streamable HTTP transport to serve compatible MCP clients (including kubectl-ai MCP client mode) over HTTP:\n\n```bash\nkubectl-ai --mcp-server --mcp-server-mode streamable-http --http-port 9080\n```\n\nThis listens on `http://localhost:9080/mcp` by default.\n\n## Configuration\n\nWhen `--external-tools` is enabled, the enhanced MCP server will automatically discover and expose tools from configured MCP servers. You can configure MCP servers using the standard MCP client configuration file.\n\n### Example MCP Configuration\n\nCreate `~/.config/kubectl-ai/mcp.yaml`:\n\n```yaml\nservers:\n  filesystem:\n    command: \"npx\"\n    args:\n      [\n        \"-y\",\n        \"@modelcontextprotocol/server-filesystem\",\n        \"/path/to/allowed/files\",\n      ]\n\n  brave-search:\n    command: \"npx\"\n    args: [\"-y\", \"@modelcontextprotocol/server-brave-search\"]\n    env:\n      BRAVE_API_KEY: \"your-api-key\"\n```\n\n## Features\n\n### Tool Aggregation\n\nWhen external tool discovery is enabled with `--external-tools`, the kubectl-ai MCP server acts as a **tool aggregator**, providing:\n\n- All kubectl-ai built-in tools (kubectl, cluster analysis, etc.)\n- Tools from external MCP servers (filesystem, web search, etc.)\n- Unified interface for all tools through a single MCP endpoint\n\n### Graceful Degradation\n\nThe server handles external MCP connection failures gracefully:\n\n- If external MCP servers are unavailable, the server continues with built-in tools only\n- Individual tool failures don't affect the overall server operation\n- Clear logging for troubleshooting connection issues\n\n### Example Usage in Claude Desktop\n\nConfigure Claude Desktop to use kubectl-ai as an MCP server:\n\n**Basic usage (built-in tools only):**\n\n```json\n{\n  \"mcpServers\": {\n    \"kubectl-ai\": {\n      \"command\": \"kubectl-ai\",\n      \"args\": [\"--mcp-server\"]\n    }\n  }\n}\n```\n\n**Enhanced usage (with external tools):**\n\n```json\n{\n  \"mcpServers\": {\n    \"kubectl-ai\": {\n      \"command\": \"kubectl-ai\",\n      \"args\": [\"--mcp-server\", \"--external-tools\"]\n    }\n  }\n}\n```\n\n## Available Tools\n\n### Built-in Tools\n\nkubectl-ai provides the following native tools:\n\n- `bash`: Executes a bash command. Use this tool only when you need to execute a shell command.\n- `kubectl`: Executes a kubectl command against the user's Kubernetes cluster. Use this tool only when you need to query or modify the state of the user's Kubernetes cluster.\n\n### External Tools (when `--external-tools` is enabled)\n\nAdditional tools are available depending on the configured MCP servers:\n\n- **Filesystem tools**: Read/write files, list directories\n- **Web search tools**: Search the internet for information\n- **Database tools**: Query databases\n- **API tools**: Interact with external APIs\n- **Custom tools**: Any MCP-compatible tools\n\n## Command Line Options\n\n| Flag                | Default          | Description                                                            |\n| ------------------- | ---------------- | ---------------------------------------------------------------------- |\n| `--mcp-server`      | `false`          | Run in MCP server mode                                                 |\n| `--external-tools`  | `false`          | Discover and expose external MCP tools (requires --mcp-server)         |\n| `--kubeconfig`      | `~/.kube/config` | Path to kubeconfig file                                                |\n| `--mcp-server-mode` | `stdio`          | Transport for the MCP server (`stdio` or `streamable-http`)    |\n| `--http-port`       | `9080`           | Port for the HTTP endpoint when using `streamable-http` modes |\n\n## Architecture\n\n```txt\n┌─────────────────┐    ┌───────────────────┐    ┌─────────────────┐\n│   MCP Client    │───▶│ kubectl-ai Server │───▶│ External Tools  │\n│  (Claude, etc.) │    │                   │    │ (filesystem,    │\n│                 │    │ ┌───────────────┐ │    │  web search,    │\n│                 │    │ │ Built-in      │ │    │  etc.)          │\n│                 │    │ │ kubectl tools │ │    │                 │\n│                 │    │ └───────────────┘ │    │                 │\n└─────────────────┘    └───────────────────┘    └─────────────────┘\n```\n\nThe kubectl-ai MCP server acts as both:\n\n- An **MCP Server** (exposing tools to clients)\n- An **MCP Client** (consuming tools from other servers, when `--external-tools` is enabled)\n\nThis creates a powerful tool aggregation pattern where kubectl-ai becomes a central hub for both Kubernetes operations and general-purpose tools.\n\n## Troubleshooting\n\n### External Tools Not Available\n\nIf external tools aren't appearing:\n\n1. Ensure you're using both `--mcp-server` and `--external-tools` flags\n2. Check MCP configuration file exists and is valid\n3. Verify external MCP servers are working independently\n4. Check kubectl-ai logs for connection errors\n5. Try running with external tools disabled to isolate issues\n\n### Performance Considerations\n\n- Tool discovery adds startup time (usually 2-3 seconds) when `--external-tools` is enabled\n- Each external tool call has network overhead\n- Consider running without `--external-tools` for faster startup if external tools aren't needed\n\n### Debugging\n\nEnable verbose logging to troubleshoot:\n\n```bash\nkubectl-ai --mcp-server --external-tools -v=2\n```\n\nThis will show:\n\n- MCP server connection attempts\n- Tool discovery results\n- Tool call routing decisions\n"
  },
  {
    "path": "docs/mocking.md",
    "content": "# Mocking in kubectl-ai\n\n## Gomock developer workflow\n\nWe use [gomock](https://github.com/uber-go/mock) to mock external dependencies. All mocks and generated files live under `internal/mocks/`.\n\n- **Everyday commands**\n  - Regenerate mocks after changing interfaces or adding new ones: `make generate`\n  - Verify nothing is stale (locally or in CI): `make verify-mocks`\n  - Run tests: `go test ./...`\n- **Generator install** (if you don’t have it yet):  \n  `go install go.uber.org/mock/mockgen@latest`\n- **What `make generate` does**  \n  Runs `go generate ./internal/mocks`. Note: `go generate` is **not** part of `go build/test`; commit generated mocks.\n- **Add a new mock**\n  1. Add a `go:generate` line in `internal/mocks/generate.go`, e.g.:  \n     ```go\n     //go:generate mockgen -destination=tools_mock.go -package=mocks      //  github.com/GoogleCloudPlatform/kubectl-ai/pkg/tools Tool\n     ```\n  2. Run `make generate` and import the mocks in tests:\n     ```go\n     ctrl := gomock.NewController(t)\n     defer ctrl.Finish()\n\n     llm := mocks.NewMockClient(ctrl) // example\n     llm.EXPECT().NewChat(gomock.Any()).AnyTimes()\n     ```\n\n## When and when not to use gomock\n\n**Use gomock for:**\n- **External boundaries / side effects**: `gollm.Client`, `gollm.Chat`, `pkg/tools.Tool`, network/IO, anything slow or flaky.\n- **Behavioral checks**: asserting specific calls/arguments or injecting failures/timeouts.\n\n**Prefer fakes/in‑memory over mocks for:**\n- **Stateful components with an in‑memory impl** (e.g., session/message store). Don’t mock storage if an in‑memory version exists.\n- **Pure functions / simple value types**—call them directly.\n\n**Good practices:**\n- Keep expectations minimal—assert only what matters. Use `gomock.Any()` and `AnyTimes()`/`MinTimes(1)` where exact call counts don’t matter.\n- Centralize `mockgen` directives in `internal/mocks/generate.go`.\n- **If an interface changes**: run `make generate`, fix compile errors in tests (signatures/matchers), update/remove `go:generate` lines if package paths or names changed, and commit the regenerated mocks.\n\n"
  },
  {
    "path": "docs/tool-samples/argocd.yaml",
    "content": "- name: argocd\n  description: \"A declarative, GitOps continuous delivery tool for Kubernetes. Use it to manage application deployments from Git repositories.\"\n  command: \"argocd\"\n  command_desc: |\n    The argocd command-line interface.\n\n    Core subcommands and usage patterns:\n    - `argocd login <server> [flags]`: Log in to an Argo CD server. This is required before most other commands.\n    - `argocd app list [flags]`: List all applications managed by Argo CD.\n    - `argocd app get <app-name> [flags]`: Get detailed information about a specific application.\n    - `argocd app sync <app-name> [flags]`: Sync an application to its target state defined in the Git repository.\n    - `argocd app history <app-name> [flags]`: View the deployment history of an application.\n    - `argocd app rollback <app-name> <history-id>`: Roll back an application to a previous deployed state.\n    - `argocd app create <app-name> [flags]`: Create a new application.\n    - `argocd app delete <app-name>`: Delete an application.\n\n    Use `argocd --help` or `argocd <command> --help` for full syntax and available flags."
  },
  {
    "path": "docs/tool-samples/gcloud.yaml",
    "content": "- name: gcloud\n  description: \"The gcloud command-line tool is the primary CLI for Google Cloud. Use it to manage Google Cloud resources, including Google Kubernetes Engine (GKE) clusters, virtual machines, and networking.\"\n  command: \"gcloud\"\n  command_desc: |\n    The gcloud CLI manages authentication, local configuration, developer workflow, and interactions with Google Cloud APIs.\n\n    For Kubernetes-related tasks, the `gcloud container clusters` command group is the most relevant.\n\n    Core subcommands and usage patterns:\n    - `gcloud container clusters list [flags]`: List all GKE clusters in the configured project and zone/region.\n    - `gcloud container clusters describe <cluster-name> [flags]`: Get detailed information about a specific GKE cluster.\n    - `gcloud container clusters get-credentials <cluster-name> [flags]`: Fetch cluster endpoint and auth data and configure kubectl to use the cluster. This is essential for connecting to a GKE cluster.\n    - `gcloud container clusters create <cluster-name> [flags]`: Create a new GKE cluster.\n    - `gcloud container clusters delete <cluster-name> [flags]`: Delete an existing GKE cluster.\n\n    You can set the default project, region, and zone using:\n    - `gcloud config set project <project-id>`\n    - `gcloud config set compute/region <region>`\n    - `gcloud config set compute/zone <zone>`\n\n    Use `gcloud --help` or `gcloud <command> <subcommand> --help` for full syntax and available flags.\n"
  },
  {
    "path": "docs/tool-samples/gh.yaml",
    "content": "- name: gh\n  description: \"The official GitHub command-line tool. Use it to interact with GitHub repositories, pull requests, issues, actions, and more, directly from the terminal.\"\n  command: \"gh\"\n  command_desc: |\n    The gh command-line interface for GitHub.\n\n    Core subcommands and usage patterns:\n    - `gh auth login`: Authenticate with a GitHub host. This is required before most other commands.\n    - `gh repo view [repo]`: View a repository.\n    - `gh pr list`: List pull requests in the current repository.\n    - `gh pr view <number>`: View a specific pull request.\n    - `gh pr checkout <number>`: Check out a pull request locally.\n    - `gh pr create [flags]`: Create a new pull request.\n    - `gh pr merge [flags]`: Merge a pull request.\n    - `gh issue list`: List issues in the current repository.\n    - `gh issue view <number>`: View a specific issue.\n    - `gh workflow list`: List workflows in the current repository.\n    - `gh workflow run <workflow-name> [flags]`: Trigger a workflow run.\n\n    Use `gh --help` or `gh <subcommand> --help` for full syntax and available flags.\n"
  },
  {
    "path": "docs/tool-samples/kustomize.yaml",
    "content": "- name: kustomize\n  description: \"A tool to customize Kubernetes resource configurations. Use it to render and apply declarative configurations from a directory containing a kustomization.yaml file.\"\n  command: \"kustomize\"\n  command_desc: |\n    The kustomize command-line interface.\n    \n    Core subcommands and usage patterns:\n    - `kustomize build <kustomization_dir>`: Prints the customized resources to standard output. This is useful for inspecting the final configuration before applying it.\n    - `kustomize build <kustomization_dir> | kubectl apply -f -`: A common pattern to apply the output directly to the cluster.\n    \n    Note: `kubectl apply -k <dir>` is a shorthand for the pipe command above and is often preferred.\n"
  },
  {
    "path": "docs/tools.md",
    "content": "# Custom Tools for kubectl-ai\n\n`kubectl-ai` leverages LLMs to suggest and execute Kubernetes operations using a set of powerful tools. It comes with built-in tools like `kubectl` and `bash`.\n\nThe `kubectl-ai` assistant can be extended with custom tools to interact with various command-line interfaces (CLIs) beyond `kubectl`. This allows the AI to perform a wider range of tasks related to infrastructure management, CI/CD, and more.\n\nThis document outlines how you can add custom tools by detailing the steps and providing samples.\n\nThis document also outlines the available tools, their locations, and how to use them.\n\n## Adding Custom Tools\n\nCustom tools can be added by following these two steps:\n\n- describing or templating the tool through YAML file\n- enabling the tool in the configuration file by pointing the **--custom-tools-config** to this file / directory\n\n## Describing the Tool in YAML file\n\nA custom tool can be described by providing the following four pieces of information:\n\n- **name**: name of the tool\n- **description**: \"A clear description that helps the LLM understand when to use this tool.\"\n- **command** : \"your_command\" # For example: 'gcloud' or 'gcloud container clusters'\n- **command_desc**: \"Detailed information for the LLM, including command syntax and usage examples.\"\n\nSamples are provided in the `pkg/tools/samples` directory. Below is a sample for the `kustomize` tool:\n\n```yaml\n- name: kustomize\n  description: \"A tool to customize Kubernetes resource configurations. Use it to render and apply declarative configurations from a directory containing a kustomization.yaml file.\"\n  command: \"kustomize\"\n  command_desc: |\n    The kustomize command-line interface.\n    \n    Core subcommands and usage patterns:\n    - `kustomize build <kustomization_dir>`: Prints the customized resources to standard output. This is useful for inspecting the final configuration before applying it.\n    - `kustomize build <kustomization_dir> | kubectl apply -f -`: A common pattern to apply the output directly to the cluster.\n    \n    Note: `kubectl apply -k <dir>` is a shorthand for the pipe command above and is often preferred.\n```\n\n## Enabling the Custom Tool\n\nTo enable the custom tools, you must point `kubectl-ai` to the directory containing the tool configuration YAML files using the `--custom-tools-config` flag. `kubectl-ai` can pick up a single YAML file (e.g., `tools.yaml`) containing all the tool descriptions or multiple individual YAML files when pointed to a directory containing them. This example uses multiple YAML files located in a single directory.\n\nIn case, you don't want to use a tool (that is provided in samples), just move the file out of the directory into some other location & restart `kubectl-ai`.\n\n### Running from a Local Binary\n\nWhen running the `kubectl-ai` binary directly, provide the path to your local tools directory.\n\n```sh\n./kubectl-ai --custom-tools-config=<path-to-tools-directory> \"your prompt here\"\n```\n\n### Running with Docker Image\n\nWhen using the Docker image, you can either use the tools baked into the image or mount your own custom directory.\n\n#### Using Built-in Tools\n\nThe official Docker image includes the default tool configurations. You can enable them by pointing to the internal path.\n\n```sh\ndocker run --rm -it your-kubectl-ai-image:latest \\\n  --custom-tools-config=/etc/kubectl-ai/tools \\\n  \"list all pull requests on GitHub\"\n```\n\n#### Using a Local Tools Directory\n\nTo use a custom set of tools from your local machine, mount the directory into the container and point the flag to the mounted path. This is useful for developing and testing new tools.\n\n```sh\ndocker run --rm -it \\\n  -v /path/to/your/local/tools:/my-custom-tools \\\n  your-kubectl-ai-image:latest \\\n  --custom-tools-config=/my-custom-tools \\\n  \"your prompt here\"\n```\n\n## Sample Custom Tools\n\nThe following sample custom tools are configured by default.\n\n| Tool                                                       | Description                                                     | YAML File                                           |\n| :--------------------------------------------------------- | :-------------------------------------------------------------- | :------------------------------------------------------ |\n| Argo CD (`argocd`)      | A declarative, GitOps continuous delivery tool for Kubernetes.  | [argocd.yaml](./tool-samples/argocd.yaml)         |\n| GitHub CLI (`gh`)               | The official command-line tool to interact with GitHub.         | [gh.yaml](./tool-samples/gh.yaml)                 |\n| Google Cloud CLI (`gcloud`) | The primary CLI for managing Google Cloud resources.            | [gcloud.yaml](./tool-samples/gcloud.yaml)               |\n| Kustomize (`kustomize`)           | A tool to customize Kubernetes resource configurations.         | [kustomize.yaml](./tool-samples/kustomize.yaml)   |\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/GoogleCloudPlatform/kubectl-ai\n\ngo 1.24.0\n\ntoolchain go1.24.3\n\n// Needed for multiple go modules in one repo\nreplace github.com/GoogleCloudPlatform/kubectl-ai/gollm => ./gollm\n\nrequire (\n\tgithub.com/GoogleCloudPlatform/kubectl-ai/gollm v0.0.0-00010101000000-000000000000\n\tgithub.com/charmbracelet/bubbles v0.21.0\n\tgithub.com/charmbracelet/bubbletea v1.3.5\n\tgithub.com/charmbracelet/glamour v0.10.0\n\tgithub.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834\n\tgithub.com/chzyer/readline v1.5.1\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/mark3labs/mcp-go v0.41.1\n\tgithub.com/spf13/cobra v1.9.1\n\tgithub.com/spf13/pflag v1.0.6\n\tgo.uber.org/mock v0.6.0\n\tgolang.org/x/sync v0.16.0\n\tgolang.org/x/term v0.33.0\n\tk8s.io/api v0.34.2\n\tk8s.io/apimachinery v0.34.2\n\tk8s.io/client-go v0.34.2\n\tk8s.io/klog/v2 v2.130.1\n\tmvdan.cc/sh/v3 v3.11.0\n\tsigs.k8s.io/yaml v1.6.0\n)\n\nrequire (\n\tcloud.google.com/go v0.118.3 // indirect\n\tcloud.google.com/go/auth v0.15.0 // indirect\n\tcloud.google.com/go/compute/metadata v0.6.0 // indirect\n\tgithub.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.7.2 // indirect\n\tgithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect\n\tgithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 // indirect\n\tgithub.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect\n\tgithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.7.0 // indirect\n\tgithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0 // indirect\n\tgithub.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect\n\tgithub.com/alecthomas/chroma/v2 v2.14.0 // indirect\n\tgithub.com/anthropics/anthropic-sdk-go v1.26.0 // indirect\n\tgithub.com/atotto/clipboard v0.1.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2 v1.36.6 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/config v1.29.18 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/credentials v1.17.71 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.31.1 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sso v1.25.6 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sts v1.34.1 // indirect\n\tgithub.com/aws/smithy-go v1.22.4 // indirect\n\tgithub.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect\n\tgithub.com/aymerick/douceur v0.2.0 // indirect\n\tgithub.com/bahlo/generic-list-go v0.2.0 // indirect\n\tgithub.com/buger/jsonparser v1.1.1 // indirect\n\tgithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect\n\tgithub.com/charmbracelet/x/ansi v0.8.0 // indirect\n\tgithub.com/charmbracelet/x/cellbuf v0.0.13 // indirect\n\tgithub.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect\n\tgithub.com/charmbracelet/x/term v0.2.1 // indirect\n\tgithub.com/davecgh/go-spew v1.1.1 // indirect\n\tgithub.com/dlclark/regexp2 v1.11.4 // indirect\n\tgithub.com/emicklei/go-restful/v3 v3.12.2 // indirect\n\tgithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/fxamacker/cbor/v2 v2.9.0 // indirect\n\tgithub.com/go-logr/logr v1.4.2 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/go-openapi/jsonpointer v0.21.0 // indirect\n\tgithub.com/go-openapi/jsonreference v0.20.2 // indirect\n\tgithub.com/go-openapi/swag v0.23.0 // indirect\n\tgithub.com/gogo/protobuf v1.3.2 // indirect\n\tgithub.com/golang-jwt/jwt/v5 v5.2.2 // indirect\n\tgithub.com/google/gnostic-models v0.7.0 // indirect\n\tgithub.com/google/go-cmp v0.7.0 // indirect\n\tgithub.com/google/s2a-go v0.1.9 // indirect\n\tgithub.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect\n\tgithub.com/googleapis/gax-go/v2 v2.14.1 // indirect\n\tgithub.com/gorilla/css v1.0.1 // indirect\n\tgithub.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/invopop/jsonschema v0.13.0 // indirect\n\tgithub.com/josharian/intern v1.0.0 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/kylelemons/godebug v1.1.0 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.2.0 // indirect\n\tgithub.com/mailru/easyjson v0.7.7 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-localereader v0.0.1 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.16 // indirect\n\tgithub.com/microcosm-cc/bluemonday v1.0.27 // indirect\n\tgithub.com/moby/spdystream v0.5.0 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect\n\tgithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect\n\tgithub.com/muesli/cancelreader v0.2.2 // indirect\n\tgithub.com/muesli/reflow v0.3.0 // indirect\n\tgithub.com/muesli/termenv v0.16.0 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect\n\tgithub.com/ollama/ollama v0.6.5 // indirect\n\tgithub.com/openai/openai-go v1.12.0 // indirect\n\tgithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/sahilm/fuzzy v0.1.1 // indirect\n\tgithub.com/spf13/cast v1.7.1 // indirect\n\tgithub.com/tidwall/gjson v1.18.0 // indirect\n\tgithub.com/tidwall/match v1.1.1 // indirect\n\tgithub.com/tidwall/pretty v1.2.1 // indirect\n\tgithub.com/tidwall/sjson v1.2.5 // indirect\n\tgithub.com/wk8/go-ordered-map/v2 v2.1.8 // indirect\n\tgithub.com/x448/float16 v0.8.4 // indirect\n\tgithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect\n\tgithub.com/yosida95/uritemplate/v3 v3.0.2 // indirect\n\tgithub.com/yuin/goldmark v1.7.8 // indirect\n\tgithub.com/yuin/goldmark-emoji v1.0.5 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.1.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect\n\tgo.opentelemetry.io/otel v1.34.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.34.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.34.0 // indirect\n\tgo.yaml.in/yaml/v2 v2.4.2 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4 // indirect\n\tgolang.org/x/crypto v0.40.0 // indirect\n\tgolang.org/x/net v0.41.0 // indirect\n\tgolang.org/x/oauth2 v0.30.0 // indirect\n\tgolang.org/x/sys v0.34.0 // indirect\n\tgolang.org/x/text v0.27.0 // indirect\n\tgolang.org/x/time v0.10.0 // indirect\n\tgoogle.golang.org/genai v1.8.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 // indirect\n\tgoogle.golang.org/grpc v1.70.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.5 // indirect\n\tgopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect\n\tgopkg.in/inf.v0 v0.9.1 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tk8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect\n\tk8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect\n\tsigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect\n\tsigs.k8s.io/randfill v1.0.0 // indirect\n\tsigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME=\ncloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc=\ncloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=\ncloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=\ncloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=\ncloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=\ngithub.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.7.2 h1:+hDUZnYHHoXu05iXiJcL53MZW7raZZejB8ZtzVW7yyc=\ngithub.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.7.2/go.mod h1:49PyorVrwk6G+e8Vghvn7EkAS6wSPdXEu5a8iW2/vC8=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0/go.mod h1:kUjrAo8bgEwLeZ/CmHqNl3Z/kPm7y6FKfxxK0izYUg4=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.7.0 h1:4exaC92+n1FzhSKb5Ghino2XEk3cClUtzvveL1U9YeM=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.7.0/go.mod h1:BkhZrH3JiVTkrTqCeYHOmqReFcZTYEMf8jcFDlrCJLk=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0 h1:UrGzkHueDwAWDdjQxC+QaXHd4tVCkISYE9j7fSSXF8k=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0/go.mod h1:qskvSQeW+cxEE2bcKYyKimB1/KiQ9xpJ99bcHY0BX6c=\ngithub.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=\ngithub.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=\ngithub.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=\ngithub.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=\ngithub.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=\ngithub.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=\ngithub.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=\ngithub.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=\ngithub.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=\ngithub.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=\ngithub.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=\ngithub.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=\ngithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=\ngithub.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=\ngithub.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=\ngithub.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=\ngithub.com/aws/aws-sdk-go-v2 v1.36.6 h1:zJqGjVbRdTPojeCGWn5IR5pbJwSQSBh5RWFTQcEQGdU=\ngithub.com/aws/aws-sdk-go-v2 v1.36.6/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY=\ngithub.com/aws/aws-sdk-go-v2/config v1.29.18 h1:x4T1GRPnqKV8HMJOMtNktbpQMl3bIsfx8KbqmveUO2I=\ngithub.com/aws/aws-sdk-go-v2/config v1.29.18/go.mod h1:bvz8oXugIsH8K7HLhBv06vDqnFv3NsGDt2Znpk7zmOU=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.17.71 h1:r2w4mQWnrTMJjOyIsZtGp3R3XGY3nqHn8C26C2lQWgA=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.17.71/go.mod h1:E7VF3acIup4GB5ckzbKFrCK0vTvEQxOxgdq4U3vcMCY=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 h1:D9ixiWSG4lyUBL2DDNK924Px9V/NBVpML90MHqyTADY=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33/go.mod h1:caS/m4DI+cij2paz3rtProRBI4s/+TCiWoaWZuQ9010=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 h1:osMWfm/sC/L4tvEdQ65Gri5ZZDCUpuYJZbTTDrsn4I0=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37/go.mod h1:ZV2/1fbjOPr4G4v38G3Ww5TBT4+hmsK45s/rxu1fGy0=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 h1:v+X21AvTb2wZ+ycg1gx+orkB/9U6L7AOp93R7qYxsxM=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37/go.mod h1:G0uM1kyssELxmJ2VZEfG0q2npObR3BAkF3c1VsfVnfs=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=\ngithub.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.31.1 h1:JDLT1baDmioiZKa2bZ6J82/Zwfv9cSAjr+LyF47TPYw=\ngithub.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.31.1/go.mod h1:FvbGcqrU4sC3qjrAKK3FzOmBoucDJF2dXsKVvAbGE8g=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 h1:CXV68E2dNqhuynZJPB80bhPQwAKqBWVer887figW6Jc=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 h1:vvbXsA2TVO80/KT7ZqCbx934dt6PY+vQ8hZpUZ/cpYg=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18/go.mod h1:m2JJHledjBGNMsLOF1g9gbAxprzq3KjC8e4lxtn+eWg=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.25.6 h1:rGtWqkQbPk7Bkwuv3NzpE/scwwL9sC1Ul3tn9x83DUI=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.25.6/go.mod h1:u4ku9OLv4TO4bCPdxf4fA1upaMaJmP9ZijGk3AAOC6Q=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 h1:OV/pxyXh+eMA0TExHEC4jyWdumLxNbzz1P0zJoezkJc=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4/go.mod h1:8Mm5VGYwtm+r305FfPSuc+aFkrypeylGYhFim6XEPoc=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.34.1 h1:aUrLQwJfZtwv3/ZNG2xRtEen+NqI3iesuacjP51Mv1s=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.34.1/go.mod h1:3wFBZKoWnX3r+Sm7in79i54fBmNfwhdNdQuscCw7QIk=\ngithub.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw=\ngithub.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=\ngithub.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=\ngithub.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=\ngithub.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=\ngithub.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=\ngithub.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=\ngithub.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=\ngithub.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=\ngithub.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=\ngithub.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=\ngithub.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=\ngithub.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=\ngithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=\ngithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=\ngithub.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=\ngithub.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=\ngithub.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=\ngithub.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=\ngithub.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=\ngithub.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=\ngithub.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=\ngithub.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=\ngithub.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=\ngithub.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=\ngithub.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=\ngithub.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=\ngithub.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=\ngithub.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=\ngithub.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=\ngithub.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=\ngithub.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=\ngithub.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=\ngithub.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=\ngithub.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=\ngithub.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=\ngithub.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=\ngithub.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=\ngithub.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=\ngithub.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=\ngithub.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=\ngithub.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=\ngithub.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=\ngithub.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=\ngithub.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=\ngithub.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=\ngithub.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=\ngithub.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=\ngithub.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=\ngithub.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=\ngithub.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=\ngithub.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=\ngithub.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=\ngithub.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=\ngithub.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=\ngithub.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=\ngithub.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=\ngithub.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=\ngithub.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=\ngithub.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=\ngithub.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=\ngithub.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=\ngithub.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=\ngithub.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=\ngithub.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=\ngithub.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=\ngithub.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=\ngithub.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=\ngithub.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=\ngithub.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=\ngithub.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=\ngithub.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=\ngithub.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=\ngithub.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=\ngithub.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=\ngithub.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=\ngithub.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=\ngithub.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=\ngithub.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA=\ngithub.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=\ngithub.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=\ngithub.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=\ngithub.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=\ngithub.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=\ngithub.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=\ngithub.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=\ngithub.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=\ngithub.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=\ngithub.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=\ngithub.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=\ngithub.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=\ngithub.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=\ngithub.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=\ngithub.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=\ngithub.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=\ngithub.com/ollama/ollama v0.6.5 h1:vXKkVX57ql/1ZzMw4SVK866Qfd6pjwEcITVyEpF0QXQ=\ngithub.com/ollama/ollama v0.6.5/go.mod h1:pGgtoNyc9DdM6oZI6yMfI6jTk2Eh4c36c2GpfQCH7PY=\ngithub.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=\ngithub.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=\ngithub.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=\ngithub.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=\ngithub.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=\ngithub.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=\ngithub.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=\ngithub.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=\ngithub.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=\ngithub.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=\ngithub.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=\ngithub.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=\ngithub.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=\ngithub.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=\ngithub.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=\ngithub.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=\ngithub.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=\ngithub.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=\ngithub.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=\ngithub.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=\ngithub.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=\ngithub.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=\ngithub.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=\ngithub.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=\ngithub.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=\ngithub.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=\ngithub.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=\ngithub.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=\ngithub.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=\ngithub.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=\ngo.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=\ngo.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=\ngo.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=\ngo.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=\ngo.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=\ngo.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=\ngo.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=\ngo.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=\ngo.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=\ngo.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=\ngo.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=\ngo.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=\ngo.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=\ngo.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=\ngo.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=\ngo.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=\ngolang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=\ngolang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4=\ngolang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=\ngolang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=\ngolang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=\ngolang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=\ngolang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=\ngolang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=\ngolang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=\ngolang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=\ngolang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=\ngolang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=\ngolang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/genai v1.8.0 h1:unX2CNWSiKDO2MSTKK3RstXg/vHp9hr42LIcL6f3Cik=\ngoogle.golang.org/genai v1.8.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 h1:DMTIbak9GhdaSxEjvVzAeNZvyc03I61duqNbnm3SU0M=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=\ngoogle.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=\ngoogle.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=\ngoogle.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=\ngoogle.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=\ngopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=\ngopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=\ngopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=\ngopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=\ngopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nk8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=\nk8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=\nk8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=\nk8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=\nk8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=\nk8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=\nk8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=\nk8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=\nk8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=\nk8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=\nk8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=\nk8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=\nmvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw=\nmvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg=\nsigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=\nsigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=\nsigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=\nsigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=\nsigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=\nsigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=\nsigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=\nsigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=\n"
  },
  {
    "path": "go.work",
    "content": "go 1.24.0\n\ntoolchain go1.24.4\n\nuse (\n\t.\n\t./gollm\n\t./kubectl-utils\n)\n"
  },
  {
    "path": "go.work.sum",
    "content": "cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=\ncloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=\ncloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=\ncloud.google.com/go/iam v1.3.1/go.mod h1:3wMtuyT4NcbnYNPLMBzYRFiEfjKfJlLVLrisE7bwm34=\ncloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA=\ncloud.google.com/go/monitoring v1.23.0/go.mod h1:034NnlQPDzrQ64G2Gavhl0LUHZs9H3rRmhtnp7jiJgg=\ncloud.google.com/go/storage v1.50.0/go.mod h1:l7XeiD//vx5lfqE3RavfmU9yvk5Pp0Zhcv482poyafY=\ncloud.google.com/go/translate v1.10.3/go.mod h1:GW0vC1qvPtd3pgtypCv4k4U8B7EdgK9/QEF2aJEUovs=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.11.0/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=\ngithub.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.49.0/go.mod h1:6fTWu4m3jocfUZLYF5KsZC1TUfRvEjs7lM4crme/irw=\ngithub.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.49.0/go.mod h1:wRbFgBQUVm1YXrvWKofAEmq9HNJTDphbAaJSSX01KUI=\ngithub.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=\ngithub.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=\ngithub.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=\ngithub.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40/go.mod h1:Q7yQnSMnLvcXlZ8RV+jwz/6y1rQTqbX6C82SndT52Zs=\ngithub.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=\ngithub.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=\ngithub.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=\ngithub.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=\ngithub.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=\ngithub.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=\ngithub.com/chewxy/hm v1.0.0/go.mod h1:qg9YI4q6Fkj/whwHR1D+bOGeF7SniIP40VweVepLjg0=\ngithub.com/chewxy/math32 v1.11.0/go.mod h1:dOB2rcuFrCn6UHrze36WSLVPKtzPMRAQvBvUwkSsLqs=\ngithub.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=\ngithub.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=\ngithub.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=\ngithub.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=\ngithub.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=\ngithub.com/d4l3k/go-bfloat16 v0.0.0-20211005043715-690c3bdd05f1/go.mod h1:uw2gLcxEuYUlAd/EXyjc/v55nd3+47YAgWbSXVxPrNI=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=\ngithub.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=\ngithub.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=\ngithub.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=\ngithub.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=\ngithub.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=\ngithub.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=\ngithub.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=\ngithub.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=\ngithub.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-openapi/jsonreference v0.20.1/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=\ngithub.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=\ngithub.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=\ngithub.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=\ngithub.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=\ngithub.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=\ngithub.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=\ngithub.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=\ngithub.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=\ngithub.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=\ngithub.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=\ngithub.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=\ngithub.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=\ngithub.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=\ngithub.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=\ngithub.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=\ngithub.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4=\ngithub.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=\ngithub.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=\ngithub.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=\ngithub.com/keybase/dbus v0.0.0-20220506165403-5aa21ea2c23a/go.mod h1:YPNKjjE7Ubp9dTbnWvsP3HT+hYnY6TfXzubYTBeUxc8=\ngithub.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/lyft/protoc-gen-star/v2 v2.0.4-0.20230330145011-496ad1ac90a4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=\ngithub.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=\ngithub.com/nlpodyssey/gopickle v0.3.0/go.mod h1:f070HJ/yR+eLi5WmM1OXJEGaTpuJEUiib19olXgYha0=\ngithub.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=\ngithub.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=\ngithub.com/onsi/ginkgo/v2 v2.20.1/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI=\ngithub.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=\ngithub.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc=\ngithub.com/pdevine/tensor v0.0.0-20240510204454-f88f4562727c/go.mod h1:PSojXDXF7TbgQiD6kkd98IHOS0QqTyUEaWRiS8+BLu8=\ngithub.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=\ngithub.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=\ngithub.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=\ngithub.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=\ngithub.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=\ngithub.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=\ngithub.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=\ngithub.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=\ngithub.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=\ngithub.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=\ngithub.com/xtgo/set v1.0.0/go.mod h1:d3NHzGzSa0NmB2NhFyECA+QdRp29oEn2xbT+TpeFoM8=\ngithub.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=\ngo.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=\ngo.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo=\ngo.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=\ngo.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=\ngo.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=\ngo.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=\ngo.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=\ngo4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=\ngolang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=\ngolang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=\ngolang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=\ngolang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=\ngolang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=\ngolang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=\ngolang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4=\ngolang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=\ngolang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=\ngolang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=\ngolang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=\ngolang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=\ngolang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=\ngolang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=\ngolang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=\ngolang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=\ngolang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=\ngolang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=\ngolang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=\ngolang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=\ngolang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=\ngolang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=\ngolang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=\ngolang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=\ngolang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/telemetry v0.0.0-20250807160809-1a19826ec488/go.mod h1:fGb/2+tgXXjhjHsTNdVEEMZNWA0quBnfrO+AfoDSAKw=\ngolang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=\ngolang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=\ngolang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=\ngolang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=\ngolang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=\ngolang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=\ngolang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=\ngolang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=\ngolang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=\ngolang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=\ngolang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=\ngolang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=\ngolang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=\ngolang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=\ngonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo=\ngoogle.golang.org/api v0.222.0/go.mod h1:efZia3nXpWELrwMlN5vyQrD4GmJN1Vw0x68Et3r+a9c=\ngoogle.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=\ngoogle.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4 h1:Pw6WnI9W/LIdRxqK7T6XGugGbHIRl5Q7q3BssH6xk4s=\ngoogle.golang.org/genproto v0.0.0-20250122153221-138b5a5a4fd4/go.mod h1:qbZzneIOXSq+KFAFut9krLfRLZiFLzZL5u2t8SV83EE=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=\ngoogle.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=\ngoogle.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=\ngoogle.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=\ngoogle.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=\ngoogle.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=\ngoogle.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=\ngoogle.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=\ngoogle.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=\ngorgonia.org/vecf32 v0.9.0/go.mod h1:NCc+5D2oxddRL11hd+pCB1PEyXWOyiQxfZ/1wwhOXCA=\ngorgonia.org/vecf64 v0.9.0/go.mod h1:hp7IOWCnRiVQKON73kkC/AUMtEXyf9kGlVrtPQ9ccVA=\nk8s.io/gengo/v2 v2.0.0-20240826214909-a7b603a56eb7/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU=\nk8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU=\nk8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=\nk8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=\nmvdan.cc/editorconfig v0.3.0/go.mod h1:NcJHuDtNOTEJ6251indKiWuzK6+VcrMuLzGMLKBFupQ=\nsigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=\nsigs.k8s.io/structured-merge-diff/v6 v6.2.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=\n"
  },
  {
    "path": "gollm/README.md",
    "content": "# gollm\n\nA Go library for calling into multiple Large Language Model (LLM) providers with a unified interface.\n\nThis library is intended for use by kubectl-ai, but may prove useful for other similar go tools in future.\n\nNote that the library is still evolving and will likely make incompatible changes often.  We are focusing on kubectl-ai's use-case,\nbut will consider changes to support additional use-cases.\n\n## Overview\n\ngollm provides a consistent API for interacting with various LLM providers, making it easy to switch between different models and services without changing your application code. The library supports both chat-based conversations and single completions, with features like function calling, streaming responses, and retry logic.\n\n## Features\n\n- **Multi-provider support**: OpenAI, Azure OpenAI, Google Gemini, Ollama, LlamaCPP, Grok, and more\n- **Unified interface**: Consistent API across all providers\n- **Chat conversations**: Multi-turn conversations with conversation history\n- **Function calling**: Define and use custom functions with LLMs\n- **Streaming support**: Real-time streaming responses\n- **Retry logic**: Built-in retry mechanisms with configurable backoff\n- **Response schemas**: Constrain LLM responses to specific JSON schemas\n- **SSL configuration**: Optional SSL certificate verification skipping\n- **Environment-based configuration**: Easy setup via environment variables\n\n## Providers\n\n| Provider | ID | Description |\n|----------|----|-------------|\n| OpenAI | `openai://` | OpenAI's GPT models |\n| Azure OpenAI | `azopenai://` | Microsoft Azure's OpenAI service |\n| Google Gemini | `gemini://` | Google's Gemini models |\n| Vertex AI | `vertexai://` | Google Cloud Vertex AI (via Gemini) |\n| Ollama | `ollama://` | Local Ollama models |\n| LlamaCPP | `llamacpp://` | Local LlamaCPP models |\n| Grok | `grok://` | xAI's Grok models |\n| Anthropic | `anthropic://` | Claude models with native tool use, prompt caching, and extended thinking |\n\n## Quick Start\n\n### Installation\n\n```bash\ngo get github.com/GoogleCloudPlatform/kubectl-ai/gollm\n```\n\n### Basic Usage\n\n```go\npackage main\n\nimport (\n    \"context\"\n    \"fmt\"\n    \"log\"\n    \n    \"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n)\n\nfunc main() {\n    ctx := context.Background()\n    \n    // Create a client using environment variable\n    client, err := gollm.NewClient(ctx, \"\")\n    if err != nil {\n        log.Fatal(err)\n    }\n    defer client.Close()\n    \n    // Start a chat conversation\n    chat := client.StartChat(\"You are a helpful assistant.\", \"gpt-3.5-turbo\")\n    \n    // Send a message\n    response, err := chat.Send(ctx, \"Hello, how are you?\")\n    if err != nil {\n        log.Fatal(err)\n    }\n    \n    // Print the response\n    for _, candidate := range response.Candidates() {\n        fmt.Println(candidate.String())\n    }\n}\n```\n\n### Environment Configuration\n\nSet the `LLM_CLIENT` environment variable to specify your preferred provider:\n\n```bash\n# OpenAI\nexport LLM_CLIENT=\"openai://api.openai.com\"\nexport OPENAI_API_KEY=\"your-api-key\"\n\n# Azure OpenAI\nexport LLM_CLIENT=\"azopenai://your-resource.openai.azure.com\"\nexport AZURE_OPENAI_API_KEY=\"your-api-key\"\n\n# Google Gemini\nexport LLM_CLIENT=\"gemini://generativelanguage.googleapis.com\"\nexport GOOGLE_API_KEY=\"your-api-key\"\n\n# Ollama (local)\nexport LLM_CLIENT=\"ollama://localhost:11434\"\n```\n\n\n## Examples\n\n### Single Completion\n\n```go\nctx := context.Background()\nclient, err := gollm.NewClient(ctx, \"openai://api.openai.com\")\nif err != nil {\n    log.Fatal(err)\n}\ndefer client.Close()\n\nreq := &gollm.CompletionRequest{\n    Model:  \"gpt-3.5-turbo\",\n    Prompt: \"Write a short poem about programming\",\n}\n\nresponse, err := client.GenerateCompletion(ctx, req)\nif err != nil {\n    log.Fatal(err)\n}\n\nfmt.Println(response.Response())\n```\n\n### Streaming Chat\n\n```go\nctx := context.Background()\nclient, err := gollm.NewClient(ctx, \"openai://api.openai.com\")\nif err != nil {\n    log.Fatal(err)\n}\ndefer client.Close()\n\nchat := client.StartChat(\"You are a helpful assistant.\", \"gpt-3.5-turbo\")\n\n// Send a streaming message\niterator, err := chat.SendStreaming(ctx, \"Tell me a story about a robot\")\nif err != nil {\n    log.Fatal(err)\n}\n\n// Process streaming response\nfor response := range iterator {\n    if response.V1 != nil {\n        for _, candidate := range response.V1.Candidates() {\n            for _, part := range candidate.Parts() {\n                if text, ok := part.AsText(); ok {\n                    fmt.Print(text)\n                }\n            }\n        }\n    }\n    if response.V2 != nil {\n        // Handle error\n        log.Printf(\"Error: %v\", response.V2)\n        break\n    }\n}\n```\n\n### Function Calling\n\n```go\n// Define a function that the LLM can call\nfunctionDef := &gollm.FunctionDefinition{\n    Name:        \"get_weather\",\n    Description: \"Get the current weather for a location\",\n    Parameters: &gollm.Schema{\n        Type: gollm.TypeObject,\n        Properties: map[string]*gollm.Schema{\n            \"location\": {\n                Type:        gollm.TypeString,\n                Description: \"The city and state, e.g. San Francisco, CA\",\n            },\n            \"unit\": {\n                Type:        gollm.TypeString,\n                Description: \"The temperature unit to use. Infer this from the user's location.\",\n                Required:    []string{\"location\"},\n            },\n        },\n    },\n}\n\nchat := client.StartChat(\"You are a helpful assistant.\", \"gpt-3.5-turbo\")\nchat.SetFunctionDefinitions([]*gollm.FunctionDefinition{functionDef})\n\nresponse, err := chat.Send(ctx, \"What's the weather like in San Francisco?\")\nif err != nil {\n    log.Fatal(err)\n}\n\n// Check for function calls in the response\nfor _, candidate := range response.Candidates() {\n    for _, part := range candidate.Parts() {\n        if functionCalls, ok := part.AsFunctionCalls(); ok {\n            for _, call := range functionCalls {\n                fmt.Printf(\"Function call: %s with args %v\\n\", call.Name, call.Arguments)\n                \n                // Execute the function and send the result back\n                result := executeWeatherFunction(call.Arguments)\n                chat.Send(ctx, gollm.FunctionCallResult{\n                    ID:     call.ID,\n                    Name:   call.Name,\n                    Result: result,\n                })\n            }\n        }\n    }\n}\n```\n\n### Response Schema Constraints\n\n```go\n// Define a schema for structured responses\nschema := &gollm.Schema{\n    Type: gollm.TypeObject,\n    Properties: map[string]*gollm.Schema{\n        \"name\": {\n            Type:        gollm.TypeString,\n            Description: \"The person's name\",\n        },\n        \"age\": {\n            Type:        gollm.TypeInteger,\n            Description: \"The person's age\",\n        },\n        \"interests\": {\n            Type: gollm.TypeArray,\n            Items: &gollm.Schema{\n                Type: gollm.TypeString,\n            },\n            Description: \"List of interests\",\n        },\n    },\n    Required: []string{\"name\", \"age\"},\n}\n\nclient.SetResponseSchema(schema)\n\n// Now all responses will be constrained to match this schema\nresponse, err := chat.Send(ctx, \"Tell me about a person named Alice who is 30 years old\")\n```\n\n### Retry Logic\n\n```go\n// Configure retry behavior\nretryConfig := gollm.RetryConfig{\n    MaxAttempts:    3,\n    InitialBackoff: time.Second,\n    MaxBackoff:     30 * time.Second,\n    BackoffFactor:  2.0,\n    Jitter:         true,\n}\n\n// Create a chat with retry logic\nchat := client.StartChat(\"You are a helpful assistant.\", \"gpt-3.5-turbo\")\nretryChat := gollm.NewRetryChat(chat, retryConfig)\n\n// Use the retry chat - it will automatically retry on retryable errors\nresponse, err := retryChat.Send(ctx, \"Hello!\")\n```\n\n### Building Schemas from Go Types\n\n```go\ntype Person struct {\n    Name     string   `json:\"name\"`\n    Age      int      `json:\"age\"`\n    Interests []string `json:\"interests,omitempty\"`\n}\n\n// Automatically build a schema from a Go struct\nschema := gollm.BuildSchemaFor(reflect.TypeOf(Person{}))\n\n// Use the schema to constrain responses\nclient.SetResponseSchema(schema)\n```\n\n## Configuration Options\n\n### Client Options\n\n```go\n// Create a client with custom options\nclient, err := gollm.NewClient(ctx, \"openai://api.openai.com\",\n    gollm.WithSkipVerifySSL(), // Skip SSL verification (for development)\n)\n```\n\n### Environment Variables\n\n- `LLM_CLIENT`: The provider URL to use (e.g., \"openai://api.openai.com\")\n- `LLM_SKIP_VERIFY_SSL`: Set to \"1\" or \"true\" to skip SSL certificate verification\n- Provider-specific API keys (e.g., `OPENAI_API_KEY`, `GOOGLE_API_KEY`)\n\n<details>\n<summary><h3>Anthropic-specific environment variables and provider features</h3></summary>\n\n#### Anthropic-specific environment variables\n\n| Variable | Description | Default |\n|----------|-------------|---------|\n| `ANTHROPIC_API_KEY` | Anthropic API key (required) | — |\n| `ANTHROPIC_MODEL` | Default Claude model to use | `claude-sonnet-4-6` |\n| `ANTHROPIC_PROMPT_CACHING` | Enable prompt caching (`\"false\"` to disable) | `true` |\n| `ANTHROPIC_EXTENDED_THINKING` | Enable extended thinking(`\"true\"` to enable) | `false` |\n| `ANTHROPIC_MAX_TOKENS` | Max output tokens per request | `4096` |\n\n### Anthropic provider features\n\n#### Prompt caching\n\nEnabled by default. Anthropic's [prompt caching](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching)\nattaches a `cache_control` breakpoint to the system prompt and the last tool\ndefinition so that both are reused from cache on every subsequent turn. Because\nkubectl-ai's system prompt is large and repeated verbatim each turn, this\ntypically cuts input token costs significantly after the first request.\n\nSet `ANTHROPIC_PROMPT_CACHING=false` to disable.\n\n#### Extended thinking\n\nDisabled by default. When enabled, Claude produces a `thinking` content block\ncontaining its internal reasoning before giving a final answer. This can improve\naccuracy on complex multi-step queries (e.g. root-cause analysis across multiple\nKubernetes resources). Requires a model that supports extended thinking\n(`claude-3-7-sonnet-20250219` or later) and reserves 8 000 tokens for the\nthinking budget.\n\nThe thinking block is kept in the conversation history (required by the API for\nmulti-turn consistency) but is **not** shown in the terminal output.\n\n```bash\nANTHROPIC_EXTENDED_THINKING=true \\\n    kubectl-ai --llm-provider=anthropic --model claude-3-7-sonnet-20250219 \\\n    \"why is my pod crashlooping\"\n```\n\nSet `ANTHROPIC_EXTENDED_THINKING=true` to enable.\n\n#### Native streaming\n\nThe Anthropic provider uses the official SSE event stream directly, bypassing\nthe OpenAI compatibility shim. Tool input JSON is accumulated across\n`content_block_delta` events and only emitted as a complete `FunctionCall` once\nthe block closes, so partial JSON is never forwarded to the agent loop.\n\n#### Retryable errors\n\nThe provider maps Anthropic-native HTTP status codes to retry decisions:\n\n| Status | Meaning | Retried? |\n|--------|---------|----------|\n| 429 | Rate limit | Yes |\n| 529 | Overloaded | Yes |\n| 5xx | Server error | Yes |\n| 4xx (other) | Client error | No |\n\n</details>\n\n## Error Handling\n\nThe library provides structured error handling with retryable error detection:\n\n```go\nvar apiErr *gollm.APIError\nif errors.As(err, &apiErr) {\n    fmt.Printf(\"API Error: Status=%d, Message=%s\\n\", apiErr.StatusCode, apiErr.Message)\n}\n\n// Check if an error is retryable\nif chat.IsRetryableError(err) {\n    // Implement retry logic\n}\n```\n\n## Adding a provider\n\nTo add a new provider:\n\n1. Create a new file (e.g., `myprovider.go`)\n2. Implement the `Client` interface\n3. Register the provider in an `init()` function:\n\n```go\nfunc init() {\n    if err := gollm.RegisterProvider(\"myprovider\", myProviderFactory); err != nil {\n        panic(err)\n    }\n}\n```\n\n## License\n\nThis project is licensed under the Apache License, Version 2.0. See the LICENSE file for details.\n"
  },
  {
    "path": "gollm/anthropic.go",
    "content": "// Copyright 2026 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\tanthropic \"github.com/anthropics/anthropic-sdk-go\"\n\t\"github.com/anthropics/anthropic-sdk-go/option\"\n\t\"k8s.io/klog/v2\"\n)\n\n// Package-level env var storage (Anthropic env)\nvar (\n\tanthropicAPIKey           string\n\tanthropicDefaultModel     string\n\tanthropicPromptCaching    bool\n\tanthropicExtendedThinking bool\n\tanthropicMaxTokens        int64\n)\n\nfunc init() {\n\tanthropicAPIKey = os.Getenv(\"ANTHROPIC_API_KEY\")\n\tanthropicDefaultModel = os.Getenv(\"ANTHROPIC_MODEL\")\n\n\t// Prompt caching defaults to true; set ANTHROPIC_PROMPT_CACHING=false to disable\n\tif v := os.Getenv(\"ANTHROPIC_PROMPT_CACHING\"); v == \"false\" {\n\t\tanthropicPromptCaching = false\n\t} else {\n\t\tanthropicPromptCaching = true\n\t}\n\n\tif v := os.Getenv(\"ANTHROPIC_EXTENDED_THINKING\"); strings.ToLower(v) == \"true\" {\n\t\tanthropicExtendedThinking = true\n\t}\n\n\tanthropicMaxTokens = 4096 // default\n\tif v := os.Getenv(\"ANTHROPIC_MAX_TOKENS\"); v != \"\" {\n\t\tif n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {\n\t\t\tanthropicMaxTokens = n\n\t\t}\n\t}\n\n\tif err := RegisterProvider(\"anthropic\", newAnthropicClientFactory); err != nil {\n\t\tklog.Fatalf(\"Failed to register anthropic provider: %v\", err)\n\t}\n}\n\n// newAnthropicClientFactory creates a new Anthropic client with the given options.\nfunc newAnthropicClientFactory(ctx context.Context, opts ClientOptions) (Client, error) {\n\treturn NewAnthropicClient(ctx, opts)\n}\n\n// AnthropicClient implements the gollm.Client interface for Anthropic models.\ntype AnthropicClient struct {\n\tclient *anthropic.Client\n}\n\n// Ensure AnthropicClient implements the Client interface.\nvar _ Client = &AnthropicClient{}\n\n// NewAnthropicClient creates a new client for interacting with Anthropic models.\nfunc NewAnthropicClient(ctx context.Context, opts ClientOptions) (*AnthropicClient, error) {\n\tapiKey := anthropicAPIKey\n\tif apiKey == \"\" {\n\t\treturn nil, errors.New(\"Anthropic API key not found. Set via ANTHROPIC_API_KEY env var\")\n\t}\n\n\thttpClient := createCustomHTTPClient(opts.SkipVerifySSL)\n\thttpClient = withJournaling(httpClient)\n\n\tclientOpts := []option.RequestOption{\n\t\toption.WithAPIKey(apiKey),\n\t\toption.WithHTTPClient(httpClient),\n\t}\n\n\tclient := anthropic.NewClient(clientOpts...)\n\treturn &AnthropicClient{client: &client}, nil\n}\n\n// Close cleans up any resources used by the client.\nfunc (c *AnthropicClient) Close() error {\n\treturn nil\n}\n\n// StartChat starts a new chat session with the specified system prompt and model.\nfunc (c *AnthropicClient) StartChat(systemPrompt, model string) Chat {\n\tselectedModel := getAnthropicModel(model)\n\tklog.V(2).Infof(\"Starting new Anthropic chat session with model: %s\", selectedModel)\n\n\treturn &anthropicChatSession{\n\t\tclient:           c.client,\n\t\tmodel:            selectedModel,\n\t\tsystemPrompt:     systemPrompt,\n\t\tmessages:         []anthropic.MessageParam{},\n\t\tpromptCaching:    anthropicPromptCaching,\n\t\textendedThinking: anthropicExtendedThinking,\n\t}\n}\n\n// GenerateCompletion generates a single completion for the given request.\nfunc (c *AnthropicClient) GenerateCompletion(ctx context.Context, req *CompletionRequest) (CompletionResponse, error) {\n\tchat := c.StartChat(\"\", req.Model)\n\tchatResponse, err := chat.Send(ctx, req.Prompt)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &anthropicCompletionResponse{chatResponse: chatResponse}, nil\n}\n\n// SetResponseSchema is not supported by the native Anthropic provider.\nfunc (c *AnthropicClient) SetResponseSchema(schema *Schema) error {\n\tklog.Warning(\"AnthropicClient.SetResponseSchema is not supported by the native Anthropic provider\")\n\treturn nil\n}\n\n// ListModels returns the list of supported Anthropic Claude models.\nfunc (c *AnthropicClient) ListModels(ctx context.Context) ([]string, error) {\n\treturn []string{\n\t\t// Claude 4.6 (latest)\n\t\t\"claude-opus-4-6\",\n\t\t\"claude-sonnet-4-6\",\n\t\t// Claude 4.5\n\t\t\"claude-opus-4-5\",\n\t\t\"claude-opus-4-5-20251101\",\n\t\t\"claude-sonnet-4-5\",\n\t\t\"claude-sonnet-4-5-20250929\",\n\t\t\"claude-haiku-4-5\",\n\t\t\"claude-haiku-4-5-20251001\",\n\t\t// Claude 3.7\n\t\t\"claude-3-7-sonnet-latest\",\n\t\t\"claude-3-7-sonnet-20250219\",\n\t\t// Claude 3.5\n\t\t\"claude-3-5-haiku-latest\",\n\t\t\"claude-3-5-haiku-20241022\",\n\t\t// Claude 3\n\t\t\"claude-3-opus-latest\",\n\t\t\"claude-3-opus-20240229\",\n\t\t\"claude-3-haiku-20240307\",\n\t}, nil\n}\n\n// anthropicChatSession implements the Chat interface for Anthropic conversations.\ntype anthropicChatSession struct {\n\tclient           *anthropic.Client\n\tmodel            string\n\tsystemPrompt     string\n\tmessages         []anthropic.MessageParam\n\ttools            []anthropic.ToolUnionParam\n\tpromptCaching    bool\n\textendedThinking bool\n}\n\n// Ensure anthropicChatSession implements the Chat interface.\nvar _ Chat = (*anthropicChatSession)(nil)\n\n// Initialize initializes the chat with a previous conversation history.\nfunc (c *anthropicChatSession) Initialize(history []*api.Message) error {\n\tc.messages = make([]anthropic.MessageParam, 0, len(history))\n\n\tfor _, msg := range history {\n\t\tvar role anthropic.MessageParamRole\n\t\tswitch msg.Source {\n\t\tcase api.MessageSourceUser:\n\t\t\trole = anthropic.MessageParamRoleUser\n\t\tcase api.MessageSourceModel, api.MessageSourceAgent:\n\t\t\trole = anthropic.MessageParamRoleAssistant\n\t\tdefault:\n\t\t\tcontinue\n\t\t}\n\n\t\tif msg.Type != api.MessageTypeText || msg.Payload == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar content string\n\t\tif textPayload, ok := msg.Payload.(string); ok {\n\t\t\tcontent = textPayload\n\t\t} else {\n\t\t\tcontent = fmt.Sprintf(\"%v\", msg.Payload)\n\t\t}\n\n\t\tif content == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tparam := anthropic.MessageParam{\n\t\t\tRole:    role,\n\t\t\tContent: []anthropic.ContentBlockParamUnion{anthropic.NewTextBlock(content)},\n\t\t}\n\t\tc.messages = append(c.messages, param)\n\t}\n\n\treturn nil\n}\n\n// SetFunctionDefinitions configures the available functions for tool use.\nfunc (c *anthropicChatSession) SetFunctionDefinitions(functions []*FunctionDefinition) error {\n\tc.tools = nil\n\n\tif len(functions) == 0 {\n\t\treturn nil\n\t}\n\n\tc.tools = make([]anthropic.ToolUnionParam, len(functions))\n\tfor i, fn := range functions {\n\t\t// Build input schema properties from gollm Schema\n\t\tvar properties any\n\t\tvar required []string\n\t\tif fn.Parameters != nil {\n\t\t\tschemaBytes, err := json.Marshal(fn.Parameters)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to marshal parameters for function %s: %w\", fn.Name, err)\n\t\t\t}\n\t\t\tvar schemaMap map[string]any\n\t\t\tif err := json.Unmarshal(schemaBytes, &schemaMap); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to unmarshal parameters for function %s: %w\", fn.Name, err)\n\t\t\t}\n\t\t\tproperties = schemaMap[\"properties\"]\n\t\t\tif reqSlice, ok := schemaMap[\"required\"].([]any); ok {\n\t\t\t\tfor _, r := range reqSlice {\n\t\t\t\t\tif s, ok := r.(string); ok {\n\t\t\t\t\t\trequired = append(required, s)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\ttool := anthropic.ToolParam{\n\t\t\tName:        fn.Name,\n\t\t\tDescription: anthropic.String(fn.Description),\n\t\t\tInputSchema: anthropic.ToolInputSchemaParam{\n\t\t\t\tProperties: properties,\n\t\t\t\tRequired:   required,\n\t\t\t},\n\t\t}\n\n\t\t// Apply prompt cache breakpoint to the last tool definition\n\t\tif c.promptCaching && i == len(functions)-1 {\n\t\t\ttool.CacheControl = anthropic.NewCacheControlEphemeralParam()\n\t\t}\n\n\t\tc.tools[i] = anthropic.ToolUnionParam{OfTool: &tool}\n\t}\n\n\treturn nil\n}\n\n// buildSystemBlocks constructs the system prompt blocks, optionally with cache_control.\nfunc (c *anthropicChatSession) buildSystemBlocks() []anthropic.TextBlockParam {\n\tif c.systemPrompt == \"\" {\n\t\treturn nil\n\t}\n\tblock := anthropic.TextBlockParam{\n\t\tText: c.systemPrompt,\n\t}\n\tif c.promptCaching {\n\t\tblock.CacheControl = anthropic.NewCacheControlEphemeralParam()\n\t}\n\treturn []anthropic.TextBlockParam{block}\n}\n\n// addContentsToHistory processes and appends user messages to chat history.\nfunc (c *anthropicChatSession) addContentsToHistory(contents []any) error {\n\tvar blocks []anthropic.ContentBlockParamUnion\n\n\tfor _, content := range contents {\n\t\tswitch v := content.(type) {\n\t\tcase string:\n\t\t\tblocks = append(blocks, anthropic.NewTextBlock(v))\n\t\tcase FunctionCallResult:\n\t\t\tresultJSON, err := json.Marshal(v.Result)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to marshal function call result %q: %w\", v.Name, err)\n\t\t\t}\n\t\t\t// Detect error from result map\n\t\t\tisError := false\n\t\t\tif v.Result != nil {\n\t\t\t\tif errVal, ok := v.Result[\"error\"]; ok {\n\t\t\t\t\tif errBool, isBool := errVal.(bool); isBool && errBool {\n\t\t\t\t\t\tisError = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif statusVal, ok := v.Result[\"status\"]; ok {\n\t\t\t\t\tif statusStr, isStr := statusVal.(string); isStr &&\n\t\t\t\t\t\t(statusStr == \"failed\" || statusStr == \"error\") {\n\t\t\t\t\t\tisError = true\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tblocks = append(blocks, anthropic.NewToolResultBlock(v.ID, string(resultJSON), isError))\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"unhandled content type: %T\", content)\n\t\t}\n\t}\n\n\tif len(blocks) > 0 {\n\t\tc.messages = append(c.messages, anthropic.NewUserMessage(blocks...))\n\t}\n\treturn nil\n}\n\n// Send sends a message and returns a non-streaming response.\nfunc (c *anthropicChatSession) Send(ctx context.Context, contents ...any) (ChatResponse, error) {\n\tif len(contents) == 0 {\n\t\treturn nil, errors.New(\"no content provided\")\n\t}\n\n\tif err := c.addContentsToHistory(contents); err != nil {\n\t\treturn nil, err\n\t}\n\n\tconst thinkingBudget = 8000\n\tmaxTokens := anthropicMaxTokens\n\tif c.extendedThinking {\n\t\t// max_tokens must exceed budget_tokens\n\t\tmaxTokens = thinkingBudget + anthropicMaxTokens\n\t}\n\n\tparams := anthropic.MessageNewParams{\n\t\tModel:     anthropic.Model(c.model),\n\t\tMaxTokens: maxTokens,\n\t\tMessages:  c.messages,\n\t\tSystem:    c.buildSystemBlocks(),\n\t}\n\n\tif len(c.tools) > 0 {\n\t\tparams.Tools = c.tools\n\t}\n\n\tif c.extendedThinking {\n\t\tparams.Thinking = anthropic.ThinkingConfigParamOfEnabled(thinkingBudget)\n\t}\n\n\tmsg, err := c.client.Messages.New(ctx, params)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"anthropic message error: %w\", err)\n\t}\n\n\t// Append assistant message to history\n\tc.messages = append(c.messages, msg.ToParam())\n\n\treturn &anthropicResponse{message: msg}, nil\n}\n\n// SendStreaming sends a message and returns a streaming response iterator.\nfunc (c *anthropicChatSession) SendStreaming(ctx context.Context, contents ...any) (ChatResponseIterator, error) {\n\tif len(contents) == 0 {\n\t\treturn nil, errors.New(\"no content provided\")\n\t}\n\n\tif err := c.addContentsToHistory(contents); err != nil {\n\t\treturn nil, err\n\t}\n\n\tconst thinkingBudget = 8000\n\tmaxTokens := anthropicMaxTokens\n\tif c.extendedThinking {\n\t\t// max_tokens must exceed budget_tokens\n\t\tmaxTokens = thinkingBudget + anthropicMaxTokens\n\t}\n\n\tparams := anthropic.MessageNewParams{\n\t\tModel:     anthropic.Model(c.model),\n\t\tMaxTokens: maxTokens,\n\t\tMessages:  c.messages,\n\t\tSystem:    c.buildSystemBlocks(),\n\t}\n\n\tif len(c.tools) > 0 {\n\t\tparams.Tools = c.tools\n\t}\n\n\tif c.extendedThinking {\n\t\tparams.Thinking = anthropic.ThinkingConfigParamOfEnabled(thinkingBudget)\n\t}\n\n\tstream := c.client.Messages.NewStreaming(ctx, params)\n\n\treturn func(yield func(ChatResponse, error) bool) {\n\t\tdefer stream.Close()\n\n\t\t// Accumulated message for history\n\t\tacc := anthropic.Message{}\n\n\t\t// Tool accumulation state\n\t\ttype partialTool struct {\n\t\t\tid    string\n\t\t\tname  string\n\t\t\tinput strings.Builder\n\t\t}\n\t\ttoolsByIndex := make(map[int64]*partialTool)\n\n\t\tfor stream.Next() {\n\t\t\tevent := stream.Current()\n\n\t\t\t// Accumulate for history\n\t\t\tif err := acc.Accumulate(event); err != nil {\n\t\t\t\tklog.V(2).Infof(\"Anthropic accumulate error: %v\", err)\n\t\t\t}\n\n\t\t\tswitch ev := event.AsAny().(type) {\n\t\t\tcase anthropic.ContentBlockStartEvent:\n\t\t\t\t// Register new tool_use block\n\t\t\t\tif ev.ContentBlock.Type == \"tool_use\" {\n\t\t\t\t\ttoolsByIndex[ev.Index] = &partialTool{\n\t\t\t\t\t\tid:   ev.ContentBlock.ID,\n\t\t\t\t\t\tname: ev.ContentBlock.Name,\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\tcase anthropic.ContentBlockDeltaEvent:\n\t\t\t\tswitch delta := ev.Delta.AsAny().(type) {\n\t\t\t\tcase anthropic.TextDelta:\n\t\t\t\t\tif !yield(&anthropicStreamResponse{text: delta.Text}, nil) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\tcase anthropic.InputJSONDelta:\n\t\t\t\t\t// Accumulate tool input JSON\n\t\t\t\t\tif pt, ok := toolsByIndex[ev.Index]; ok {\n\t\t\t\t\t\tpt.input.WriteString(delta.PartialJSON)\n\t\t\t\t\t}\n\t\t\t\tcase anthropic.ThinkingDelta:\n\t\t\t\t\t// thinking content is kept in history via accumulator, not yielded to UI\n\t\t\t\t}\n\n\t\t\tcase anthropic.ContentBlockStopEvent:\n\t\t\t\t// Check if a tool_use block completed\n\t\t\t\tif pt, ok := toolsByIndex[ev.Index]; ok {\n\t\t\t\t\tinputJSON := pt.input.String()\n\t\t\t\t\tvar args map[string]any\n\t\t\t\t\tif inputJSON != \"\" {\n\t\t\t\t\t\tif err := json.Unmarshal([]byte(inputJSON), &args); err != nil {\n\t\t\t\t\t\t\tklog.V(2).Infof(\"Failed to unmarshal tool input: %v\", err)\n\t\t\t\t\t\t\targs = make(map[string]any)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\targs = make(map[string]any)\n\t\t\t\t\t}\n\n\t\t\t\t\tfc := FunctionCall{\n\t\t\t\t\t\tID:        pt.id,\n\t\t\t\t\t\tName:      pt.name,\n\t\t\t\t\t\tArguments: args,\n\t\t\t\t\t}\n\t\t\t\t\tif !yield(&anthropicStreamResponse{functionCall: &fc}, nil) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tdelete(toolsByIndex, ev.Index)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif err := stream.Err(); err != nil {\n\t\t\tyield(nil, fmt.Errorf(\"anthropic stream error: %w\", err))\n\t\t\treturn\n\t\t}\n\n\t\t// Append accumulated assistant message to history\n\t\tif len(acc.Content) > 0 {\n\t\t\tc.messages = append(c.messages, acc.ToParam())\n\t\t}\n\t\t// Yield final usage so callers can observe token/cache counts\n\t\tif acc.Usage.InputTokens > 0 || acc.Usage.OutputTokens > 0 {\n\t\t\tyield(&anthropicStreamResponse{usage: &acc.Usage}, nil)\n\t\t}\n\t}, nil\n}\n\n// IsRetryableError determines if an error from the Anthropic API should be retried.\nfunc (c *anthropicChatSession) IsRetryableError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\tvar apiErr *anthropic.Error\n\tif errors.As(err, &apiErr) {\n\t\tsc := apiErr.StatusCode\n\t\t// 429 = rate limit, 529 = overloaded, 5xx = server errors\n\t\treturn sc == 429 || sc == 529 || (sc >= 500 && sc < 600)\n\t}\n\n\treturn DefaultIsRetryableError(err)\n}\n\n// anthropicResponse implements ChatResponse for non-streaming responses.\ntype anthropicResponse struct {\n\tmessage *anthropic.Message\n}\n\nvar _ ChatResponse = (*anthropicResponse)(nil)\n\nfunc (r *anthropicResponse) UsageMetadata() any {\n\tif r.message != nil {\n\t\treturn r.message.Usage\n\t}\n\treturn nil\n}\n\nfunc (r *anthropicResponse) Candidates() []Candidate {\n\tif r.message == nil {\n\t\treturn nil\n\t}\n\treturn []Candidate{&anthropicCandidate{content: r.message.Content}}\n}\n\n// anthropicStreamResponse implements ChatResponse for streaming responses.\ntype anthropicStreamResponse struct {\n\ttext         string\n\tfunctionCall *FunctionCall\n\tusage        *anthropic.Usage\n}\n\nvar _ ChatResponse = (*anthropicStreamResponse)(nil)\n\nfunc (r *anthropicStreamResponse) UsageMetadata() any {\n\tif r.usage != nil {\n\t\treturn r.usage\n\t}\n\treturn nil\n}\n\nfunc (r *anthropicStreamResponse) Candidates() []Candidate {\n\tif r.text == \"\" && r.functionCall == nil {\n\t\treturn nil\n\t}\n\treturn []Candidate{&anthropicStreamCandidate{\n\t\ttext:         r.text,\n\t\tfunctionCall: r.functionCall,\n\t}}\n}\n\n// anthropicCandidate implements Candidate for non-streaming responses.\ntype anthropicCandidate struct {\n\tcontent []anthropic.ContentBlockUnion\n}\n\nvar _ Candidate = (*anthropicCandidate)(nil)\n\nfunc (c *anthropicCandidate) String() string {\n\tvar sb strings.Builder\n\tfor _, block := range c.content {\n\t\tswitch block.Type {\n\t\tcase \"text\":\n\t\t\ttb := block.AsText()\n\t\t\tsb.WriteString(tb.Text)\n\t\t}\n\t}\n\treturn sb.String()\n}\n\nfunc (c *anthropicCandidate) Parts() []Part {\n\tvar parts []Part\n\tfor _, block := range c.content {\n\t\tswitch block.Type {\n\t\tcase \"text\":\n\t\t\ttb := block.AsText()\n\t\t\tif tb.Text != \"\" {\n\t\t\t\tparts = append(parts, &anthropicTextPart{text: tb.Text})\n\t\t\t}\n\t\tcase \"tool_use\":\n\t\t\ttu := block.AsToolUse()\n\t\t\tvar args map[string]any\n\t\t\tif len(tu.Input) > 0 {\n\t\t\t\tif err := json.Unmarshal(tu.Input, &args); err != nil {\n\t\t\t\t\tklog.V(2).Infof(\"Failed to unmarshal tool input: %v\", err)\n\t\t\t\t\targs = make(map[string]any)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\targs = make(map[string]any)\n\t\t\t}\n\t\t\tparts = append(parts, &anthropicToolPart{\n\t\t\t\tfunctionCall: FunctionCall{\n\t\t\t\t\tID:        tu.ID,\n\t\t\t\t\tName:      tu.Name,\n\t\t\t\t\tArguments: args,\n\t\t\t\t},\n\t\t\t})\n\t\tcase \"thinking\":\n\t\t\t// ThinkingBlock — do not yield to UI, skip\n\t\t}\n\t}\n\treturn parts\n}\n\n// anthropicStreamCandidate implements Candidate for streaming responses.\ntype anthropicStreamCandidate struct {\n\ttext         string\n\tfunctionCall *FunctionCall\n}\n\nvar _ Candidate = (*anthropicStreamCandidate)(nil)\n\nfunc (c *anthropicStreamCandidate) String() string {\n\tif c.text != \"\" {\n\t\treturn c.text\n\t}\n\tif c.functionCall != nil {\n\t\treturn fmt.Sprintf(\"FunctionCall(%s)\", c.functionCall.Name)\n\t}\n\treturn \"\"\n}\n\nfunc (c *anthropicStreamCandidate) Parts() []Part {\n\tvar parts []Part\n\tif c.text != \"\" {\n\t\tparts = append(parts, &anthropicTextPart{text: c.text})\n\t}\n\tif c.functionCall != nil {\n\t\tparts = append(parts, &anthropicToolPart{functionCall: *c.functionCall})\n\t}\n\treturn parts\n}\n\n// anthropicTextPart implements Part for text content.\ntype anthropicTextPart struct {\n\ttext string\n}\n\nvar _ Part = (*anthropicTextPart)(nil)\n\nfunc (p *anthropicTextPart) AsText() (string, bool) {\n\treturn p.text, p.text != \"\"\n}\n\nfunc (p *anthropicTextPart) AsFunctionCalls() ([]FunctionCall, bool) {\n\treturn nil, false\n}\n\n// anthropicToolPart implements Part for tool/function calls.\ntype anthropicToolPart struct {\n\tfunctionCall FunctionCall\n}\n\nvar _ Part = (*anthropicToolPart)(nil)\n\nfunc (p *anthropicToolPart) AsText() (string, bool) {\n\treturn \"\", false\n}\n\nfunc (p *anthropicToolPart) AsFunctionCalls() ([]FunctionCall, bool) {\n\treturn []FunctionCall{p.functionCall}, true\n}\n\n// anthropicCompletionResponse wraps a ChatResponse to implement CompletionResponse.\ntype anthropicCompletionResponse struct {\n\tchatResponse ChatResponse\n}\n\nvar _ CompletionResponse = (*anthropicCompletionResponse)(nil)\n\nfunc (r *anthropicCompletionResponse) Response() string {\n\tif r.chatResponse == nil {\n\t\treturn \"\"\n\t}\n\tcandidates := r.chatResponse.Candidates()\n\tif len(candidates) == 0 {\n\t\treturn \"\"\n\t}\n\tfor _, part := range candidates[0].Parts() {\n\t\tif text, ok := part.AsText(); ok {\n\t\t\treturn text\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc (r *anthropicCompletionResponse) UsageMetadata() any {\n\tif r.chatResponse == nil {\n\t\treturn nil\n\t}\n\treturn r.chatResponse.UsageMetadata()\n}\n\nfunc getAnthropicModel(model string) string {\n\tif model != \"\" && strings.HasPrefix(model, \"claude\") {\n\t\tklog.V(4).Infof(\"Using explicitly provided Anthropic model: %s\", model)\n\t\treturn model\n\t}\n\tif model != \"\" {\n\t\tklog.V(2).Infof(\"Ignoring non-Claude model %q, falling back to default\", model)\n\t}\n\tif anthropicDefaultModel != \"\" {\n\t\tklog.V(2).Infof(\"Using Anthropic model from environment: %s\", anthropicDefaultModel)\n\t\treturn anthropicDefaultModel\n\t}\n\tdefaultModel := \"claude-sonnet-4-6\"\n\tklog.V(2).Infof(\"Using default Anthropic model: %s\", defaultModel)\n\treturn defaultModel\n}\n"
  },
  {
    "path": "gollm/anthropic_test.go",
    "content": "// Copyright 2026 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"testing\"\n\n\tanthropic \"github.com/anthropics/anthropic-sdk-go\"\n)\n\n// TestAnthropicProviderRegistration verifies that the \"anthropic\" provider\n// is registered in the global registry when the package initializes.\nfunc TestAnthropicProviderRegistration(t *testing.T) {\n\tproviders := globalRegistry.listProviders()\n\tfound := false\n\tfor _, p := range providers {\n\t\tif p == \"anthropic\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Errorf(\"expected 'anthropic' to be registered, got providers: %v\", providers)\n\t}\n}\n\n// TestAnthropicAddContentsToHistory verifies that string and FunctionCallResult\n// contents are correctly converted to Anthropic MessageParam history entries.\nfunc TestAnthropicAddContentsToHistory(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tcontents []any\n\t\twantMsgs int\n\t\twantRole anthropic.MessageParamRole\n\t\twantErr  bool\n\t}{\n\t\t{\n\t\t\tname:     \"string content creates user message\",\n\t\t\tcontents: []any{\"hello world\"},\n\t\t\twantMsgs: 1,\n\t\t\twantRole: anthropic.MessageParamRoleUser,\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname: \"FunctionCallResult creates user message with tool_result block\",\n\t\t\tcontents: []any{FunctionCallResult{\n\t\t\t\tID:     \"tool_123\",\n\t\t\t\tName:   \"kubectl\",\n\t\t\t\tResult: map[string]any{\"output\": \"pods running\"},\n\t\t\t}},\n\t\t\twantMsgs: 1,\n\t\t\twantRole: anthropic.MessageParamRoleUser,\n\t\t\twantErr:  false,\n\t\t},\n\t\t{\n\t\t\tname:     \"unhandled content type returns error\",\n\t\t\tcontents: []any{12345},\n\t\t\twantMsgs: 0,\n\t\t\twantErr:  true,\n\t\t},\n\t\t{\n\t\t\tname:     \"empty contents adds no messages\",\n\t\t\tcontents: []any{},\n\t\t\twantMsgs: 0,\n\t\t\twantErr:  false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tsession := &anthropicChatSession{\n\t\t\t\tmessages:      []anthropic.MessageParam{},\n\t\t\t\tpromptCaching: false,\n\t\t\t}\n\n\t\t\terr := session.addContentsToHistory(tt.contents)\n\n\t\t\tif tt.wantErr {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error but got none\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(session.messages) != tt.wantMsgs {\n\t\t\t\tt.Errorf(\"expected %d messages, got %d\", tt.wantMsgs, len(session.messages))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif tt.wantMsgs > 0 {\n\t\t\t\tif session.messages[0].Role != tt.wantRole {\n\t\t\t\t\tt.Errorf(\"expected role %q, got %q\", tt.wantRole, session.messages[0].Role)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestAnthropicBuildSystemBlocks_WithCaching verifies that the system prompt\n// block includes cache_control when prompt caching is enabled.\nfunc TestAnthropicBuildSystemBlocks_WithCaching(t *testing.T) {\n\tsession := &anthropicChatSession{\n\t\tsystemPrompt:  \"You are a helpful Kubernetes assistant.\",\n\t\tpromptCaching: true,\n\t}\n\n\tblocks := session.buildSystemBlocks()\n\tif len(blocks) != 1 {\n\t\tt.Fatalf(\"expected 1 system block, got %d\", len(blocks))\n\t}\n\n\tblock := blocks[0]\n\tif block.Text != session.systemPrompt {\n\t\tt.Errorf(\"expected text %q, got %q\", session.systemPrompt, block.Text)\n\t}\n\n\t// With caching enabled, CacheControl should be set (non-zero type field)\n\tif block.CacheControl.Type == \"\" {\n\t\tt.Error(\"expected CacheControl.Type to be set when prompt caching is enabled\")\n\t}\n}\n\n// TestAnthropicBuildSystemBlocks_WithoutCaching verifies that the system prompt\n// block does NOT include cache_control when prompt caching is disabled.\nfunc TestAnthropicBuildSystemBlocks_WithoutCaching(t *testing.T) {\n\tsession := &anthropicChatSession{\n\t\tsystemPrompt:  \"You are a helpful Kubernetes assistant.\",\n\t\tpromptCaching: false,\n\t}\n\n\tblocks := session.buildSystemBlocks()\n\tif len(blocks) != 1 {\n\t\tt.Fatalf(\"expected 1 system block, got %d\", len(blocks))\n\t}\n\n\tblock := blocks[0]\n\tif block.Text != session.systemPrompt {\n\t\tt.Errorf(\"expected text %q, got %q\", session.systemPrompt, block.Text)\n\t}\n\n\t// Without caching, CacheControl.Type should be empty/zero\n\tif block.CacheControl.Type != \"\" {\n\t\tt.Error(\"expected CacheControl to be empty when prompt caching is disabled\")\n\t}\n}\n\n// TestAnthropicBuildSystemBlocks_EmptyPrompt verifies that an empty system\n// prompt returns nil blocks.\nfunc TestAnthropicBuildSystemBlocks_EmptyPrompt(t *testing.T) {\n\tsession := &anthropicChatSession{\n\t\tsystemPrompt:  \"\",\n\t\tpromptCaching: true,\n\t}\n\n\tblocks := session.buildSystemBlocks()\n\tif blocks != nil {\n\t\tt.Errorf(\"expected nil blocks for empty system prompt, got %v\", blocks)\n\t}\n}\n\n// TestAnthropicSetFunctionDefinitions_CacheControl verifies that when prompt\n// caching is enabled, the cache breakpoint is placed on the last tool definition.\nfunc TestAnthropicSetFunctionDefinitions_CacheControl(t *testing.T) {\n\tsession := &anthropicChatSession{\n\t\tpromptCaching: true,\n\t}\n\n\tfunctions := []*FunctionDefinition{\n\t\t{\n\t\t\tName:        \"kubectl\",\n\t\t\tDescription: \"Run a kubectl command\",\n\t\t\tParameters: &Schema{\n\t\t\t\tType: TypeObject,\n\t\t\t\tProperties: map[string]*Schema{\n\t\t\t\t\t\"command\": {Type: TypeString, Description: \"The kubectl command\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tName:        \"bash\",\n\t\t\tDescription: \"Run a bash command\",\n\t\t\tParameters: &Schema{\n\t\t\t\tType: TypeObject,\n\t\t\t\tProperties: map[string]*Schema{\n\t\t\t\t\t\"command\": {Type: TypeString, Description: \"The bash command\"},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n\n\tif err := session.SetFunctionDefinitions(functions); err != nil {\n\t\tt.Fatalf(\"SetFunctionDefinitions error: %v\", err)\n\t}\n\n\tif len(session.tools) != 2 {\n\t\tt.Fatalf(\"expected 2 tools, got %d\", len(session.tools))\n\t}\n\n\t// The first tool should NOT have cache_control\n\tfirstTool := session.tools[0].OfTool\n\tif firstTool == nil {\n\t\tt.Fatal(\"expected first tool to be non-nil\")\n\t}\n\tif firstTool.CacheControl.Type != \"\" {\n\t\tt.Error(\"expected first tool to NOT have cache_control\")\n\t}\n\n\t// The last tool SHOULD have cache_control\n\tlastTool := session.tools[1].OfTool\n\tif lastTool == nil {\n\t\tt.Fatal(\"expected last tool to be non-nil\")\n\t}\n\tif lastTool.CacheControl.Type == \"\" {\n\t\tt.Error(\"expected last tool to have cache_control set\")\n\t}\n}\n\n// TestAnthropicSetFunctionDefinitions_NoCacheControl verifies that when prompt\n// caching is disabled, no tool gets a cache breakpoint.\nfunc TestAnthropicSetFunctionDefinitions_NoCacheControl(t *testing.T) {\n\tsession := &anthropicChatSession{\n\t\tpromptCaching: false,\n\t}\n\n\tfunctions := []*FunctionDefinition{\n\t\t{Name: \"kubectl\", Description: \"Run kubectl\"},\n\t\t{Name: \"bash\", Description: \"Run bash\"},\n\t}\n\n\tif err := session.SetFunctionDefinitions(functions); err != nil {\n\t\tt.Fatalf(\"SetFunctionDefinitions error: %v\", err)\n\t}\n\n\tfor i, tool := range session.tools {\n\t\tif tool.OfTool == nil {\n\t\t\tt.Fatalf(\"tool[%d] is nil\", i)\n\t\t}\n\t\tif tool.OfTool.CacheControl.Type != \"\" {\n\t\t\tt.Errorf(\"tool[%d] should NOT have cache_control when caching is disabled\", i)\n\t\t}\n\t}\n}\n\n// TestAnthropicSetFunctionDefinitions_EmptyList verifies that setting an empty\n// function list clears any previously set tools.\nfunc TestAnthropicSetFunctionDefinitions_EmptyList(t *testing.T) {\n\tsession := &anthropicChatSession{\n\t\ttools: []anthropic.ToolUnionParam{{OfTool: &anthropic.ToolParam{Name: \"old\"}}},\n\t}\n\n\tif err := session.SetFunctionDefinitions([]*FunctionDefinition{}); err != nil {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n\n\tif len(session.tools) != 0 {\n\t\tt.Errorf(\"expected tools to be cleared, got %d tools\", len(session.tools))\n\t}\n}\n\n// TestAnthropicIsRetryableError verifies the retry classification logic.\nfunc TestAnthropicIsRetryableError(t *testing.T) {\n\tsession := &anthropicChatSession{}\n\n\ttests := []struct {\n\t\tname      string\n\t\terr       error\n\t\twantRetry bool\n\t}{\n\t\t{\n\t\t\tname:      \"nil error is not retryable\",\n\t\t\terr:       nil,\n\t\t\twantRetry: false,\n\t\t},\n\t\t{\n\t\t\tname:      \"rate limit 429 is retryable\",\n\t\t\terr:       makeAnthropicAPIError(429),\n\t\t\twantRetry: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"overloaded 529 is retryable\",\n\t\t\terr:       makeAnthropicAPIError(529),\n\t\t\twantRetry: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"internal server error 500 is retryable\",\n\t\t\terr:       makeAnthropicAPIError(500),\n\t\t\twantRetry: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"bad gateway 502 is retryable\",\n\t\t\terr:       makeAnthropicAPIError(502),\n\t\t\twantRetry: true,\n\t\t},\n\t\t{\n\t\t\tname:      \"bad request 400 is not retryable\",\n\t\t\terr:       makeAnthropicAPIError(400),\n\t\t\twantRetry: false,\n\t\t},\n\t\t{\n\t\t\tname:      \"not found 404 is not retryable\",\n\t\t\terr:       makeAnthropicAPIError(404),\n\t\t\twantRetry: false,\n\t\t},\n\t\t{\n\t\t\tname:      \"authentication error 401 is not retryable\",\n\t\t\terr:       makeAnthropicAPIError(401),\n\t\t\twantRetry: false,\n\t\t},\n\t\t{\n\t\t\tname:      \"plain error falls through to default\",\n\t\t\terr:       fmt.Errorf(\"some generic error\"),\n\t\t\twantRetry: false,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tgot := session.IsRetryableError(tt.err)\n\t\t\tif got != tt.wantRetry {\n\t\t\t\tt.Errorf(\"IsRetryableError(%v) = %v, want %v\", tt.err, got, tt.wantRetry)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// makeAnthropicAPIError creates a fake *anthropic.Error with the given status code\n// for testing IsRetryableError. anthropic.Error is a public alias for the internal\n// apierror.Error struct, so we construct it via the alias directly.\nfunc makeAnthropicAPIError(statusCode int) error {\n\treturn &anthropic.Error{\n\t\tStatusCode: statusCode,\n\t\tRequest:    &http.Request{Method: \"POST\"},\n\t\tResponse:   &http.Response{StatusCode: statusCode},\n\t}\n}\n\n// TestAnthropicStreamResponseUsageMetadata verifies that UsageMetadata returns\n// non-nil when a usage struct is set, and nil otherwise.\nfunc TestAnthropicStreamResponseUsageMetadata(t *testing.T) {\n\tt.Run(\"returns nil when no usage set\", func(t *testing.T) {\n\t\tr := &anthropicStreamResponse{text: \"hello\"}\n\t\tif r.UsageMetadata() != nil {\n\t\t\tt.Error(\"expected nil UsageMetadata for text-only response\")\n\t\t}\n\t})\n\n\tt.Run(\"returns usage when set\", func(t *testing.T) {\n\t\tusage := &anthropic.Usage{InputTokens: 10, OutputTokens: 20}\n\t\tr := &anthropicStreamResponse{usage: usage}\n\t\tgot := r.UsageMetadata()\n\t\tif got == nil {\n\t\t\tt.Fatal(\"expected non-nil UsageMetadata\")\n\t\t}\n\t\tu, ok := got.(*anthropic.Usage)\n\t\tif !ok {\n\t\t\tt.Fatalf(\"expected *anthropic.Usage, got %T\", got)\n\t\t}\n\t\tif u.InputTokens != 10 || u.OutputTokens != 20 {\n\t\t\tt.Errorf(\"unexpected usage values: %+v\", u)\n\t\t}\n\t})\n\n\tt.Run(\"usage-only response returns nil candidates\", func(t *testing.T) {\n\t\tusage := &anthropic.Usage{InputTokens: 5, OutputTokens: 15}\n\t\tr := &anthropicStreamResponse{usage: usage}\n\t\tif r.Candidates() != nil {\n\t\t\tt.Error(\"expected nil Candidates for usage-only response\")\n\t\t}\n\t})\n}\n\n// TestAnthropicMaxTokensDefault verifies that the package-level default is 4096.\nfunc TestAnthropicMaxTokensDefault(t *testing.T) {\n\t// Save and restore\n\torig := anthropicMaxTokens\n\tdefer func() { anthropicMaxTokens = orig }()\n\n\tanthropicMaxTokens = 4096\n\tif anthropicMaxTokens != 4096 {\n\t\tt.Errorf(\"expected default max tokens 4096, got %d\", anthropicMaxTokens)\n\t}\n}\n\n// TestAnthropicMaxTokensEnvVar verifies that ANTHROPIC_MAX_TOKENS is parsed\n// and applied to the package-level variable.\nfunc TestAnthropicMaxTokensEnvVar(t *testing.T) {\n\torig := anthropicMaxTokens\n\tdefer func() { anthropicMaxTokens = orig }()\n\n\tt.Run(\"valid value is applied\", func(t *testing.T) {\n\t\tt.Setenv(\"ANTHROPIC_MAX_TOKENS\", \"2048\")\n\t\t// Simulate what init() does\n\t\tanthropicMaxTokens = 4096\n\t\tif v := t.TempDir(); v != \"\" { // just to use t\n\t\t}\n\t\t// Re-run the parsing logic inline (mirrors init())\n\t\tif v := \"2048\"; v != \"\" {\n\t\t\tif n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {\n\t\t\t\tanthropicMaxTokens = n\n\t\t\t}\n\t\t}\n\t\tif anthropicMaxTokens != 2048 {\n\t\t\tt.Errorf(\"expected 2048, got %d\", anthropicMaxTokens)\n\t\t}\n\t})\n\n\tt.Run(\"zero value is rejected, default kept\", func(t *testing.T) {\n\t\tanthropicMaxTokens = 4096\n\t\tif v := \"0\"; v != \"\" {\n\t\t\tif n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {\n\t\t\t\tanthropicMaxTokens = n\n\t\t\t}\n\t\t}\n\t\tif anthropicMaxTokens != 4096 {\n\t\t\tt.Errorf(\"expected default 4096 to be kept, got %d\", anthropicMaxTokens)\n\t\t}\n\t})\n\n\tt.Run(\"negative value is rejected, default kept\", func(t *testing.T) {\n\t\tanthropicMaxTokens = 4096\n\t\tif v := \"-100\"; v != \"\" {\n\t\t\tif n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {\n\t\t\t\tanthropicMaxTokens = n\n\t\t\t}\n\t\t}\n\t\tif anthropicMaxTokens != 4096 {\n\t\t\tt.Errorf(\"expected default 4096 to be kept, got %d\", anthropicMaxTokens)\n\t\t}\n\t})\n\n\tt.Run(\"non-numeric value is rejected, default kept\", func(t *testing.T) {\n\t\tanthropicMaxTokens = 4096\n\t\tif v := \"abc\"; v != \"\" {\n\t\t\tif n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {\n\t\t\t\tanthropicMaxTokens = n\n\t\t\t}\n\t\t}\n\t\tif anthropicMaxTokens != 4096 {\n\t\t\tt.Errorf(\"expected default 4096 to be kept, got %d\", anthropicMaxTokens)\n\t\t}\n\t})\n}\n\n// TestGetAnthropicModel verifies the model selection priority.\nfunc TestGetAnthropicModel(t *testing.T) {\n\t// Save and restore original values\n\torigDefault := anthropicDefaultModel\n\tdefer func() { anthropicDefaultModel = origDefault }()\n\n\tt.Run(\"explicit model is highest priority\", func(t *testing.T) {\n\t\tanthropicDefaultModel = \"env-model\"\n\t\tgot := getAnthropicModel(\"explicit-model\")\n\t\tif got != \"explicit-model\" {\n\t\t\tt.Errorf(\"expected 'explicit-model', got %q\", got)\n\t\t}\n\t})\n\n\tt.Run(\"env var used when no explicit model\", func(t *testing.T) {\n\t\tanthropicDefaultModel = \"env-model\"\n\t\tgot := getAnthropicModel(\"\")\n\t\tif got != \"env-model\" {\n\t\t\tt.Errorf(\"expected 'env-model', got %q\", got)\n\t\t}\n\t})\n\n\tt.Run(\"default used when no explicit model and no env var\", func(t *testing.T) {\n\t\tanthropicDefaultModel = \"\"\n\t\tgot := getAnthropicModel(\"\")\n\t\tif got != \"claude-sonnet-4-6\" {\n\t\t\tt.Errorf(\"expected 'claude-sonnet-4-6', got %q\", got)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "gollm/azopenai.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"k8s.io/klog/v2\"\n\n\t\"github.com/Azure/azure-sdk-for-go/sdk/ai/azopenai\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/azidentity\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices\"\n\t\"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n)\n\nfunc init() {\n\tif err := RegisterProvider(\"azopenai\", azureOpenAIFactory); err != nil {\n\t\tklog.Fatalf(\"Failed to register azopenai provider: %v\", err)\n\t}\n}\n\n/*\nazureOpenAIFactory is the provider factory function for Azure OpenAI.\nSupports ClientOptions for custom configuration.\n*/\nfunc azureOpenAIFactory(ctx context.Context, opts ClientOptions) (Client, error) {\n\treturn NewAzureOpenAIClient(ctx, opts)\n}\n\ntype AzureOpenAIClient struct {\n\tclient   *azopenai.Client\n\tendpoint string\n}\n\nvar _ Client = &AzureOpenAIClient{}\n\n// NewAzureOpenAIClient creates a new Azure OpenAI client.\n// Supports ClientOptions and SkipVerifySSL for custom HTTP transport.\nfunc NewAzureOpenAIClient(ctx context.Context, opts ClientOptions) (*AzureOpenAIClient, error) {\n\tazureOpenAIEndpoint := os.Getenv(\"AZURE_OPENAI_ENDPOINT\")\n\tif opts.URL != nil && opts.URL.Host != \"\" {\n\t\topts.URL.Scheme = \"https\"\n\t\tazureOpenAIEndpoint = opts.URL.String()\n\t}\n\tif azureOpenAIEndpoint == \"\" {\n\t\treturn nil, fmt.Errorf(\"AZURE_OPENAI_ENDPOINT environment variable not set\")\n\t}\n\tazureOpenAIClient := AzureOpenAIClient{\n\t\tendpoint: azureOpenAIEndpoint,\n\t}\n\n\t// Create a custom HTTP client (supports SkipVerifySSL)\n\thttpClient := createCustomHTTPClient(opts.SkipVerifySSL)\n\n\tazureOpenAIKey := os.Getenv(\"AZURE_OPENAI_API_KEY\")\n\tclientOpts := &azopenai.ClientOptions{\n\t\tClientOptions: azcore.ClientOptions{\n\t\t\tTransport: httpClient,\n\t\t},\n\t}\n\tif azureOpenAIKey != \"\" {\n\t\tkeyCredential := azcore.NewKeyCredential(azureOpenAIKey)\n\t\tclient, err := azopenai.NewClientWithKeyCredential(azureOpenAIEndpoint, keyCredential, clientOpts)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create azure openai client: %w\", err)\n\t\t}\n\t\tazureOpenAIClient.client = client\n\t} else {\n\t\tcredential, err := azidentity.NewDefaultAzureCredential(nil)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get credential: %w\", err)\n\t\t}\n\t\tclient, err := azopenai.NewClient(azureOpenAIEndpoint, credential, clientOpts)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to create azure openai client: %w\", err)\n\t\t}\n\t\tazureOpenAIClient.client = client\n\t}\n\n\treturn &azureOpenAIClient, nil\n}\n\nfunc (c *AzureOpenAIClient) Close() error {\n\treturn nil\n}\n\nfunc (c *AzureOpenAIClient) GenerateCompletion(ctx context.Context, request *CompletionRequest) (CompletionResponse, error) {\n\treq := azopenai.ChatCompletionsOptions{\n\t\tMessages: []azopenai.ChatRequestMessageClassification{\n\t\t\t&azopenai.ChatRequestUserMessage{Content: azopenai.NewChatRequestUserMessageContent(request.Prompt)},\n\t\t},\n\t\tDeploymentName: &request.Model,\n\t}\n\n\tresp, err := c.client.GetChatCompletions(ctx, req, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(resp.Choices) == 0 || resp.Choices[0].Message == nil || resp.Choices[0].Message.Content == nil {\n\t\treturn nil, fmt.Errorf(\"invalid completion response: %v\", resp)\n\t}\n\n\treturn &AzureOpenAICompletionResponse{response: *resp.Choices[0].Message.Content}, nil\n}\n\nfunc (c *AzureOpenAIClient) ListModels(ctx context.Context) ([]string, error) {\n\tcred, err := azidentity.NewDefaultAzureCredential(nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to get credential: %w\", err)\n\t}\n\n\tsubClient, err := armsubscription.NewSubscriptionsClient(cred, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create subscriptions client: %w\", err)\n\t}\n\n\tsubPager := subClient.NewListPager(nil)\n\tfor subPager.More() {\n\t\tsubResp, err := subPager.NextPage(context.Background())\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get subscriptions page: %w\", err)\n\t\t}\n\n\t\tfor _, sub := range subResp.Value {\n\t\t\taccountClient, err := armcognitiveservices.NewAccountsClient(*sub.SubscriptionID, cred, nil)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to create accounts client: %w\", err)\n\t\t\t}\n\n\t\t\taccountPager := accountClient.NewListPager(nil)\n\t\t\tfor accountPager.More() {\n\t\t\t\taccountResp, err := accountPager.NextPage(context.Background())\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"failed to to get accounts page: %w\", err)\n\t\t\t\t}\n\n\t\t\t\tfor _, account := range accountResp.Value {\n\t\t\t\t\tif account.Kind == nil || !slices.Contains([]string{\"OpenAI\", \"CognitiveServices\", \"AIServices\"}, *account.Kind) {\n\t\t\t\t\t\t// Not an Azure OpenAI service\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif account.Properties == nil || account.Properties.Endpoint == nil || strings.TrimSuffix(*account.Properties.Endpoint, \"/\") != c.endpoint {\n\t\t\t\t\t\t// Not the expected endpoint\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tresourceID, err := arm.ParseResourceID(*account.ID)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"failed to parse resource ID %q: %w\", *account.Name, err)\n\t\t\t\t\t}\n\n\t\t\t\t\tdeploymentClient, err := armcognitiveservices.NewDeploymentsClient(*sub.SubscriptionID, cred, nil)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"failed to create deployments client: %w\", err)\n\t\t\t\t\t}\n\n\t\t\t\t\tvar modelNames []string\n\t\t\t\t\tdeploymentPager := deploymentClient.NewListPager(resourceID.ResourceGroupName, *account.Name, nil)\n\t\t\t\t\tfor deploymentPager.More() {\n\t\t\t\t\t\tdeploymentResp, err := deploymentPager.NextPage(context.Background())\n\t\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t\treturn nil, fmt.Errorf(\"failed to get deployments page: %w\", err)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tfor _, deployment := range deploymentResp.Value {\n\t\t\t\t\t\t\tmodelNames = append(modelNames, *deployment.Name)\n\t\t\t\t\t\t}\n\n\t\t\t\t\t}\n\t\t\t\t\tslices.Sort(modelNames)\n\t\t\t\t\treturn modelNames, nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\nfunc (c *AzureOpenAIClient) SetResponseSchema(schema *Schema) error {\n\treturn nil\n}\n\nfunc (c *AzureOpenAIClient) StartChat(systemPrompt string, model string) Chat {\n\treturn &AzureOpenAIChat{\n\t\tclient: c.client,\n\t\tmodel:  model,\n\t\thistory: []azopenai.ChatRequestMessageClassification{\n\t\t\t&azopenai.ChatRequestSystemMessage{Content: azopenai.NewChatRequestSystemMessageContent(systemPrompt)},\n\t\t},\n\t}\n}\n\ntype AzureOpenAICompletionResponse struct {\n\tresponse string\n}\n\nfunc (r *AzureOpenAICompletionResponse) Response() string {\n\treturn r.response\n}\n\nfunc (r *AzureOpenAICompletionResponse) UsageMetadata() any {\n\treturn nil\n}\n\ntype AzureOpenAIChat struct {\n\tclient  *azopenai.Client\n\tmodel   string\n\thistory []azopenai.ChatRequestMessageClassification\n\ttools   []azopenai.ChatCompletionsToolDefinitionClassification\n}\n\nfunc (c *AzureOpenAIChat) Send(ctx context.Context, contents ...any) (ChatResponse, error) {\n\tfor _, content := range contents {\n\t\tswitch v := content.(type) {\n\t\tcase string:\n\t\t\tmessage := azopenai.ChatRequestUserMessage{\n\t\t\t\tContent: azopenai.NewChatRequestUserMessageContent(v),\n\t\t\t}\n\t\t\tc.history = append(c.history, &message)\n\t\tcase FunctionCallResult:\n\t\t\tmessage := azopenai.ChatRequestUserMessage{\n\t\t\t\tContent: azopenai.NewChatRequestUserMessageContent(fmt.Sprintf(\"Function call result: %s\", v.Result)),\n\t\t\t}\n\t\t\tc.history = append(c.history, &message)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"unsupported content type: %T\", v)\n\t\t}\n\t}\n\n\tresp, err := c.client.GetChatCompletions(ctx, azopenai.ChatCompletionsOptions{\n\t\tDeploymentName: &c.model,\n\t\tMessages:       c.history,\n\t\tTools:          c.tools,\n\t}, nil)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(resp.Choices) == 0 {\n\t\treturn nil, fmt.Errorf(\"no response from Azure OpenAI: %v\", resp)\n\t}\n\n\treturn &AzureOpenAIChatResponse{azureOpenAIResponse: resp}, nil\n}\n\nfunc (c *AzureOpenAIChat) IsRetryableError(err error) bool {\n\t// TODO: Implement this\n\treturn false\n}\n\nfunc (c *AzureOpenAIChat) Initialize(messages []*api.Message) error {\n\tklog.Warning(\"chat history persistence is not supported for provider 'azopenai', using in-memory chat history\")\n\treturn nil\n}\n\nfunc (c *AzureOpenAIChat) SendStreaming(ctx context.Context, contents ...any) (ChatResponseIterator, error) {\n\t// TODO: Implement streaming\n\tresponse, err := c.Send(ctx, contents...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn singletonChatResponseIterator(response), nil\n}\n\ntype AzureOpenAIChatResponse struct {\n\tazureOpenAIResponse azopenai.GetChatCompletionsResponse\n}\n\nvar _ ChatResponse = &AzureOpenAIChatResponse{}\n\nfunc (r *AzureOpenAIChatResponse) MarshalJSON() ([]byte, error) {\n\tformatted := RecordChatResponse{\n\t\tRaw: r.azureOpenAIResponse,\n\t}\n\treturn json.Marshal(&formatted)\n}\n\nfunc (r *AzureOpenAIChatResponse) String() string {\n\treturn fmt.Sprintf(\"AzureOpenAIChatResponse{candidates=%v}\", r.azureOpenAIResponse.Choices)\n}\n\nfunc (r *AzureOpenAIChatResponse) UsageMetadata() any {\n\treturn r.azureOpenAIResponse.Usage\n}\n\nfunc (r *AzureOpenAIChatResponse) Candidates() []Candidate {\n\tvar candidates []Candidate\n\tfor _, candidate := range r.azureOpenAIResponse.Choices {\n\t\tcandidates = append(candidates, &AzureOpenAICandidate{candidate: candidate})\n\t}\n\treturn candidates\n}\n\ntype AzureOpenAICandidate struct {\n\tcandidate azopenai.ChatChoice\n}\n\nfunc (r *AzureOpenAICandidate) String() string {\n\tvar response strings.Builder\n\tresponse.WriteString(\"[\")\n\tfor i, parts := range r.Parts() {\n\t\tif i > 0 {\n\t\t\tresponse.WriteString(\", \")\n\t\t}\n\t\ttext, ok := parts.AsText()\n\t\tif ok {\n\t\t\tresponse.WriteString(text)\n\t\t}\n\t\tfunctionCalls, ok := parts.AsFunctionCalls()\n\t\tif ok {\n\t\t\tresponse.WriteString(\"functionCalls=[\")\n\t\t\tfor _, functionCall := range functionCalls {\n\t\t\t\tresponse.WriteString(fmt.Sprintf(\"%q(args=%v)\", functionCall.Name, functionCall.Arguments))\n\t\t\t}\n\t\t\tresponse.WriteString(\"]}\")\n\t\t}\n\t}\n\tresponse.WriteString(\"]}\")\n\treturn response.String()\n}\n\nfunc (r *AzureOpenAICandidate) Parts() []Part {\n\tvar parts []Part\n\n\tif r.candidate.Message != nil {\n\t\tparts = append(parts, &AzureOpenAIPart{\n\t\t\ttext: r.candidate.Message.Content,\n\t\t})\n\t}\n\n\tfor _, tool := range r.candidate.Message.ToolCalls {\n\t\tif tool == nil {\n\t\t\tcontinue\n\t\t}\n\t\tparts = append(parts, &AzureOpenAIPart{\n\t\t\tfunctionCall: tool.(*azopenai.ChatCompletionsFunctionToolCall).Function,\n\t\t})\n\t}\n\n\treturn parts\n}\n\ntype AzureOpenAIPart struct {\n\ttext         *string\n\tfunctionCall *azopenai.FunctionCall\n}\n\nfunc (p *AzureOpenAIPart) AsText() (string, bool) {\n\tif p.text != nil && len(*p.text) > 0 {\n\t\treturn *p.text, true\n\t}\n\treturn \"\", false\n}\n\nfunc (p *AzureOpenAIPart) AsFunctionCalls() ([]FunctionCall, bool) {\n\tif p.functionCall != nil {\n\t\targumentsObj := map[string]any{}\n\t\terr := json.Unmarshal([]byte(*p.functionCall.Arguments), &argumentsObj)\n\t\tif err != nil {\n\t\t\treturn nil, false\n\t\t}\n\t\tfunctionCalls := []FunctionCall{\n\t\t\t{\n\t\t\t\tName:      *p.functionCall.Name,\n\t\t\t\tArguments: argumentsObj,\n\t\t\t},\n\t\t}\n\t\treturn functionCalls, true\n\t}\n\treturn nil, false\n}\n\nfunc (c *AzureOpenAIChat) SetFunctionDefinitions(functionDefinitions []*FunctionDefinition) error {\n\tvar tools []azopenai.ChatCompletionsToolDefinitionClassification\n\tfor _, functionDefinition := range functionDefinitions {\n\t\ttools = append(tools, &azopenai.ChatCompletionsFunctionToolDefinition{Function: fnDefToAzureOpenAITool(functionDefinition)})\n\t}\n\tc.tools = tools\n\treturn nil\n}\n\nfunc fnDefToAzureOpenAITool(fnDef *FunctionDefinition) *azopenai.ChatCompletionsFunctionToolDefinitionFunction {\n\tproperties := make(map[string]any)\n\tfor paramName, param := range fnDef.Parameters.Properties {\n\t\tproperties[paramName] = map[string]any{\n\t\t\t\"type\":        string(param.Type),\n\t\t\t\"description\": param.Description,\n\t\t}\n\t}\n\tparameters := map[string]any{\n\t\t\"type\":       \"object\",\n\t\t\"properties\": properties,\n\t}\n\tif len(fnDef.Parameters.Required) > 0 {\n\t\tparameters[\"required\"] = fnDef.Parameters.Required\n\t}\n\tjsonBytes, _ := json.Marshal(parameters)\n\n\ttool := azopenai.ChatCompletionsFunctionToolDefinitionFunction{\n\t\tName:        &fnDef.Name,\n\t\tDescription: &fnDef.Description,\n\t\tParameters:  jsonBytes,\n\t}\n\n\treturn &tool\n}\n"
  },
  {
    "path": "gollm/bedrock.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\t\"github.com/aws/aws-sdk-go-v2/aws\"\n\t\"github.com/aws/aws-sdk-go-v2/config\"\n\t\"github.com/aws/aws-sdk-go-v2/service/bedrockruntime\"\n\t\"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/document\"\n\t\"github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types\"\n\t\"k8s.io/klog/v2\"\n)\n\n// Register the Bedrock provider factory on package initialization\nfunc init() {\n\tif err := RegisterProvider(\"bedrock\", newBedrockClientFactory); err != nil {\n\t\tklog.Fatalf(\"Failed to register bedrock provider: %v\", err)\n\t}\n}\n\n// newBedrockClientFactory creates a new Bedrock client with the given options\nfunc newBedrockClientFactory(ctx context.Context, opts ClientOptions) (Client, error) {\n\treturn NewBedrockClient(ctx, opts)\n}\n\n// BedrockClient implements the gollm.Client interface for AWS Bedrock models\ntype BedrockClient struct {\n\tclient *bedrockruntime.Client\n}\n\n// Ensure BedrockClient implements the Client interface\nvar _ Client = &BedrockClient{}\n\n// NewBedrockClient creates a new client for interacting with AWS Bedrock models\nfunc NewBedrockClient(ctx context.Context, opts ClientOptions) (*BedrockClient, error) {\n\t// Load AWS config with timeout protection\n\tconfigCtx, cancel := context.WithTimeout(ctx, 30*time.Second)\n\tdefer cancel()\n\n\tcfg, err := config.LoadDefaultConfig(configCtx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to load AWS config: %w\", err)\n\t}\n\n\t// Default to us-east-1 for Bedrock if no region is set\n\tif cfg.Region == \"\" {\n\t\tcfg.Region = \"us-east-1\"\n\t}\n\n\treturn &BedrockClient{\n\t\tclient: bedrockruntime.NewFromConfig(cfg),\n\t}, nil\n}\n\n// Close cleans up any resources used by the client\nfunc (c *BedrockClient) Close() error {\n\treturn nil\n}\n\n// StartChat starts a new chat session with the specified system prompt and model\nfunc (c *BedrockClient) StartChat(systemPrompt, model string) Chat {\n\tselectedModel := getBedrockModel(model)\n\n\t// Enhance system prompt for tool-use shim compatibility\n\t// Detect if tool-use shim is enabled by looking for JSON formatting instructions\n\tenhancedPrompt := systemPrompt\n\tif strings.Contains(systemPrompt, \"```json\") && strings.Contains(systemPrompt, \"\\\"action\\\"\") {\n\t\t// Tool-use shim is enabled - add stronger JSON formatting instructions for all Bedrock models\n\t\tenhancedPrompt += \"\\n\\nCRITICAL JSON FORMATTING REQUIREMENTS:\\n\"\n\t\tenhancedPrompt += \"1. You MUST ALWAYS wrap your JSON responses in ```json code blocks exactly as shown in the examples above.\\n\"\n\t\tenhancedPrompt += \"2. NEVER respond with raw JSON without the markdown ```json formatting.\\n\"\n\t\tenhancedPrompt += \"3. Ensure your JSON is syntactically correct with proper commas between fields.\\n\"\n\t\tenhancedPrompt += \"4. This is critical for proper parsing. Example format:\\n\"\n\t\tenhancedPrompt += \"```json\\n{\\\"thought\\\": \\\"your reasoning\\\", \\\"action\\\": {\\\"name\\\": \\\"tool_name\\\", \\\"command\\\": \\\"command\\\"}}\\n```\\n\"\n\t\tenhancedPrompt += \"Note the comma after the \\\"thought\\\" field! Malformed JSON will cause failures.\"\n\t}\n\n\treturn &bedrockChat{\n\t\tclient:       c,\n\t\tsystemPrompt: enhancedPrompt,\n\t\tmodel:        selectedModel,\n\t\tmessages:     []types.Message{},\n\t}\n}\n\n// GenerateCompletion generates a single completion for the given request\nfunc (c *BedrockClient) GenerateCompletion(ctx context.Context, req *CompletionRequest) (CompletionResponse, error) {\n\tchat := c.StartChat(\"\", req.Model)\n\tchatResponse, err := chat.Send(ctx, req.Prompt)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Wrap ChatResponse in a CompletionResponse\n\treturn &bedrockCompletionResponse{\n\t\tchatResponse: chatResponse,\n\t}, nil\n}\n\n// SetResponseSchema sets the response schema for the client (not supported by Bedrock)\nfunc (c *BedrockClient) SetResponseSchema(schema *Schema) error {\n\treturn fmt.Errorf(\"response schema not supported by Bedrock\")\n}\n\n// ListModels returns the list of supported Bedrock models\nfunc (c *BedrockClient) ListModels(ctx context.Context) ([]string, error) {\n\treturn []string{\n\t\t\"us.anthropic.claude-sonnet-4-20250514-v1:0\",   // Claude Sonnet 4 (default)\n\t\t\"us.anthropic.claude-3-7-sonnet-20250219-v1:0\", // Claude 3.7 Sonnet\n\t}, nil\n}\n\n// bedrockChat implements the Chat interface for Bedrock conversations\ntype bedrockChat struct {\n\tclient       *BedrockClient\n\tsystemPrompt string\n\tmodel        string\n\tmessages     []types.Message\n\ttoolConfig   *types.ToolConfiguration\n\tfunctionDefs []*FunctionDefinition\n}\n\nfunc (cs *bedrockChat) Initialize(history []*api.Message) error {\n\tcs.messages = make([]types.Message, 0, len(history))\n\n\tfor _, msg := range history {\n\t\t// Convert api.Message to types.Message\n\t\tvar role types.ConversationRole\n\t\tswitch msg.Source {\n\t\tcase api.MessageSourceUser:\n\t\t\trole = types.ConversationRoleUser\n\t\tcase api.MessageSourceModel, api.MessageSourceAgent:\n\t\t\trole = types.ConversationRoleAssistant\n\t\tdefault:\n\t\t\t// Skip unknown message sources\n\t\t\tcontinue\n\t\t}\n\n\t\t// Convert payload to string content\n\t\tvar content string\n\t\tif msg.Type == api.MessageTypeText && msg.Payload != nil {\n\t\t\tif textPayload, ok := msg.Payload.(string); ok {\n\t\t\t\tcontent = textPayload\n\t\t\t} else {\n\t\t\t\t// Try to convert other types to string\n\t\t\t\tcontent = fmt.Sprintf(\"%v\", msg.Payload)\n\t\t\t}\n\t\t} else {\n\t\t\t// Skip non-text messages for now\n\t\t\tcontinue\n\t\t}\n\n\t\tif content == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tbedrockMsg := types.Message{\n\t\t\tRole: role,\n\t\t\tContent: []types.ContentBlock{\n\t\t\t\t&types.ContentBlockMemberText{Value: content},\n\t\t\t},\n\t\t}\n\n\t\tcs.messages = append(cs.messages, bedrockMsg)\n\t}\n\n\treturn nil\n}\n\n// Send sends a message to the chat and returns the response\nfunc (c *bedrockChat) Send(ctx context.Context, contents ...any) (ChatResponse, error) {\n\tif len(contents) == 0 {\n\t\treturn nil, errors.New(\"no content provided\")\n\t}\n\n\t// Process and append contents to conversation history\n\tif err := c.addContentsToHistory(contents); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Prepare the request\n\tinput := &bedrockruntime.ConverseInput{\n\t\tModelId:  aws.String(c.model),\n\t\tMessages: c.messages,\n\t\tInferenceConfig: &types.InferenceConfiguration{\n\t\t\tMaxTokens: aws.Int32(4096),\n\t\t},\n\t}\n\n\t// Add system prompt if provided\n\tif c.systemPrompt != \"\" {\n\t\tinput.System = []types.SystemContentBlock{\n\t\t\t&types.SystemContentBlockMemberText{Value: c.systemPrompt},\n\t\t}\n\t}\n\n\t// Add tool configuration if functions are defined\n\tif c.toolConfig != nil {\n\t\tinput.ToolConfig = c.toolConfig\n\t}\n\n\t// Call the Bedrock Converse API\n\toutput, err := c.client.client.Converse(ctx, input)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"bedrock converse error: %w\", err)\n\t}\n\n\t// Extract response content and update conversation history\n\tresponse := &bedrockResponse{\n\t\toutput: output,\n\t\tmodel:  c.model,\n\t}\n\n\t// Update conversation history with assistant's response\n\tif output.Output != nil {\n\t\tif msg, ok := output.Output.(*types.ConverseOutputMemberMessage); ok {\n\t\t\tc.messages = append(c.messages, msg.Value)\n\t\t}\n\t}\n\n\treturn response, nil\n}\n\n// SendStreaming sends a message and returns a streaming response\nfunc (c *bedrockChat) SendStreaming(ctx context.Context, contents ...any) (ChatResponseIterator, error) {\n\tif len(contents) == 0 {\n\t\treturn nil, errors.New(\"no content provided\")\n\t}\n\n\t// Process and append contents to conversation history\n\tif err := c.addContentsToHistory(contents); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Prepare the streaming request\n\tinput := &bedrockruntime.ConverseStreamInput{\n\t\tModelId:  aws.String(c.model),\n\t\tMessages: c.messages,\n\t\tInferenceConfig: &types.InferenceConfiguration{\n\t\t\tMaxTokens: aws.Int32(4096),\n\t\t},\n\t}\n\n\t// Add system prompt if provided\n\tif c.systemPrompt != \"\" {\n\t\tinput.System = []types.SystemContentBlock{\n\t\t\t&types.SystemContentBlockMemberText{Value: c.systemPrompt},\n\t\t}\n\t}\n\n\t// Add tool configuration if functions are defined\n\tif c.toolConfig != nil {\n\t\tinput.ToolConfig = c.toolConfig\n\t}\n\n\t// Start the streaming request\n\toutput, err := c.client.client.ConverseStream(ctx, input)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"bedrock stream error: %w\", err)\n\t}\n\n\t// Return streaming iterator\n\treturn func(yield func(ChatResponse, error) bool) {\n\t\tdefer func() {\n\t\t\tif stream := output.GetStream(); stream != nil {\n\t\t\t\tstream.Close()\n\t\t\t}\n\t\t}()\n\n\t\tvar assistantMessage types.Message\n\t\tassistantMessage.Role = types.ConversationRoleAssistant\n\t\tvar fullContent strings.Builder\n\n\t\t// Tool state tracking for streaming\n\t\ttype partialTool struct {\n\t\t\tid    string\n\t\t\tname  string\n\t\t\tinput strings.Builder\n\t\t}\n\t\tpartialTools := make(map[int32]*partialTool)\n\t\tvar completedTools []types.ToolUseBlock\n\n\t\t// Process streaming events\n\t\tstream := output.GetStream()\n\t\tfor event := range stream.Events() {\n\t\t\tswitch v := event.(type) {\n\t\t\tcase *types.ConverseStreamOutputMemberContentBlockDelta:\n\t\t\t\t// Handle text deltas\n\t\t\t\tif textDelta, ok := v.Value.Delta.(*types.ContentBlockDeltaMemberText); ok {\n\t\t\t\t\tfullContent.WriteString(textDelta.Value)\n\n\t\t\t\t\tresponse := &bedrockStreamResponse{\n\t\t\t\t\t\tcontent: textDelta.Value,\n\t\t\t\t\t\tmodel:   c.model,\n\t\t\t\t\t\tdone:    false,\n\t\t\t\t\t}\n\n\t\t\t\t\tif !yield(response, nil) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Handle tool input deltas\n\t\t\t\tif toolDelta, ok := v.Value.Delta.(*types.ContentBlockDeltaMemberToolUse); ok {\n\t\t\t\t\tidx := aws.ToInt32(v.Value.ContentBlockIndex)\n\t\t\t\t\tif partial, exists := partialTools[idx]; exists {\n\t\t\t\t\t\tdeltaInput := aws.ToString(toolDelta.Value.Input)\n\t\t\t\t\t\tpartial.input.WriteString(deltaInput)\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\tcase *types.ConverseStreamOutputMemberContentBlockStart:\n\t\t\t\t// Handle content block start (for tool calls)\n\t\t\t\tif v.Value.Start != nil {\n\t\t\t\t\tif toolStart, ok := v.Value.Start.(*types.ContentBlockStartMemberToolUse); ok {\n\t\t\t\t\t\t// Store partial tool for input accumulation\n\t\t\t\t\t\tidx := aws.ToInt32(v.Value.ContentBlockIndex)\n\t\t\t\t\t\tpartialTools[idx] = &partialTool{\n\t\t\t\t\t\t\tid:   aws.ToString(toolStart.Value.ToolUseId),\n\t\t\t\t\t\t\tname: aws.ToString(toolStart.Value.Name),\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\tcase *types.ConverseStreamOutputMemberContentBlockStop:\n\t\t\t\t// Handle content block stop (tool completion)\n\t\t\t\tidx := aws.ToInt32(v.Value.ContentBlockIndex)\n\t\t\t\tif partial, exists := partialTools[idx]; exists {\n\t\t\t\t\t// Parse the JSON to extract arguments for function call\n\t\t\t\t\tinputJSON := partial.input.String()\n\n\t\t\t\t\tvar args map[string]any\n\t\t\t\t\tif inputJSON != \"\" {\n\t\t\t\t\t\tif err := json.Unmarshal([]byte(inputJSON), &args); err != nil {\n\t\t\t\t\t\t\targs = make(map[string]any)\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\targs = make(map[string]any)\n\t\t\t\t\t}\n\n\t\t\t\t\t// Create ToolUseBlock for conversation history\n\t\t\t\t\t// Use the accumulated JSON string to create proper Input document\n\t\t\t\t\ttoolUse := types.ToolUseBlock{\n\t\t\t\t\t\tToolUseId: aws.String(partial.id),\n\t\t\t\t\t\tName:      aws.String(partial.name),\n\t\t\t\t\t\tInput:     document.NewLazyDocument(args),\n\t\t\t\t\t}\n\t\t\t\t\tcompletedTools = append(completedTools, toolUse)\n\n\t\t\t\t\t// Yield tool immediately with parsed arguments\n\t\t\t\t\tresponse := &bedrockStreamResponse{\n\t\t\t\t\t\tcontent:       \"\",\n\t\t\t\t\t\tmodel:         c.model,\n\t\t\t\t\t\tdone:          false,\n\t\t\t\t\t\ttoolUses:      []types.ToolUseBlock{toolUse},\n\t\t\t\t\t\tstreamingArgs: map[int]map[string]any{0: args},\n\t\t\t\t\t}\n\t\t\t\t\tif !yield(response, nil) {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tdelete(partialTools, idx)\n\t\t\t\t}\n\n\t\t\tcase *types.ConverseStreamOutputMemberMetadata:\n\t\t\t\t// Handle final usage metadata\n\t\t\t\tif v.Value.Usage != nil {\n\t\t\t\t\tfinalResponse := &bedrockStreamResponse{\n\t\t\t\t\t\tcontent: \"\",\n\t\t\t\t\t\tusage:   v.Value.Usage,\n\t\t\t\t\t\tmodel:   c.model,\n\t\t\t\t\t\tdone:    true,\n\t\t\t\t\t}\n\t\t\t\t\tyield(finalResponse, nil)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Update conversation history with the full response\n\t\tif fullContent.Len() > 0 {\n\t\t\tassistantMessage.Content = append(assistantMessage.Content,\n\t\t\t\t&types.ContentBlockMemberText{Value: fullContent.String()})\n\t\t}\n\n\t\t// Include completed tools in conversation history\n\t\tfor _, tool := range completedTools {\n\t\t\tassistantMessage.Content = append(assistantMessage.Content,\n\t\t\t\t&types.ContentBlockMemberToolUse{Value: tool})\n\t\t}\n\n\t\t// Only add to history if there's content or tools\n\t\tif len(assistantMessage.Content) > 0 {\n\t\t\tc.messages = append(c.messages, assistantMessage)\n\t\t}\n\n\t\t// Check for stream errors\n\t\tif err := stream.Err(); err != nil {\n\t\t\tyield(nil, fmt.Errorf(\"stream error: %w\", err))\n\t\t}\n\t}, nil\n}\n\n// addContentsToHistory processes and appends user messages to chat history\n// following AWS Bedrock Converse API patterns\nfunc (c *bedrockChat) addContentsToHistory(contents []any) error {\n\tvar contentBlocks []types.ContentBlock\n\n\tfor _, content := range contents {\n\t\tswitch c := content.(type) {\n\t\tcase string:\n\t\t\t// Add text content block\n\t\t\tcontentBlocks = append(contentBlocks, &types.ContentBlockMemberText{Value: c})\n\t\tcase FunctionCallResult:\n\t\t\t// Determine status based on Result content\n\t\t\tstatus := types.ToolResultStatusSuccess\n\t\t\tif c.Result != nil {\n\t\t\t\t// Check for error field\n\t\t\t\tif errorVal, hasError := c.Result[\"error\"]; hasError {\n\t\t\t\t\tif errorBool, isBool := errorVal.(bool); isBool && errorBool {\n\t\t\t\t\t\tstatus = types.ToolResultStatusError\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// Check for status field\n\t\t\t\tif statusVal, hasStatus := c.Result[\"status\"]; hasStatus {\n\t\t\t\t\tif statusStr, isString := statusVal.(string); isString &&\n\t\t\t\t\t\t(statusStr == \"failed\" || statusStr == \"error\") {\n\t\t\t\t\t\tstatus = types.ToolResultStatusError\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Convert to AWS Bedrock ToolResultBlock format per official docs\n\t\t\ttoolResult := types.ToolResultBlock{\n\t\t\t\tToolUseId: aws.String(c.ID),\n\t\t\t\tContent: []types.ToolResultContentBlock{\n\t\t\t\t\t&types.ToolResultContentBlockMemberJson{\n\t\t\t\t\t\tValue: document.NewLazyDocument(c.Result),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tStatus: status,\n\t\t\t}\n\t\t\tcontentBlocks = append(contentBlocks, &types.ContentBlockMemberToolResult{Value: toolResult})\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"unhandled content type: %T\", content)\n\t\t}\n\t}\n\n\tif len(contentBlocks) > 0 {\n\t\t// Add user message with all content blocks to conversation history\n\t\tc.messages = append(c.messages, types.Message{\n\t\t\tRole:    types.ConversationRoleUser,\n\t\t\tContent: contentBlocks,\n\t\t})\n\t}\n\n\treturn nil\n}\n\n// SetFunctionDefinitions configures the available functions for tool use\nfunc (c *bedrockChat) SetFunctionDefinitions(functions []*FunctionDefinition) error {\n\tc.functionDefs = functions\n\n\tif len(functions) == 0 {\n\t\tc.toolConfig = nil\n\t\treturn nil\n\t}\n\n\tvar tools []types.Tool\n\tfor _, fn := range functions {\n\t\t// Convert gollm function definition to AWS tool specification\n\t\tinputSchema := make(map[string]interface{})\n\t\tif fn.Parameters != nil {\n\t\t\t// Convert Schema to map[string]interface{}\n\t\t\tjsonData, err := json.Marshal(fn.Parameters)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to marshal function parameters: %w\", err)\n\t\t\t}\n\t\t\tif err := json.Unmarshal(jsonData, &inputSchema); err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to unmarshal function parameters: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\ttoolSpec := types.ToolSpecification{\n\t\t\tName:        aws.String(fn.Name),\n\t\t\tDescription: aws.String(fn.Description),\n\t\t\tInputSchema: &types.ToolInputSchemaMemberJson{\n\t\t\t\tValue: document.NewLazyDocument(inputSchema),\n\t\t\t},\n\t\t}\n\n\t\ttools = append(tools, &types.ToolMemberToolSpec{Value: toolSpec})\n\t}\n\n\tc.toolConfig = &types.ToolConfiguration{\n\t\tTools: tools,\n\t\tToolChoice: &types.ToolChoiceMemberAny{\n\t\t\tValue: types.AnyToolChoice{},\n\t\t},\n\t}\n\n\treturn nil\n}\n\n// IsRetryableError determines if an error is retryable\nfunc (c *bedrockChat) IsRetryableError(err error) bool {\n\treturn DefaultIsRetryableError(err)\n}\n\n// bedrockResponse implements ChatResponse for regular (non-streaming) responses\ntype bedrockResponse struct {\n\toutput *bedrockruntime.ConverseOutput\n\tmodel  string\n}\n\n// UsageMetadata returns the usage metadata from the response\nfunc (r *bedrockResponse) UsageMetadata() any {\n\tif r.output != nil && r.output.Usage != nil {\n\t\treturn r.output.Usage\n\t}\n\treturn nil\n}\n\n// Candidates returns the candidate responses\nfunc (r *bedrockResponse) Candidates() []Candidate {\n\tif r.output == nil || r.output.Output == nil {\n\t\treturn []Candidate{}\n\t}\n\n\tif msg, ok := r.output.Output.(*types.ConverseOutputMemberMessage); ok {\n\t\tcandidate := &bedrockCandidate{\n\t\t\tmessage: &msg.Value,\n\t\t\tmodel:   r.model,\n\t\t}\n\t\treturn []Candidate{candidate}\n\t}\n\n\treturn []Candidate{}\n}\n\n// bedrockStreamResponse implements ChatResponse for streaming responses\ntype bedrockStreamResponse struct {\n\tcontent       string\n\tusage         *types.TokenUsage\n\tmodel         string\n\tdone          bool\n\ttoolUses      []types.ToolUseBlock\n\tstreamingArgs map[int]map[string]any\n}\n\n// UsageMetadata returns the usage metadata from the streaming response\nfunc (r *bedrockStreamResponse) UsageMetadata() any {\n\treturn r.usage\n}\n\n// Candidates returns the candidate responses for streaming\nfunc (r *bedrockStreamResponse) Candidates() []Candidate {\n\tif r.content == \"\" && r.usage == nil && len(r.toolUses) == 0 {\n\t\treturn []Candidate{}\n\t}\n\n\tcandidate := &bedrockStreamCandidate{\n\t\tcontent:       r.content,\n\t\tmodel:         r.model,\n\t\ttoolUses:      r.toolUses,\n\t\tstreamingArgs: r.streamingArgs,\n\t}\n\treturn []Candidate{candidate}\n}\n\n// bedrockCandidate implements Candidate for regular responses\ntype bedrockCandidate struct {\n\tmessage *types.Message\n\tmodel   string\n}\n\n// String returns a string representation of the candidate\nfunc (c *bedrockCandidate) String() string {\n\tif c.message == nil {\n\t\treturn \"\"\n\t}\n\n\tvar content strings.Builder\n\tfor _, block := range c.message.Content {\n\t\tif textBlock, ok := block.(*types.ContentBlockMemberText); ok {\n\t\t\tcontent.WriteString(textBlock.Value)\n\t\t}\n\t}\n\treturn content.String()\n}\n\n// Parts returns the parts of the candidate\nfunc (c *bedrockCandidate) Parts() []Part {\n\tif c.message == nil {\n\t\treturn []Part{}\n\t}\n\n\tvar parts []Part\n\tfor _, block := range c.message.Content {\n\t\tswitch v := block.(type) {\n\t\tcase *types.ContentBlockMemberText:\n\t\t\tparts = append(parts, &bedrockTextPart{text: v.Value})\n\t\tcase *types.ContentBlockMemberToolUse:\n\t\t\tparts = append(parts, &bedrockToolPart{toolUse: &v.Value})\n\t\t}\n\t}\n\treturn parts\n}\n\n// bedrockStreamCandidate implements Candidate for streaming responses\ntype bedrockStreamCandidate struct {\n\tcontent       string\n\tmodel         string\n\ttoolUses      []types.ToolUseBlock\n\tstreamingArgs map[int]map[string]any\n}\n\n// String returns a string representation of the streaming candidate\nfunc (c *bedrockStreamCandidate) String() string {\n\treturn c.content\n}\n\n// Parts returns the parts of the streaming candidate\nfunc (c *bedrockStreamCandidate) Parts() []Part {\n\tvar parts []Part\n\n\t// Handle text content\n\tif c.content != \"\" {\n\t\tparts = append(parts, &bedrockTextPart{text: c.content})\n\t}\n\n\t// Handle tool calls with streaming args\n\tfor i, toolUse := range c.toolUses {\n\t\tvar args map[string]any\n\t\tif c.streamingArgs != nil {\n\t\t\targs = c.streamingArgs[i]\n\t\t}\n\t\tparts = append(parts, &bedrockToolPart{\n\t\t\ttoolUse: &toolUse,\n\t\t\targs:    args,\n\t\t})\n\t}\n\n\treturn parts\n}\n\n// bedrockTextPart implements Part for text content\ntype bedrockTextPart struct {\n\ttext string\n}\n\n// AsText returns the text content\nfunc (p *bedrockTextPart) AsText() (string, bool) {\n\treturn p.text, true\n}\n\n// AsFunctionCalls returns nil since this is a text part\nfunc (p *bedrockTextPart) AsFunctionCalls() ([]FunctionCall, bool) {\n\treturn nil, false\n}\n\n// bedrockToolPart implements Part for tool/function calls\ntype bedrockToolPart struct {\n\ttoolUse *types.ToolUseBlock\n\targs    map[string]any // For streaming case when Input can't be unmarshaled\n}\n\n// AsText returns empty string since this is a tool part\nfunc (p *bedrockToolPart) AsText() (string, bool) {\n\treturn \"\", false\n}\n\n// AsFunctionCalls returns the function calls\nfunc (p *bedrockToolPart) AsFunctionCalls() ([]FunctionCall, bool) {\n\tif p.toolUse == nil {\n\t\treturn nil, false\n\t}\n\n\t// Get arguments - prefer pre-parsed args (streaming), fall back to unmarshaling\n\tvar args map[string]any\n\tif p.args != nil {\n\t\t// Streaming case - use pre-parsed arguments\n\t\targs = p.args\n\t} else if p.toolUse.Input != nil {\n\t\t// Non-streaming case - unmarshal from Input\n\t\tif err := p.toolUse.Input.UnmarshalSmithyDocument(&args); err != nil {\n\t\t\tklog.V(2).Infof(\"Failed to unmarshal tool input: %v\", err)\n\t\t\targs = make(map[string]any)\n\t\t}\n\t} else {\n\t\targs = make(map[string]any)\n\t}\n\n\tfuncCall := FunctionCall{\n\t\tID:        aws.ToString(p.toolUse.ToolUseId),\n\t\tName:      aws.ToString(p.toolUse.Name),\n\t\tArguments: args,\n\t}\n\n\treturn []FunctionCall{funcCall}, true\n}\n\n// Helper functions\n\n// getBedrockModel returns the model to use, checking in order:\n// 1. Explicitly provided model\n// 2. Environment variable BEDROCK_MODEL\n// 3. Default model (Claude Sonnet 4)\nfunc getBedrockModel(model string) string {\n\tif model != \"\" {\n\t\tklog.V(2).Infof(\"Using explicitly provided model: %s\", model)\n\t\treturn model\n\t}\n\n\tif envModel := os.Getenv(\"BEDROCK_MODEL\"); envModel != \"\" {\n\t\tklog.V(1).Infof(\"Using model from environment variable: %s\", envModel)\n\t\treturn envModel\n\t}\n\n\tdefaultModel := \"us.anthropic.claude-sonnet-4-20250514-v1:0\"\n\tklog.V(1).Infof(\"Using default model: %s\", defaultModel)\n\treturn defaultModel\n}\n\n// bedrockCompletionResponse wraps a ChatResponse to implement CompletionResponse\ntype bedrockCompletionResponse struct {\n\tchatResponse ChatResponse\n}\n\nvar _ CompletionResponse = (*bedrockCompletionResponse)(nil)\n\nfunc (r *bedrockCompletionResponse) Response() string {\n\tif r.chatResponse == nil {\n\t\treturn \"\"\n\t}\n\tcandidates := r.chatResponse.Candidates()\n\tif len(candidates) == 0 {\n\t\treturn \"\"\n\t}\n\tparts := candidates[0].Parts()\n\tfor _, part := range parts {\n\t\tif text, ok := part.AsText(); ok {\n\t\t\treturn text\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc (r *bedrockCompletionResponse) UsageMetadata() any {\n\tif r.chatResponse == nil {\n\t\treturn nil\n\t}\n\treturn r.chatResponse.UsageMetadata()\n}\n"
  },
  {
    "path": "gollm/factory.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"errors\"\n\t\"fmt\"\n\t\"maps\"\n\t\"math/rand/v2\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"slices\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\n\t\"k8s.io/klog/v2\"\n)\n\nvar globalRegistry registry\n\ntype registry struct {\n\tmutex     sync.Mutex\n\tproviders map[string]FactoryFunc\n}\n\nfunc (r *registry) listProviders() []string {\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\tproviders := make([]string, 0, len(r.providers))\n\tfor k := range r.providers {\n\t\tproviders = append(providers, k)\n\t}\n\treturn providers\n}\n\ntype ClientOptions struct {\n\tURL           *url.URL\n\tSkipVerifySSL bool\n\t// Extend with more options as needed\n}\n\n// Option is a functional option for configuring ClientOptions.\ntype Option func(*ClientOptions)\n\n// WithSkipVerifySSL enables skipping SSL certificate verification for HTTP clients.\nfunc WithSkipVerifySSL() Option {\n\treturn func(o *ClientOptions) {\n\t\to.SkipVerifySSL = true\n\t}\n}\n\ntype FactoryFunc func(ctx context.Context, opts ClientOptions) (Client, error)\n\nfunc RegisterProvider(id string, factoryFunc FactoryFunc) error {\n\treturn globalRegistry.RegisterProvider(id, factoryFunc)\n}\n\nfunc (r *registry) RegisterProvider(id string, factoryFunc FactoryFunc) error {\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\n\tif r.providers == nil {\n\t\tr.providers = make(map[string]FactoryFunc)\n\t}\n\t_, exists := r.providers[id]\n\tif exists {\n\t\treturn fmt.Errorf(\"provider %q is already registered\", id)\n\t}\n\tr.providers[id] = factoryFunc\n\treturn nil\n}\n\nfunc (r *registry) NewClient(ctx context.Context, providerID string, opts ...Option) (Client, error) {\n\t// providerID can be just an ID, for example \"gemini\" instead of \"gemini://\"\n\tif !strings.Contains(providerID, \"/\") && !strings.Contains(providerID, \":\") {\n\t\tproviderID = providerID + \"://\"\n\t}\n\n\tr.mutex.Lock()\n\tdefer r.mutex.Unlock()\n\n\tu, err := url.Parse(providerID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing provider id %q: %w\", providerID, err)\n\t}\n\n\tfactoryFunc := r.providers[u.Scheme]\n\tif factoryFunc == nil {\n\t\tkeys := strings.Join(slices.Collect(maps.Keys(r.providers)), \", \")\n\t\treturn nil, fmt.Errorf(\"provider %q not registered. Available providers: %v\", u.Scheme, keys)\n\t}\n\n\t// Build ClientOptions\n\tclientOpts := ClientOptions{\n\t\tURL: u,\n\t}\n\t// Support environment variable override for SkipVerifySSL\n\tif v := os.Getenv(\"LLM_SKIP_VERIFY_SSL\"); v == \"1\" || strings.ToLower(v) == \"true\" {\n\t\tclientOpts.SkipVerifySSL = true\n\t}\n\tfor _, opt := range opts {\n\t\topt(&clientOpts)\n\t}\n\n\treturn factoryFunc(ctx, clientOpts)\n}\n\n/*\nNewClient builds a Client based on the LLM_CLIENT environment variable or the provided providerID.\nIf providerID is not empty, it overrides the value from LLM_CLIENT.\nSupports Option parameters and the LLM_SKIP_VERIFY_SSL environment variable.\n*/\nfunc NewClient(ctx context.Context, providerID string, opts ...Option) (Client, error) {\n\tif providerID == \"\" {\n\t\ts := os.Getenv(\"LLM_CLIENT\")\n\t\tif s == \"\" {\n\t\t\treturn nil, fmt.Errorf(\"LLM_CLIENT is not set. Available providers: %v\", globalRegistry.listProviders())\n\t\t}\n\t\tproviderID = s\n\t}\n\n\treturn globalRegistry.NewClient(ctx, providerID, opts...)\n}\n\n// APIError represents an error returned by the LLM client.\ntype APIError struct {\n\tStatusCode int\n\tMessage    string\n\tErr        error\n}\n\nfunc (e *APIError) Error() string {\n\tif e.Err != nil {\n\t\treturn fmt.Sprintf(\"API Error: Status=%d, Message='%s', OriginalErr=%v\", e.StatusCode, e.Message, e.Err)\n\t}\n\treturn fmt.Sprintf(\"API Error: Status=%d, Message='%s'\", e.StatusCode, e.Message)\n}\n\nfunc (e *APIError) Unwrap() error {\n\treturn e.Err\n}\n\n// IsRetryableFunc defines the signature for functions that check if an error is retryable.\n// TODO (droot): Adjust the signature to allow underlying client to relay the backoff\n// delay etc. for example, Gemini's error codes contain retryDelay information.\ntype IsRetryableFunc func(error) bool\n\n// DefaultIsRetryableError provides a default implementation based on common HTTP codes and network errors.\nfunc DefaultIsRetryableError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\tvar apiErr *APIError\n\tif errors.As(err, &apiErr) {\n\t\tswitch apiErr.StatusCode {\n\t\tcase http.StatusConflict, http.StatusTooManyRequests,\n\t\t\thttp.StatusInternalServerError, http.StatusBadGateway,\n\t\t\thttp.StatusServiceUnavailable, http.StatusGatewayTimeout:\n\t\t\treturn true\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t}\n\n\tvar netErr net.Error\n\tif errors.As(err, &netErr) && netErr.Timeout() {\n\t\treturn true\n\t}\n\n\t// Add other error checks specific to LLM clients if needed\n\t// e.g., if errors.Is(err, specificLLMRateLimitError) { return true }\n\n\treturn false\n}\n\n// createCustomHTTPClient returns an *http.Client that optionally skips SSL certificate verification.\n// This is shared by all providers that need custom HTTP transport.\nfunc createCustomHTTPClient(skipVerify bool) *http.Client {\n\ttransport := http.DefaultTransport.(*http.Transport).Clone()\n\ttransport.Proxy = http.ProxyFromEnvironment\n\tif skipVerify {\n\t\ttransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}\n\n\t}\n\n\treturn &http.Client{\n\t\tTransport: transport,\n\t\tTimeout:   180 * time.Second,\n\t}\n}\n\n// RetryConfig holds the configuration for the retry mechanism (same as before)\ntype RetryConfig struct {\n\tMaxAttempts    int\n\tInitialBackoff time.Duration\n\tMaxBackoff     time.Duration\n\tBackoffFactor  float64\n\tJitter         bool\n}\n\n// DefaultRetryConfig provides sensible defaults (same as before)\nvar DefaultRetryConfig = RetryConfig{\n\tMaxAttempts:    5,\n\tInitialBackoff: 200 * time.Millisecond, // Slightly increased default\n\tMaxBackoff:     10 * time.Second,\n\tBackoffFactor:  2.0,\n\tJitter:         true,\n}\n\n// Retry executes the provided operation with retries, returning the result and error.\n// It's now generic to handle any return type T.\nfunc Retry[T any](\n\tctx context.Context,\n\tconfig RetryConfig,\n\tisRetryable IsRetryableFunc,\n\toperation func(ctx context.Context) (T, error),\n) (T, error) {\n\tvar lastErr error\n\tvar zero T // Zero value of the return type T\n\n\tlog := klog.FromContext(ctx)\n\n\tbackoff := config.InitialBackoff\n\n\tfor attempt := 1; attempt <= config.MaxAttempts; attempt++ {\n\t\tlog.V(2).Info(\"Retry attempt started\", \"attempt\", attempt, \"maxAttempts\", config.MaxAttempts, \"backoff\", backoff)\n\t\tresult, err := operation(ctx)\n\n\t\tif err == nil {\n\t\t\tlog.V(2).Info(\"Retry attempt succeeded\", \"attempt\", attempt)\n\t\t\treturn result, nil\n\t\t}\n\t\tlastErr = err // Store the last error encountered\n\n\t\t// Check if context was cancelled *after* the operation\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tlog.Info(\"Context cancelled after attempt %d failed.\", \"attempt\", attempt)\n\t\t\treturn zero, ctx.Err() // Return context error preferentially\n\t\tdefault:\n\t\t\t// Context not cancelled, proceed with error checking\n\t\t}\n\n\t\tif !isRetryable(lastErr) {\n\t\t\tlog.Info(\"Attempt failed with non-retryable error\", \"attempt\", attempt, \"error\", lastErr)\n\t\t\treturn zero, lastErr // Return the non-retryable error immediately\n\t\t}\n\n\t\tlog.Info(\"Attempt failed with retryable error\", \"attempt\", attempt, \"error\", lastErr)\n\n\t\tif attempt == config.MaxAttempts {\n\t\t\t// Max attempts reached\n\t\t\tbreak\n\t\t}\n\n\t\t// Calculate wait time\n\t\twaitTime := backoff\n\t\tif config.Jitter {\n\t\t\twaitTime += time.Duration(rand.Float64() * float64(backoff) / 2)\n\t\t}\n\n\t\tlog.V(2).Info(\"Waiting before next retry attempt\", \"waitTime\", waitTime, \"nextAttempt\", attempt+1, \"maxAttempts\", config.MaxAttempts)\n\n\t\t// Wait or react to context cancellation\n\t\tselect {\n\t\tcase <-time.After(waitTime):\n\t\t\t// Wait finished\n\t\tcase <-ctx.Done():\n\t\t\tlog.Info(\"Context cancelled while waiting for retry after attempt %d.\", \"attempt\", attempt)\n\t\t\treturn zero, ctx.Err()\n\t\t}\n\n\t\t// Increase backoff\n\t\tbackoff = time.Duration(float64(backoff) * config.BackoffFactor)\n\t\tif backoff > config.MaxBackoff {\n\t\t\tbackoff = config.MaxBackoff\n\t\t}\n\t}\n\n\t// If the loop finished, it means all attempts failed\n\terrFinal := fmt.Errorf(\"operation failed after %d attempts: %w\", config.MaxAttempts, lastErr)\n\treturn zero, errFinal\n}\n\n// retryChat is a generic decorator that adds retry logic to any Chat implementation.\ntype retryChat[C Chat] struct {\n\tunderlying  Chat // The actual client implementation being wrapped\n\tconfig      RetryConfig\n\tisRetryable IsRetryableFunc\n}\n\n// NewRetryChat creates a new Chat that wraps the given underlying client\n// with retry logic using the provided configuration.\n// It returns the Chat interface type, hiding the generic implementation detail.\nfunc NewRetryChat[C Chat](\n\tunderlying C,\n\tconfig RetryConfig,\n) Chat {\n\treturn &retryChat[C]{\n\t\tunderlying: underlying,\n\t\tconfig:     config,\n\t}\n}\n\n// Embed implements the Client interface for the retryClient decorator.\nfunc (rc *retryChat[C]) Send(ctx context.Context, contents ...any) (ChatResponse, error) {\n\t// Define the operation\n\toperation := func(ctx context.Context) (ChatResponse, error) {\n\t\treturn rc.underlying.Send(ctx, contents...)\n\t}\n\n\t// Execute with retry\n\treturn Retry[ChatResponse](ctx, rc.config, rc.underlying.IsRetryableError, operation)\n}\n\n// Embed implements the Client interface for the retryClient decorator.\nfunc (rc *retryChat[C]) SendStreaming(ctx context.Context, contents ...any) (ChatResponseIterator, error) {\n\treturn rc.underlying.SendStreaming(ctx, contents...)\n}\n\nfunc (rc *retryChat[C]) SetFunctionDefinitions(functionDefinitions []*FunctionDefinition) error {\n\treturn rc.underlying.SetFunctionDefinitions(functionDefinitions)\n}\n\nfunc (rc *retryChat[C]) IsRetryableError(err error) bool {\n\treturn rc.underlying.IsRetryableError(err)\n}\n\nfunc (rc *retryChat[C]) Initialize(messages []*api.Message) error {\n\treturn rc.underlying.Initialize(messages)\n}\n"
  },
  {
    "path": "gollm/factory_test.go",
    "content": "// Copyright 2026 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestNewClient(t *testing.T) {\n\t_, err := NewClient(context.Background(), \"gemini\")\n\tif err == nil || err.Error() != \"GEMINI_API_KEY environment variable not set\" {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n\n\t_, err = NewClient(context.Background(), \"invalid\")\n\tif err == nil || !strings.Contains(err.Error(), \"provider \\\"invalid\\\" not registered\") {\n\t\tt.Fatalf(\"Unexpected error: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "gollm/gemini.go",
    "content": "// Copyright 2024 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"iter\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n\n\t\"google.golang.org/genai\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\n\t\"k8s.io/klog/v2\"\n)\n\nfunc init() {\n\tif err := RegisterProvider(\"gemini\", geminiFactory); err != nil {\n\t\tklog.Fatalf(\"Failed to register gemini provider: %v\", err)\n\t}\n\tif err := RegisterProvider(\"vertexai\", vertexaiViaGeminiFactory); err != nil {\n\t\tklog.Fatalf(\"Failed to register vertexai provider: %v\", err)\n\t}\n}\n\n// geminiFactory is the provider factory function for Gemini.\n// Supports ClientOptions for consistency, but skipVerifySSL is not used.\nfunc geminiFactory(ctx context.Context, opts ClientOptions) (Client, error) {\n\topt := GeminiAPIClientOptions{}\n\treturn NewGeminiAPIClient(ctx, opt)\n}\n\n// GeminiAPIClientOptions are the options for the Gemini API client.\ntype GeminiAPIClientOptions struct {\n\t// API Key for GenAI. Required for BackendGeminiAPI.\n\tAPIKey string\n}\n\n// NewGeminiAPIClient builds a client for the Gemini API.\nfunc NewGeminiAPIClient(ctx context.Context, opt GeminiAPIClientOptions) (*GoogleAIClient, error) {\n\tapiKey := opt.APIKey\n\tif apiKey == \"\" {\n\t\tapiKey = os.Getenv(\"GEMINI_API_KEY\")\n\t}\n\tif apiKey == \"\" {\n\t\treturn nil, fmt.Errorf(\"GEMINI_API_KEY environment variable not set\")\n\t}\n\tskipVerifySSL := false\n\thttpClient := createCustomHTTPClient(skipVerifySSL)\n\thttpClient = withJournaling(httpClient)\n\tcc := &genai.ClientConfig{\n\t\tAPIKey:     apiKey,\n\t\tBackend:    genai.BackendGeminiAPI,\n\t\tHTTPClient: httpClient,\n\t}\n\n\tclient, err := genai.NewClient(ctx, cc)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"building gemini client: %w\", err)\n\t}\n\n\treturn &GoogleAIClient{\n\t\tclient: client,\n\t}, nil\n}\n\n// VertexAIClientOptions are the options for using the VertexAPI.\ntype VertexAIClientOptions struct {\n\t// GCP Project ID for Vertex AI. Required for BackendVertexAI.\n\tProject string\n\t// GCP Location/Region for Vertex AI. Required for BackendVertexAI. See https://cloud.google.com/vertex-ai/docs/general/locations\n\tLocation string\n}\n\n// vertexaiViaGeminiFactory is the provider factory function for VertexAI via Gemini.\n// Supports ClientOptions for consistency, but skipVerifySSL is not used.\nfunc vertexaiViaGeminiFactory(ctx context.Context, opts ClientOptions) (Client, error) {\n\topt := VertexAIClientOptions{}\n\treturn NewVertexAIClient(ctx, opt)\n}\n\n// findDefaultGCPProject gets the default GCP project ID from gcloud\nfunc findDefaultGCPProject(ctx context.Context) (string, error) {\n\tlog := klog.FromContext(ctx)\n\n\t// First check env vars\n\t// GOOGLE_CLOUD_PROJECT is the default for the genai library and a GCP convention\n\tprojectID := \"\"\n\tfor _, env := range []string{\"GOOGLE_CLOUD_PROJECT\"} {\n\t\tif v := os.Getenv(env); v != \"\" {\n\t\t\tprojectID = v\n\t\t\tlog.Info(\"got project for vertex client from env var\", \"project\", projectID, \"env\", env)\n\t\t\treturn projectID, nil\n\t\t}\n\t}\n\n\t// Now check default project in gcloud\n\t{\n\t\tcmd := exec.CommandContext(ctx, \"gcloud\", \"config\", \"get\", \"project\")\n\t\tvar stdout bytes.Buffer\n\t\tcmd.Stdout = &stdout\n\t\tif err := cmd.Run(); err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"cannot get project (using gcloud config get project): %w\", err)\n\t\t}\n\t\tprojectID = strings.TrimSpace(stdout.String())\n\t\tif projectID != \"\" {\n\t\t\tlog.Info(\"got project from gcloud config\", \"project\", projectID)\n\t\t\treturn projectID, nil\n\t\t}\n\t}\n\n\treturn \"\", fmt.Errorf(\"project was not set in gcloud config (or GOOGLE_CLOUD_PROJECT env var)\")\n}\n\n// NewVertexAIClient builds a client for the vertexai API.\nfunc NewVertexAIClient(ctx context.Context, opt VertexAIClientOptions) (*GoogleAIClient, error) {\n\tlog := klog.FromContext(ctx)\n\n\tcc := &genai.ClientConfig{\n\t\t// Project ID is loaded from the GOOGLE_CLOUD_PROJECT environment variable\n\t\t// Location/Region is loaded from either GOOGLE_CLOUD_LOCATION or GOOGLE_CLOUD_REGION environment variable\n\t\tBackend:  genai.BackendVertexAI,\n\t\tProject:  opt.Project,\n\t\tLocation: opt.Location,\n\t}\n\n\t// ProjectID is required\n\tif cc.Project == \"\" {\n\t\tprojectID, err := findDefaultGCPProject(ctx)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"finding default GCP project ID: %w\", err)\n\t\t}\n\t\tcc.Project = projectID\n\t}\n\n\t// Location is also required\n\tif cc.Location == \"\" {\n\t\tlocation := \"\"\n\n\t\t// Check well-known env vars\n\t\tfor _, env := range []string{\"GOOGLE_CLOUD_LOCATION\", \"GOOGLE_CLOUD_REGION\"} {\n\t\t\tif v := os.Getenv(env); v != \"\" {\n\t\t\t\tlocation = v\n\t\t\t\tlog.Info(\"got location for vertex client from env var\", \"location\", location, \"env\", env)\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// Fallback to us-central1\n\t\tif location == \"\" {\n\t\t\tlocation = \"us-central1\"\n\t\t\tlog.Info(\"defaulted location for vertex client\", \"location\", opt.Location)\n\t\t}\n\n\t\tcc.Location = location\n\t}\n\n\tclient, err := genai.NewClient(ctx, cc)\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"building vertexai client: %w\", err)\n\t}\n\n\treturn &GoogleAIClient{\n\t\tclient: client,\n\t}, nil\n}\n\n// GoogleAIClient is a client for the google AI APIs.\n// It implements the Client interface.\ntype GoogleAIClient struct {\n\tclient *genai.Client\n\n\t// responseSchema will constrain the output to match the given schema\n\tresponseSchema *genai.Schema\n}\n\nvar _ Client = &GoogleAIClient{}\n\n// ListModels lists the models available in the Gemini API.\nfunc (c *GoogleAIClient) ListModels(ctx context.Context) (modelNames []string, err error) {\n\tfor model, err := range c.client.Models.All(ctx) {\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error listing models: %w\", err)\n\t\t}\n\t\tmodelNames = append(modelNames, strings.TrimPrefix(model.Name, \"models/\"))\n\t}\n\treturn modelNames, nil\n}\n\n// Close frees the resources used by the client.\nfunc (c *GoogleAIClient) Close() error {\n\treturn nil\n}\n\n// SetResponseSchema constrains LLM responses to match the provided schema.\n// Calling with nil will clear the current schema.\nfunc (c *GoogleAIClient) SetResponseSchema(responseSchema *Schema) error {\n\tif responseSchema == nil {\n\t\tc.responseSchema = nil\n\t\treturn nil\n\t}\n\n\tgeminiSchema, err := toGeminiSchema(responseSchema)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tc.responseSchema = geminiSchema\n\treturn nil\n}\n\nfunc (c *GoogleAIClient) GenerateCompletion(ctx context.Context, request *CompletionRequest) (CompletionResponse, error) {\n\tlog := klog.FromContext(ctx)\n\n\tvar config *genai.GenerateContentConfig\n\n\tif c.responseSchema != nil {\n\t\tconfig = &genai.GenerateContentConfig{\n\t\t\tResponseSchema:   c.responseSchema,\n\t\t\tResponseMIMEType: \"application/json\",\n\t\t}\n\t}\n\n\tcontent := []*genai.Content{\n\t\t{Role: \"user\", Parts: []*genai.Part{{Text: request.Prompt}}},\n\t}\n\n\tlog.Info(\"sending GenerateContent request to gemini\", \"content\", content)\n\tresult, err := c.client.Models.GenerateContent(ctx, request.Model, content, config)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &GeminiCompletionResponse{geminiResponse: result, text: result.Text()}, nil\n}\n\n// StartChat starts a new chat with the model.\nfunc (c *GoogleAIClient) StartChat(systemPrompt string, model string) Chat {\n\t// Some values that are recommended by aistudio\n\ttemperature := float32(1.0)\n\ttopK := float32(40)\n\ttopP := float32(0.95)\n\tmaxOutputTokens := int32(8192)\n\n\tchat := &GeminiChat{\n\t\tmodel:  model,\n\t\tclient: c.client,\n\t\tgenConfig: &genai.GenerateContentConfig{\n\t\t\tSystemInstruction: &genai.Content{\n\t\t\t\tParts: []*genai.Part{\n\t\t\t\t\t{Text: systemPrompt},\n\t\t\t\t},\n\t\t\t},\n\t\t\tTemperature:      &temperature,\n\t\t\tTopK:             &topK,\n\t\t\tTopP:             &topP,\n\t\t\tMaxOutputTokens:  maxOutputTokens,\n\t\t\tResponseMIMEType: \"text/plain\",\n\t\t},\n\t\thistory: []*genai.Content{},\n\t}\n\n\tif chat.model == \"gemma-3-27b-it\" {\n\t\t// Note: gemma-3-27b-it does not allow system prompt\n\t\t// xref: https://discuss.ai.google.dev/t/gemma-3-missing-features-despite-announcement/71692\n\t\t// TODO: remove this hack once gemma-3-27b-it supports system prompt\n\t\tchat.genConfig.SystemInstruction = nil\n\t\tchat.history = []*genai.Content{\n\t\t\t{Role: \"user\", Parts: []*genai.Part{{Text: systemPrompt}}},\n\t\t}\n\t}\n\n\tif c.responseSchema != nil {\n\t\tchat.genConfig.ResponseSchema = c.responseSchema\n\t\tchat.genConfig.ResponseMIMEType = \"application/json\"\n\t}\n\treturn chat\n}\n\n// GeminiChat is a chat with the model.\n// It implements the Chat interface.\ntype GeminiChat struct {\n\tmodel     string\n\tclient    *genai.Client\n\thistory   []*genai.Content\n\tgenConfig *genai.GenerateContentConfig\n}\n\n// SetFunctionDefinitions sets the function definitions for the chat.\n// This allows the LLM to call user-defined functions.\nfunc (c *GeminiChat) SetFunctionDefinitions(functionDefinitions []*FunctionDefinition) error {\n\tvar genaiFunctionDeclarations []*genai.FunctionDeclaration\n\tfor _, functionDefinition := range functionDefinitions {\n\t\tif functionDefinition.Parameters == nil {\n\t\t\treturn fmt.Errorf(\"function %q has no parameters\", functionDefinition.Name)\n\t\t}\n\t\tparameters, err := toGeminiSchema(functionDefinition.Parameters)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tgenaiFunctionDeclarations = append(genaiFunctionDeclarations, &genai.FunctionDeclaration{\n\t\t\tName:        functionDefinition.Name,\n\t\t\tDescription: functionDefinition.Description,\n\t\t\tParameters:  parameters,\n\t\t})\n\t}\n\tc.genConfig.Tools = []*genai.Tool{\n\t\t{\n\t\t\tFunctionDeclarations: genaiFunctionDeclarations,\n\t\t},\n\t}\n\treturn nil\n}\n\n// toGeminiSchema converts our generic Schema to a genai.Schema\nfunc toGeminiSchema(schema *Schema) (*genai.Schema, error) {\n\tret := &genai.Schema{\n\t\tDescription: schema.Description,\n\t\tRequired:    schema.Required,\n\t}\n\n\tswitch schema.Type {\n\tcase TypeObject:\n\t\tret.Type = genai.TypeObject\n\tcase TypeString:\n\t\tret.Type = genai.TypeString\n\tcase TypeNumber:\n\t\tret.Type = genai.TypeNumber\n\tcase TypeBoolean:\n\t\tret.Type = genai.TypeBoolean\n\tcase TypeInteger:\n\t\tret.Type = genai.TypeInteger\n\tcase TypeArray:\n\t\tret.Type = genai.TypeArray\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"type %q not handled by genai.Schema\", schema.Type)\n\t}\n\tif schema.Properties != nil {\n\t\tret.Properties = make(map[string]*genai.Schema)\n\t\tfor k, v := range schema.Properties {\n\t\t\tgeminiValue, err := toGeminiSchema(v)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tret.Properties[k] = geminiValue\n\t\t}\n\t}\n\tif schema.Items != nil {\n\t\tgeminiValue, err := toGeminiSchema(schema.Items)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tret.Items = geminiValue\n\t}\n\treturn ret, nil\n}\n\nfunc (c *GeminiChat) partsToGemini(contents ...any) ([]*genai.Part, error) {\n\tvar parts []*genai.Part\n\n\tfor _, content := range contents {\n\t\tswitch v := content.(type) {\n\t\tcase string:\n\t\t\tparts = append(parts, genai.NewPartFromText(v))\n\t\tcase FunctionCallResult:\n\t\t\tparts = append(parts, &genai.Part{\n\t\t\t\tFunctionResponse: &genai.FunctionResponse{\n\t\t\t\t\tID:       v.ID,\n\t\t\t\t\tName:     v.Name,\n\t\t\t\t\tResponse: v.Result,\n\t\t\t\t},\n\t\t\t})\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"unexpected type of content: %T\", content)\n\t\t}\n\t}\n\treturn parts, nil\n}\n\n// Send sends a message to the model.\n// It returns a ChatResponse object containing the response from the model.\nfunc (c *GeminiChat) Send(ctx context.Context, contents ...any) (ChatResponse, error) {\n\tlog := klog.FromContext(ctx)\n\tlog.V(1).Info(\"sending LLM request\", \"user\", contents)\n\n\tparts, err := c.partsToGemini(contents...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tgenaiContent := &genai.Content{\n\t\tRole:  \"user\",\n\t\tParts: parts,\n\t}\n\n\tc.history = append(c.history, genaiContent)\n\tresult, err := c.client.Models.GenerateContent(ctx, c.model, c.history, c.genConfig)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to generate content: %w\", err)\n\t}\n\tif result == nil || len(result.Candidates) == 0 {\n\t\treturn nil, fmt.Errorf(\"no response from Gemini\")\n\t}\n\tc.history = append(c.history, result.Candidates[0].Content)\n\tgeminiResponse := result\n\tlog.V(1).Info(\"got LLM response\", \"response\", geminiResponse)\n\treturn &GeminiChatResponse{geminiResponse: geminiResponse}, nil\n}\n\nfunc (c *GeminiChat) SendStreaming(ctx context.Context, contents ...any) (ChatResponseIterator, error) {\n\tlog := klog.FromContext(ctx)\n\tlog.V(1).Info(\"sending LLM streaming request\", \"user\", contents)\n\n\tparts, err := c.partsToGemini(contents...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tgenaiContent := &genai.Content{\n\t\tRole:  \"user\",\n\t\tParts: parts,\n\t}\n\n\tc.history = append(c.history, genaiContent)\n\tstream := c.client.Models.GenerateContentStream(ctx, c.model, c.history, c.genConfig)\n\n\treturn func(yield func(ChatResponse, error) bool) {\n\t\tnext, stop := iter.Pull2(stream)\n\t\tdefer stop()\n\t\tfor {\n\t\t\tgeminiResponse, err, ok := next()\n\t\t\tif !ok {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\t// Always check for and yield an error first.\n\t\t\t\tyield(nil, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif geminiResponse == nil || len(geminiResponse.Candidates) == 0 {\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tcontent := geminiResponse.Candidates[0].Content\n\t\t\tpartsIsEmpty := true\n\t\t\tif content != nil {\n\t\t\t\tfor _, part := range content.Parts {\n\t\t\t\t\tif part.Text != \"\" || part.FunctionCall != nil {\n\t\t\t\t\t\tpartsIsEmpty = false\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif partsIsEmpty {\n\t\t\t\t// This happens when there is empty content with the finish reason (STOP) to indicate that streaming response is finished.\n\t\t\t\t// xref: https://github.com/GoogleCloudPlatform/kubectl-ai/issues/306\n\t\t\t\tlog.V(1).Info(\"empty response probably with STOP finishedReason\")\n\t\t\t\treturn\n\t\t\t}\n\t\t\tc.history = append(c.history, content)\n\t\t\t// yield only when we have a non-empty response\n\t\t\tif !yield(&GeminiChatResponse{geminiResponse: geminiResponse}, err) {\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}, nil\n}\n\nfunc (c *GeminiChat) Initialize(messages []*api.Message) error {\n\tklog.Info(\"Initializing gemini chat\")\n\tc.history = make([]*genai.Content, 0, len(messages))\n\tfor _, msg := range messages {\n\t\tcontent, err := c.messageToContent(msg)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tc.history = append(c.history, content)\n\t}\n\treturn nil\n}\n\nfunc (c *GeminiChat) messageToContent(msg *api.Message) (*genai.Content, error) {\n\tvar role string\n\tswitch msg.Source {\n\tcase api.MessageSourceUser:\n\t\trole = \"user\"\n\tcase api.MessageSourceModel:\n\t\trole = \"model\"\n\tcase api.MessageSourceAgent:\n\t\trole = \"user\" // Treat agent messages as user messages for Gemini history\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unknown message source: %s\", msg.Source)\n\t}\n\n\tparts, err := c.partsToGemini(msg.Payload)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to convert message payload to parts: %w\", err)\n\t}\n\n\treturn &genai.Content{Role: role, Parts: parts}, nil\n}\n\n// GeminiChatResponse is a response from the Gemini API.\n// It implements the ChatResponse interface.\ntype GeminiChatResponse struct {\n\tgeminiResponse *genai.GenerateContentResponse\n}\n\nvar _ ChatResponse = &GeminiChatResponse{}\n\nfunc (r *GeminiChatResponse) MarshalJSON() ([]byte, error) {\n\tformatted := RecordChatResponse{\n\t\tRaw: r.geminiResponse,\n\t}\n\treturn json.Marshal(&formatted)\n}\n\n// String returns a string representation of the response.\nfunc (r *GeminiChatResponse) String() string {\n\treturn r.geminiResponse.Text()\n}\n\n// UsageMetadata returns the usage metadata for the response.\nfunc (r *GeminiChatResponse) UsageMetadata() any {\n\treturn r.geminiResponse.UsageMetadata\n}\n\n// Candidates returns the candidates for the response.\nfunc (r *GeminiChatResponse) Candidates() []Candidate {\n\tvar candidates []Candidate\n\tfor _, candidate := range r.geminiResponse.Candidates {\n\t\tcandidates = append(candidates, &GeminiCandidate{candidate: candidate})\n\t}\n\treturn candidates\n}\n\n// GeminiCandidate is a candidate for the response.\n// It implements the Candidate interface.\ntype GeminiCandidate struct {\n\tcandidate *genai.Candidate\n}\n\n// String returns a string representation of the response.\nfunc (r *GeminiCandidate) String() string {\n\tvar response strings.Builder\n\tresponse.WriteString(\"[\")\n\tfor i, parts := range r.Parts() {\n\t\tif i > 0 {\n\t\t\tresponse.WriteString(\", \")\n\t\t}\n\t\ttext, ok := parts.AsText()\n\t\tif ok {\n\t\t\tresponse.WriteString(text)\n\t\t}\n\t\tfunctionCalls, ok := parts.AsFunctionCalls()\n\t\tif ok {\n\t\t\tresponse.WriteString(\"functionCalls=[\")\n\t\t\tfor _, functionCall := range functionCalls {\n\t\t\t\tresponse.WriteString(fmt.Sprintf(\"%q(args=%v)\", functionCall.Name, functionCall.Arguments))\n\t\t\t}\n\t\t\tresponse.WriteString(\"]}\")\n\t\t}\n\t}\n\tresponse.WriteString(\"]}\")\n\treturn response.String()\n}\n\n// Parts returns the parts of the candidate.\nfunc (r *GeminiCandidate) Parts() []Part {\n\tvar parts []Part\n\tif r.candidate.Content != nil {\n\t\tfor _, part := range r.candidate.Content.Parts {\n\t\t\tparts = append(parts, &GeminiPart{part: *part})\n\t\t}\n\t}\n\treturn parts\n}\n\n// GeminiPart is a part of a candidate.\n// It implements the Part interface.\ntype GeminiPart struct {\n\tpart genai.Part\n}\n\n// AsText returns the text of the part.\nfunc (p *GeminiPart) AsText() (string, bool) {\n\tif p.part.Text != \"\" {\n\t\treturn p.part.Text, true\n\t}\n\treturn \"\", false\n}\n\n// AsFunctionCalls returns the function calls of the part.\nfunc (p *GeminiPart) AsFunctionCalls() ([]FunctionCall, bool) {\n\tif p.part.FunctionCall != nil {\n\t\treturn []FunctionCall{\n\t\t\t{\n\t\t\t\tID:        p.part.FunctionCall.ID,\n\t\t\t\tName:      p.part.FunctionCall.Name,\n\t\t\t\tArguments: p.part.FunctionCall.Args,\n\t\t\t},\n\t\t}, true\n\t}\n\treturn nil, false\n}\n\ntype GeminiCompletionResponse struct {\n\tgeminiResponse *genai.GenerateContentResponse\n\ttext           string\n}\n\nvar _ CompletionResponse = &GeminiCompletionResponse{}\n\nfunc (r *GeminiCompletionResponse) MarshalJSON() ([]byte, error) {\n\tformatted := RecordCompletionResponse{\n\t\tText: r.text,\n\t\tRaw:  r.geminiResponse,\n\t}\n\treturn json.Marshal(&formatted)\n}\n\nfunc (r *GeminiCompletionResponse) Response() string {\n\treturn r.text\n}\n\nfunc (r *GeminiCompletionResponse) UsageMetadata() any {\n\treturn r.geminiResponse.UsageMetadata\n}\n\nfunc (r *GeminiCompletionResponse) String() string {\n\treturn fmt.Sprintf(\"{text=%q}\", r.text)\n}\n\nfunc (c *GeminiChat) IsRetryableError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\n\tvar apiErr genai.APIError\n\tif errors.As(err, &apiErr) {\n\t\tswitch apiErr.Code {\n\t\tcase http.StatusConflict, http.StatusTooManyRequests,\n\t\t\thttp.StatusInternalServerError, http.StatusBadGateway,\n\t\t\thttp.StatusServiceUnavailable, http.StatusGatewayTimeout:\n\t\t\treturn true\n\t\tdefault:\n\t\t\treturn false\n\t\t}\n\t}\n\n\tvar netErr net.Error\n\tif errors.As(err, &netErr) && netErr.Timeout() {\n\t\treturn true\n\t}\n\n\t// Add other error checks specific to LLM clients if needed\n\t// e.g., if errors.Is(err, specificLLMRateLimitError) { return true }\n\n\treturn false\n}\n"
  },
  {
    "path": "gollm/go.mod",
    "content": "module github.com/GoogleCloudPlatform/kubectl-ai/gollm\n\ngo 1.24.0\n\ntoolchain go1.24.3\n\nrequire (\n\tgithub.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.7.2\n\tgithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0\n\tgithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0\n\tgithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.7.0\n\tgithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0\n\tgithub.com/GoogleCloudPlatform/kubectl-ai v0.0.19\n\tgithub.com/anthropics/anthropic-sdk-go v1.26.0\n\tgithub.com/aws/aws-sdk-go-v2 v1.36.6\n\tgithub.com/aws/aws-sdk-go-v2/config v1.29.18\n\tgithub.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.31.1\n\tgithub.com/ollama/ollama v0.6.5\n\tgithub.com/openai/openai-go v1.11.0\n\tgoogle.golang.org/genai v1.8.0\n\tk8s.io/klog/v2 v2.130.1\n)\n\nrequire (\n\tcloud.google.com/go v0.118.3 // indirect\n\tcloud.google.com/go/auth v0.15.0 // indirect\n\tcloud.google.com/go/compute/metadata v0.6.0 // indirect\n\tgithub.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect\n\tgithub.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/credentials v1.17.71 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sso v1.25.6 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 // indirect\n\tgithub.com/aws/aws-sdk-go-v2/service/sts v1.34.1 // indirect\n\tgithub.com/aws/smithy-go v1.22.4 // indirect\n\tgithub.com/felixge/httpsnoop v1.0.4 // indirect\n\tgithub.com/go-logr/logr v1.4.2 // indirect\n\tgithub.com/go-logr/stdr v1.2.2 // indirect\n\tgithub.com/golang-jwt/jwt/v5 v5.2.2 // indirect\n\tgithub.com/google/go-cmp v0.7.0 // indirect\n\tgithub.com/google/s2a-go v0.1.9 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect\n\tgithub.com/googleapis/gax-go/v2 v2.14.1 // indirect\n\tgithub.com/gorilla/websocket v1.5.3 // indirect\n\tgithub.com/kylelemons/godebug v1.1.0 // indirect\n\tgithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect\n\tgithub.com/tidwall/gjson v1.18.0 // indirect\n\tgithub.com/tidwall/match v1.1.1 // indirect\n\tgithub.com/tidwall/pretty v1.2.1 // indirect\n\tgithub.com/tidwall/sjson v1.2.5 // indirect\n\tgo.opentelemetry.io/auto/sdk v1.1.0 // indirect\n\tgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect\n\tgo.opentelemetry.io/otel v1.34.0 // indirect\n\tgo.opentelemetry.io/otel/metric v1.34.0 // indirect\n\tgo.opentelemetry.io/otel/trace v1.34.0 // indirect\n\tgolang.org/x/crypto v0.40.0 // indirect\n\tgolang.org/x/net v0.41.0 // indirect\n\tgolang.org/x/sync v0.16.0 // indirect\n\tgolang.org/x/sys v0.34.0 // indirect\n\tgolang.org/x/text v0.27.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 // indirect\n\tgoogle.golang.org/grpc v1.70.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.5 // indirect\n\tsigs.k8s.io/yaml v1.4.0 // indirect\n)\n"
  },
  {
    "path": "gollm/go.sum",
    "content": "cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME=\ncloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc=\ncloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=\ncloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=\ncloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=\ncloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=\ngithub.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.7.2 h1:+hDUZnYHHoXu05iXiJcL53MZW7raZZejB8ZtzVW7yyc=\ngithub.com/Azure/azure-sdk-for-go/sdk/ai/azopenai v0.7.2/go.mod h1:49PyorVrwk6G+e8Vghvn7EkAS6wSPdXEu5a8iW2/vC8=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=\ngithub.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0/go.mod h1:kUjrAo8bgEwLeZ/CmHqNl3Z/kPm7y6FKfxxK0izYUg4=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=\ngithub.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=\ngithub.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.7.0 h1:4exaC92+n1FzhSKb5Ghino2XEk3cClUtzvveL1U9YeM=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices v1.7.0/go.mod h1:BkhZrH3JiVTkrTqCeYHOmqReFcZTYEMf8jcFDlrCJLk=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0 h1:UrGzkHueDwAWDdjQxC+QaXHd4tVCkISYE9j7fSSXF8k=\ngithub.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0/go.mod h1:qskvSQeW+cxEE2bcKYyKimB1/KiQ9xpJ99bcHY0BX6c=\ngithub.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=\ngithub.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=\ngithub.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=\ngithub.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=\ngithub.com/GoogleCloudPlatform/kubectl-ai v0.0.19 h1:RdVCft8obsRZaoyVjqaOMu+ylnPb+CKcd8pRYPq3zvs=\ngithub.com/GoogleCloudPlatform/kubectl-ai v0.0.19/go.mod h1:VOHud1Et2RE668c2dcdxApYclNk74SjKLxoaQQtK6Bc=\ngithub.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=\ngithub.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=\ngithub.com/aws/aws-sdk-go-v2 v1.36.6 h1:zJqGjVbRdTPojeCGWn5IR5pbJwSQSBh5RWFTQcEQGdU=\ngithub.com/aws/aws-sdk-go-v2 v1.36.6/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA=\ngithub.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY=\ngithub.com/aws/aws-sdk-go-v2/config v1.29.18 h1:x4T1GRPnqKV8HMJOMtNktbpQMl3bIsfx8KbqmveUO2I=\ngithub.com/aws/aws-sdk-go-v2/config v1.29.18/go.mod h1:bvz8oXugIsH8K7HLhBv06vDqnFv3NsGDt2Znpk7zmOU=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.17.71 h1:r2w4mQWnrTMJjOyIsZtGp3R3XGY3nqHn8C26C2lQWgA=\ngithub.com/aws/aws-sdk-go-v2/credentials v1.17.71/go.mod h1:E7VF3acIup4GB5ckzbKFrCK0vTvEQxOxgdq4U3vcMCY=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33 h1:D9ixiWSG4lyUBL2DDNK924Px9V/NBVpML90MHqyTADY=\ngithub.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.33/go.mod h1:caS/m4DI+cij2paz3rtProRBI4s/+TCiWoaWZuQ9010=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37 h1:osMWfm/sC/L4tvEdQ65Gri5ZZDCUpuYJZbTTDrsn4I0=\ngithub.com/aws/aws-sdk-go-v2/internal/configsources v1.3.37/go.mod h1:ZV2/1fbjOPr4G4v38G3Ww5TBT4+hmsK45s/rxu1fGy0=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37 h1:v+X21AvTb2wZ+ycg1gx+orkB/9U6L7AOp93R7qYxsxM=\ngithub.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.37/go.mod h1:G0uM1kyssELxmJ2VZEfG0q2npObR3BAkF3c1VsfVnfs=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=\ngithub.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=\ngithub.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.31.1 h1:JDLT1baDmioiZKa2bZ6J82/Zwfv9cSAjr+LyF47TPYw=\ngithub.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.31.1/go.mod h1:FvbGcqrU4sC3qjrAKK3FzOmBoucDJF2dXsKVvAbGE8g=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 h1:CXV68E2dNqhuynZJPB80bhPQwAKqBWVer887figW6Jc=\ngithub.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18 h1:vvbXsA2TVO80/KT7ZqCbx934dt6PY+vQ8hZpUZ/cpYg=\ngithub.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.18/go.mod h1:m2JJHledjBGNMsLOF1g9gbAxprzq3KjC8e4lxtn+eWg=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.25.6 h1:rGtWqkQbPk7Bkwuv3NzpE/scwwL9sC1Ul3tn9x83DUI=\ngithub.com/aws/aws-sdk-go-v2/service/sso v1.25.6/go.mod h1:u4ku9OLv4TO4bCPdxf4fA1upaMaJmP9ZijGk3AAOC6Q=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4 h1:OV/pxyXh+eMA0TExHEC4jyWdumLxNbzz1P0zJoezkJc=\ngithub.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.4/go.mod h1:8Mm5VGYwtm+r305FfPSuc+aFkrypeylGYhFim6XEPoc=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.34.1 h1:aUrLQwJfZtwv3/ZNG2xRtEen+NqI3iesuacjP51Mv1s=\ngithub.com/aws/aws-sdk-go-v2/service/sts v1.34.1/go.mod h1:3wFBZKoWnX3r+Sm7in79i54fBmNfwhdNdQuscCw7QIk=\ngithub.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw=\ngithub.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=\ngithub.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=\ngithub.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=\ngithub.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=\ngithub.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=\ngithub.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=\ngithub.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=\ngithub.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=\ngithub.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=\ngithub.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=\ngithub.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=\ngithub.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=\ngithub.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=\ngithub.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=\ngithub.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=\ngithub.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=\ngithub.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=\ngithub.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=\ngithub.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=\ngithub.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=\ngithub.com/ollama/ollama v0.6.5 h1:vXKkVX57ql/1ZzMw4SVK866Qfd6pjwEcITVyEpF0QXQ=\ngithub.com/ollama/ollama v0.6.5/go.mod h1:pGgtoNyc9DdM6oZI6yMfI6jTk2Eh4c36c2GpfQCH7PY=\ngithub.com/openai/openai-go v1.11.0 h1:ztH+W0ug5Kh9+/EErHa8KAmhwixkzjK57rXyE+ZnSCk=\ngithub.com/openai/openai-go v1.11.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=\ngithub.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=\ngithub.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=\ngithub.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=\ngithub.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=\ngithub.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=\ngithub.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=\ngithub.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=\ngithub.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=\ngithub.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=\ngithub.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=\ngithub.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=\ngo.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=\ngo.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=\ngo.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=\ngo.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=\ngo.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=\ngo.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=\ngo.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=\ngo.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=\ngo.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=\ngo.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=\ngo.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=\ngo.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=\ngo.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=\ngolang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=\ngolang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=\ngolang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=\ngolang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=\ngolang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=\ngolang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=\ngolang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=\ngolang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=\ngolang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=\ngoogle.golang.org/genai v1.8.0 h1:unX2CNWSiKDO2MSTKK3RstXg/vHp9hr42LIcL6f3Cik=\ngoogle.golang.org/genai v1.8.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 h1:DMTIbak9GhdaSxEjvVzAeNZvyc03I61duqNbnm3SU0M=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=\ngoogle.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=\ngoogle.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=\ngoogle.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=\ngoogle.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=\ngopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nk8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=\nk8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=\nsigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=\nsigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=\n"
  },
  {
    "path": "gollm/grok.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\n\topenai \"github.com/openai/openai-go\"\n\t\"github.com/openai/openai-go/option\"\n\t\"k8s.io/klog/v2\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n)\n\n// Register the Grok provider factory on package initialization.\n// The new factory function supports ClientOptions, including skipVerifySSL.\nfunc init() {\n\tif err := RegisterProvider(\"grok\", newGrokClientFactory); err != nil {\n\t\tklog.Fatalf(\"Failed to register Grok provider: %v\", err)\n\t}\n}\n\n// newGrokClientFactory is the factory function for creating Grok clients with options.\nfunc newGrokClientFactory(ctx context.Context, opts ClientOptions) (Client, error) {\n\treturn NewGrokClient(ctx, opts)\n}\n\n// GrokClient implements the gollm.Client interface for X.AI's Grok model.\ntype GrokClient struct {\n\tclient openai.Client\n}\n\n// Ensure GrokClient implements the Client interface.\nvar _ Client = &GrokClient{}\n\n// NewGrokClient creates a new client for interacting with X.AI's Grok model.\n// Supports custom HTTP client and skipVerifySSL via ClientOptions.\nfunc NewGrokClient(ctx context.Context, opts ClientOptions) (*GrokClient, error) {\n\tapiKey := os.Getenv(\"GROK_API_KEY\")\n\tif apiKey == \"\" {\n\t\treturn nil, errors.New(\"GROK_API_KEY environment variable not set\")\n\t}\n\n\t// Default API endpoint for X.AI\n\tendpoint := \"https://api.x.ai/v1\"\n\n\t// Allow endpoint override\n\tcustomEndpoint := os.Getenv(\"GROK_ENDPOINT\")\n\tif customEndpoint != \"\" {\n\t\tendpoint = customEndpoint\n\t\tklog.Infof(\"Using custom Grok endpoint: %s\", endpoint)\n\t}\n\n\t// Use the OpenAI client with custom base URL and custom HTTP client\n\thttpClient := createCustomHTTPClient(opts.SkipVerifySSL)\n\treturn &GrokClient{\n\t\tclient: openai.NewClient(\n\t\t\toption.WithAPIKey(apiKey),\n\t\t\toption.WithBaseURL(endpoint),\n\t\t\toption.WithHTTPClient(httpClient),\n\t\t),\n\t}, nil\n}\n\n// Close cleans up any resources used by the client.\nfunc (c *GrokClient) Close() error {\n\t// No specific cleanup needed for the Grok client currently.\n\treturn nil\n}\n\n// StartChat starts a new chat session.\nfunc (c *GrokClient) StartChat(systemPrompt, model string) Chat {\n\t// Default to Grok-3-beta if no model is specified\n\tif model == \"\" {\n\t\tmodel = \"grok-3-beta\"\n\t\tklog.V(1).Info(\"No model specified, defaulting to grok-3-beta\")\n\t}\n\tklog.V(1).Infof(\"Starting new Grok chat session with model: %s\", model)\n\n\t// Initialize history with system prompt if provided\n\thistory := []openai.ChatCompletionMessageParamUnion{}\n\tif systemPrompt != \"\" {\n\t\thistory = append(history, openai.SystemMessage(systemPrompt))\n\t}\n\n\treturn &grokChatSession{\n\t\tclient:  c.client,\n\t\thistory: history,\n\t\tmodel:   model,\n\t}\n}\n\n// simpleGrokCompletionResponse is a basic implementation of CompletionResponse.\ntype simpleGrokCompletionResponse struct {\n\tcontent string\n}\n\n// Response returns the completion content.\nfunc (r *simpleGrokCompletionResponse) Response() string {\n\treturn r.content\n}\n\n// UsageMetadata returns nil for now.\nfunc (r *simpleGrokCompletionResponse) UsageMetadata() any {\n\treturn nil\n}\n\n// GenerateCompletion sends a completion request to the Grok API.\nfunc (c *GrokClient) GenerateCompletion(ctx context.Context, req *CompletionRequest) (CompletionResponse, error) {\n\tklog.Infof(\"Grok GenerateCompletion called with model: %s\", req.Model)\n\tklog.V(1).Infof(\"Prompt:\\n%s\", req.Prompt)\n\n\t// Use the Chat Completions API as shown in examples\n\tchatReq := openai.ChatCompletionNewParams{\n\t\tModel: openai.ChatModel(req.Model), // Use the model specified in the request\n\t\tMessages: []openai.ChatCompletionMessageParamUnion{\n\t\t\t// Assuming a simple user message structure for now\n\t\t\topenai.UserMessage(req.Prompt),\n\t\t},\n\t}\n\n\tcompletion, err := c.client.Chat.Completions.New(ctx, chatReq)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to generate Grok completion: %w\", err)\n\t}\n\n\t// Check if there are choices and a message\n\tif len(completion.Choices) == 0 || completion.Choices[0].Message.Content == \"\" {\n\t\treturn nil, errors.New(\"received an empty response from Grok\")\n\t}\n\n\t// Return the content of the first choice\n\tresp := &simpleGrokCompletionResponse{\n\t\tcontent: completion.Choices[0].Message.Content,\n\t}\n\n\treturn resp, nil\n}\n\n// SetResponseSchema is not implemented yet for Grok.\nfunc (c *GrokClient) SetResponseSchema(schema *Schema) error {\n\tklog.Warning(\"GrokClient.SetResponseSchema is not implemented yet\")\n\treturn nil\n}\n\n// ListModels returns a list of available Grok models.\nfunc (c *GrokClient) ListModels(ctx context.Context) ([]string, error) {\n\t// Currently, Grok only has a fixed set of models\n\t// This could be updated to call a models endpoint if X.AI provides one in the future\n\treturn []string{\"grok-3-beta\"}, nil\n}\n\n// --- Chat Session Implementation ---\n\ntype grokChatSession struct {\n\tclient              openai.Client\n\thistory             []openai.ChatCompletionMessageParamUnion\n\tmodel               string\n\tfunctionDefinitions []*FunctionDefinition            // Stored in gollm format\n\ttools               []openai.ChatCompletionToolParam // Stored in OpenAI format\n}\n\n// Ensure grokChatSession implements the Chat interface.\nvar _ Chat = (*grokChatSession)(nil)\n\n// SetFunctionDefinitions stores the function definitions and converts them to Grok format.\nfunc (cs *grokChatSession) SetFunctionDefinitions(defs []*FunctionDefinition) error {\n\tcs.functionDefinitions = defs\n\tcs.tools = nil // Clear previous tools\n\tif len(defs) > 0 {\n\t\tcs.tools = make([]openai.ChatCompletionToolParam, len(defs))\n\t\tfor i, gollmDef := range defs {\n\t\t\t// Basic conversion, assuming schema is compatible or nil\n\t\t\tvar params openai.FunctionParameters\n\t\t\tif gollmDef.Parameters != nil {\n\t\t\t\t// NOTE: This assumes gollmDef.Parameters is directly marshalable to JSON\n\t\t\t\t// that fits openai.FunctionParameters. May need refinement.\n\t\t\t\tbytes, err := gollmDef.Parameters.ToRawSchema()\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to convert schema for function %s: %w\", gollmDef.Name, err)\n\t\t\t\t}\n\t\t\t\tif err := json.Unmarshal(bytes, &params); err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"failed to unmarshal schema for function %s: %w\", gollmDef.Name, err)\n\t\t\t\t}\n\t\t\t}\n\t\t\tcs.tools[i] = openai.ChatCompletionToolParam{\n\t\t\t\tFunction: openai.FunctionDefinitionParam{\n\t\t\t\t\tName:        gollmDef.Name,\n\t\t\t\t\tDescription: openai.String(gollmDef.Description),\n\t\t\t\t\tParameters:  params,\n\t\t\t\t},\n\t\t\t}\n\t\t}\n\t}\n\tklog.V(1).Infof(\"Set %d function definitions for Grok chat session\", len(cs.functionDefinitions))\n\treturn nil\n}\n\n// Send sends the user message(s), appends to history, and gets the LLM response.\nfunc (cs *grokChatSession) Send(ctx context.Context, contents ...any) (ChatResponse, error) {\n\tklog.V(1).InfoS(\"grokChatSession.Send called\", \"model\", cs.model, \"history_len\", len(cs.history))\n\n\t// Append user message(s) to history\n\tfor _, content := range contents {\n\t\tswitch c := content.(type) {\n\t\tcase string:\n\t\t\tklog.V(2).Infof(\"Adding user message to history: %s\", c)\n\t\t\tcs.history = append(cs.history, openai.UserMessage(c))\n\t\tcase FunctionCallResult:\n\t\t\tklog.V(2).Infof(\"Adding tool call result to history: Name=%s, ID=%s\", c.Name, c.ID)\n\t\t\t// Marshal the result map into a JSON string for the message content\n\t\t\tresultJSON, err := json.Marshal(c.Result)\n\t\t\tif err != nil {\n\t\t\t\tklog.Errorf(\"Failed to marshal function call result: %v\", err)\n\t\t\t\treturn nil, fmt.Errorf(\"failed to marshal function call result %q: %w\", c.Name, err)\n\t\t\t}\n\t\t\tcs.history = append(cs.history, openai.ToolMessage(string(resultJSON), c.ID))\n\t\tdefault:\n\t\t\t// TODO: Handle other content types if necessary?\n\t\t\tklog.Warningf(\"Unhandled content type in Send: %T\", content)\n\t\t\treturn nil, fmt.Errorf(\"unhandled content type: %T\", content)\n\t\t}\n\t}\n\n\t// Prepare the API request\n\tchatReq := openai.ChatCompletionNewParams{\n\t\tModel:    openai.ChatModel(cs.model),\n\t\tMessages: cs.history,\n\t}\n\tif len(cs.tools) > 0 {\n\t\tchatReq.Tools = cs.tools\n\t\t// chatReq.ToolChoice = openai.ToolChoiceAuto // Or specify if needed\n\t}\n\n\t// Call the Grok API\n\tklog.V(1).InfoS(\"Sending request to Grok Chat API\", \"model\", cs.model, \"messages\", len(chatReq.Messages), \"tools\", len(chatReq.Tools))\n\tcompletion, err := cs.client.Chat.Completions.New(ctx, chatReq)\n\tif err != nil {\n\t\tklog.Errorf(\"Grok ChatCompletion API error: %v\", err)\n\t\treturn nil, fmt.Errorf(\"Grok chat completion failed: %w\", err)\n\t}\n\tklog.V(1).InfoS(\"Received response from Grok Chat API\", \"id\", completion.ID, \"choices\", len(completion.Choices))\n\n\t// Process the response\n\tif len(completion.Choices) == 0 {\n\t\tklog.Warning(\"Received response with no choices from Grok\")\n\t\treturn nil, errors.New(\"received empty response from Grok (no choices)\")\n\t}\n\n\t// Add assistant's response (first choice) to history\n\tassistantMsg := completion.Choices[0].Message\n\t// Convert to param type before appending to history\n\tcs.history = append(cs.history, assistantMsg.ToParam())\n\tklog.V(2).InfoS(\"Added assistant message to history\", \"content_present\", assistantMsg.Content != \"\", \"tool_calls\", len(assistantMsg.ToolCalls))\n\n\t// Wrap the response\n\tresp := &grokChatResponse{\n\t\tgrokCompletion: completion,\n\t}\n\n\treturn resp, nil\n}\n\n// SendStreaming sends the user message(s) and returns an iterator for the LLM response stream.\nfunc (cs *grokChatSession) SendStreaming(ctx context.Context, contents ...any) (ChatResponseIterator, error) {\n\tklog.V(1).InfoS(\"Starting Grok streaming request\", \"model\", cs.model, \"streamingEnabled\", true)\n\n\t// Append user message(s) to history\n\tfor _, content := range contents {\n\t\tswitch c := content.(type) {\n\t\tcase string:\n\t\t\tklog.V(2).Infof(\"Adding user message to history: %s\", c)\n\t\t\tcs.history = append(cs.history, openai.UserMessage(c))\n\t\tcase FunctionCallResult:\n\t\t\tklog.V(2).Infof(\"Adding tool call result to history: Name=%s, ID=%s\", c.Name, c.ID)\n\t\t\tresultJSON, err := json.Marshal(c.Result)\n\t\t\tif err != nil {\n\t\t\t\tklog.Errorf(\"Failed to marshal function call result: %v\", err)\n\t\t\t\treturn nil, fmt.Errorf(\"failed to marshal function call result %q: %w\", c.Name, err)\n\t\t\t}\n\t\t\tcs.history = append(cs.history, openai.ToolMessage(string(resultJSON), c.ID))\n\t\tdefault:\n\t\t\tklog.Warningf(\"Unhandled content type in SendStreaming: %T\", content)\n\t\t\treturn nil, fmt.Errorf(\"unhandled content type: %T\", content)\n\t\t}\n\t}\n\n\t// Prepare the API request\n\tchatReq := openai.ChatCompletionNewParams{\n\t\tModel:    openai.ChatModel(cs.model),\n\t\tMessages: cs.history,\n\t}\n\tif len(cs.tools) > 0 {\n\t\tchatReq.Tools = cs.tools\n\t}\n\n\t// Start the Grok streaming request\n\tklog.V(1).InfoS(\"Sending streaming request to Grok API\",\n\t\t\"model\", cs.model,\n\t\t\"messageCount\", len(chatReq.Messages),\n\t\t\"toolCount\", len(chatReq.Tools))\n\tstream := cs.client.Chat.Completions.NewStreaming(ctx, chatReq)\n\n\t// Create an accumulator to track the full response\n\tacc := openai.ChatCompletionAccumulator{}\n\n\t// Create and return the stream iterator\n\treturn func(yield func(ChatResponse, error) bool) {\n\t\tvar lastResponseChunk *grokChatStreamResponse\n\n\t\t// Process stream chunks\n\t\tfor stream.Next() {\n\t\t\tchunk := stream.Current()\n\n\t\t\t// Update the accumulator with the new chunk\n\t\t\tacc.AddChunk(chunk)\n\n\t\t\t// Create a streaming response for this chunk\n\t\t\tstreamResponse := &grokChatStreamResponse{\n\t\t\t\tstreamChunk: chunk,\n\t\t\t\taccumulator: acc,\n\t\t\t}\n\n\t\t\t// Keep track of the last response to append to history\n\t\t\tlastResponseChunk = streamResponse\n\n\t\t\t// Yield the streaming response\n\t\t\tif !yield(streamResponse, nil) {\n\t\t\t\t// Consumer wants to stop\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\t// Check for errors after streaming completes\n\t\tif err := stream.Err(); err != nil {\n\t\t\tklog.Errorf(\"Error in Grok streaming: %v\", err)\n\t\t\tyield(nil, fmt.Errorf(\"Grok streaming error: %w\", err))\n\t\t\treturn\n\t\t}\n\n\t\t// Update conversation history with the complete message\n\t\tif lastResponseChunk != nil && acc.Choices != nil && len(acc.Choices) > 0 {\n\t\t\t// The accumulator has the complete message\n\t\t\tcompleteMessage := openai.ChatCompletionMessage{\n\t\t\t\tContent:   acc.Choices[0].Message.Content,\n\t\t\t\tRole:      acc.Choices[0].Message.Role,\n\t\t\t\tToolCalls: acc.Choices[0].Message.ToolCalls,\n\t\t\t}\n\n\t\t\t// Append the full assistant response to history\n\t\t\tcs.history = append(cs.history, completeMessage.ToParam())\n\t\t\tklog.V(2).InfoS(\"Added complete assistant message to history\",\n\t\t\t\t\"content_present\", completeMessage.Content != \"\",\n\t\t\t\t\"tool_calls\", len(completeMessage.ToolCalls))\n\t\t}\n\t}, nil\n}\n\n// IsRetryableError determines if an error from the Grok API should be retried.\nfunc (cs *grokChatSession) IsRetryableError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\treturn DefaultIsRetryableError(err)\n}\n\nfunc (cs *grokChatSession) Initialize(messages []*api.Message) error {\n\tklog.Warning(\"chat history persistence is not supported for provider 'grok', using in-memory chat history\")\n\treturn nil\n}\n\n// --- Helper structs for ChatResponse interface ---\n\ntype grokChatResponse struct {\n\tgrokCompletion *openai.ChatCompletion\n}\n\nvar _ ChatResponse = (*grokChatResponse)(nil)\n\nfunc (r *grokChatResponse) UsageMetadata() any {\n\t// Check if the main completion object and Usage exist\n\tif r.grokCompletion != nil && r.grokCompletion.Usage.TotalTokens > 0 { // Check a field within Usage\n\t\treturn r.grokCompletion.Usage\n\t}\n\treturn nil\n}\n\nfunc (r *grokChatResponse) Candidates() []Candidate {\n\tif r.grokCompletion == nil {\n\t\treturn nil\n\t}\n\tcandidates := make([]Candidate, len(r.grokCompletion.Choices))\n\tfor i, choice := range r.grokCompletion.Choices {\n\t\tcandidates[i] = &grokCandidate{grokChoice: &choice}\n\t}\n\treturn candidates\n}\n\ntype grokCandidate struct {\n\tgrokChoice *openai.ChatCompletionChoice\n}\n\nvar _ Candidate = (*grokCandidate)(nil)\n\nfunc (c *grokCandidate) Parts() []Part {\n\t// Check if the choice exists before accessing Message\n\tif c.grokChoice == nil {\n\t\treturn nil\n\t}\n\n\t// Grok message can have Content AND ToolCalls\n\tvar parts []Part\n\tif c.grokChoice.Message.Content != \"\" {\n\t\tparts = append(parts, &grokPart{content: c.grokChoice.Message.Content})\n\t}\n\tif len(c.grokChoice.Message.ToolCalls) > 0 {\n\t\tparts = append(parts, &grokPart{toolCalls: c.grokChoice.Message.ToolCalls})\n\t}\n\treturn parts\n}\n\n// String provides a simple string representation for logging/debugging.\nfunc (c *grokCandidate) String() string {\n\tif c.grokChoice == nil {\n\t\treturn \"<nil candidate>\"\n\t}\n\tcontent := \"<no content>\"\n\tif c.grokChoice.Message.Content != \"\" {\n\t\tcontent = c.grokChoice.Message.Content\n\t}\n\ttoolCalls := len(c.grokChoice.Message.ToolCalls)\n\tfinishReason := string(c.grokChoice.FinishReason)\n\treturn fmt.Sprintf(\"Candidate(FinishReason: %s, ToolCalls: %d, Content: %q)\", finishReason, toolCalls, content)\n}\n\ntype grokPart struct {\n\tcontent   string\n\ttoolCalls []openai.ChatCompletionMessageToolCall\n}\n\nvar _ Part = (*grokPart)(nil)\n\nfunc (p *grokPart) AsText() (string, bool) {\n\treturn p.content, p.content != \"\"\n}\n\nfunc (p *grokPart) AsFunctionCalls() ([]FunctionCall, bool) {\n\tif len(p.toolCalls) == 0 {\n\t\treturn nil, false\n\t}\n\n\tgollmCalls := make([]FunctionCall, len(p.toolCalls))\n\tfor i, tc := range p.toolCalls {\n\t\t// Check if it's a function call by seeing if Function Name is populated\n\t\tif tc.Function.Name == \"\" {\n\t\t\tklog.V(2).Infof(\"Skipping non-function tool call ID: %s\", tc.ID)\n\t\t\tcontinue\n\t\t}\n\t\tvar args map[string]any\n\t\t// Attempt to unmarshal arguments, ignore error for now if it fails\n\t\t_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)\n\n\t\tgollmCalls[i] = FunctionCall{\n\t\t\tID:        tc.ID,\n\t\t\tName:      tc.Function.Name,\n\t\t\tArguments: args,\n\t\t}\n\t}\n\treturn gollmCalls, true\n}\n\n// grokChatStreamResponse represents a streaming response chunk from Grok.\ntype grokChatStreamResponse struct {\n\tstreamChunk openai.ChatCompletionChunk\n\taccumulator openai.ChatCompletionAccumulator\n}\n\n// Ensure the streaming response implements ChatResponse interface.\nvar _ ChatResponse = (*grokChatStreamResponse)(nil)\n\n// UsageMetadata returns usage metadata if available in the final chunk.\nfunc (r *grokChatStreamResponse) UsageMetadata() any {\n\tif r.accumulator.Usage.TotalTokens > 0 {\n\t\treturn r.accumulator.Usage\n\t}\n\treturn nil\n}\n\n// Candidates returns a slice with a single streaming candidate.\nfunc (r *grokChatStreamResponse) Candidates() []Candidate {\n\t// Each streaming chunk gets converted to a candidate\n\tif len(r.streamChunk.Choices) == 0 {\n\t\treturn nil\n\t}\n\n\tcandidates := make([]Candidate, len(r.streamChunk.Choices))\n\tfor i, choice := range r.streamChunk.Choices {\n\t\tcandidates[i] = &grokStreamCandidate{streamChoice: choice}\n\t}\n\treturn candidates\n}\n\n// grokStreamCandidate adapts a streaming chunk choice to the Candidate interface.\ntype grokStreamCandidate struct {\n\tstreamChoice openai.ChatCompletionChunkChoice\n}\n\n// Ensure the streaming candidate implements Candidate interface.\nvar _ Candidate = (*grokStreamCandidate)(nil)\n\n// String provides a string representation of the candidate.\nfunc (c *grokStreamCandidate) String() string {\n\treturn fmt.Sprintf(\"StreamingCandidate(Index: %d, FinishReason: %s)\",\n\t\tc.streamChoice.Index, c.streamChoice.FinishReason)\n}\n\n// Parts returns the parts of this streaming chunk candidate.\nfunc (c *grokStreamCandidate) Parts() []Part {\n\tvar parts []Part\n\n\t// Include text content if present\n\tif c.streamChoice.Delta.Content != \"\" {\n\t\tparts = append(parts, &grokStreamPart{\n\t\t\tcontent: c.streamChoice.Delta.Content,\n\t\t})\n\t}\n\n\t// Include tool calls if present\n\tif len(c.streamChoice.Delta.ToolCalls) > 0 {\n\t\t// Convert ChatCompletionToolCallDelta to ChatCompletionMessageToolCall\n\t\ttoolCalls := make([]openai.ChatCompletionMessageToolCall, 0, len(c.streamChoice.Delta.ToolCalls))\n\t\tfor _, delta := range c.streamChoice.Delta.ToolCalls {\n\t\t\t// Create a new ChatCompletionMessageToolCall directly\n\t\t\ttoolCall := openai.ChatCompletionMessageToolCall{\n\t\t\t\tID: delta.ID,\n\t\t\t\tFunction: openai.ChatCompletionMessageToolCallFunction{\n\t\t\t\t\tName:      delta.Function.Name,\n\t\t\t\t\tArguments: delta.Function.Arguments,\n\t\t\t\t},\n\t\t\t\tType: \"function\", // The type is always \"function\" for function calls\n\t\t\t}\n\n\t\t\ttoolCalls = append(toolCalls, toolCall)\n\t\t}\n\n\t\tparts = append(parts, &grokStreamPart{\n\t\t\ttoolCalls: toolCalls,\n\t\t})\n\t}\n\n\treturn parts\n}\n\n// grokStreamPart adapts streaming parts to the Part interface.\ntype grokStreamPart struct {\n\tcontent   string\n\ttoolCalls []openai.ChatCompletionMessageToolCall\n}\n\n// Ensure the streaming part implements Part interface.\nvar _ Part = (*grokStreamPart)(nil)\n\n// AsText returns the text content of this part if it has any.\nfunc (p *grokStreamPart) AsText() (string, bool) {\n\treturn p.content, p.content != \"\"\n}\n\n// AsFunctionCalls returns the function calls from this part if it has any.\nfunc (p *grokStreamPart) AsFunctionCalls() ([]FunctionCall, bool) {\n\tif len(p.toolCalls) == 0 {\n\t\treturn nil, false\n\t}\n\n\t// Count valid function calls first\n\tvalidCount := 0\n\tfor _, tc := range p.toolCalls {\n\t\t// Only count tool calls that have a function name\n\t\tif tc.Function.Name != \"\" {\n\t\t\tvalidCount++\n\t\t}\n\t}\n\n\t// If no valid function calls, return nil\n\tif validCount == 0 {\n\t\treturn nil, false\n\t}\n\n\t// Create properly sized array\n\tcompleteCalls := make([]FunctionCall, 0, validCount)\n\n\t// Process tool calls\n\tfor _, tc := range p.toolCalls {\n\t\t// Skip tool calls that don't have a complete function definition yet\n\t\tif tc.Function.Name == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar args map[string]any\n\t\t// Attempt to unmarshal arguments if present\n\t\tif tc.Function.Arguments != \"\" {\n\t\t\tif err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {\n\t\t\t\tklog.V(2).Infof(\"Error unmarshaling function arguments: %v\", err)\n\t\t\t\t// Continue with empty args if unmarshal fails\n\t\t\t\targs = make(map[string]any)\n\t\t\t}\n\t\t} else {\n\t\t\t// Initialize empty args map if no arguments provided\n\t\t\targs = make(map[string]any)\n\t\t}\n\n\t\tcompleteCalls = append(completeCalls, FunctionCall{\n\t\t\tID:        tc.ID,\n\t\t\tName:      tc.Function.Name,\n\t\t\tArguments: args,\n\t\t})\n\t}\n\n\treturn completeCalls, len(completeCalls) > 0\n}\n"
  },
  {
    "path": "gollm/http_journal.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/http/httputil\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/journal\"\n\n\t\"k8s.io/klog/v2\"\n)\n\n// journalingRoundTripper wraps an existing http.RoundTripper to record requests and responses.\ntype journalingRoundTripper struct {\n\tnext http.RoundTripper // The actual transport that does the network call\n}\n\n// RoundTrip satisfies the http.RoundTripper interface. It intercepts an HTTP request,\n// logs it, passes it to the next handler, and then logs the response.\n// It includes special handling to correctly parse and summarize streaming responses.\nfunc (jrt *journalingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {\n\trecorder := journal.RecorderFromContext(req.Context())\n\n\t// Log the outgoing request.\n\treqBytes, err := httputil.DumpRequestOut(req, true)\n\tif err == nil {\n\t\terr = recorder.Write(req.Context(), &journal.Event{\n\t\t\tAction:  journal.ActionHTTPRequest,\n\t\t\tPayload: map[string]any{\"request\": string(reqBytes)},\n\t\t})\n\t\tif err != nil {\n\t\t\tklog.Errorf(\"Error writing outgoing request to journal: %v\", err)\n\t\t}\n\t}\n\n\t// Pass the request to the next RoundTripper to make the actual network call.\n\tresp, err := jrt.next.RoundTrip(req)\n\tif err != nil {\n\t\twriteErr := recorder.Write(req.Context(), &journal.Event{\n\t\t\tAction:  journal.ActionHTTPError,\n\t\t\tPayload: map[string]any{\"error\": \"http transport failed\", \"detail\": err.Error()},\n\t\t})\n\t\tif writeErr != nil {\n\t\t\tklog.Errorf(\"Error writing RoundTripper error to journal: %v\", writeErr)\n\t\t}\n\t\tklog.Errorf(\"RoundTripper error: %v\", err)\n\t\treturn nil, err\n\t}\n\n\t// Read the entire response body so we can log it and then pass it along.\n\tbodyBytes, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\t// handle error\n\t\tklog.Errorf(\"Error reading response body (for logging): %v\", err)\n\t\treturn nil, err\n\t}\n\tresp.Body.Close() // Close the original body\n\n\t// Default payload is the raw body, for non-streaming responses.\n\tlogPayload := map[string]any{\n\t\t\"status\":  resp.Status,\n\t\t\"headers\": resp.Header,\n\t\t\"body\":    string(bodyBytes),\n\t}\n\n\t// Write the final event to the journal.\n\terr = recorder.Write(req.Context(), &journal.Event{\n\t\tAction:  journal.ActionHTTPResponse,\n\t\tPayload: logPayload,\n\t})\n\tif err != nil {\n\t\t// Log the error and continue\n\t\tklog.Errorf(\"Error writing to journal: %v\", err)\n\t}\n\n\t// IMPORTANT: Return the original, untouched body to the client.\n\tresp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))\n\treturn resp, nil\n}\n\n// withJournaling is a decorator function that wraps an http.Client's transport\n// with the journalingRoundTripper, but only if a recorder is found in the context.\nfunc withJournaling(client *http.Client) *http.Client {\n\t// wrap the transport\n\tclient.Transport = &journalingRoundTripper{\n\t\tnext: client.Transport,\n\t}\n\n\treturn client\n}\n"
  },
  {
    "path": "gollm/interfaces.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"iter\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n)\n\n// Client is a client for a language model.\ntype Client interface {\n\tio.Closer\n\n\t// StartChat starts a new multi-turn chat with a language model.\n\tStartChat(systemPrompt, model string) Chat\n\n\t// GenerateCompletion generates a single completion for a given prompt.\n\tGenerateCompletion(ctx context.Context, req *CompletionRequest) (CompletionResponse, error)\n\n\t// SetResponseSchema constrains LLM responses to match the provided schema.\n\t// Calling with nil will clear the current schema.\n\tSetResponseSchema(schema *Schema) error\n\n\t// ListModels lists the models available in the LLM.\n\tListModels(ctx context.Context) ([]string, error)\n}\n\n// Chat is an active conversation with a language model.\n// Messages are sent and received, and add to a conversation history.\ntype Chat interface {\n\t// Send adds a user message to the chat, and gets the response from the LLM.\n\t// Note that this method automatically updates the state of the Chat,\n\t// you do not need to \"replay\" any messages from the LLM.\n\tSend(ctx context.Context, contents ...any) (ChatResponse, error)\n\n\t// SendStreaming is the streaming version of Send.\n\tSendStreaming(ctx context.Context, contents ...any) (ChatResponseIterator, error)\n\n\t// SetFunctionDefinitions configures the set of tools (functions) available to the LLM\n\t// for function calling.\n\tSetFunctionDefinitions(functionDefinitions []*FunctionDefinition) error\n\n\t// IsRetryableError returns true if the error is retryable.\n\tIsRetryableError(error) bool\n\n\t// Initialize initializes the chat with a previous conversation history.\n\tInitialize(messages []*api.Message) error\n}\n\n// CompletionRequest is a request to generate a completion for a given prompt.\ntype CompletionRequest struct {\n\tModel  string `json:\"model,omitempty\"`\n\tPrompt string `json:\"prompt,omitempty\"`\n}\n\n// CompletionResponse is a response from the GenerateCompletion method.\ntype CompletionResponse interface {\n\tResponse() string\n\tUsageMetadata() any\n}\n\n// FunctionCall is a function call to a language model.\n// The LLM will reply with a FunctionCall to a user-defined function, and we will send the results back.\ntype FunctionCall struct {\n\tID        string         `json:\"id,omitempty\"`\n\tName      string         `json:\"name,omitempty\"`\n\tArguments map[string]any `json:\"arguments,omitempty\"`\n}\n\n// FunctionDefinition is a user-defined function that can be called by the LLM.\n// If the LLM determines the function should be called, it will reply with a FunctionCall object;\n// we will invoke the function and the results back.\ntype FunctionDefinition struct {\n\tName        string  `json:\"name,omitempty\"`\n\tDescription string  `json:\"description,omitempty\"`\n\tParameters  *Schema `json:\"parameters,omitempty\"`\n}\n\n// Schema is a schema for a function definition.\ntype Schema struct {\n\tType        SchemaType         `json:\"type,omitempty\"`\n\tProperties  map[string]*Schema `json:\"properties,omitempty\"`\n\tItems       *Schema            `json:\"items,omitempty\"`\n\tDescription string             `json:\"description,omitempty\"`\n\tRequired    []string           `json:\"required,omitempty\"`\n}\n\n// ToRawSchema converts a Schema to a json.RawMessage.\nfunc (s *Schema) ToRawSchema() (json.RawMessage, error) {\n\tjsonSchema, err := json.Marshal(s)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tool schema to json: %w\", err)\n\t}\n\tvar rawSchema json.RawMessage\n\tif err := json.Unmarshal(jsonSchema, &rawSchema); err != nil {\n\t\treturn nil, fmt.Errorf(\"converting tool schema to json.RawMessage: %w\", err)\n\t}\n\treturn rawSchema, nil\n}\n\n// SchemaType is the type of a field in a Schema.\ntype SchemaType string\n\nconst (\n\tTypeObject SchemaType = \"object\"\n\tTypeArray  SchemaType = \"array\"\n\n\tTypeString  SchemaType = \"string\"\n\tTypeBoolean SchemaType = \"boolean\"\n\tTypeNumber  SchemaType = \"number\"\n\tTypeInteger SchemaType = \"integer\"\n)\n\n// FunctionCallResult is the result of a function call.\n// We use this to send the results back to the LLM.\ntype FunctionCallResult struct {\n\tID     string         `json:\"id,omitempty\"`\n\tName   string         `json:\"name,omitempty\"`\n\tResult map[string]any `json:\"result,omitempty\"`\n}\n\n// ChatResponse is a generic chat response from the LLM.\ntype ChatResponse interface {\n\tUsageMetadata() any\n\n\t// Candidates are a set of candidate responses from the LLM.\n\t// The LLM may return multiple candidates, and we can choose the best one.\n\tCandidates() []Candidate\n}\n\n// ChatResponseIterator is a streaming chat response from the LLM.\ntype ChatResponseIterator iter.Seq2[ChatResponse, error]\n\n// Candidate is one of a set of candidate response from the LLM.\ntype Candidate interface {\n\t// String returns a string representation of the candidate.\n\tfmt.Stringer\n\n\t// Parts returns the parts of the candidate.\n\tParts() []Part\n}\n\n// Part is a part of a candidate response from the LLM.\n// It can be a text response, or a function call.\n// A response may comprise multiple parts,\n// for example a text response and a function call\n// where the text response is \"I need to do the necessary\"\n// and then the function call is \"do_necessary\".\ntype Part interface {\n\t// AsText returns the text of the part.\n\t// if the part is not text, it returns (\"\", false)\n\tAsText() (string, bool)\n\n\t// AsFunctionCalls returns the function calls of the part.\n\t// if the part is not a function call, it returns (nil, false)\n\tAsFunctionCalls() ([]FunctionCall, bool)\n}\n"
  },
  {
    "path": "gollm/llamacpp.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\n\t\"k8s.io/klog/v2\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n)\n\nfunc init() {\n\tif err := RegisterProvider(\"llamacpp\", llamacppFactory); err != nil {\n\t\tklog.Fatalf(\"Failed to register llamacpp provider: %v\", err)\n\t}\n}\n\n// llamacppFactory is the provider factory function for llama.cpp.\n// Supports ClientOptions for custom configuration, including skipVerifySSL.\nfunc llamacppFactory(ctx context.Context, opts ClientOptions) (Client, error) {\n\treturn NewLlamaCppClient(ctx, opts)\n}\n\ntype LlamaCppClient struct {\n\tbaseURL        *url.URL\n\thttpClient     *http.Client\n\tresponseSchema *llamacppSchema\n}\n\ntype LlamaCppChat struct {\n\tclient  *LlamaCppClient\n\tmodel   string\n\thistory []llamacppChatMessage\n\ttools   []llamacppTool\n}\n\nvar _ Client = &LlamaCppClient{}\n\n// NewLlamaCppClient creates a new client for llama.cpp.\n// Supports custom HTTP client and skipVerifySSL via ClientOptions.\nfunc NewLlamaCppClient(ctx context.Context, opts ClientOptions) (*LlamaCppClient, error) {\n\thost := os.Getenv(\"LLAMACPP_HOST\")\n\tif host == \"\" {\n\t\thost = \"http://127.0.0.1:8080/\"\n\t}\n\n\tbaseURL, err := url.Parse(host)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing host %q: %w\", host, err)\n\t}\n\tklog.Infof(\"using llama.cpp with base url %v\", baseURL.String())\n\n\thttpClient := createCustomHTTPClient(opts.SkipVerifySSL)\n\n\treturn &LlamaCppClient{\n\t\tbaseURL:    baseURL,\n\t\thttpClient: httpClient,\n\t}, nil\n}\n\nfunc (c *LlamaCppClient) Close() error {\n\treturn nil\n}\n\nfunc (c *LlamaCppClient) GenerateCompletion(ctx context.Context, request *CompletionRequest) (CompletionResponse, error) {\n\tllamacppRequest := &llamacppCompletionRequest{\n\t\tPrompt:     request.Prompt,\n\t\tJSONSchema: c.responseSchema,\n\t}\n\n\tllamacppResponse, err := c.doCompletion(ctx, llamacppRequest)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif llamacppResponse.Content == \"\" {\n\t\treturn nil, fmt.Errorf(\"no response returned from llamacpp\")\n\t}\n\n\tresponse := &LlamaCppCompletionResponse{llamacppResponse: llamacppResponse}\n\treturn response, nil\n}\n\nfunc (c *LlamaCppClient) doRequest(ctx context.Context, httpMethod, relativePath string, req any, response any) error {\n\tbody, err := json.Marshal(req)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"building json body: %w\", err)\n\t}\n\tu := c.baseURL.JoinPath(relativePath)\n\tklog.V(2).Infof(\"sending %s request to %v: %v\", httpMethod, u.String(), string(body))\n\thttpRequest, err := http.NewRequestWithContext(ctx, httpMethod, u.String(), bytes.NewReader(body))\n\tif err != nil {\n\t\treturn fmt.Errorf(\"building http request: %w\", err)\n\t}\n\thttpRequest.Header.Set(\"Content-Type\", \"application/json\")\n\n\thttpResponse, err := c.httpClient.Do(httpRequest)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"performing http request: %w\", err)\n\t}\n\tdefer httpResponse.Body.Close()\n\n\tb, err := io.ReadAll(httpResponse.Body)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"reading response body: %w\", err)\n\t}\n\n\tif httpResponse.StatusCode != 200 {\n\t\treturn fmt.Errorf(\"unexpected http status: %q with response %q\", httpResponse.Status, string(b))\n\t}\n\n\tif err := json.Unmarshal(b, response); err != nil {\n\t\treturn fmt.Errorf(\"unmarshalling json response: %w\", err)\n\t}\n\n\treturn nil\n}\n\nfunc (c *LlamaCppClient) doCompletion(ctx context.Context, req *llamacppCompletionRequest) (*llamacppCompletionResponse, error) {\n\tcompletionResponse := &llamacppCompletionResponse{}\n\tif err := c.doRequest(ctx, \"POST\", \"completion\", req, completionResponse); err != nil {\n\t\treturn nil, err\n\t}\n\treturn completionResponse, nil\n}\n\nfunc (c *LlamaCppClient) doChat(ctx context.Context, req *llamacppChatRequest) (*llamacppChatResponse, error) {\n\tchatResponse := &llamacppChatResponse{}\n\tif err := c.doRequest(ctx, \"POST\", \"v1/chat/completions\", req, chatResponse); err != nil {\n\t\treturn nil, err\n\t}\n\treturn chatResponse, nil\n}\n\nfunc (c *LlamaCppClient) ListModels(ctx context.Context) ([]string, error) {\n\treturn nil, fmt.Errorf(\"model switching not supported by llama.cpp\")\n}\n\nfunc (c *LlamaCppClient) SetResponseSchema(responseSchema *Schema) error {\n\tllamaSchema := toLlamacppSchema(responseSchema)\n\tc.responseSchema = llamaSchema\n\treturn nil\n}\n\nfunc (c *LlamaCppClient) StartChat(systemPrompt, model string) Chat {\n\treturn &LlamaCppChat{\n\t\tclient: c,\n\t\tmodel:  model,\n\t\thistory: []llamacppChatMessage{\n\t\t\t{\n\t\t\t\tRole:    \"system\",\n\t\t\t\tContent: ptrTo(systemPrompt),\n\t\t\t},\n\t\t},\n\t}\n}\n\ntype LlamaCppCompletionResponse struct {\n\tllamacppResponse *llamacppCompletionResponse\n}\n\nfunc (r *LlamaCppCompletionResponse) Response() string {\n\treturn r.llamacppResponse.Content\n}\n\nfunc (r *LlamaCppCompletionResponse) UsageMetadata() any {\n\treturn nil\n}\n\nfunc (c *LlamaCppChat) Send(ctx context.Context, contents ...any) (ChatResponse, error) {\n\tlog := klog.FromContext(ctx)\n\tfor _, content := range contents {\n\t\tswitch v := content.(type) {\n\t\tcase string:\n\t\t\tmessage := llamacppChatMessage{\n\t\t\t\tRole:    \"user\",\n\t\t\t\tContent: ptrTo(v),\n\t\t\t}\n\t\t\tc.history = append(c.history, message)\n\t\tcase FunctionCallResult:\n\t\t\tresultJSON, err := json.Marshal(v.Result)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"marshalling function call result: %w\", err)\n\t\t\t}\n\n\t\t\tmessage := llamacppChatMessage{\n\t\t\t\tRole: \"tool\",\n\t\t\t\t// TODO: Do we need ToolCallID?  ToolCallID: toolCallId,\n\t\t\t\tContent: ptrTo(string(resultJSON)),\n\t\t\t}\n\t\t\tc.history = append(c.history, message)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"unsupported content type: %T\", v)\n\t\t}\n\t}\n\n\treq := &llamacppChatRequest{\n\t\tModel:    c.model,\n\t\tMessages: c.history,\n\t\t// Stream:   ptrTo(false),\n\t\tTools: c.tools,\n\t}\n\n\tvar llmacppResponse *LlamaCppChatResponse\n\n\tresp, err := c.client.doChat(ctx, req)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.V(2).Info(\"received response from llama.cpp\", \"resp\", resp)\n\tllmacppResponse = &LlamaCppChatResponse{\n\t\tLlamaCppResponse: *resp,\n\t}\n\tfor i, choice := range resp.Choices {\n\t\tcandidate := &LlamaCppCandidate{}\n\n\t\tif choice.Message != nil && choice.Message.Content != nil {\n\t\t\tparts := &LlamaCppPart{\n\t\t\t\ttext: *choice.Message.Content,\n\t\t\t}\n\t\t\tcandidate.parts = append(candidate.parts, parts)\n\t\t}\n\t\tif choice.Message != nil && len(choice.Message.ToolCalls) != 0 {\n\t\t\tvar functionCalls []FunctionCall\n\t\t\tfor _, toolCall := range choice.Message.ToolCalls {\n\t\t\t\tfunctionCall := FunctionCall{\n\t\t\t\t\tName: toolCall.Function.Name,\n\t\t\t\t}\n\n\t\t\t\tif toolCall.Function.Arguments != \"\" {\n\t\t\t\t\targuments := make(map[string]any)\n\t\t\t\t\tif err := json.Unmarshal([]byte(toolCall.Function.Arguments), &arguments); err != nil {\n\t\t\t\t\t\treturn nil, fmt.Errorf(\"parsing function call arguments: %w\", err)\n\t\t\t\t\t}\n\t\t\t\t\tfunctionCall.Arguments = arguments\n\t\t\t\t}\n\t\t\t\tfunctionCalls = append(functionCalls, functionCall)\n\t\t\t}\n\n\t\t\tparts := &LlamaCppPart{\n\t\t\t\tfunctionCalls: functionCalls,\n\t\t\t}\n\t\t\tcandidate.parts = append(candidate.parts, parts)\n\t\t}\n\t\tllmacppResponse.candidates = append(llmacppResponse.candidates, candidate)\n\n\t\tif i == 0 {\n\t\t\tif choice.Message != nil {\n\t\t\t\tmsg := llamacppChatMessage{\n\t\t\t\t\tRole:       \"assistant\",\n\t\t\t\t\tContent:    choice.Message.Content,\n\t\t\t\t\tToolCalls:  choice.Message.ToolCalls,\n\t\t\t\t\tToolCallID: choice.Message.ToolCallID,\n\t\t\t\t}\n\t\t\t\tc.history = append(c.history, msg)\n\t\t\t}\n\t\t}\n\t}\n\n\treturn llmacppResponse, nil\n}\n\nfunc (c *LlamaCppChat) SendStreaming(ctx context.Context, contents ...any) (ChatResponseIterator, error) {\n\t// TODO: Implement streaming\n\tresponse, err := c.Send(ctx, contents...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn singletonChatResponseIterator(response), nil\n}\n\nfunc (c *LlamaCppChat) IsRetryableError(err error) bool {\n\t// TODO(droot): Implement this\n\treturn false\n}\n\nfunc (c *LlamaCppChat) Initialize(messages []*api.Message) error {\n\tklog.Warning(\"chat history persistence is not supported for provider 'llamacpp', using in-memory chat history\")\n\treturn nil\n}\n\nfunc ptrTo[T any](t T) *T {\n\treturn &t\n}\n\ntype LlamaCppChatResponse struct {\n\tcandidates       []*LlamaCppCandidate\n\tLlamaCppResponse llamacppChatResponse\n}\n\nvar _ ChatResponse = &LlamaCppChatResponse{}\n\nfunc (r *LlamaCppChatResponse) MarshalJSON() ([]byte, error) {\n\tformatted := RecordChatResponse{\n\t\tRaw: r.LlamaCppResponse,\n\t}\n\treturn json.Marshal(&formatted)\n}\n\nfunc (r *LlamaCppChatResponse) String() string {\n\treturn fmt.Sprintf(\"LlamaCppChatResponse{candidates=%v}\", r.candidates)\n}\n\n// func (r *LlamaCppChatResponse) String() string {\n// \tvar sb strings.Builder\n\n// \tfmt.Fprintf(&sb, \"LlamaCppChatResponse{candidates=[\")\n// \tfor _, candidate := range r.candidates {\n// \t\tfmt.Fprintf(&sb, \"%v\", candidate)\n// \t}\n// \tfmt.Fprintf(&sb, \"]}\")\n// \treturn sb.String()\n// }\n\nfunc (r *LlamaCppChatResponse) UsageMetadata() any {\n\treturn nil\n}\n\nfunc (r *LlamaCppChatResponse) Candidates() []Candidate {\n\tvar cads []Candidate\n\tfor _, candidate := range r.candidates {\n\t\tcads = append(cads, candidate)\n\t}\n\treturn cads\n}\n\ntype LlamaCppCandidate struct {\n\tparts []*LlamaCppPart\n}\n\nfunc (r *LlamaCppCandidate) String() string {\n\treturn r.parts[0].text\n}\n\nfunc (r *LlamaCppCandidate) Parts() []Part {\n\tvar out []Part\n\tfor _, part := range r.parts {\n\t\tout = append(out, part)\n\t}\n\treturn out\n}\n\ntype LlamaCppPart struct {\n\ttext          string\n\tfunctionCalls []FunctionCall\n}\n\nfunc (p *LlamaCppPart) AsText() (string, bool) {\n\tif len(p.text) > 0 {\n\t\treturn p.text, true\n\t}\n\treturn \"\", false\n}\n\nfunc (p *LlamaCppPart) AsFunctionCalls() ([]FunctionCall, bool) {\n\tif len(p.functionCalls) > 0 {\n\t\treturn p.functionCalls, true\n\t}\n\treturn nil, false\n}\n\nfunc (c *LlamaCppChat) SetFunctionDefinitions(functionDefinitions []*FunctionDefinition) error {\n\tvar tools []llamacppTool\n\tfor _, functionDefinition := range functionDefinitions {\n\t\ttools = append(tools, toLlamacppTool(functionDefinition))\n\t}\n\tc.tools = tools\n\treturn nil\n}\n\nfunc toLlamacppTool(fnDef *FunctionDefinition) llamacppTool {\n\tfunction := &llamacppFunction{\n\t\tDescription: fnDef.Description,\n\t\tName:        fnDef.Name,\n\t}\n\n\tif fnDef.Parameters != nil {\n\t\tfunction.Parameters = toLlamacppSchema(fnDef.Parameters)\n\t}\n\n\ttool := llamacppTool{\n\t\tType:     \"function\",\n\t\tFunction: function,\n\t}\n\n\treturn tool\n}\n\nfunc toLlamacppSchema(in *Schema) *llamacppSchema {\n\tif in == nil {\n\t\treturn nil\n\t}\n\n\tout := &llamacppSchema{\n\t\tType:        string(in.Type),\n\t\tItems:       toLlamacppSchema(in.Items),\n\t\tDescription: in.Description,\n\t\tRequired:    in.Required,\n\t}\n\n\tif in.Properties != nil {\n\t\tout.Properties = make(map[string]llamacppSchema, len(in.Properties))\n\t\tfor k, v := range in.Properties {\n\t\t\tout.Properties[k] = *toLlamacppSchema(v)\n\t\t}\n\t}\n\n\treturn out\n}\n\ntype llamacppCompletionRequest struct {\n\t// See https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md#post-completion-given-a-prompt-it-returns-the-predicted-completion\n\n\tPrompt string `json:\"prompt,omitempty\"`\n\n\tJSONSchema *llamacppSchema `json:\"json_schema,omitempty\"`\n}\n\ntype llamacppCompletionResponse struct {\n\tContent string `json:\"content,omitempty\"`\n\n\tIndex int32 `json:\"index,omitempty\"`\n\n\tIDSlot int32 `json:\"id_slot,omitempty\"`\n\n\tStop bool `json:\"stop,omitempty\"`\n\n\tModel string `json:\"model,omitempty\"`\n\n\tTokensPredicted int32 `json:\"tokens_predicted,omitempty\"`\n\n\tTokensEvaluated int32 `json:\"tokens_evaluated,omitempty\"`\n\n\t// \"generation_settings\":{\"n_predict\":-1,\"seed\":4294967295,\"temperature\":0.800000011920929,\"dynatemp_range\":0.0,\"dynatemp_exponent\":1.0,\"top_k\":40,\"top_p\":0.949999988079071,\"min_p\":0.05000000074505806,\"xtc_probability\":0.0,\"xtc_threshold\":0.10000000149011612,\"typical_p\":1.0,\"repeat_last_n\":64,\"repeat_penalty\":1.0,\"presence_penalty\":0.0,\"frequency_penalty\":0.0,\"dry_multiplier\":0.0,\"dry_base\":1.75,\"dry_allowed_length\":2,\"dry_penalty_last_n\":16384,\"dry_sequence_breakers\":[\"\\n\",\":\",\"\\\"\",\"*\"],\"mirostat\":0,\"mirostat_tau\":5.0,\"mirostat_eta\":0.10000000149011612,\"stop\":[],\"max_tokens\":-1,\"n_keep\":0,\"n_discard\":0,\"ignore_eos\":false,\"stream\":false,\"logit_bias\":[],\"n_probs\":0,\"min_keep\":0,\"grammar\":\"\",\"grammar_lazy\":false,\"grammar_triggers\":[],\"preserved_tokens\":[],\"chat_format\":\"Content-only\",\"samplers\":[\"penalties\",\"dry\",\"top_k\",\"typ_p\",\"top_p\",\"min_p\",\"xtc\",\"temperature\"],\"speculative.n_max\":16,\"speculative.n_min\":0,\"speculative.p_min\":0.75,\"timings_per_token\":false,\"post_sampling_probs\":false,\"lora\":[]},\n\t// GenerationSettings llamacppGenerationSettings `json:\"generation_settings,omitempty\"`\n\n\tPrompt string `json:\"prompt,omitempty\"`\n\n\tHasNewLine bool `json:\"has_new_line,omitempty\"`\n\n\tTruncated bool `json:\"truncated,omitempty\"`\n\n\tStopType string `json:\"stop_type,omitempty\"`\n\n\tStoppingWord string `json:\"stopping_word,omitempty\"`\n\n\tTokensCached int32 `json:\"tokens_cached,omitempty\"`\n\n\tTimings llamacppTimings `json:\"timings,omitempty\"`\n}\n\ntype llamacppTimings struct {\n\tPromptN             int32   `json:\"prompt_n,omitempty\"`\n\tPromptMs            float64 `json:\"prompt_ms,omitempty\"`\n\tPromptPerTokenMs    float64 `json:\"prompt_per_token_ms,omitempty\"`\n\tPromptPerSecond     float64 `json:\"prompt_per_second,omitempty\"`\n\tPredictedN          int32   `json:\"predicted_n,omitempty\"`\n\tPredictedMs         float64 `json:\"predicted_ms,omitempty\"`\n\tPredictedPerTokenMs float64 `json:\"predicted_per_token_ms,omitempty\"`\n\tPredictedPerSecond  float64 `json:\"predicted_per_second,omitempty\"`\n}\n\ntype llamacppChatRequest struct {\n\tModel    string                `json:\"model,omitempty\"`\n\tMessages []llamacppChatMessage `json:\"messages,omitempty\"`\n\tTools    []llamacppTool        `json:\"tools,omitempty\"`\n}\n\ntype llamacppChatResponse struct {\n\tChoices           []llamacppChoice `json:\"choices,omitempty\"`\n\tCreated           int64            `json:\"created,omitempty\"`\n\tModel             string           `json:\"model,omitempty\"`\n\tSystemFingerprint string           `json:\"system_fingerprint,omitempty\"`\n\tObject            string           `json:\"object,omitempty\"`\n\tUsage             *llamacppUsage   `json:\"usage,omitempty\"`\n\tId                string           `json:\"id,omitempty\"`\n\tTimings           *llamacppTimings `json:\"timings,omitempty\"`\n}\n\ntype llamacppChoice struct {\n\tFinishReason string               `json:\"finish_reason,omitempty\"`\n\tIndex        int32                `json:\"index,omitempty\"`\n\tMessage      *llamacppChatMessage `json:\"message,omitempty\"`\n}\n\ntype llamacppUsage struct {\n\tCompletionTokens int32 `json:\"completion_tokens,omitempty\"`\n\tPromptTokens     int32 `json:\"prompt_tokens,omitempty\"`\n\tTotalTokens      int32 `json:\"total_tokens,omitempty\"`\n}\n\ntype llamacppChatMessage struct {\n\tRole       string             `json:\"role,omitempty\"`\n\tContent    *string            `json:\"content,omitempty\"`\n\tToolCalls  []llamacppToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string             `json:\"tool_call_id,omitempty\"`\n}\n\ntype llamacppToolCall struct {\n\tType     string               `json:\"type,omitempty\"`\n\tFunction llamacppFunctionCall `json:\"function,omitempty\"`\n}\n\ntype llamacppFunctionCall struct {\n\tName      string `json:\"name,omitempty\"`\n\tArguments string `json:\"arguments,omitempty\"`\n\tID        string `json:\"id,omitempty\"`\n}\n\ntype llamacppTool struct {\n\tType     string            `json:\"type,omitempty\"`\n\tFunction *llamacppFunction `json:\"function,omitempty\"`\n}\n\ntype llamacppFunction struct {\n\tDescription string          `json:\"description,omitempty\"`\n\tName        string          `json:\"name,omitempty\"`\n\tParameters  *llamacppSchema `json:\"parameters,omitempty\"`\n}\n\ntype llamacppSchema struct {\n\tType        string                    `json:\"type,omitempty\"`\n\tRequired    []string                  `json:\"required,omitempty\"`\n\tItems       *llamacppSchema           `json:\"items,omitempty\"`\n\tProperties  map[string]llamacppSchema `json:\"properties,omitempty\"`\n\tDescription string                    `json:\"description,omitempty\"`\n\tEnum        []string                  `json:\"enum,omitempty\"`\n}\n"
  },
  {
    "path": "gollm/ollama.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\n\t\"github.com/ollama/ollama/api\"\n\t\"github.com/ollama/ollama/envconfig\"\n\t\"k8s.io/klog/v2\"\n\n\tkctlApi \"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n)\n\nfunc init() {\n\tif err := RegisterProvider(\"ollama\", ollamaFactory); err != nil {\n\t\tklog.Fatalf(\"Failed to register ollama provider: %v\", err)\n\t}\n}\n\n// ollamaFactory is the provider factory function for Ollama.\n// Supports ClientOptions for custom configuration, including skipVerifySSL.\nfunc ollamaFactory(ctx context.Context, opts ClientOptions) (Client, error) {\n\treturn NewOllamaClient(ctx, opts)\n}\n\nconst (\n\tdefaultOllamaModel = \"gemma3:latest\"\n)\n\ntype OllamaClient struct {\n\tclient *api.Client\n}\n\ntype OllamaChat struct {\n\tclient  *api.Client\n\tmodel   string\n\thistory []api.Message\n\ttools   []api.Tool\n}\n\nvar _ Client = &OllamaClient{}\n\n// NewOllamaClient creates a new client for Ollama.\n// Supports custom HTTP client and skipVerifySSL via ClientOptions if the SDK supports it.\nfunc NewOllamaClient(ctx context.Context, opts ClientOptions) (*OllamaClient, error) {\n\t// Create custom HTTP client with SSL verification option from client options\n\thttpClient := createCustomHTTPClient(opts.SkipVerifySSL)\n\tclient := api.NewClient(envconfig.Host(), httpClient)\n\n\treturn &OllamaClient{\n\t\tclient: client,\n\t}, nil\n}\n\nfunc (c *OllamaClient) Close() error {\n\treturn nil\n}\n\nfunc (c *OllamaClient) GenerateCompletion(ctx context.Context, request *CompletionRequest) (CompletionResponse, error) {\n\treq := &api.GenerateRequest{\n\t\tModel:  request.Model,\n\t\tPrompt: request.Prompt,\n\t\tStream: ptrTo(false),\n\t}\n\n\tvar ollamaResponse *OllamaCompletionResponse\n\n\trespFunc := func(resp api.GenerateResponse) error {\n\t\tollamaResponse = &OllamaCompletionResponse{response: resp.Response}\n\t\treturn nil\n\t}\n\n\terr := c.client.Generate(ctx, req, respFunc)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn ollamaResponse, nil\n}\n\nfunc (c *OllamaClient) ListModels(ctx context.Context) ([]string, error) {\n\tmodelResponse, err := c.client.List(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar models []string\n\tfor _, model := range modelResponse.Models {\n\t\tmodels = append(models, model.Name)\n\t}\n\n\treturn models, nil\n}\n\nfunc (c *OllamaClient) SetResponseSchema(schema *Schema) error {\n\treturn nil\n}\n\nfunc (c *OllamaClient) StartChat(systemPrompt, model string) Chat {\n\treturn &OllamaChat{\n\t\tclient: c.client,\n\t\tmodel:  model,\n\t\thistory: []api.Message{\n\t\t\t{\n\t\t\t\tRole:    \"system\",\n\t\t\t\tContent: systemPrompt,\n\t\t\t},\n\t\t},\n\t}\n}\n\ntype OllamaCompletionResponse struct {\n\tresponse string\n}\n\nfunc (r *OllamaCompletionResponse) Response() string {\n\treturn r.response\n}\n\nfunc (r *OllamaCompletionResponse) UsageMetadata() any {\n\treturn nil\n}\n\nfunc (c *OllamaChat) Send(ctx context.Context, contents ...any) (ChatResponse, error) {\n\tlog := klog.FromContext(ctx)\n\tfor _, content := range contents {\n\t\tswitch v := content.(type) {\n\t\tcase string:\n\t\t\tmessage := api.Message{\n\t\t\t\tRole:    \"user\",\n\t\t\t\tContent: v,\n\t\t\t}\n\t\t\tc.history = append(c.history, message)\n\t\tcase FunctionCallResult:\n\t\t\tmessage := api.Message{\n\t\t\t\tRole:    \"user\",\n\t\t\t\tContent: fmt.Sprintf(\"Function call result: %s\", v.Result),\n\t\t\t}\n\t\t\tc.history = append(c.history, message)\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"unsupported content type: %T\", v)\n\t\t}\n\t}\n\n\treq := &api.ChatRequest{\n\t\tModel:    c.model,\n\t\tMessages: c.history,\n\t\t// set streaming to false\n\t\tStream: new(bool),\n\t\tTools:  c.tools,\n\t}\n\n\tvar ollamaResponse *OllamaChatResponse\n\n\trespFunc := func(resp api.ChatResponse) error {\n\t\tlog.Info(\"received response from ollama\", \"resp\", resp)\n\t\tollamaResponse = &OllamaChatResponse{\n\t\t\tollamaResponse: resp,\n\t\t\tcandidates: []*OllamaCandidate{\n\t\t\t\t{\n\t\t\t\t\tparts: []OllamaPart{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttext:      resp.Message.Content,\n\t\t\t\t\t\t\ttoolCalls: resp.Message.ToolCalls,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t\tc.history = append(c.history, resp.Message)\n\t\treturn nil\n\t}\n\n\terr := c.client.Chat(ctx, req, respFunc)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.Info(\"ollama response\", \"parsed_response\", ollamaResponse)\n\treturn ollamaResponse, nil\n}\n\nfunc (c *OllamaChat) IsRetryableError(err error) bool {\n\t// TODO(droot): Implement this\n\treturn false\n}\n\nfunc (c *OllamaChat) SendStreaming(ctx context.Context, contents ...any) (ChatResponseIterator, error) {\n\t// TODO: Implement streaming\n\tresponse, err := c.Send(ctx, contents...)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn singletonChatResponseIterator(response), nil\n}\n\nfunc (c *OllamaChat) Initialize(messages []*kctlApi.Message) error {\n\tklog.Warning(\"chat history persistence is not supported for provider 'ollama', using in-memory chat history\")\n\treturn nil\n}\n\ntype OllamaChatResponse struct {\n\tcandidates     []*OllamaCandidate\n\tollamaResponse api.ChatResponse\n}\n\nvar _ ChatResponse = &OllamaChatResponse{}\n\nfunc (r *OllamaChatResponse) MarshalJSON() ([]byte, error) {\n\tformatted := RecordChatResponse{\n\t\tRaw: r.ollamaResponse,\n\t}\n\treturn json.Marshal(&formatted)\n}\n\nfunc (r *OllamaChatResponse) String() string {\n\treturn fmt.Sprintf(\"OllamaChatResponse{candidates=%v}\", r.candidates)\n}\n\nfunc (r *OllamaChatResponse) UsageMetadata() any {\n\treturn nil\n}\n\nfunc (r *OllamaChatResponse) Candidates() []Candidate {\n\tvar cads []Candidate\n\tfor _, candidate := range r.candidates {\n\t\tcads = append(cads, candidate)\n\t}\n\treturn cads\n}\n\ntype OllamaCandidate struct {\n\tparts []OllamaPart\n}\n\nfunc (r *OllamaCandidate) String() string {\n\treturn r.parts[0].text\n}\n\nfunc (r *OllamaCandidate) Parts() []Part {\n\tvar parts []Part\n\tfor _, part := range r.parts {\n\t\tparts = append(parts, &OllamaPart{\n\t\t\ttext:      part.text,\n\t\t\ttoolCalls: part.toolCalls,\n\t\t})\n\t}\n\treturn parts\n}\n\ntype OllamaPart struct {\n\ttext      string\n\ttoolCalls []api.ToolCall\n}\n\nfunc (p *OllamaPart) AsText() (string, bool) {\n\tif len(p.text) > 0 {\n\t\treturn p.text, true\n\t}\n\treturn \"\", false\n}\n\nfunc (p *OllamaPart) AsFunctionCalls() ([]FunctionCall, bool) {\n\tif len(p.toolCalls) > 0 {\n\t\tvar functionCalls []FunctionCall\n\t\tfor _, toolCall := range p.toolCalls {\n\t\t\tfunctionCalls = append(functionCalls, FunctionCall{\n\t\t\t\tName:      toolCall.Function.Name,\n\t\t\t\tArguments: toolCall.Function.Arguments,\n\t\t\t})\n\t\t}\n\t\treturn functionCalls, true\n\t}\n\treturn nil, false\n}\n\nfunc (c *OllamaChat) SetFunctionDefinitions(functionDefinitions []*FunctionDefinition) error {\n\tvar tools []api.Tool\n\tfor _, functionDefinition := range functionDefinitions {\n\t\ttools = append(tools, fnDefToOllamaTool(functionDefinition))\n\t}\n\tc.tools = tools\n\treturn nil\n}\n\nfunc fnDefToOllamaTool(fnDef *FunctionDefinition) api.Tool {\n\ttool := api.Tool{\n\t\tType: \"function\",\n\t\tFunction: api.ToolFunction{\n\t\t\tName:        fnDef.Name,\n\t\t\tDescription: fnDef.Description,\n\t\t\tParameters: struct {\n\t\t\t\tType       string   `json:\"type\"`\n\t\t\t\tRequired   []string `json:\"required\"`\n\t\t\t\tProperties map[string]struct {\n\t\t\t\t\tType        string   `json:\"type\"`\n\t\t\t\t\tDescription string   `json:\"description\"`\n\t\t\t\t\tEnum        []string `json:\"enum,omitempty\"`\n\t\t\t\t} `json:\"properties\"`\n\t\t\t}{\n\t\t\t\tType:     \"object\",\n\t\t\t\tRequired: fnDef.Parameters.Required,\n\t\t\t\tProperties: map[string]struct {\n\t\t\t\t\tType        string   `json:\"type\"`\n\t\t\t\t\tDescription string   `json:\"description\"`\n\t\t\t\t\tEnum        []string `json:\"enum,omitempty\"`\n\t\t\t\t}{},\n\t\t\t},\n\t\t},\n\t}\n\n\tfor paramName, param := range fnDef.Parameters.Properties {\n\t\ttool.Function.Parameters.Properties[paramName] = struct {\n\t\t\tType        string   `json:\"type\"`\n\t\t\tDescription string   `json:\"description\"`\n\t\t\tEnum        []string `json:\"enum,omitempty\"`\n\t\t}{\n\t\t\tType:        string(param.Type),\n\t\t\tDescription: param.Description,\n\t\t}\n\t}\n\n\treturn tool\n}\n"
  },
  {
    "path": "gollm/openai.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\topenai \"github.com/openai/openai-go\"\n\t\"github.com/openai/openai-go/option\"\n\t\"github.com/openai/openai-go/responses\"\n\t\"k8s.io/klog/v2\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n)\n\n// Package-level env var storage (OpenAI env)\nvar (\n\topenAIAPIKey          string\n\topenAIEndpoint        string\n\topenAIAPIBase         string\n\topenAIModel           string\n\topenAIUseResponsesAPI bool\n)\n\n// init reads and caches OpenAI environment variables:\n//   - OPENAI_API_KEY, OPENAI_ENDPOINT, OPENAI_API_BASE, OPENAI_MODEL\n//\n// These serve as defaults; the model can be overridden by the Cobra --model flag.\n// After loading env values, it registers the OpenAI provider factory.\nfunc init() {\n\t// Load environment variables\n\topenAIAPIKey = os.Getenv(\"OPENAI_API_KEY\")\n\topenAIEndpoint = os.Getenv(\"OPENAI_ENDPOINT\")\n\topenAIAPIBase = os.Getenv(\"OPENAI_API_BASE\")\n\topenAIModel = os.Getenv(\"OPENAI_MODEL\")\n\n\tif val := os.Getenv(\"OPENAI_USE_RESPONSES_API\"); strings.ToLower(val) == \"true\" {\n\t\topenAIUseResponsesAPI = true\n\t\tklog.InfoS(\"Using responses API for openai\",\n\t\t\t\"baseURL\", openAIAPIBase, \"endpoint\", openAIEndpoint, \"model\", openAIModel)\n\t}\n\n\t// Register \"openai\" as the provider ID\n\tif err := RegisterProvider(\"openai\", newOpenAIClientFactory); err != nil {\n\t\tklog.Fatalf(\"Failed to register openai provider: %v\", err)\n\t}\n\n\t// Also register with any aliases defined in config\n\taliases := []string{\"openai-compatible\"}\n\tfor _, alias := range aliases {\n\t\tif err := RegisterProvider(alias, newOpenAIClientFactory); err != nil {\n\t\t\tklog.Warningf(\"Failed to register openai provider alias %q: %v\", alias, err)\n\t\t}\n\t}\n}\n\n// OpenAIClient implements the gollm.Client interface for OpenAI models.\ntype OpenAIClient struct {\n\tclient openai.Client\n}\n\n// Ensure OpenAIClient implements the Client interface.\nvar _ Client = &OpenAIClient{}\n\n// NewOpenAIClient creates a new client for interacting with OpenAI.\n// Supports custom HTTP client (e.g., for skipping SSL verification).\nfunc NewOpenAIClient(ctx context.Context, opts ClientOptions) (*OpenAIClient, error) {\n\t// Get API key from loaded env var\n\tapiKey := openAIAPIKey\n\tif apiKey == \"\" {\n\t\treturn nil, errors.New(\"OpenAI API key not found. Set via OPENAI_API_KEY env var\")\n\t}\n\n\t// Set options for client creation\n\toptions := []option.RequestOption{option.WithAPIKey(apiKey)}\n\n\t// Check for custom endpoint or API base URL\n\tbaseURL := openAIEndpoint\n\tif baseURL == \"\" {\n\t\tbaseURL = openAIAPIBase\n\t}\n\n\tif baseURL != \"\" {\n\t\tklog.Infof(\"Using custom OpenAI base URL: %s\", baseURL)\n\t\toptions = append(options, option.WithBaseURL(baseURL))\n\t}\n\n\t// Support custom HTTP client (e.g., skip SSL verification)\n\thttpClient := createCustomHTTPClient(opts.SkipVerifySSL)\n\thttpClient = withJournaling(httpClient)\n\toptions = append(options, option.WithHTTPClient(httpClient))\n\n\treturn &OpenAIClient{\n\t\tclient: openai.NewClient(options...),\n\t}, nil\n}\n\n// Close cleans up any resources used by the client.\nfunc (c *OpenAIClient) Close() error {\n\t// No specific cleanup needed for the OpenAI client currently.\n\treturn nil\n}\n\n// StartChat starts a new chat session.\nfunc (c *OpenAIClient) StartChat(systemPrompt, model string) Chat {\n\t// Get the model to use for this chat\n\tselectedModel := getOpenAIModel(model)\n\n\tklog.V(1).Infof(\"Starting new OpenAI chat session with model: %s\", selectedModel)\n\n\tif openAIUseResponsesAPI {\n\t\t// Initialize history with system prompt if provided\n\t\thistory := responses.ResponseInputParam{}\n\t\tif systemPrompt != \"\" {\n\t\t\thistory = append(history, responses.ResponseInputItemUnionParam{\n\t\t\t\tOfMessage: &responses.EasyInputMessageParam{\n\t\t\t\t\tContent: responses.EasyInputMessageContentUnionParam{\n\t\t\t\t\t\tOfString: openai.String(systemPrompt),\n\t\t\t\t\t},\n\t\t\t\t\tRole: responses.EasyInputMessageRoleSystem,\n\t\t\t\t},\n\t\t\t})\n\t\t}\n\n\t\treturn &openAIResponseChatSession{\n\t\t\tclient:  c.client,\n\t\t\thistory: history,\n\t\t\tmodel:   selectedModel,\n\t\t\t// functionDefinitions and tools will be set later via SetFunctionDefinitions\n\t\t\tparams: responses.ResponseNewParams{\n\t\t\t\tModel:           selectedModel,\n\t\t\t\tTemperature:     openai.Float(0.2),\n\t\t\t\tMaxOutputTokens: openai.Int(2048),\n\t\t\t\tReasoning: responses.ReasoningParam{\n\t\t\t\t\tEffort: responses.ReasoningEffortLow,\n\t\t\t\t},\n\t\t\t\tStore: openai.Bool(false),\n\t\t\t},\n\t\t}\n\t}\n\t// by default use completion endpoint\n\n\t// Initialize history with system prompt if provided\n\thistory := []openai.ChatCompletionMessageParamUnion{}\n\tif systemPrompt != \"\" {\n\t\thistory = append(history, openai.SystemMessage(systemPrompt))\n\t}\n\n\treturn &openAIChatSession{\n\t\tclient:  c.client,\n\t\thistory: history,\n\t\tmodel:   selectedModel,\n\t\t// functionDefinitions and tools will be set later via SetFunctionDefinitions\n\t}\n}\n\n// simpleCompletionResponse is a basic implementation of CompletionResponse.\ntype simpleCompletionResponse struct {\n\tcontent string\n}\n\n// Response returns the completion content.\nfunc (r *simpleCompletionResponse) Response() string {\n\treturn r.content\n}\n\n// UsageMetadata returns nil for now.\nfunc (r *simpleCompletionResponse) UsageMetadata() any {\n\treturn nil\n}\n\n// GenerateCompletion sends a completion request to the OpenAI API.\nfunc (c *OpenAIClient) GenerateCompletion(ctx context.Context, req *CompletionRequest) (CompletionResponse, error) {\n\tklog.Infof(\"OpenAI GenerateCompletion called with model: %s\", req.Model)\n\tklog.V(1).Infof(\"Prompt:\\n%s\", req.Prompt)\n\n\t// Use the Chat Completions API with the new v1.0.0 API\n\tcompletion, err := c.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{\n\t\tModel: openai.ChatModel(req.Model),\n\t\tMessages: []openai.ChatCompletionMessageParamUnion{\n\t\t\topenai.UserMessage(req.Prompt),\n\t\t},\n\t})\n\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to generate OpenAI completion: %w\", err)\n\t}\n\n\t// Check if there are choices and a message\n\tif len(completion.Choices) == 0 || completion.Choices[0].Message.Content == \"\" {\n\t\treturn nil, errors.New(\"received an empty response from OpenAI\")\n\t}\n\n\t// Return the content of the first choice\n\tresp := &simpleCompletionResponse{\n\t\tcontent: completion.Choices[0].Message.Content,\n\t}\n\n\treturn resp, nil\n}\n\n// SetResponseSchema is not implemented yet.\nfunc (c *OpenAIClient) SetResponseSchema(schema *Schema) error {\n\tklog.Warning(\"OpenAIClient.SetResponseSchema is not implemented yet\")\n\treturn nil\n}\n\n// ListModels returns a slice of strings with model IDs.\n// Note: This may not work with all OpenAI-compatible providers if they don't fully implement\n// the Models.List endpoint or return data in a different format.\nfunc (c *OpenAIClient) ListModels(ctx context.Context) ([]string, error) {\n\tres, err := c.client.Models.List(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error listing models from OpenAI: %w\", err)\n\t}\n\n\tmodelIDs := make([]string, 0, len(res.Data))\n\tfor _, model := range res.Data {\n\t\tmodelIDs = append(modelIDs, model.ID)\n\t}\n\n\treturn modelIDs, nil\n}\n\n// Chat Session Implementation\n\ntype openAIChatSession struct {\n\tclient              openai.Client\n\thistory             []openai.ChatCompletionMessageParamUnion\n\tmodel               string\n\tfunctionDefinitions []*FunctionDefinition            // Stored in gollm format\n\ttools               []openai.ChatCompletionToolParam // Stored in OpenAI format\n}\n\n// Ensure openAIChatSession implements the Chat interface.\nvar _ Chat = (*openAIChatSession)(nil)\n\n// SetFunctionDefinitions stores the function definitions and converts them to OpenAI format.\nfunc (cs *openAIChatSession) SetFunctionDefinitions(defs []*FunctionDefinition) error {\n\tcs.functionDefinitions = defs\n\tcs.tools = nil // Clear previous tools\n\tif len(defs) > 0 {\n\t\tcs.tools = make([]openai.ChatCompletionToolParam, len(defs))\n\t\tfor i, gollmDef := range defs {\n\t\t\tklog.Infof(\"Processing function definition: %s\", gollmDef.Name)\n\n\t\t\t// Process function parameters\n\t\t\tparams, err := cs.convertFunctionParameters(gollmDef)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to process parameters for function %s: %w\", gollmDef.Name, err)\n\t\t\t}\n\n\t\t\tcs.tools[i] = openai.ChatCompletionToolParam{\n\t\t\t\tFunction: openai.FunctionDefinitionParam{\n\t\t\t\t\tName:        gollmDef.Name,\n\t\t\t\t\tDescription: openai.String(gollmDef.Description),\n\t\t\t\t\tParameters:  params,\n\t\t\t\t},\n\t\t\t}\n\t\t}\n\t}\n\tklog.V(1).Infof(\"Set %d function definitions for OpenAI chat session\", len(cs.functionDefinitions))\n\treturn nil\n}\n\n// Send sends the user message(s), appends to history, and gets the LLM response.\nfunc (cs *openAIChatSession) Send(ctx context.Context, contents ...any) (ChatResponse, error) {\n\tklog.V(1).InfoS(\"openAIChatSession.Send called\", \"model\", cs.model, \"history_len\", len(cs.history))\n\n\t// Process and append messages to history\n\tif err := cs.addContentsToHistory(contents); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Prepare and send API request\n\tchatReq := openai.ChatCompletionNewParams{\n\t\tModel:    openai.ChatModel(cs.model),\n\t\tMessages: cs.history,\n\t}\n\tif len(cs.tools) > 0 {\n\t\tchatReq.Tools = cs.tools\n\t}\n\n\t// Call the OpenAI API\n\tklog.V(1).InfoS(\"Sending request to OpenAI Chat API\", \"model\", cs.model, \"messages\", len(chatReq.Messages), \"tools\", len(chatReq.Tools))\n\tcompletion, err := cs.client.Chat.Completions.New(ctx, chatReq)\n\tif err != nil {\n\t\t// TODO: Check if error is retryable using cs.IsRetryableError\n\t\tklog.Errorf(\"OpenAI ChatCompletion API error: %v\", err)\n\t\treturn nil, fmt.Errorf(\"OpenAI chat completion failed: %w\", err)\n\t}\n\tklog.V(1).InfoS(\"Received response from OpenAI Chat API\", \"id\", completion.ID, \"choices\", len(completion.Choices))\n\n\t// Process the response\n\tif len(completion.Choices) == 0 {\n\t\tklog.Warning(\"Received response with no choices from OpenAI\")\n\t\treturn nil, errors.New(\"received empty response from OpenAI (no choices)\")\n\t}\n\n\t// Add assistant's response (first choice) to history\n\tassistantMsg := completion.Choices[0].Message\n\t// Convert to param type before appending to history\n\tcs.history = append(cs.history, assistantMsg.ToParam())\n\tklog.V(2).InfoS(\"Added assistant message to history\", \"content_present\", assistantMsg.Content != \"\", \"tool_calls\", len(assistantMsg.ToolCalls))\n\n\t// Wrap the response\n\tresp := &openAIChatResponse{\n\t\topenaiCompletion: completion,\n\t}\n\n\treturn resp, nil\n}\n\n// SendStreaming sends the user message(s) and returns an iterator for the LLM response stream.\nfunc (cs *openAIChatSession) SendStreaming(ctx context.Context, contents ...any) (ChatResponseIterator, error) {\n\tklog.V(1).InfoS(\"Starting OpenAI streaming request\", \"model\", cs.model)\n\n\t// Process and append messages to history\n\tif err := cs.addContentsToHistory(contents); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Prepare and send API request\n\tchatReq := openai.ChatCompletionNewParams{\n\t\tModel:    openai.ChatModel(cs.model),\n\t\tMessages: cs.history,\n\t}\n\tif len(cs.tools) > 0 {\n\t\tchatReq.Tools = cs.tools\n\t}\n\n\t// Start the OpenAI streaming request\n\tklog.V(1).InfoS(\"Sending streaming request to OpenAI API\",\n\t\t\"model\", cs.model,\n\t\t\"messageCount\", len(chatReq.Messages),\n\t\t\"toolCount\", len(chatReq.Tools))\n\n\tstream := cs.client.Chat.Completions.NewStreaming(ctx, chatReq)\n\n\t// Create an accumulator to track the full response\n\tacc := openai.ChatCompletionAccumulator{}\n\n\t// Create and return the stream iterator\n\treturn func(yield func(ChatResponse, error) bool) {\n\t\tdefer stream.Close()\n\n\t\tvar lastResponseChunk *openAIChatStreamResponse\n\t\tvar currentContent strings.Builder\n\t\tvar currentToolCalls []openai.ChatCompletionMessageToolCall\n\n\t\t// Process stream chunks\n\t\tfor stream.Next() {\n\t\t\tchunk := stream.Current()\n\n\t\t\t// Update the accumulator with the new chunk\n\t\t\tacc.AddChunk(chunk)\n\n\t\t\t// Handle content completion\n\t\t\tif _, ok := acc.JustFinishedContent(); ok {\n\t\t\t\tklog.V(2).Info(\"Content stream finished\")\n\t\t\t}\n\n\t\t\t// Handle refusal completion\n\t\t\tif refusal, ok := acc.JustFinishedRefusal(); ok {\n\t\t\t\tklog.V(2).Infof(\"Refusal stream finished: %v\", refusal)\n\t\t\t\tyield(nil, fmt.Errorf(\"model refused to respond: %v\", refusal))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Handle tool call completion\n\t\t\tvar toolCallsForThisChunk []openai.ChatCompletionMessageToolCall\n\t\t\tif tool, ok := acc.JustFinishedToolCall(); ok {\n\t\t\t\tklog.V(2).Infof(\"Tool call finished: %s %s\", tool.Name, tool.Arguments)\n\t\t\t\tnewToolCall := openai.ChatCompletionMessageToolCall{\n\t\t\t\t\tID: tool.ID,\n\t\t\t\t\tFunction: openai.ChatCompletionMessageToolCallFunction{\n\t\t\t\t\t\tName:      tool.Name,\n\t\t\t\t\t\tArguments: tool.Arguments,\n\t\t\t\t\t},\n\t\t\t\t}\n\t\t\t\tcurrentToolCalls = append(currentToolCalls, newToolCall)\n\t\t\t\t// Only include the newly finished tool call in this chunk\n\t\t\t\ttoolCallsForThisChunk = []openai.ChatCompletionMessageToolCall{newToolCall}\n\t\t\t}\n\n\t\t\tstreamResponse := &openAIChatStreamResponse{\n\t\t\t\tstreamChunk: chunk,\n\t\t\t\taccumulator: acc,\n\t\t\t\tcontent:     \"\", // Default to empty content\n\t\t\t\ttoolCalls:   toolCallsForThisChunk,\n\t\t\t}\n\n\t\t\t// Only process content if there are choices and a delta\n\t\t\tif len(chunk.Choices) > 0 {\n\t\t\t\tdelta := chunk.Choices[0].Delta\n\t\t\t\tif delta.Content != \"\" {\n\t\t\t\t\tcurrentContent.WriteString(delta.Content)\n\t\t\t\t\tstreamResponse.content = delta.Content // Only set content if there's new content\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Keep track of the last response for history\n\t\t\tlastResponseChunk = &openAIChatStreamResponse{\n\t\t\t\tstreamChunk: chunk,\n\t\t\t\taccumulator: acc,\n\t\t\t\tcontent:     currentContent.String(), // Full accumulated content for history\n\t\t\t\ttoolCalls:   currentToolCalls,\n\t\t\t}\n\n\t\t\t// Only yield if there's actual content or tool calls to report\n\t\t\tif streamResponse.content != \"\" || len(streamResponse.toolCalls) > 0 {\n\t\t\t\tif !yield(streamResponse, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Check for errors after streaming completes\n\t\tif err := stream.Err(); err != nil {\n\t\t\tklog.Errorf(\"Error in OpenAI streaming: %v\", err)\n\t\t\tyield(nil, fmt.Errorf(\"OpenAI streaming error: %w\", err))\n\t\t\treturn\n\t\t}\n\n\t\t// Update conversation history with the complete message\n\t\tif lastResponseChunk != nil {\n\t\t\tcompleteMessage := openai.ChatCompletionMessage{\n\t\t\t\tContent:   currentContent.String(),\n\t\t\t\tRole:      \"assistant\",\n\t\t\t\tToolCalls: currentToolCalls,\n\t\t\t}\n\n\t\t\t// Append the full assistant response to history\n\t\t\tcs.history = append(cs.history, completeMessage.ToParam())\n\t\t\tklog.V(2).InfoS(\"Added complete assistant message to history\",\n\t\t\t\t\"content_present\", completeMessage.Content != \"\",\n\t\t\t\t\"tool_calls\", len(completeMessage.ToolCalls))\n\t\t}\n\t}, nil\n}\n\n// IsRetryableError determines if an error from the OpenAI API should be retried.\nfunc (cs *openAIChatSession) IsRetryableError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\treturn DefaultIsRetryableError(err)\n}\n\nfunc (cs *openAIChatSession) Initialize(messages []*api.Message) error {\n\tklog.Warning(\"chat history persistence is not supported for provider 'openai', using in-memory chat history\")\n\treturn nil\n}\n\n// Helper structs for ChatResponse interface\n\ntype openAIChatResponse struct {\n\topenaiCompletion *openai.ChatCompletion\n}\n\nvar _ ChatResponse = (*openAIChatResponse)(nil)\n\nfunc (r *openAIChatResponse) UsageMetadata() any {\n\t// Check if the main completion object and Usage exist\n\tif r.openaiCompletion != nil && r.openaiCompletion.Usage.TotalTokens > 0 { // Check a field within Usage\n\t\treturn r.openaiCompletion.Usage\n\t}\n\treturn nil\n}\n\nfunc (r *openAIChatResponse) Candidates() []Candidate {\n\tif r.openaiCompletion == nil {\n\t\treturn nil\n\t}\n\tcandidates := make([]Candidate, len(r.openaiCompletion.Choices))\n\tfor i, choice := range r.openaiCompletion.Choices {\n\t\tcandidates[i] = &openAICandidate{openaiChoice: &choice}\n\t}\n\treturn candidates\n}\n\ntype openAICandidate struct {\n\topenaiChoice *openai.ChatCompletionChoice\n}\n\nvar _ Candidate = (*openAICandidate)(nil)\n\nfunc (c *openAICandidate) Parts() []Part {\n\t// Check if the choice exists before accessing Message\n\tif c.openaiChoice == nil {\n\t\treturn nil\n\t}\n\n\t// OpenAI message can have Content AND ToolCalls\n\tvar parts []Part\n\tif c.openaiChoice.Message.Content != \"\" {\n\t\tparts = append(parts, &openAIPart{content: c.openaiChoice.Message.Content})\n\t}\n\tif len(c.openaiChoice.Message.ToolCalls) > 0 {\n\t\tparts = append(parts, &openAIPart{toolCalls: c.openaiChoice.Message.ToolCalls})\n\t}\n\treturn parts\n}\n\n// String provides a simple string representation for logging/debugging.\nfunc (c *openAICandidate) String() string {\n\tif c.openaiChoice == nil {\n\t\treturn \"<nil candidate>\"\n\t}\n\tcontent := \"<no content>\"\n\tif c.openaiChoice.Message.Content != \"\" {\n\t\tcontent = c.openaiChoice.Message.Content\n\t}\n\ttoolCalls := len(c.openaiChoice.Message.ToolCalls)\n\tfinishReason := string(c.openaiChoice.FinishReason)\n\treturn fmt.Sprintf(\"Candidate(FinishReason: %s, ToolCalls: %d, Content: %q)\", finishReason, toolCalls, content)\n}\n\ntype openAIPart struct {\n\tcontent   string\n\ttoolCalls []openai.ChatCompletionMessageToolCall // Correct type\n}\n\nvar _ Part = (*openAIPart)(nil)\n\nfunc (p *openAIPart) AsText() (string, bool) {\n\treturn p.content, p.content != \"\"\n}\n\nfunc (p *openAIPart) AsFunctionCalls() ([]FunctionCall, bool) {\n\treturn convertToolCallsToFunctionCalls(p.toolCalls)\n}\n\n// Update openAIChatStreamResponse to include accumulated content\ntype openAIChatStreamResponse struct {\n\tstreamChunk openai.ChatCompletionChunk\n\taccumulator openai.ChatCompletionAccumulator\n\tcontent     string\n\ttoolCalls   []openai.ChatCompletionMessageToolCall\n}\n\n// Update Candidates() to use accumulated content\nfunc (r *openAIChatStreamResponse) Candidates() []Candidate {\n\tif len(r.streamChunk.Choices) == 0 {\n\t\treturn nil\n\t}\n\n\tcandidates := make([]Candidate, len(r.streamChunk.Choices))\n\tfor i, choice := range r.streamChunk.Choices {\n\t\tcandidates[i] = &openAIStreamCandidate{\n\t\t\tstreamChoice: choice,\n\t\t\tcontent:      r.content,\n\t\t\ttoolCalls:    r.toolCalls,\n\t\t}\n\t}\n\treturn candidates\n}\n\n// Update openAIStreamCandidate to handle delta content\ntype openAIStreamCandidate struct {\n\tstreamChoice openai.ChatCompletionChunkChoice\n\tcontent      string // This will now be just the delta content\n\ttoolCalls    []openai.ChatCompletionMessageToolCall\n}\n\n// Update Parts() to handle delta content\nfunc (c *openAIStreamCandidate) Parts() []Part {\n\tvar parts []Part\n\n\t// Only include the delta content\n\tif c.content != \"\" {\n\t\tparts = append(parts, &openAIStreamPart{\n\t\t\tcontent: c.content,\n\t\t})\n\t}\n\n\t// Include accumulated tool calls\n\tif len(c.toolCalls) > 0 {\n\t\tparts = append(parts, &openAIStreamPart{\n\t\t\ttoolCalls: c.toolCalls,\n\t\t})\n\t}\n\n\treturn parts\n}\n\n// Add UsageMetadata implementation\nfunc (r *openAIChatStreamResponse) UsageMetadata() any {\n\tif r.accumulator.Usage.TotalTokens > 0 {\n\t\treturn r.accumulator.Usage\n\t}\n\treturn nil\n}\n\n// Add String implementation\nfunc (c *openAIStreamCandidate) String() string {\n\treturn fmt.Sprintf(\"StreamingCandidate(Content: %q, ToolCalls: %d)\",\n\t\tc.content, len(c.toolCalls))\n}\n\n// Define openAIStreamPart\ntype openAIStreamPart struct {\n\tcontent   string\n\ttoolCalls []openai.ChatCompletionMessageToolCall\n}\n\n// Ensure openAIStreamPart implements Part interface\nvar _ Part = (*openAIStreamPart)(nil)\n\nfunc (p *openAIStreamPart) AsText() (string, bool) {\n\treturn p.content, p.content != \"\"\n}\n\nfunc (p *openAIStreamPart) AsFunctionCalls() ([]FunctionCall, bool) {\n\treturn convertToolCallsToFunctionCalls(p.toolCalls)\n}\n\n// convertSchemaForOpenAI converts and transforms a schema for OpenAI compatibility\n// This function handles both gollm Schema objects and ensures the final JSON meets OpenAI requirements\nfunc convertSchemaForOpenAI(schema *Schema) (*Schema, error) {\n\tif schema == nil {\n\t\t// Return a minimal valid object schema for OpenAI\n\t\treturn &Schema{\n\t\t\tType:       TypeObject,\n\t\t\tProperties: make(map[string]*Schema),\n\t\t}, nil\n\t}\n\n\t// Create a deep copy to avoid modifying the original\n\tvalidated := &Schema{\n\t\tDescription: schema.Description,\n\t\tRequired:    make([]string, len(schema.Required)),\n\t}\n\tcopy(validated.Required, schema.Required)\n\n\t// Handle type validation and normalization based on OpenAI requirements\n\tswitch schema.Type {\n\tcase TypeObject:\n\t\tvalidated.Type = TypeObject\n\t\t// Objects MUST have properties for OpenAI (even if empty)\n\t\tvalidated.Properties = make(map[string]*Schema)\n\t\tif schema.Properties != nil {\n\t\t\tfor key, prop := range schema.Properties {\n\t\t\t\tvalidatedProp, err := convertSchemaForOpenAI(prop)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"validating property %q: %w\", key, err)\n\t\t\t\t}\n\t\t\t\tvalidated.Properties[key] = validatedProp\n\t\t\t}\n\t\t}\n\n\tcase TypeArray:\n\t\tvalidated.Type = TypeArray\n\t\t// Arrays MUST have items schema for OpenAI\n\t\tif schema.Items != nil {\n\t\t\tvalidatedItems, err := convertSchemaForOpenAI(schema.Items)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"validating array items: %w\", err)\n\t\t\t}\n\t\t\tvalidated.Items = validatedItems\n\t\t} else {\n\t\t\t// Default to string items if not specified\n\t\t\tvalidated.Items = &Schema{Type: TypeString}\n\t\t}\n\n\tcase TypeString:\n\t\tvalidated.Type = TypeString\n\n\tcase TypeNumber:\n\t\tvalidated.Type = TypeNumber\n\n\tcase TypeInteger:\n\t\t// OpenAI prefers \"number\" for integers\n\t\tvalidated.Type = TypeNumber\n\n\tcase TypeBoolean:\n\t\tvalidated.Type = TypeBoolean\n\n\tcase \"\":\n\t\t// If no type specified, default to object with empty properties\n\t\tklog.Warningf(\"Schema has no type, defaulting to object\")\n\t\tvalidated.Type = TypeObject\n\t\tvalidated.Properties = make(map[string]*Schema)\n\n\tdefault:\n\t\t// For unknown types, log a warning and default to object\n\t\tklog.Warningf(\"Unknown schema type '%s', defaulting to object\", schema.Type)\n\t\tvalidated.Type = TypeObject\n\t\tvalidated.Properties = make(map[string]*Schema)\n\t}\n\n\t// Final validation: Ensure object types always have properties\n\t// This handles edge cases where malformed schemas might slip through\n\tif validated.Type == TypeObject && validated.Properties == nil {\n\t\tklog.Warningf(\"Object schema missing properties, initializing empty properties map\")\n\t\tvalidated.Properties = make(map[string]*Schema)\n\t}\n\n\treturn validated, nil\n}\n\n// convertFunctionParameters handles the conversion of gollm parameters to OpenAI format\nfunc (cs *openAIChatSession) convertFunctionParameters(gollmDef *FunctionDefinition) (openai.FunctionParameters, error) {\n\tvar params openai.FunctionParameters\n\n\tif gollmDef.Parameters == nil {\n\t\treturn params, nil\n\t}\n\n\t// Convert the schema for OpenAI compatibility\n\tklog.V(2).Infof(\"Original schema for function %s: %+v\", gollmDef.Name, gollmDef.Parameters)\n\tvalidatedSchema, err := convertSchemaForOpenAI(gollmDef.Parameters)\n\tif err != nil {\n\t\treturn params, fmt.Errorf(\"schema conversion failed: %w\", err)\n\t}\n\tklog.V(2).Infof(\"Converted schema for function %s: %+v\", gollmDef.Name, validatedSchema)\n\n\t// Convert to raw schema bytes\n\tschemaBytes, err := cs.convertSchemaToBytes(validatedSchema, gollmDef.Name)\n\tif err != nil {\n\t\treturn params, err\n\t}\n\n\t// Unmarshal into OpenAI parameters format\n\tif err := json.Unmarshal(schemaBytes, &params); err != nil {\n\t\treturn params, fmt.Errorf(\"failed to unmarshal schema: %w\", err)\n\t}\n\n\treturn params, nil\n}\n\n// openAISchema wraps a gollm Schema with OpenAI-specific marshaling behavior\ntype openAISchema struct {\n\t*Schema\n}\n\n// MarshalJSON provides OpenAI-specific JSON marshaling that ensures object schemas have properties\nfunc (s openAISchema) MarshalJSON() ([]byte, error) {\n\t// Create a map to build the JSON representation\n\tresult := make(map[string]interface{})\n\n\tif s.Type != \"\" {\n\t\tresult[\"type\"] = s.Type\n\t}\n\n\tif s.Description != \"\" {\n\t\tresult[\"description\"] = s.Description\n\t}\n\n\tif len(s.Required) > 0 {\n\t\tresult[\"required\"] = s.Required\n\t}\n\n\t// For object types, always include properties (even if empty) to satisfy OpenAI\n\tif s.Type == TypeObject {\n\t\tif s.Properties != nil {\n\t\t\tresult[\"properties\"] = s.Properties\n\t\t} else {\n\t\t\tresult[\"properties\"] = make(map[string]*Schema)\n\t\t}\n\t} else if s.Properties != nil && len(s.Properties) > 0 {\n\t\t// For non-object types, only include properties if they exist and are non-empty\n\t\tresult[\"properties\"] = s.Properties\n\t}\n\n\tif s.Items != nil {\n\t\tresult[\"items\"] = s.Items\n\t}\n\n\treturn json.Marshal(result)\n}\n\n// convertSchemaToBytes converts a validated schema to JSON bytes using OpenAI-specific marshaling\nfunc (cs *openAIChatSession) convertSchemaToBytes(schema *Schema, functionName string) ([]byte, error) {\n\t// Wrap the schema with OpenAI-specific marshaling behavior\n\topenAIWrapper := openAISchema{Schema: schema}\n\n\tbytes, err := json.Marshal(openAIWrapper)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to convert schema: %w\", err)\n\t}\n\n\tklog.Infof(\"OpenAI schema for function %s: %s\", functionName, string(bytes))\n\n\treturn bytes, nil\n}\n\n// newOpenAIClientFactory is the factory function for creating OpenAI clients.\nfunc newOpenAIClientFactory(ctx context.Context, opts ClientOptions) (Client, error) {\n\treturn NewOpenAIClient(ctx, opts)\n}\n\n// addContentsToHistory processes and appends user messages to chat history\nfunc (cs *openAIChatSession) addContentsToHistory(contents []any) error {\n\tfor _, content := range contents {\n\t\tswitch c := content.(type) {\n\t\tcase string:\n\t\t\tklog.V(2).Infof(\"Adding user message to history: %s\", c)\n\t\t\tcs.history = append(cs.history, openai.UserMessage(c))\n\t\tcase FunctionCallResult:\n\t\t\tklog.V(2).Infof(\"Adding tool call result to history: Name=%s, ID=%s\", c.Name, c.ID)\n\t\t\t// Marshal the result map into a JSON string for the message content\n\t\t\tresultJSON, err := json.Marshal(c.Result)\n\t\t\tif err != nil {\n\t\t\t\tklog.Errorf(\"Failed to marshal function call result: %v\", err)\n\t\t\t\treturn fmt.Errorf(\"failed to marshal function call result %q: %w\", c.Name, err)\n\t\t\t}\n\t\t\tcs.history = append(cs.history, openai.ToolMessage(string(resultJSON), c.ID))\n\t\tdefault:\n\t\t\tklog.Warningf(\"Unhandled content type: %T\", content)\n\t\t\treturn fmt.Errorf(\"unhandled content type: %T\", content)\n\t\t}\n\t}\n\treturn nil\n}\n\n// convertToolCallsToFunctionCalls converts OpenAI tool calls to gollm function calls\nfunc convertToolCallsToFunctionCalls(toolCalls []openai.ChatCompletionMessageToolCall) ([]FunctionCall, bool) {\n\tif len(toolCalls) == 0 {\n\t\treturn nil, false\n\t}\n\n\tcalls := make([]FunctionCall, 0, len(toolCalls))\n\tfor _, tc := range toolCalls {\n\t\t// Skip non-function tool calls\n\t\tif tc.Function.Name == \"\" {\n\t\t\tklog.V(2).Infof(\"Skipping non-function tool call ID: %s\", tc.ID)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Parse function arguments with error handling\n\t\tvar args map[string]any\n\t\tif tc.Function.Arguments != \"\" {\n\t\t\tif err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {\n\t\t\t\tklog.V(2).Infof(\"Error unmarshalling function arguments for %s: %v\", tc.Function.Name, err)\n\t\t\t\targs = make(map[string]any)\n\t\t\t}\n\t\t} else {\n\t\t\targs = make(map[string]any)\n\t\t}\n\n\t\tcalls = append(calls, FunctionCall{\n\t\t\tID:        tc.ID,\n\t\t\tName:      tc.Function.Name,\n\t\t\tArguments: args,\n\t\t})\n\t}\n\treturn calls, len(calls) > 0\n}\n\n// getOpenAIModel returns the appropriate model based on configuration and explicitly provided model name\nfunc getOpenAIModel(model string) string {\n\t// If explicit model is provided, use it\n\tif model != \"\" {\n\t\tklog.V(2).Infof(\"Using explicitly provided model: %s\", model)\n\t\treturn model\n\t}\n\n\t// Check configuration\n\tconfigModel := openAIModel\n\tif configModel != \"\" {\n\t\tklog.V(1).Infof(\"Using model from config: %s\", configModel)\n\t\treturn configModel\n\t}\n\n\t// Default model as fallback\n\tklog.V(2).Info(\"No model specified, defaulting to gpt-4.1\")\n\treturn \"gpt-4.1\"\n}\n"
  },
  {
    "path": "gollm/openai_response.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"log\"\n\n\topenai \"github.com/openai/openai-go\"\n\t\"github.com/openai/openai-go/responses\"\n\t\"k8s.io/klog/v2\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n)\n\n// Chat Session Implementation\ntype openAIResponseChatSession struct {\n\tclient              openai.Client\n\thistory             responses.ResponseInputParam\n\tmodel               string\n\tfunctionDefinitions []*FunctionDefinition      // Stored in gollm format\n\ttools               []responses.ToolUnionParam // Stored in OpenAI format\n\n\t// params to be intialized at the beginning of the session\n\tparams responses.ResponseNewParams\n}\n\n// Ensure openAIChatSession implements the Chat interface.\nvar _ Chat = (*openAIChatSession)(nil)\n\n// SetFunctionDefinitions stores the function definitions and converts them to OpenAI format.\nfunc (cs *openAIResponseChatSession) SetFunctionDefinitions(defs []*FunctionDefinition) error {\n\tcs.functionDefinitions = defs\n\tcs.tools = nil // Clear previous tools\n\tif len(defs) > 0 {\n\t\tcs.tools = make([]responses.ToolUnionParam, len(defs))\n\t\tfor i, gollmDef := range defs {\n\t\t\tklog.Infof(\"Processing function definition: %s\", gollmDef.Name)\n\t\t\t// Process function parameters\n\t\t\tparams, err := cs.convertFunctionParameters(gollmDef)\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to process parameters for function %s: %w\", gollmDef.Name, err)\n\t\t\t}\n\t\t\tcs.tools[i] = responses.ToolUnionParam{\n\t\t\t\tOfFunction: &responses.FunctionToolParam{\n\t\t\t\t\tName:        gollmDef.Name,\n\t\t\t\t\tDescription: openai.String(gollmDef.Description),\n\t\t\t\t\tParameters:  params,\n\t\t\t\t},\n\t\t\t}\n\t\t}\n\t}\n\tklog.V(1).Infof(\"Set %d function definitions for OpenAI chat session\", len(cs.functionDefinitions))\n\treturn nil\n}\n\n// Send sends the user message(s), appends to history, and gets the LLM response.\nfunc (cs *openAIResponseChatSession) Send(ctx context.Context, contents ...any) (ChatResponse, error) {\n\tklog.V(1).InfoS(\"openAIChatSession.Send called\", \"model\", cs.model, \"history_len\", len(cs.history))\n\n\t// TODO(droot): kubectl-ai agent uses SendStreaming instead of Send so deferred the implementation for now.\n\treturn &openAIChatResponse{}, errors.ErrUnsupported\n}\n\n// SendStreaming sends the user message(s) and returns an iterator for the LLM response stream.\nfunc (cs *openAIResponseChatSession) SendStreaming(ctx context.Context, contents ...any) (ChatResponseIterator, error) {\n\tklog.V(1).InfoS(\"Starting OpenAI streaming request\", \"model\", cs.model)\n\n\t// Process and append messages to history\n\tif err := cs.addContentsToHistory(contents); err != nil {\n\t\treturn nil, err\n\t}\n\t// Prepare and send API request\n\tcs.params.Input = responses.ResponseNewParamsInputUnion{\n\t\tOfInputItemList: cs.history,\n\t}\n\tcs.params.Tools = cs.tools\n\n\tklog.V(1).InfoS(\"Sending streaming request to OpenAI API\",\n\t\t\"model\", cs.model,\n\t\t\"messageCount\", len(cs.params.Input.OfInputItemList),\n\t\t\"toolCount\", len(cs.params.Tools))\n\n\tresp, err := cs.client.Responses.New(ctx, cs.params)\n\tif err == nil {\n\t\tfor _, output := range resp.Output {\n\t\t\tswitch output.AsAny().(type) {\n\t\t\tcase responses.ResponseFunctionToolCall:\n\t\t\t\tfc := output.AsFunctionCall()\n\t\t\t\tlog.Printf(\"Inspected function call item: %+v\", fc)\n\t\t\t\tfpP := fc.ToParam()\n\t\t\t\tcs.history = append(cs.history, responses.ResponseInputItemUnionParam{\n\t\t\t\t\tOfFunctionCall: &fpP,\n\t\t\t\t})\n\t\t\tcase responses.ResponseReasoningItem:\n\t\t\t\treason := output.AsReasoning()\n\t\t\t\tlog.Printf(\"Inspected Reasoning item: %+v\", reason)\n\t\t\t\treasonParam := reason.ToParam()\n\t\t\t\tcs.history = append(cs.history, responses.ResponseInputItemUnionParam{\n\t\t\t\t\tOfReasoning: &reasonParam,\n\t\t\t\t})\n\t\t\tcase responses.ResponseOutputMessage:\n\t\t\t\tmsg := output.AsMessage()\n\t\t\t\tlog.Printf(\"Inspected Output Message: %+v\", msg.Content[0].Text)\n\t\t\t\tcs.history = append(cs.history,\n\t\t\t\t\tresponses.ResponseInputItemParamOfOutputMessage([]responses.ResponseOutputMessageContentUnionParam{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tOfOutputText: &responses.ResponseOutputTextParam{\n\t\t\t\t\t\t\t\tAnnotations: []responses.ResponseOutputTextAnnotationUnionParam{},\n\t\t\t\t\t\t\t\tText:        msg.Content[0].Text,\n\t\t\t\t\t\t\t\tType:        \"output_text\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}, msg.ID, msg.Status),\n\t\t\t\t)\n\t\t\tdefault:\n\t\t\t\tlog.Println(\"no variant present\", output)\n\t\t\t}\n\t\t}\n\t}\n\treturn singletonChatResponseIterator(&openAIResponseChatResponse{resp: resp}), err\n}\n\n// IsRetryableError determines if an error from the OpenAI API should be retried.\nfunc (cs *openAIResponseChatSession) IsRetryableError(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\treturn DefaultIsRetryableError(err)\n}\n\nfunc (cs *openAIResponseChatSession) Initialize(messages []*api.Message) error {\n\tklog.Warning(\"chat history persistence is not supported for provider 'openai', using in-memory chat history\")\n\treturn nil\n}\n\n// Helper structs for ChatResponse interface\ntype openAIResponseChatResponse struct {\n\tresp *responses.Response\n}\n\nvar _ ChatResponse = (*openAIResponseChatResponse)(nil)\n\nfunc (r *openAIResponseChatResponse) UsageMetadata() any {\n\treturn nil\n}\n\nfunc (r *openAIResponseChatResponse) Candidates() []Candidate {\n\tif r.resp == nil {\n\t\treturn nil\n\t}\n\tvar candidates []Candidate\n\tfor _, output := range r.resp.Output {\n\t\tswitch output.AsAny().(type) {\n\t\tcase responses.ResponseFunctionToolCall, responses.ResponseOutputMessage:\n\t\t\tcandidates = append(candidates, &openAIResponseCandidate{\n\t\t\t\tcandidate: &output,\n\t\t\t})\n\t\tdefault:\n\t\t\t// skip reasoning messages because agentic loop doesn't know\n\t\t\t// how to handle them yet.\n\t\t}\n\t}\n\treturn candidates\n}\n\ntype openAIResponseCandidate struct {\n\tcandidate *responses.ResponseOutputItemUnion\n}\n\nvar _ Candidate = (*openAIResponseCandidate)(nil)\n\nfunc (c *openAIResponseCandidate) Parts() []Part {\n\tif c.candidate == nil {\n\t\treturn nil\n\t}\n\t// OpenAI message can have Content AND ToolCalls\n\tvar parts []Part\n\n\toutput := c.candidate\n\tswitch output.AsAny().(type) {\n\tcase responses.ResponseFunctionToolCall:\n\t\tfc := output.AsFunctionCall()\n\t\ttoolCall, err := convertResponseToolCallToFunctionCall(fc)\n\t\tif err != nil {\n\t\t\t//\n\t\t}\n\t\tparts = append(parts, &openAIResponsePart{\n\t\t\ttoolCall: toolCall,\n\t\t})\n\tcase responses.ResponseReasoningItem:\n\t\treason := output.AsReasoning()\n\t\tlog.Printf(\"Inspected Reasoning item: %+v\", reason)\n\tcase responses.ResponseOutputMessage:\n\t\tmsg := output.AsMessage()\n\t\tparts = append(parts, &openAIResponsePart{\n\t\t\tcontent: msg.Content[0].AsOutputText().Text,\n\t\t})\n\tdefault:\n\t\tlog.Println(\"no variant present\", output)\n\t}\n\treturn parts\n}\n\n// String provides a simple string representation for logging/debugging.\nfunc (c *openAIResponseCandidate) String() string {\n\treturn fmt.Sprintf(\"%+v\", c.candidate)\n}\n\ntype openAIResponsePart struct {\n\tcontent  string\n\ttoolCall FunctionCall\n}\n\nvar _ Part = (*openAIResponsePart)(nil)\n\nfunc (p *openAIResponsePart) AsText() (string, bool) {\n\treturn p.content, p.content != \"\"\n}\n\nfunc (p *openAIResponsePart) AsFunctionCalls() ([]FunctionCall, bool) {\n\treturn []FunctionCall{p.toolCall}, p.content == \"\"\n}\n\n// convertFunctionParameters handles the conversion of gollm parameters to OpenAI format\nfunc (cs *openAIResponseChatSession) convertFunctionParameters(gollmDef *FunctionDefinition) (openai.FunctionParameters, error) {\n\tvar params openai.FunctionParameters\n\n\tif gollmDef.Parameters == nil {\n\t\treturn params, nil\n\t}\n\n\t// Convert the schema for OpenAI compatibility\n\tklog.V(2).Infof(\"Original schema for function %s: %+v\", gollmDef.Name, gollmDef.Parameters)\n\tvalidatedSchema, err := convertSchemaForOpenAI(gollmDef.Parameters)\n\tif err != nil {\n\t\treturn params, fmt.Errorf(\"schema conversion failed: %w\", err)\n\t}\n\tklog.V(2).Infof(\"Converted schema for function %s: %+v\", gollmDef.Name, validatedSchema)\n\n\t// Convert to raw schema bytes\n\tschemaBytes, err := cs.convertSchemaToBytes(validatedSchema, gollmDef.Name)\n\tif err != nil {\n\t\treturn params, err\n\t}\n\n\t// Unmarshal into OpenAI parameters format\n\tif err := json.Unmarshal(schemaBytes, &params); err != nil {\n\t\treturn params, fmt.Errorf(\"failed to unmarshal schema: %w\", err)\n\t}\n\n\treturn params, nil\n}\n\n// convertSchemaToBytes converts a validated schema to JSON bytes using OpenAI-specific marshaling\nfunc (cs *openAIResponseChatSession) convertSchemaToBytes(schema *Schema, functionName string) ([]byte, error) {\n\t// Wrap the schema with OpenAI-specific marshaling behavior\n\topenAIWrapper := openAISchema{Schema: schema}\n\n\tbytes, err := json.Marshal(openAIWrapper)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to convert schema: %w\", err)\n\t}\n\n\tklog.Infof(\"OpenAI schema for function %s: %s\", functionName, string(bytes))\n\n\treturn bytes, nil\n}\n\n// addContentsToHistory processes and appends user messages to chat history\nfunc (cs *openAIResponseChatSession) addContentsToHistory(contents []any) error {\n\tfor _, content := range contents {\n\t\tswitch c := content.(type) {\n\t\tcase string:\n\t\t\tklog.V(2).Infof(\"Adding user message to history: %s\", c)\n\t\t\tcs.history = append(cs.history, responses.ResponseInputItemUnionParam{\n\t\t\t\tOfMessage: &responses.EasyInputMessageParam{\n\t\t\t\t\tContent: responses.EasyInputMessageContentUnionParam{\n\t\t\t\t\t\tOfString: openai.String(c),\n\t\t\t\t\t},\n\t\t\t\t\tRole: responses.EasyInputMessageRoleUser,\n\t\t\t\t},\n\t\t\t})\n\t\tcase FunctionCallResult:\n\t\t\tklog.V(2).Infof(\"Adding tool call result to history: Name=%s, ID=%s\", c.Name, c.ID)\n\t\t\t// Marshal the result map into a JSON string for the message content\n\t\t\tresultJSON, err := json.Marshal(c.Result)\n\t\t\tif err != nil {\n\t\t\t\tklog.Errorf(\"Failed to marshal function call result: %v\", err)\n\t\t\t\treturn fmt.Errorf(\"failed to marshal function call result %q: %w\", c.Name, err)\n\t\t\t}\n\t\t\t// cs.history = append(cs.history, openai.ToolMessage(string(resultJSON), c.ID))\n\t\t\tcs.history = append(cs.history, responses.ResponseInputItemParamOfFunctionCallOutput(c.ID, string(resultJSON)))\n\t\tdefault:\n\t\t\tklog.Warningf(\"Unhandled content type: %T\", content)\n\t\t\treturn fmt.Errorf(\"unhandled content type: %T\", content)\n\t\t}\n\t}\n\treturn nil\n}\n\n// convertToolCallsToFunctionCalls converts OpenAI tool calls to gollm function calls\nfunc convertResponseToolCallToFunctionCall(responseToolCall responses.ResponseFunctionToolCall) (FunctionCall, error) {\n\tfc := FunctionCall{}\n\n\t// Skip non-function tool calls\n\tif responseToolCall.Name == \"\" {\n\t\tklog.V(2).Infof(\"Skipping non-function tool call ID: %s\", responseToolCall.ID)\n\t\treturn fc, fmt.Errorf(\"missing name %v\", responseToolCall)\n\t}\n\tfc.Name = responseToolCall.Name\n\t// Parse function arguments with error handling\n\tvar args map[string]any\n\tif responseToolCall.Arguments != \"\" {\n\t\tif err := json.Unmarshal([]byte(responseToolCall.Arguments), &args); err != nil {\n\t\t\tklog.V(2).Infof(\"Error unmarshalling function arguments for %s: %v\", fc.Name, err)\n\t\t\targs = make(map[string]any)\n\t\t}\n\t} else {\n\t\targs = make(map[string]any)\n\t}\n\n\treturn FunctionCall{\n\t\tID:        responseToolCall.CallID,\n\t\tName:      responseToolCall.Name,\n\t\tArguments: args,\n\t}, nil\n}\n"
  },
  {
    "path": "gollm/openai_test.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"encoding/json\"\n\t\"testing\"\n\n\t\"github.com/openai/openai-go\"\n)\n\nfunc TestConvertSchemaForOpenAI(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tinputSchema    *Schema\n\t\texpectedType   SchemaType\n\t\texpectedError  bool\n\t\tvalidateResult func(t *testing.T, result *Schema)\n\t}{\n\t\t// Core logic tests\n\t\t{\n\t\t\tname:          \"nil schema\",\n\t\t\tinputSchema:   nil,\n\t\t\texpectedType:  TypeObject,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif result.Properties == nil {\n\t\t\t\t\tt.Error(\"expected properties map to be initialized\")\n\t\t\t\t}\n\t\t\t\tif len(result.Properties) != 0 {\n\t\t\t\t\tt.Error(\"expected empty properties map\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"simple string schema\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType:        TypeString,\n\t\t\t\tDescription: \"A simple string\",\n\t\t\t},\n\t\t\texpectedType:  TypeString,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif result.Description != \"A simple string\" {\n\t\t\t\t\tt.Errorf(\"expected description 'A simple string', got %q\", result.Description)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"simple number schema\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType: TypeNumber,\n\t\t\t},\n\t\t\texpectedType:  TypeNumber,\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname: \"integer schema converted to number\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType:        TypeInteger,\n\t\t\t\tDescription: \"An integer value\",\n\t\t\t},\n\t\t\texpectedType:  TypeNumber, // OpenAI prefers number for integers\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif result.Description != \"An integer value\" {\n\t\t\t\t\tt.Errorf(\"expected description preserved\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"boolean schema\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType: TypeBoolean,\n\t\t\t},\n\t\t\texpectedType:  TypeBoolean,\n\t\t\texpectedError: false,\n\t\t},\n\t\t{\n\t\t\tname: \"empty type defaults to object\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tDescription: \"No type specified\",\n\t\t\t},\n\t\t\texpectedType:  TypeObject,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif result.Properties == nil {\n\t\t\t\t\tt.Error(\"expected properties map to be initialized\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"unknown type defaults to object\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType: \"unknown\",\n\t\t\t},\n\t\t\texpectedType:  TypeObject,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif result.Properties == nil {\n\t\t\t\t\tt.Error(\"expected properties map to be initialized\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"object schema with properties\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType: TypeObject,\n\t\t\t\tProperties: map[string]*Schema{\n\t\t\t\t\t\"name\": {Type: TypeString, Description: \"User name\"},\n\t\t\t\t\t\"age\":  {Type: TypeInteger, Description: \"User age\"},\n\t\t\t\t},\n\t\t\t\tRequired: []string{\"name\"},\n\t\t\t},\n\t\t\texpectedType:  TypeObject,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif len(result.Properties) != 2 {\n\t\t\t\t\tt.Errorf(\"expected 2 properties, got %d\", len(result.Properties))\n\t\t\t\t}\n\t\t\t\tif result.Properties[\"name\"].Type != TypeString {\n\t\t\t\t\tt.Error(\"expected name property to be string\")\n\t\t\t\t}\n\t\t\t\t// Age should be converted from integer to number\n\t\t\t\tif result.Properties[\"age\"].Type != TypeNumber {\n\t\t\t\t\tt.Error(\"expected age property to be converted to number\")\n\t\t\t\t}\n\t\t\t\tif len(result.Required) != 1 || result.Required[0] != \"name\" {\n\t\t\t\t\tt.Error(\"expected required fields to be preserved\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"object schema without properties\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType: TypeObject,\n\t\t\t},\n\t\t\texpectedType:  TypeObject,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif result.Properties == nil {\n\t\t\t\t\tt.Error(\"expected properties map to be initialized\")\n\t\t\t\t}\n\t\t\t\tif len(result.Properties) != 0 {\n\t\t\t\t\tt.Error(\"expected empty properties map\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"array schema with string items\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType:  TypeArray,\n\t\t\t\tItems: &Schema{Type: TypeString},\n\t\t\t},\n\t\t\texpectedType:  TypeArray,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif result.Items == nil {\n\t\t\t\t\tt.Error(\"expected items schema to be present\")\n\t\t\t\t}\n\t\t\t\tif result.Items.Type != TypeString {\n\t\t\t\t\tt.Error(\"expected items to be string type\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"array schema with integer items (converted to number)\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType:  TypeArray,\n\t\t\t\tItems: &Schema{Type: TypeInteger},\n\t\t\t},\n\t\t\texpectedType:  TypeArray,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif result.Items == nil {\n\t\t\t\t\tt.Error(\"expected items schema to be present\")\n\t\t\t\t}\n\t\t\t\tif result.Items.Type != TypeNumber {\n\t\t\t\t\tt.Error(\"expected items to be converted to number type\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"array schema without items (defaults to string)\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType: TypeArray,\n\t\t\t},\n\t\t\texpectedType:  TypeArray,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif result.Items == nil {\n\t\t\t\t\tt.Error(\"expected items schema to be defaulted\")\n\t\t\t\t}\n\t\t\t\tif result.Items.Type != TypeString {\n\t\t\t\t\tt.Error(\"expected default items to be string type\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"nested object in array\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType: TypeArray,\n\t\t\t\tItems: &Schema{\n\t\t\t\t\tType: TypeObject,\n\t\t\t\t\tProperties: map[string]*Schema{\n\t\t\t\t\t\t\"id\":   {Type: TypeInteger},\n\t\t\t\t\t\t\"name\": {Type: TypeString},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedType:  TypeArray,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif result.Items == nil {\n\t\t\t\t\tt.Error(\"expected items schema to be present\")\n\t\t\t\t}\n\t\t\t\tif result.Items.Type != TypeObject {\n\t\t\t\t\tt.Error(\"expected items to be object type\")\n\t\t\t\t}\n\t\t\t\tif result.Items.Properties[\"id\"].Type != TypeNumber {\n\t\t\t\t\tt.Error(\"expected nested integer to be converted to number\")\n\t\t\t\t}\n\t\t\t\tif result.Items.Properties[\"name\"].Type != TypeString {\n\t\t\t\t\tt.Error(\"expected nested string to remain string\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\n\t\t// Built-in tool schema tests\n\t\t{\n\t\t\tname: \"kubectl tool schema\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType: TypeObject,\n\t\t\t\tProperties: map[string]*Schema{\n\t\t\t\t\t\"command\": {\n\t\t\t\t\t\tType:        TypeString,\n\t\t\t\t\t\tDescription: \"The complete kubectl command to execute\",\n\t\t\t\t\t},\n\t\t\t\t\t\"modifies_resource\": {\n\t\t\t\t\t\tType:        TypeString,\n\t\t\t\t\t\tDescription: \"Whether the command modifies a kubernetes resource\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedType:  TypeObject,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif len(result.Properties) != 2 {\n\t\t\t\t\tt.Errorf(\"expected 2 properties, got %d\", len(result.Properties))\n\t\t\t\t}\n\t\t\t\tif result.Properties[\"command\"].Type != TypeString {\n\t\t\t\t\tt.Error(\"expected command property to be string\")\n\t\t\t\t}\n\t\t\t\tif result.Properties[\"modifies_resource\"].Type != TypeString {\n\t\t\t\t\tt.Error(\"expected modifies_resource property to be string\")\n\t\t\t\t}\n\t\t\t\t// Properties should be initialized\n\t\t\t\tif result.Properties == nil {\n\t\t\t\t\tt.Error(\"expected properties to be initialized\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"bash tool schema\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType: TypeObject,\n\t\t\t\tProperties: map[string]*Schema{\n\t\t\t\t\t\"command\": {\n\t\t\t\t\t\tType:        TypeString,\n\t\t\t\t\t\tDescription: \"The bash command to execute\",\n\t\t\t\t\t},\n\t\t\t\t\t\"modifies_resource\": {\n\t\t\t\t\t\tType:        TypeString,\n\t\t\t\t\t\tDescription: \"Whether the command modifies a kubernetes resource\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedType:  TypeObject,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\tif len(result.Properties) != 2 {\n\t\t\t\t\tt.Errorf(\"expected 2 properties, got %d\", len(result.Properties))\n\t\t\t\t}\n\t\t\t\t// All string properties should remain strings\n\t\t\t\tif result.Properties[\"command\"].Type != TypeString {\n\t\t\t\t\tt.Error(\"expected command property to remain string\")\n\t\t\t\t}\n\t\t\t\tif result.Properties[\"modifies_resource\"].Type != TypeString {\n\t\t\t\t\tt.Error(\"expected modifies_resource property to remain string\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"mcp tool schema with complex nested structure\",\n\t\t\tinputSchema: &Schema{\n\t\t\t\tType: TypeObject,\n\t\t\t\tProperties: map[string]*Schema{\n\t\t\t\t\t\"server_name\": {\n\t\t\t\t\t\tType:        TypeString,\n\t\t\t\t\t\tDescription: \"Name of the MCP server\",\n\t\t\t\t\t},\n\t\t\t\t\t\"method\": {\n\t\t\t\t\t\tType:        TypeString,\n\t\t\t\t\t\tDescription: \"MCP method name\",\n\t\t\t\t\t},\n\t\t\t\t\t\"params\": {\n\t\t\t\t\t\tType: TypeObject,\n\t\t\t\t\t\tProperties: map[string]*Schema{\n\t\t\t\t\t\t\t\"query\": {Type: TypeString},\n\t\t\t\t\t\t\t\"limit\": {Type: TypeInteger}, // Should convert to number\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tRequired: []string{\"server_name\", \"method\"},\n\t\t\t},\n\t\t\texpectedType:  TypeObject,\n\t\t\texpectedError: false,\n\t\t\tvalidateResult: func(t *testing.T, result *Schema) {\n\t\t\t\t// Check top-level properties\n\t\t\t\tif len(result.Properties) != 3 {\n\t\t\t\t\tt.Errorf(\"expected 3 properties, got %d\", len(result.Properties))\n\t\t\t\t}\n\t\t\t\t// Check nested object conversion\n\t\t\t\tparams := result.Properties[\"params\"]\n\t\t\t\tif params.Type != TypeObject {\n\t\t\t\t\tt.Error(\"expected params to be object type\")\n\t\t\t\t}\n\t\t\t\tif params.Properties == nil {\n\t\t\t\t\tt.Error(\"expected params properties to be initialized\")\n\t\t\t\t}\n\t\t\t\t// Check nested integer conversion\n\t\t\t\tif params.Properties[\"limit\"].Type != TypeNumber {\n\t\t\t\t\tt.Error(\"expected nested limit property to be converted to number\")\n\t\t\t\t}\n\t\t\t\t// Check required fields preservation\n\t\t\t\tif len(result.Required) != 2 {\n\t\t\t\t\tt.Error(\"expected required fields to be preserved\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult, err := convertSchemaForOpenAI(tt.inputSchema)\n\n\t\t\tif tt.expectedError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Error(\"expected error but got none\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif result == nil {\n\t\t\t\tt.Error(\"expected non-nil result\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif result.Type != tt.expectedType {\n\t\t\t\tt.Errorf(\"expected type %q, got %q\", tt.expectedType, result.Type)\n\t\t\t}\n\n\t\t\t// Run custom validation if provided\n\t\t\tif tt.validateResult != nil {\n\t\t\t\ttt.validateResult(t, result)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestConvertSchemaToBytes tests the JSON-level fix for the omitempty issue\nfunc TestConvertSchemaToBytes(t *testing.T) {\n\tsession := &openAIChatSession{}\n\n\t// Test case: Object schema with empty properties map (which gets omitted by omitempty)\n\tschema := &Schema{\n\t\tType:       TypeObject,\n\t\tProperties: make(map[string]*Schema), // Empty map gets omitted by omitempty\n\t}\n\n\tbytes, err := session.convertSchemaToBytes(schema, \"test_function\")\n\tif err != nil {\n\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\treturn\n\t}\n\n\t// Parse the JSON to verify it has properties field\n\tvar schemaMap map[string]any\n\tif err := json.Unmarshal(bytes, &schemaMap); err != nil {\n\t\tt.Errorf(\"failed to unmarshal schema: %v\", err)\n\t\treturn\n\t}\n\n\t// Verify the schema has type: object\n\tif schemaType, ok := schemaMap[\"type\"].(string); !ok || schemaType != \"object\" {\n\t\tt.Errorf(\"expected type 'object', got %v\", schemaMap[\"type\"])\n\t}\n\n\t// Verify the schema has properties field (even if empty)\n\tif _, hasProperties := schemaMap[\"properties\"]; !hasProperties {\n\t\tt.Error(\"expected properties field to be present in JSON, but it was missing\")\n\t}\n\n\t// Verify properties is an empty object\n\tif props, ok := schemaMap[\"properties\"].(map[string]any); !ok {\n\t\tt.Error(\"expected properties to be an object\")\n\t} else if len(props) != 0 {\n\t\tt.Errorf(\"expected empty properties object, got %v\", props)\n\t}\n}\n\n// TestConvertToolCallsToFunctionCalls tests the tool call conversion logic\nfunc TestConvertToolCallsToFunctionCalls(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\ttoolCalls      []openai.ChatCompletionMessageToolCall\n\t\texpectedCount  int\n\t\texpectedResult bool\n\t\tvalidateCalls  func(t *testing.T, calls []FunctionCall)\n\t}{\n\t\t{\n\t\t\tname:           \"empty tool calls\",\n\t\t\ttoolCalls:      []openai.ChatCompletionMessageToolCall{},\n\t\t\texpectedCount:  0,\n\t\t\texpectedResult: false,\n\t\t},\n\t\t{\n\t\t\tname:           \"nil tool calls\",\n\t\t\ttoolCalls:      nil,\n\t\t\texpectedCount:  0,\n\t\t\texpectedResult: false,\n\t\t},\n\t\t{\n\t\t\tname: \"single valid tool call\",\n\t\t\ttoolCalls: []openai.ChatCompletionMessageToolCall{\n\t\t\t\t{\n\t\t\t\t\tID: \"call_123\",\n\t\t\t\t\tFunction: openai.ChatCompletionMessageToolCallFunction{\n\t\t\t\t\t\tName:      \"kubectl\",\n\t\t\t\t\t\tArguments: `{\"command\":\"kubectl get pods --namespace=app-dev01\",\"modifies_resource\":\"no\"}`,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount:  1,\n\t\t\texpectedResult: true,\n\t\t\tvalidateCalls: func(t *testing.T, calls []FunctionCall) {\n\t\t\t\tif calls[0].ID != \"call_123\" {\n\t\t\t\t\tt.Errorf(\"expected ID 'call_123', got %s\", calls[0].ID)\n\t\t\t\t}\n\t\t\t\tif calls[0].Name != \"kubectl\" {\n\t\t\t\t\tt.Errorf(\"expected Name 'kubectl', got %s\", calls[0].Name)\n\t\t\t\t}\n\t\t\t\tif calls[0].Arguments[\"command\"] != \"kubectl get pods --namespace=app-dev01\" {\n\t\t\t\t\tt.Errorf(\"expected command argument, got %v\", calls[0].Arguments[\"command\"])\n\t\t\t\t}\n\t\t\t\tif calls[0].Arguments[\"modifies_resource\"] != \"no\" {\n\t\t\t\t\tt.Errorf(\"expected modifies_resource argument, got %v\", calls[0].Arguments[\"modifies_resource\"])\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"tool call with empty function name\",\n\t\t\ttoolCalls: []openai.ChatCompletionMessageToolCall{\n\t\t\t\t{\n\t\t\t\t\tID: \"call_456\",\n\t\t\t\t\tFunction: openai.ChatCompletionMessageToolCallFunction{\n\t\t\t\t\t\tName:      \"\",\n\t\t\t\t\t\tArguments: `{\"command\":\"kubectl get pods\"}`,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount:  0,\n\t\t\texpectedResult: false,\n\t\t},\n\t\t{\n\t\t\tname: \"tool call with invalid JSON arguments\",\n\t\t\ttoolCalls: []openai.ChatCompletionMessageToolCall{\n\t\t\t\t{\n\t\t\t\t\tID: \"call_789\",\n\t\t\t\t\tFunction: openai.ChatCompletionMessageToolCallFunction{\n\t\t\t\t\t\tName:      \"kubectl\",\n\t\t\t\t\t\tArguments: `{\"command\":\"kubectl get pods\", invalid json}`,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount:  1,\n\t\t\texpectedResult: true,\n\t\t\tvalidateCalls: func(t *testing.T, calls []FunctionCall) {\n\t\t\t\tif calls[0].ID != \"call_789\" {\n\t\t\t\t\tt.Errorf(\"expected ID 'call_789', got %s\", calls[0].ID)\n\t\t\t\t}\n\t\t\t\tif calls[0].Name != \"kubectl\" {\n\t\t\t\t\tt.Errorf(\"expected Name 'kubectl', got %s\", calls[0].Name)\n\t\t\t\t}\n\t\t\t\t// Arguments should be empty due to parsing error\n\t\t\t\tif len(calls[0].Arguments) != 0 {\n\t\t\t\t\tt.Errorf(\"expected empty arguments due to parse error, got %v\", calls[0].Arguments)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"tool call with empty arguments\",\n\t\t\ttoolCalls: []openai.ChatCompletionMessageToolCall{\n\t\t\t\t{\n\t\t\t\t\tID: \"call_empty\",\n\t\t\t\t\tFunction: openai.ChatCompletionMessageToolCallFunction{\n\t\t\t\t\t\tName:      \"kubectl\",\n\t\t\t\t\t\tArguments: \"\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount:  1,\n\t\t\texpectedResult: true,\n\t\t\tvalidateCalls: func(t *testing.T, calls []FunctionCall) {\n\t\t\t\tif calls[0].ID != \"call_empty\" {\n\t\t\t\t\tt.Errorf(\"expected ID 'call_empty', got %s\", calls[0].ID)\n\t\t\t\t}\n\t\t\t\tif calls[0].Name != \"kubectl\" {\n\t\t\t\t\tt.Errorf(\"expected Name 'kubectl', got %s\", calls[0].Name)\n\t\t\t\t}\n\t\t\t\t// Arguments should be empty but not nil\n\t\t\t\tif calls[0].Arguments == nil {\n\t\t\t\t\tt.Error(\"expected non-nil arguments map\")\n\t\t\t\t}\n\t\t\t\tif len(calls[0].Arguments) != 0 {\n\t\t\t\t\tt.Errorf(\"expected empty arguments, got %v\", calls[0].Arguments)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"multiple tool calls with reasoning model pattern\",\n\t\t\ttoolCalls: []openai.ChatCompletionMessageToolCall{\n\t\t\t\t{\n\t\t\t\t\tID: \"call_1\",\n\t\t\t\t\tFunction: openai.ChatCompletionMessageToolCallFunction{\n\t\t\t\t\t\tName:      \"kubectl\",\n\t\t\t\t\t\tArguments: `{\"command\":\"kubectl get pods --namespace=app-dev01\\nkubectl get pods --namespace=app-dev02\",\"modifies_resource\":\"no\"}`,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tID: \"call_2\",\n\t\t\t\t\tFunction: openai.ChatCompletionMessageToolCallFunction{\n\t\t\t\t\t\tName:      \"bash\",\n\t\t\t\t\t\tArguments: `{\"command\":\"echo 'test'\",\"modifies_resource\":\"no\"}`,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\texpectedCount:  2,\n\t\t\texpectedResult: true,\n\t\t\tvalidateCalls: func(t *testing.T, calls []FunctionCall) {\n\t\t\t\tif len(calls) != 2 {\n\t\t\t\t\tt.Errorf(\"expected 2 calls, got %d\", len(calls))\n\t\t\t\t}\n\t\t\t\t// Check first call\n\t\t\t\tif calls[0].Name != \"kubectl\" {\n\t\t\t\t\tt.Errorf(\"expected first call to be 'kubectl', got %s\", calls[0].Name)\n\t\t\t\t}\n\t\t\t\tif calls[0].Arguments[\"command\"] != \"kubectl get pods --namespace=app-dev01\\nkubectl get pods --namespace=app-dev02\" {\n\t\t\t\t\tt.Errorf(\"expected multi-line command, got %v\", calls[0].Arguments[\"command\"])\n\t\t\t\t}\n\t\t\t\t// Check second call\n\t\t\t\tif calls[1].Name != \"bash\" {\n\t\t\t\t\tt.Errorf(\"expected second call to be 'bash', got %s\", calls[1].Name)\n\t\t\t\t}\n\t\t\t\tif calls[1].Arguments[\"command\"] != \"echo 'test'\" {\n\t\t\t\t\tt.Errorf(\"expected echo command, got %v\", calls[1].Arguments[\"command\"])\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcalls, ok := convertToolCallsToFunctionCalls(tt.toolCalls)\n\n\t\t\tif ok != tt.expectedResult {\n\t\t\t\tt.Errorf(\"expected result %v, got %v\", tt.expectedResult, ok)\n\t\t\t}\n\n\t\t\tif len(calls) != tt.expectedCount {\n\t\t\t\tt.Errorf(\"expected %d calls, got %d\", tt.expectedCount, len(calls))\n\t\t\t}\n\n\t\t\tif tt.validateCalls != nil && len(calls) > 0 {\n\t\t\t\ttt.validateCalls(t, calls)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "gollm/persist.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\n// We define some standard structs to allow for persistence of the LLM requests and responses.\n// This lets us store the history of the conversation for later analysis.\n\ntype RecordCompletionResponse struct {\n\tText string `json:\"text\"`\n\tRaw  any    `json:\"raw\"`\n}\n\ntype RecordChatResponse struct {\n\t// TODO: Structured data?\n\tRaw any `json:\"raw\"`\n}\n"
  },
  {
    "path": "gollm/schema.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nimport (\n\t\"reflect\"\n\t\"strings\"\n\n\t\"k8s.io/klog/v2\"\n)\n\n// BuildSchemaFor will build a schema for the given golang type.\n// Because this does not have description populated, it is more useful for the response schema than tools/functions.\nfunc BuildSchemaFor(t reflect.Type) *Schema {\n\tout := &Schema{}\n\n\tswitch t.Kind() {\n\tcase reflect.String:\n\t\tout.Type = TypeString\n\tcase reflect.Bool:\n\t\tout.Type = TypeBoolean\n\tcase reflect.Int:\n\t\tout.Type = TypeInteger\n\tcase reflect.Struct:\n\t\tout.Type = TypeObject\n\t\tout.Properties = make(map[string]*Schema)\n\t\tnumFields := t.NumField()\n\t\trequired := []string{}\n\t\tfor i := 0; i < numFields; i++ {\n\t\t\tfield := t.Field(i)\n\t\t\tjsonTag := field.Tag.Get(\"json\")\n\t\t\tif jsonTag == \"\" {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif strings.HasSuffix(jsonTag, \",omitempty\") {\n\t\t\t\tjsonTag = strings.TrimSuffix(jsonTag, \",omitempty\")\n\t\t\t} else {\n\t\t\t\trequired = append(required, jsonTag)\n\t\t\t}\n\n\t\t\tfieldType := field.Type\n\n\t\t\tfieldSchema := BuildSchemaFor(fieldType)\n\t\t\tout.Properties[jsonTag] = fieldSchema\n\t\t}\n\n\t\tif len(required) != 0 {\n\t\t\tout.Required = required\n\t\t}\n\tcase reflect.Slice:\n\t\tout.Type = TypeArray\n\t\tout.Items = BuildSchemaFor(t.Elem())\n\tdefault:\n\t\tklog.Fatalf(\"unhandled kind %v\", t.Kind())\n\t}\n\n\treturn out\n}\n"
  },
  {
    "path": "gollm/shims.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage gollm\n\nfunc singletonChatResponseIterator(response ChatResponse) ChatResponseIterator {\n\treturn func(yield func(ChatResponse, error) bool) {\n\t\tif !yield(response, nil) {\n\t\t\treturn\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "images/kubectl-ai/Dockerfile",
    "content": "ARG GO_VERSION=\"1.24.3\"\nARG GCLOUD_CLI_VERSION=\"530.0.0-stable\"\n\nFROM golang:${GO_VERSION}-bookworm AS builder\n\nWORKDIR /src\nCOPY go.mod go.sum ./\nCOPY gollm/ ./gollm/\nRUN go mod download\n\nCOPY cmd/ ./cmd/\nCOPY pkg/ ./pkg/\n\nRUN CGO_ENABLED=0 go build -o kubectl-ai ./cmd/\nFROM debian:bookworm-slim AS kubectl-tool\nENV DEBIAN_FRONTEND=noninteractive\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends curl ca-certificates && \\\n    mkdir -p /opt/tools/kubectl/bin/ && \\\n    curl -v -L \"https://dl.k8s.io/release/v1.33.0/bin/linux/amd64/kubectl\" -o /opt/tools/kubectl/bin/kubectl && \\\n    chmod +x /opt/tools/kubectl/bin/kubectl && \\\n    apt-get clean && \\\n    rm -rf /var/lib/apt/lists/*\n\nFROM gcr.io/google.com/cloudsdktool/google-cloud-cli:${GCLOUD_CLI_VERSION} AS runtime\nRUN apt-get update -y && \\\n    apt-get install -y apt-transport-https ca-certificates gnupg curl ca-certificates && \\\n    curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg && \\\n    echo \"deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main\" | tee /etc/apt/sources.list.d/google-cloud-sdk.list && \\\n    apt-get update -y && \\\n    apt-get install -y google-cloud-cli-gke-gcloud-auth-plugin && \\\n    apt-get clean && \\\n    rm -rf /var/lib/apt/lists/*\n\n\nCOPY --from=builder /src/kubectl-ai /bin/kubectl-ai\nCOPY --from=kubectl-tool /opt/tools/kubectl/ /opt/tools/kubectl/\nRUN ln -sf /opt/tools/kubectl/bin/kubectl /bin/kubectl\n\n# Copy the custom tool configurations into the runtime image.\nCOPY docs/tool-samples /etc/kubectl-ai/tools/\n\nENTRYPOINT [ \"/bin/kubectl-ai\" ]"
  },
  {
    "path": "install.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\n# Check for required commands\nfor cmd in curl tar; do\n  if ! command -v \"$cmd\" >/dev/null 2>&1; then\n    echo \"Error: $cmd is not installed. Please install $cmd to proceed.\"\n    exit 1\n  fi\ndone\n\n# Set the insecure SSL argument\nINSECURE_ARG=\"\"\nif [ -n \"${INSECURE:-}\" ]; then\n  INSECURE_ARG=\"--insecure\"\nfi\n\nREPO=\"GoogleCloudPlatform/kubectl-ai\"\nBINARY=\"kubectl-ai\"\n\n# Detect OS\nsysOS=\"$(uname | tr '[:upper:]' '[:lower:]')\"\ncase \"$sysOS\" in\n  linux)   OS=\"Linux\" ;;\n  darwin)  OS=\"Darwin\" ;;\n  *)\n    echo \"If you are on Windows or another unsupported OS, please follow the manual installation instructions at:\"\n    echo \"https://github.com/GoogleCloudPlatform/kubectl-ai#manual-installation-linux-macos-and-windows\"\n    exit 1\n    ;;\nesac\n\n# Detect NixOS\nnixos_check=\"$(grep \"ID=nixos\" /etc/os-release 2>/dev/null || echo \"no-match\")\"\ncase \"$nixos_check\" in\n  *nixos*)\n    echo \"NixOS detected, please follow the manual installation instructions at:\"\n    echo \"https://github.com/GoogleCloudPlatform/kubectl-ai#install-on-nixos\"\n    exit 1\n    ;;\nesac\n\n# Detect ARCH\nARCH=\"$(uname -m)\"\ncase \"$ARCH\" in\n  x86_64|amd64) ARCH=\"x86_64\" ;;\n  arm64|aarch64) ARCH=\"arm64\" ;;\n  *)\n    echo \"If you are on an unsupported architecture, please follow the manual installation instructions at:\"\n    echo \"https://github.com/GoogleCloudPlatform/kubectl-ai#manual-installation-linux-macos-and-windows\"\n    exit 1\n    ;;\nesac\n\n# Get latest version tag from GitHub API, Use GITHUB_TOKEN if available to avoid potential rate limit\nif [ -n \"${GITHUB_TOKEN:-}\" ]; then\n  auth_hdr=\"Authorization: token $GITHUB_TOKEN\"\nelse\n  auth_hdr=\"\"\n\nfi\nif [ -n \"${INSECURE:-}\" ]; then\n  echo \"⚠️  SECURITY WARNING: INSECURE is set, SSL certificate validation will be skipped!\"\n  echo \"   This makes you vulnerable to man-in-the-middle attacks and other security risks.\"\n  echo \"   Only proceed if you understand the security implications and trust your network.\"\n  echo \"\"\n  echo \"   Continue with unsafe download? (yes/no)\"\n  read -r response\n  case \"$response\" in\n    [yY][eE][sS]|[yY])\n      echo \"Proceeding with insecure connection...\"\n      ;;\n    *)\n      echo \"Installation aborted for security reasons.\"\n      exit 1\n      ;;\n  esac\nfi\nLATEST_TAG=$(curl $INSECURE_ARG -s -H \"$auth_hdr\" \\\n  \"https://api.github.com/repos/$REPO/releases/latest\" \\\n  | sed -n 's/.*\"tag_name\": *\"\\([^\"]*\\)\".*/\\1/p')\nif [ -z \"$LATEST_TAG\" ]; then\n  echo \"Failed to fetch latest release tag.\"\n  exit 1\nfi\n\n# Compose download URL\nTARBALL=\"kubectl-ai_${OS}_${ARCH}.tar.gz\"\nURL=\"https://github.com/$REPO/releases/download/$LATEST_TAG/$TARBALL\"\n\n# Create temp dir and set cleanup trap\nTEMP_DIR=\"$(mktemp -d)\"\ncleanup() {\n  if [ -n \"${TEMP_DIR:-}\" ] && [ -d \"$TEMP_DIR\" ]; then\n    rm -rf \"$TEMP_DIR\"\n  fi\n}\ntrap cleanup EXIT INT TERM\n\n# Download and extract in temp dir; install from there\n(\n  cd \"$TEMP_DIR\"\n  echo \"Downloading $URL ...\"\n  CURL_FLAGS=\"-fSL --retry 3\"\n  if [ -n \"${INSECURE:-}\" ]; then\n    echo \"⚠️  SSL certificate validation will be skipped for this download.\"\n  fi\n  curl $INSECURE_ARG $CURL_FLAGS \"$URL\" -o \"$TARBALL\"\n  tar --no-same-owner -xzf \"$TARBALL\"\n\n  if [ ! -f \"$BINARY\" ]; then\n    echo \"Error: expected binary '$BINARY' not found after extraction.\"\n    exit 1\n  fi\n\n  INSTALL_DIR=\"${INSTALL_DIR:-/usr/local/bin}\"\n  echo \"Installing $BINARY to $INSTALL_DIR (may require sudo)...\"\n  sudo install -m 0755 \"$BINARY\" \"$INSTALL_DIR/\"\n)\n\necho \"✅ $BINARY installed successfully! Run '$BINARY --help' to get started.\"\n"
  },
  {
    "path": "internal/mocks/agent_mock.go",
    "content": "// Code generated by MockGen. DO NOT EDIT.\n// Source: github.com/GoogleCloudPlatform/kubectl-ai/pkg/api (interfaces: ChatMessageStore)\n//\n// Generated by this command:\n//\n//\tmockgen -destination=agent_mock.go -package=mocks github.com/GoogleCloudPlatform/kubectl-ai/pkg/api ChatMessageStore\n//\n\n// Package mocks is a generated GoMock package.\npackage mocks\n\nimport (\n\treflect \"reflect\"\n\n\tapi \"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\tgomock \"go.uber.org/mock/gomock\"\n)\n\n// MockChatMessageStore is a mock of ChatMessageStore interface.\ntype MockChatMessageStore struct {\n\tctrl     *gomock.Controller\n\trecorder *MockChatMessageStoreMockRecorder\n\tisgomock struct{}\n}\n\n// MockChatMessageStoreMockRecorder is the mock recorder for MockChatMessageStore.\ntype MockChatMessageStoreMockRecorder struct {\n\tmock *MockChatMessageStore\n}\n\n// NewMockChatMessageStore creates a new mock instance.\nfunc NewMockChatMessageStore(ctrl *gomock.Controller) *MockChatMessageStore {\n\tmock := &MockChatMessageStore{ctrl: ctrl}\n\tmock.recorder = &MockChatMessageStoreMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockChatMessageStore) EXPECT() *MockChatMessageStoreMockRecorder {\n\treturn m.recorder\n}\n\n// AddChatMessage mocks base method.\nfunc (m *MockChatMessageStore) AddChatMessage(record *api.Message) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"AddChatMessage\", record)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// AddChatMessage indicates an expected call of AddChatMessage.\nfunc (mr *MockChatMessageStoreMockRecorder) AddChatMessage(record any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"AddChatMessage\", reflect.TypeOf((*MockChatMessageStore)(nil).AddChatMessage), record)\n}\n\n// ChatMessages mocks base method.\nfunc (m *MockChatMessageStore) ChatMessages() []*api.Message {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"ChatMessages\")\n\tret0, _ := ret[0].([]*api.Message)\n\treturn ret0\n}\n\n// ChatMessages indicates an expected call of ChatMessages.\nfunc (mr *MockChatMessageStoreMockRecorder) ChatMessages() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"ChatMessages\", reflect.TypeOf((*MockChatMessageStore)(nil).ChatMessages))\n}\n\n// ClearChatMessages mocks base method.\nfunc (m *MockChatMessageStore) ClearChatMessages() error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"ClearChatMessages\")\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// ClearChatMessages indicates an expected call of ClearChatMessages.\nfunc (mr *MockChatMessageStoreMockRecorder) ClearChatMessages() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"ClearChatMessages\", reflect.TypeOf((*MockChatMessageStore)(nil).ClearChatMessages))\n}\n\n// SetChatMessages mocks base method.\nfunc (m *MockChatMessageStore) SetChatMessages(newHistory []*api.Message) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"SetChatMessages\", newHistory)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// SetChatMessages indicates an expected call of SetChatMessages.\nfunc (mr *MockChatMessageStoreMockRecorder) SetChatMessages(newHistory any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"SetChatMessages\", reflect.TypeOf((*MockChatMessageStore)(nil).SetChatMessages), newHistory)\n}\n"
  },
  {
    "path": "internal/mocks/generate.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package mocks holds go:generate directives for gomock.\npackage mocks\n\n// Generate gomock types for external interfaces we depend on.\n// NOTE: run `go generate ./...` from repo root to (re)create mocks.\n// Requires: go install go.uber.org/mock/mockgen@latest\n\n// gollm interfaces\n//   - Client, Chat\n// tools interface\n//   - Tool\n\n//go:generate mockgen -destination=gollm_mock.go -package=mocks github.com/GoogleCloudPlatform/kubectl-ai/gollm Client,Chat\n//go:generate mockgen -destination=tools_mock.go -package=mocks github.com/GoogleCloudPlatform/kubectl-ai/pkg/tools Tool\n//go:generate mockgen -destination=agent_mock.go -package=mocks github.com/GoogleCloudPlatform/kubectl-ai/pkg/api ChatMessageStore\n"
  },
  {
    "path": "internal/mocks/gollm_mock.go",
    "content": "// Code generated by MockGen. DO NOT EDIT.\n// Source: github.com/GoogleCloudPlatform/kubectl-ai/gollm (interfaces: Client,Chat)\n//\n// Generated by this command:\n//\n//\tmockgen -destination=gollm_mock.go -package=mocks github.com/GoogleCloudPlatform/kubectl-ai/gollm Client,Chat\n//\n\n// Package mocks is a generated GoMock package.\npackage mocks\n\nimport (\n\tcontext \"context\"\n\treflect \"reflect\"\n\n\tgollm \"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\tapi \"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\tgomock \"go.uber.org/mock/gomock\"\n)\n\n// MockClient is a mock of Client interface.\ntype MockClient struct {\n\tctrl     *gomock.Controller\n\trecorder *MockClientMockRecorder\n\tisgomock struct{}\n}\n\n// MockClientMockRecorder is the mock recorder for MockClient.\ntype MockClientMockRecorder struct {\n\tmock *MockClient\n}\n\n// NewMockClient creates a new mock instance.\nfunc NewMockClient(ctrl *gomock.Controller) *MockClient {\n\tmock := &MockClient{ctrl: ctrl}\n\tmock.recorder = &MockClientMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockClient) EXPECT() *MockClientMockRecorder {\n\treturn m.recorder\n}\n\n// Close mocks base method.\nfunc (m *MockClient) Close() error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Close\")\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Close indicates an expected call of Close.\nfunc (mr *MockClientMockRecorder) Close() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Close\", reflect.TypeOf((*MockClient)(nil).Close))\n}\n\n// GenerateCompletion mocks base method.\nfunc (m *MockClient) GenerateCompletion(ctx context.Context, req *gollm.CompletionRequest) (gollm.CompletionResponse, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"GenerateCompletion\", ctx, req)\n\tret0, _ := ret[0].(gollm.CompletionResponse)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// GenerateCompletion indicates an expected call of GenerateCompletion.\nfunc (mr *MockClientMockRecorder) GenerateCompletion(ctx, req any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"GenerateCompletion\", reflect.TypeOf((*MockClient)(nil).GenerateCompletion), ctx, req)\n}\n\n// ListModels mocks base method.\nfunc (m *MockClient) ListModels(ctx context.Context) ([]string, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"ListModels\", ctx)\n\tret0, _ := ret[0].([]string)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// ListModels indicates an expected call of ListModels.\nfunc (mr *MockClientMockRecorder) ListModels(ctx any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"ListModels\", reflect.TypeOf((*MockClient)(nil).ListModels), ctx)\n}\n\n// SetResponseSchema mocks base method.\nfunc (m *MockClient) SetResponseSchema(schema *gollm.Schema) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"SetResponseSchema\", schema)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// SetResponseSchema indicates an expected call of SetResponseSchema.\nfunc (mr *MockClientMockRecorder) SetResponseSchema(schema any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"SetResponseSchema\", reflect.TypeOf((*MockClient)(nil).SetResponseSchema), schema)\n}\n\n// StartChat mocks base method.\nfunc (m *MockClient) StartChat(systemPrompt, model string) gollm.Chat {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"StartChat\", systemPrompt, model)\n\tret0, _ := ret[0].(gollm.Chat)\n\treturn ret0\n}\n\n// StartChat indicates an expected call of StartChat.\nfunc (mr *MockClientMockRecorder) StartChat(systemPrompt, model any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"StartChat\", reflect.TypeOf((*MockClient)(nil).StartChat), systemPrompt, model)\n}\n\n// MockChat is a mock of Chat interface.\ntype MockChat struct {\n\tctrl     *gomock.Controller\n\trecorder *MockChatMockRecorder\n\tisgomock struct{}\n}\n\n// MockChatMockRecorder is the mock recorder for MockChat.\ntype MockChatMockRecorder struct {\n\tmock *MockChat\n}\n\n// NewMockChat creates a new mock instance.\nfunc NewMockChat(ctrl *gomock.Controller) *MockChat {\n\tmock := &MockChat{ctrl: ctrl}\n\tmock.recorder = &MockChatMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockChat) EXPECT() *MockChatMockRecorder {\n\treturn m.recorder\n}\n\n// Initialize mocks base method.\nfunc (m *MockChat) Initialize(messages []*api.Message) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Initialize\", messages)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// Initialize indicates an expected call of Initialize.\nfunc (mr *MockChatMockRecorder) Initialize(messages any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Initialize\", reflect.TypeOf((*MockChat)(nil).Initialize), messages)\n}\n\n// IsRetryableError mocks base method.\nfunc (m *MockChat) IsRetryableError(arg0 error) bool {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"IsRetryableError\", arg0)\n\tret0, _ := ret[0].(bool)\n\treturn ret0\n}\n\n// IsRetryableError indicates an expected call of IsRetryableError.\nfunc (mr *MockChatMockRecorder) IsRetryableError(arg0 any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"IsRetryableError\", reflect.TypeOf((*MockChat)(nil).IsRetryableError), arg0)\n}\n\n// Send mocks base method.\nfunc (m *MockChat) Send(ctx context.Context, contents ...any) (gollm.ChatResponse, error) {\n\tm.ctrl.T.Helper()\n\tvarargs := []any{ctx}\n\tfor _, a := range contents {\n\t\tvarargs = append(varargs, a)\n\t}\n\tret := m.ctrl.Call(m, \"Send\", varargs...)\n\tret0, _ := ret[0].(gollm.ChatResponse)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// Send indicates an expected call of Send.\nfunc (mr *MockChatMockRecorder) Send(ctx any, contents ...any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\tvarargs := append([]any{ctx}, contents...)\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Send\", reflect.TypeOf((*MockChat)(nil).Send), varargs...)\n}\n\n// SendStreaming mocks base method.\nfunc (m *MockChat) SendStreaming(ctx context.Context, contents ...any) (gollm.ChatResponseIterator, error) {\n\tm.ctrl.T.Helper()\n\tvarargs := []any{ctx}\n\tfor _, a := range contents {\n\t\tvarargs = append(varargs, a)\n\t}\n\tret := m.ctrl.Call(m, \"SendStreaming\", varargs...)\n\tret0, _ := ret[0].(gollm.ChatResponseIterator)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// SendStreaming indicates an expected call of SendStreaming.\nfunc (mr *MockChatMockRecorder) SendStreaming(ctx any, contents ...any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\tvarargs := append([]any{ctx}, contents...)\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"SendStreaming\", reflect.TypeOf((*MockChat)(nil).SendStreaming), varargs...)\n}\n\n// SetFunctionDefinitions mocks base method.\nfunc (m *MockChat) SetFunctionDefinitions(functionDefinitions []*gollm.FunctionDefinition) error {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"SetFunctionDefinitions\", functionDefinitions)\n\tret0, _ := ret[0].(error)\n\treturn ret0\n}\n\n// SetFunctionDefinitions indicates an expected call of SetFunctionDefinitions.\nfunc (mr *MockChatMockRecorder) SetFunctionDefinitions(functionDefinitions any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"SetFunctionDefinitions\", reflect.TypeOf((*MockChat)(nil).SetFunctionDefinitions), functionDefinitions)\n}\n"
  },
  {
    "path": "internal/mocks/tools_mock.go",
    "content": "// Code generated by MockGen. DO NOT EDIT.\n// Source: github.com/GoogleCloudPlatform/kubectl-ai/pkg/tools (interfaces: Tool)\n//\n// Generated by this command:\n//\n//\tmockgen -destination=tools_mock.go -package=mocks github.com/GoogleCloudPlatform/kubectl-ai/pkg/tools Tool\n//\n\n// Package mocks is a generated GoMock package.\npackage mocks\n\nimport (\n\tcontext \"context\"\n\treflect \"reflect\"\n\n\tgollm \"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\tgomock \"go.uber.org/mock/gomock\"\n)\n\n// MockTool is a mock of Tool interface.\ntype MockTool struct {\n\tctrl     *gomock.Controller\n\trecorder *MockToolMockRecorder\n\tisgomock struct{}\n}\n\n// MockToolMockRecorder is the mock recorder for MockTool.\ntype MockToolMockRecorder struct {\n\tmock *MockTool\n}\n\n// NewMockTool creates a new mock instance.\nfunc NewMockTool(ctrl *gomock.Controller) *MockTool {\n\tmock := &MockTool{ctrl: ctrl}\n\tmock.recorder = &MockToolMockRecorder{mock}\n\treturn mock\n}\n\n// EXPECT returns an object that allows the caller to indicate expected use.\nfunc (m *MockTool) EXPECT() *MockToolMockRecorder {\n\treturn m.recorder\n}\n\n// CheckModifiesResource mocks base method.\nfunc (m *MockTool) CheckModifiesResource(args map[string]any) string {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"CheckModifiesResource\", args)\n\tret0, _ := ret[0].(string)\n\treturn ret0\n}\n\n// CheckModifiesResource indicates an expected call of CheckModifiesResource.\nfunc (mr *MockToolMockRecorder) CheckModifiesResource(args any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"CheckModifiesResource\", reflect.TypeOf((*MockTool)(nil).CheckModifiesResource), args)\n}\n\n// Description mocks base method.\nfunc (m *MockTool) Description() string {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Description\")\n\tret0, _ := ret[0].(string)\n\treturn ret0\n}\n\n// Description indicates an expected call of Description.\nfunc (mr *MockToolMockRecorder) Description() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Description\", reflect.TypeOf((*MockTool)(nil).Description))\n}\n\n// FunctionDefinition mocks base method.\nfunc (m *MockTool) FunctionDefinition() *gollm.FunctionDefinition {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"FunctionDefinition\")\n\tret0, _ := ret[0].(*gollm.FunctionDefinition)\n\treturn ret0\n}\n\n// FunctionDefinition indicates an expected call of FunctionDefinition.\nfunc (mr *MockToolMockRecorder) FunctionDefinition() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"FunctionDefinition\", reflect.TypeOf((*MockTool)(nil).FunctionDefinition))\n}\n\n// IsInteractive mocks base method.\nfunc (m *MockTool) IsInteractive(args map[string]any) (bool, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"IsInteractive\", args)\n\tret0, _ := ret[0].(bool)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// IsInteractive indicates an expected call of IsInteractive.\nfunc (mr *MockToolMockRecorder) IsInteractive(args any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"IsInteractive\", reflect.TypeOf((*MockTool)(nil).IsInteractive), args)\n}\n\n// Name mocks base method.\nfunc (m *MockTool) Name() string {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Name\")\n\tret0, _ := ret[0].(string)\n\treturn ret0\n}\n\n// Name indicates an expected call of Name.\nfunc (mr *MockToolMockRecorder) Name() *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Name\", reflect.TypeOf((*MockTool)(nil).Name))\n}\n\n// Run mocks base method.\nfunc (m *MockTool) Run(ctx context.Context, args map[string]any) (any, error) {\n\tm.ctrl.T.Helper()\n\tret := m.ctrl.Call(m, \"Run\", ctx, args)\n\tret0, _ := ret[0].(any)\n\tret1, _ := ret[1].(error)\n\treturn ret0, ret1\n}\n\n// Run indicates an expected call of Run.\nfunc (mr *MockToolMockRecorder) Run(ctx, args any) *gomock.Call {\n\tmr.mock.ctrl.T.Helper()\n\treturn mr.mock.ctrl.RecordCallWithMethodType(mr.mock, \"Run\", reflect.TypeOf((*MockTool)(nil).Run), ctx, args)\n}\n"
  },
  {
    "path": "k8s/all_in_one.yaml",
    "content": "apiVersion: v1\nkind: Namespace\nmetadata:\n  name: computer\n  labels:\n    name: computer\n---\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: normal-user\n  namespace: computer\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n  namespace: computer\n  name: reader-all-but-secrets\nrules:\n- apiGroups: [\"\"]\n  resources: [\"pods\", \"pods/log\", \"pods/status\", \"configmaps\", \"persistentvolumeclaims\", \"replicationcontrollers\", \"resourcequotas\", \"limitranges\", \"endpoints\", \"events\", \"services\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"apps\"]\n  resources: [\"deployments\", \"daemonsets\", \"replicasets\", \"statefulsets\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"autoscaling\"]\n  resources: [\"horizontalpodautoscalers\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"batch\"]\n  resources: [\"jobs\", \"cronjobs\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"extensions\"]\n  resources: [\"deployments\", \"daemonsets\", \"replicasets\", \"ingresses\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"policy\"]\n  resources: [\"poddisruptionbudgets\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"networking.k8s.io\"]\n  resources: [\"networkpolicies\", \"ingresses\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: RoleBinding\nmetadata:\n  name: normal-user-reader-binding\n  namespace: computer\nsubjects:\n- kind: ServiceAccount\n  name: normal-user\n  namespace: computer\nroleRef:\n  kind: Role\n  name: reader-all-but-secrets\n  apiGroup: rbac.authorization.k8s.io\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: reader-cluster-resources\nrules:\n- apiGroups: [\"apiextensions.k8s.io\"]\n  resources: [\"customresourcedefinitions\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\", \"persistentvolumes\", \"namespaces\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"*\"]\n  resources: [\"*\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: normal-user-cluster-reader-binding\nsubjects:\n- kind: ServiceAccount\n  name: normal-user\n  namespace: computer\nroleRef:\n  kind: ClusterRole\n  name: reader-cluster-resources\n  apiGroup: rbac.authorization.k8s.io"
  },
  {
    "path": "k8s/kubectl-ai-gke.yaml",
    "content": "apiVersion: v1\nkind: Namespace\nmetadata:\n  name: kubectl-ai\n---\nkind: ServiceAccount\napiVersion: v1\nmetadata:\n  name: kubectl-ai\n  namespace: kubectl-ai\n---\nkind: Deployment\napiVersion: apps/v1\nmetadata:\n  name: kubectl-ai\n  namespace: kubectl-ai\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: kubectl-ai\n  template:\n    metadata:\n      labels:\n        app: kubectl-ai\n    spec:\n      serviceAccountName: kubectl-ai\n      containers:\n      - name: kubectl-ai\n        image: REPLACE_WITH_YOUR_IMAGE # e.g. us-central1-docker.pkg.dev/PROJECT_ID/kubectl-ai/kubectl-ai:latest\n        args:\n        - --ui-type=web\n        - --ui-listen-address=0.0.0.0:8080\n        - --v=4\n        - --alsologtostderr\n        - --sandbox=k8s\n        env:\n        - name: GOOGLE_CLOUD_PROJECT\n          value: \"PROJECT_ID\"\n        - name: GOOGLE_CLOUD_LOCATION\n          value: \"global\"\n        - name: GEMINI_API_KEY\n          value: \"REPLACE_WITH_YOUR_GEMINI_API_KEY\"\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: RoleBinding\nmetadata:\n  name: kubectl-ai:view\n  namespace: kubectl-ai\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: view\nsubjects:\n- kind: ServiceAccount\n  name: kubectl-ai\n---\nkind: Service\napiVersion: v1\nmetadata:\n  name: kubectl-ai\n  namespace: kubectl-ai\n  labels:\n    app: kubectl-ai\nspec:\n  selector:\n    app: kubectl-ai\n  ports:\n  - port: 80\n    targetPort: 8080\n    protocol: TCP \n---\n# 1. The ClusterRole that grants read-only access to most resources\n# This is a cluster-wide role, so it does not have a namespace.\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  # This name is shared across the cluster.\n  name: read-only-except-secrets-cluster-role\nrules:\n- apiGroups:\n  - \"\" # core API group\n  resources:\n  # List all core resource types EXCEPT \"secrets\"\n  - configmaps\n  - endpoints\n  - events\n  - limitranges\n  - namespaces\n  - nodes\n  - persistentvolumeclaims\n  - persistentvolumes\n  - pods\n  - podtemplates\n  - replicationcontrollers\n  - resourcequotas\n  - serviceaccounts\n  - services\n  verbs:\n  - get\n  - list\n  - watch\n- apiGroups:\n  - \"*\" # All other current and future API groups\n  resources:\n  - \"*\" # All current and future resources in those groups (including CRDs and CRs)\n  verbs:\n  - get\n  - list\n  - watch\n---\n# 2. The ClusterRoleBinding that connects the ServiceAccount to the Role\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: read-only-kubectl-ai-binding\nsubjects:\n# Grant the permissions to the specific ServiceAccount in the specific namespace\n- kind: ServiceAccount\n  name: kubectl-ai\n  namespace: kubectl-ai\nroleRef:\n  # This refers to the ClusterRole defined above\n  kind: ClusterRole\n  name: read-only-except-secrets-cluster-role\n  apiGroup: rbac.authorization.k8s.io\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: kubectl-ai-computer-manager\nrules:\n- apiGroups:\n  - \"\"\n  resources:\n  - pods\n  - pods/exec\n  - configmaps\n  - secrets\n  verbs:\n  - create\n  - get\n  - delete\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: RoleBinding\nmetadata:\n  name: kubectl-ai-computer-manager-binding\n  namespace: computer\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: kubectl-ai-computer-manager\nsubjects:\n- kind: ServiceAccount\n  name: kubectl-ai\n  namespace: kubectl-ai"
  },
  {
    "path": "k8s/kubectl-ai.yaml",
    "content": "kind: Deployment\napiVersion: apps/v1\nmetadata:\n  name: kubectl-ai\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: kubectl-ai\n  template:\n    metadata:\n      labels:\n        app: kubectl-ai\n    spec:\n      serviceAccountName: kubectl-ai\n      containers:\n      - name: kubectl-ai\n        image: kubectl-ai:latest\n        args:\n        - --ui-type=web\n        envFrom:\n        - secretRef:\n            name: kubectl-ai\n---\n\nkind: Secret\napiVersion: v1\nmetadata:\n  name: kubectl-ai\n  labels:\n    app: kubectl-ai\ntype: Opaque\n\n---\n\nkind: ServiceAccount\napiVersion: v1\nmetadata:\n  name: kubectl-ai\n\n---\n\napiVersion: rbac.authorization.k8s.io/v1\nkind: RoleBinding\nmetadata:\n  name: kubectl-ai:view\nroleRef:\n  apiGroup: rbac.authorization.k8s.io\n  kind: ClusterRole\n  name: view\nsubjects:\n- kind: ServiceAccount\n  name: kubectl-ai\n\n---\n\nkind: Service\napiVersion: v1\nmetadata:\n  name: kubectl-ai\n  labels:\n    app: kubectl-ai\nspec:\n  selector:\n    app: kubectl-ai\n  ports:\n  - port: 80\n    targetPort: 8888\n    protocol: TCP\n"
  },
  {
    "path": "k8s/sandbox/all-in-one.yaml",
    "content": "apiVersion: v1\nkind: Namespace\nmetadata:\n  name: computer\n  labels:\n    name: computer\n---\napiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: normal-user\n  namespace: computer\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n  namespace: computer\n  name: reader-all-but-secrets\nrules:\n- apiGroups: [\"\"]\n  resources: [\"pods\", \"pods/log\", \"pods/status\", \"configmaps\", \"persistentvolumeclaims\", \"replicationcontrollers\", \"resourcequotas\", \"limitranges\", \"endpoints\", \"events\", \"services\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"apps\"]\n  resources: [\"deployments\", \"daemonsets\", \"replicasets\", \"statefulsets\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"autoscaling\"]\n  resources: [\"horizontalpodautoscalers\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"batch\"]\n  resources: [\"jobs\", \"cronjobs\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"extensions\"]\n  resources: [\"deployments\", \"daemonsets\", \"replicasets\", \"ingresses\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"policy\"]\n  resources: [\"poddisruptionbudgets\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"networking.k8s.io\"]\n  resources: [\"networkpolicies\", \"ingresses\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: RoleBinding\nmetadata:\n  name: normal-user-reader-binding\n  namespace: computer\nsubjects:\n- kind: ServiceAccount\n  name: normal-user\n  namespace: computer\nroleRef:\n  kind: Role\n  name: reader-all-but-secrets\n  apiGroup: rbac.authorization.k8s.io\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: reader-cluster-resources\nrules:\n- apiGroups: [\"apiextensions.k8s.io\"]\n  resources: [\"customresourcedefinitions\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\", \"persistentvolumes\", \"namespaces\", \"pods\", \"services\", \"endpoints\", \"events\", \"configmaps\", \"serviceaccounts\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"apps\"]\n  resources: [\"deployments\", \"daemonsets\", \"statefulsets\", \"replicasets\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"batch\"]\n  resources: [\"jobs\", \"cronjobs\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"networking.k8s.io\"]\n  resources: [\"ingresses\", \"networkpolicies\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"storage.k8s.io\"]\n  resources: [\"storageclasses\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"rbac.authorization.k8s.io\"]\n  resources: [\"roles\", \"rolebindings\", \"clusterroles\", \"clusterrolebindings\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n\n---\napiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: normal-user-cluster-reader-binding\nsubjects:\n- kind: ServiceAccount\n  name: normal-user\n  namespace: computer\nroleRef:\n  kind: ClusterRole\n  name: reader-cluster-resources\n  apiGroup: rbac.authorization.k8s.io"
  },
  {
    "path": "k8s/sandbox/cluster_role.yaml",
    "content": "apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n  name: reader-cluster-resources\nrules:\n- apiGroups: [\"apiextensions.k8s.io\"]\n  resources: [\"customresourcedefinitions\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"\"]\n  resources: [\"nodes\", \"persistentvolumes\", \"namespaces\", \"pods\", \"services\", \"endpoints\", \"events\", \"configmaps\", \"serviceaccounts\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"apps\"]\n  resources: [\"deployments\", \"daemonsets\", \"statefulsets\", \"replicasets\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"batch\"]\n  resources: [\"jobs\", \"cronjobs\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"networking.k8s.io\"]\n  resources: [\"ingresses\", \"networkpolicies\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"storage.k8s.io\"]\n  resources: [\"storageclasses\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"rbac.authorization.k8s.io\"]\n  resources: [\"roles\", \"rolebindings\", \"clusterroles\", \"clusterrolebindings\"]\n  verbs: [\"get\", \"list\", \"watch\"] "
  },
  {
    "path": "k8s/sandbox/cluster_role_binding.yaml",
    "content": "apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n  name: normal-user-cluster-reader-binding\nsubjects:\n- kind: ServiceAccount\n  name: normal-user\n  namespace: computer\nroleRef:\n  kind: ClusterRole\n  name: reader-cluster-resources\n  apiGroup: rbac.authorization.k8s.io"
  },
  {
    "path": "k8s/sandbox/namespace.yaml",
    "content": "apiVersion: v1\nkind: Namespace\nmetadata:\n  name: computer\n  labels:\n    name: computer "
  },
  {
    "path": "k8s/sandbox/role.yaml",
    "content": "apiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n  namespace: computer\n  name: reader-all-but-secrets\nrules:\n- apiGroups: [\"\"]\n  resources: [\"pods\", \"pods/log\", \"pods/status\", \"configmaps\", \"persistentvolumeclaims\", \"replicationcontrollers\", \"resourcequotas\", \"limitranges\", \"endpoints\", \"events\", \"services\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"apps\"]\n  resources: [\"deployments\", \"daemonsets\", \"replicasets\", \"statefulsets\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"autoscaling\"]\n  resources: [\"horizontalpodautoscalers\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"batch\"]\n  resources: [\"jobs\", \"cronjobs\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"extensions\"]\n  resources: [\"deployments\", \"daemonsets\", \"replicasets\", \"ingresses\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"policy\"]\n  resources: [\"poddisruptionbudgets\"]\n  verbs: [\"get\", \"list\", \"watch\"]\n- apiGroups: [\"networking.k8s.io\"]\n  resources: [\"networkpolicies\", \"ingresses\"]\n  verbs: [\"get\", \"list\", \"watch\"] "
  },
  {
    "path": "k8s/sandbox/role_binding.yaml",
    "content": "apiVersion: rbac.authorization.k8s.io/v1\nkind: RoleBinding\nmetadata:\n  name: normal-user-reader-binding\n  namespace: computer\nsubjects:\n- kind: ServiceAccount\n  name: normal-user\n  namespace: computer\nroleRef:\n  kind: Role\n  name: reader-all-but-secrets\n  apiGroup: rbac.authorization.k8s.io"
  },
  {
    "path": "k8s/sandbox/service_account.yaml",
    "content": "apiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: normal-user\n  namespace: computer"
  },
  {
    "path": "kubectl-utils/README.md",
    "content": "kubectl-utils contains some experimental kubectl extensions that should help us write simpler evals for kubectl-ai\n\nThey may one day be useful in their own right, but that is not the current goal.\n\n# kubectl expect\n\nkubectl expect polls an object, waiting for a CEL expression to be true.\n\nExample usage: `kubectl expect StatefulSet/mysql 'self.status.replicas >= 1'`\n"
  },
  {
    "path": "kubectl-utils/cmd/kubectl-expect/main.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage main\n\nimport (\n\t\"context\"\n\t\"flag\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/kubectl-utils/pkg/kel\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/kubectl-utils/pkg/kube\"\n\tceltypes \"github.com/google/cel-go/common/types\"\n\t\"github.com/spf13/pflag\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/apimachinery/pkg/types\"\n\t\"k8s.io/klog/v2\"\n)\n\nfunc main() {\n\tctx := context.Background()\n\tif err := run(ctx); err != nil {\n\t\tfmt.Fprintf(os.Stderr, \"%v\\n\", err)\n\t}\n}\n\nfunc run(ctx context.Context) error {\n\t// log := klog.FromContext(ctx)\n\n\tnamespace := \"\"\n\tkubeconfig := \"\"\n\n\tpflag.StringVarP(&namespace, \"namespace\", \"n\", namespace, \"If present, the namespace scope for this CLI request\")\n\tpflag.StringVar(&kubeconfig, \"kubeconfig\", kubeconfig, \"Path to the kubeconfig file to use for CLI requests.\")\n\n\tklog.InitFlags(nil)\n\tpflag.CommandLine.AddGoFlagSet(flag.CommandLine)\n\tpflag.Parse()\n\n\targs := pflag.Args()\n\n\tif len(args) < 2 {\n\t\treturn fmt.Errorf(\"expected [target] [cel-expression]\")\n\t}\n\n\ttarget := args[0]\n\tcelExpressionText := args[1]\n\n\tkubeClient, err := kube.NewClient(kubeconfig)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttokens := strings.Split(target, \"/\")\n\tif len(tokens) != 2 {\n\t\treturn fmt.Errorf(\"expected target like Pod/<name>\")\n\t}\n\n\t// Find the resource (kind) the user is asking about\n\tresource, err := kubeClient.FindResource(ctx, tokens[0])\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Compute namespace, defaulting to kubeconfig or default\n\tif namespace == \"\" && resource.Namespaced {\n\t\tnamespace, err = kubeClient.DefaultNamespace()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Compile the CEL expression\n\tenv, err := kel.NewEnv()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"initializing CEL: %w\", err)\n\t}\n\tcelExpression, err := kel.NewExpression(env, celExpressionText)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// build a pretty-printer for outputting status while polling\n\tprinter, err := celExpression.BuildStatusPrinter(ctx)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"building status printer: %w\", err)\n\t}\n\n\t// Get ready to get the object\n\tid := types.NamespacedName{\n\t\tNamespace: namespace,\n\t\tName:      tokens[1],\n\t}\n\n\tgv := schema.GroupVersion{\n\t\tGroup:   resource.Group,\n\t\tVersion: resource.Version,\n\t}\n\tgvr := gv.WithResource(resource.Name)\n\tgvk := gv.WithKind(resource.Kind)\n\n\tclient := kubeClient.ForGVR(gvr, id.Namespace)\n\n\t// Poll the object until the CEL expression returns true\n\tfor {\n\t\t// We _could_ watch...\n\t\ttime.Sleep(1 * time.Second)\n\n\t\tu, err := client.Get(ctx, id.Name, metav1.GetOptions{})\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"getting %s %s: %w\", gvk.Kind, id.Name, err)\n\t\t}\n\n\t\tout, err := celExpression.Eval(ctx, u)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tdone := false\n\t\tswitch out.Type() {\n\t\tcase celtypes.BoolType:\n\t\t\tv := out.Value().(bool)\n\t\t\tif v {\n\t\t\t\tdone = true\n\t\t\t}\n\t\tdefault:\n\t\t\treturn fmt.Errorf(\"unhandled type for CEL expression: %v\", out.Type())\n\t\t}\n\t\tif done {\n\t\t\tbreak\n\t\t}\n\n\t\t// Pretty print some intermediate values if we can\n\t\tif printer != nil {\n\t\t\ts := printer(ctx, u)\n\t\t\tfmt.Printf(\"waiting for %q (%s)\\n\", celExpression.CELText, s)\n\t\t} else {\n\t\t\tfmt.Printf(\"waiting for %q\\n\", celExpression.CELText)\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "kubectl-utils/go.mod",
    "content": "module github.com/GoogleCloudPlatform/kubectl-ai/kubectl-utils\n\ngo 1.24.0\n\ntoolchain go1.24.3\n\nrequire (\n\tgithub.com/google/cel-go v0.25.0\n\tgithub.com/spf13/pflag v1.0.6\n\tgoogle.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2\n\tgoogle.golang.org/protobuf v1.36.6\n\tk8s.io/apimachinery v0.33.0\n\tk8s.io/client-go v0.33.0\n\tk8s.io/klog/v2 v2.130.1\n)\n\nrequire (\n\tcel.dev/expr v0.23.1 // indirect\n\tgithub.com/antlr4-go/antlr/v4 v4.13.0 // indirect\n\tgithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect\n\tgithub.com/emicklei/go-restful/v3 v3.11.0 // indirect\n\tgithub.com/fxamacker/cbor/v2 v2.7.0 // indirect\n\tgithub.com/go-logr/logr v1.4.2 // indirect\n\tgithub.com/go-openapi/jsonpointer v0.21.0 // indirect\n\tgithub.com/go-openapi/jsonreference v0.20.2 // indirect\n\tgithub.com/go-openapi/swag v0.23.0 // indirect\n\tgithub.com/gogo/protobuf v1.3.2 // indirect\n\tgithub.com/google/gnostic-models v0.6.9 // indirect\n\tgithub.com/google/go-cmp v0.7.0 // indirect\n\tgithub.com/google/uuid v1.6.0 // indirect\n\tgithub.com/josharian/intern v1.0.0 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/mailru/easyjson v0.7.7 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect\n\tgithub.com/stoewer/go-strcase v1.2.0 // indirect\n\tgithub.com/x448/float16 v0.8.4 // indirect\n\tgolang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect\n\tgolang.org/x/net v0.38.0 // indirect\n\tgolang.org/x/oauth2 v0.27.0 // indirect\n\tgolang.org/x/sys v0.31.0 // indirect\n\tgolang.org/x/term v0.30.0 // indirect\n\tgolang.org/x/text v0.23.0 // indirect\n\tgolang.org/x/time v0.9.0 // indirect\n\tgoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect\n\tgopkg.in/inf.v0 v0.9.1 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n\tk8s.io/api v0.33.0 // indirect\n\tk8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect\n\tk8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect\n\tsigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect\n\tsigs.k8s.io/randfill v1.0.0 // indirect\n\tsigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect\n\tsigs.k8s.io/yaml v1.4.0 // indirect\n)\n"
  },
  {
    "path": "kubectl-utils/go.sum",
    "content": "cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg=\ncel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=\ngithub.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=\ngithub.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=\ngithub.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=\ngithub.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=\ngithub.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=\ngithub.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=\ngithub.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=\ngithub.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=\ngithub.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=\ngithub.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=\ngithub.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=\ngithub.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=\ngithub.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=\ngithub.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=\ngithub.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=\ngithub.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=\ngithub.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=\ngithub.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=\ngithub.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=\ngithub.com/google/cel-go v0.25.0 h1:jsFw9Fhn+3y2kBbltZR4VEz5xKkcIFRPDnuEzAGv5GY=\ngithub.com/google/cel-go v0.25.0/go.mod h1:hjEb6r5SuOSlhCHmFoLzu8HGCERvIsDAbxDAyNU/MmI=\ngithub.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=\ngithub.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=\ngithub.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=\ngithub.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=\ngithub.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=\ngithub.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=\ngithub.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=\ngithub.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=\ngithub.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=\ngithub.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=\ngithub.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=\ngithub.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=\ngithub.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=\ngithub.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=\ngithub.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU=\ngithub.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=\ngithub.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=\ngithub.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=\ngithub.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=\ngithub.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=\ngithub.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngithub.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=\ngolang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=\ngolang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=\ngolang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=\ngolang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=\ngolang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=\ngolang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=\ngolang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=\ngolang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=\ngolang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=\ngolang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=\ngolang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=\ngolang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=\ngolang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=\ngolang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=\ngolang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=\ngolang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=\ngolang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=\ngolang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=\ngolang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=\ngolang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=\ngolang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=\ngolang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=\ngolang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=\ngolang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=\ngolang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=\ngolang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=\ngolang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=\ngolang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=\ngolang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngolang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0=\ngoogle.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 h1:h6p3mQqrmT1XkHVTfzLdNz1u7IhINeZkz67/xTbOuWs=\ngoogle.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=\ngoogle.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=\ngoogle.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=\ngopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=\ngopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=\ngopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=\ngopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\nk8s.io/api v0.33.0 h1:yTgZVn1XEe6opVpP1FylmNrIFWuDqe2H0V8CT5gxfIU=\nk8s.io/api v0.33.0/go.mod h1:CTO61ECK/KU7haa3qq8sarQ0biLq2ju405IZAd9zsiM=\nk8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ=\nk8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=\nk8s.io/client-go v0.33.0 h1:UASR0sAYVUzs2kYuKn/ZakZlcs2bEHaizrrHUZg0G98=\nk8s.io/client-go v0.33.0/go.mod h1:kGkd+l/gNGg8GYWAPr0xF1rRKvVWvzh9vmZAMXtaKOg=\nk8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=\nk8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=\nk8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4=\nk8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=\nk8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=\nk8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=\nsigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=\nsigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=\nsigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=\nsigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=\nsigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=\nsigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc=\nsigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=\nsigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=\nsigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=\n"
  },
  {
    "path": "kubectl-utils/pkg/kel/expression.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kel\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/google/cel-go/cel\"\n\tceltypes \"github.com/google/cel-go/common/types\"\n\t\"github.com/google/cel-go/common/types/ref\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/klog/v2\"\n)\n\nfunc NewEnv() (*cel.Env, error) {\n\t// TODO: Can we / should we do better than AnyType?\n\tenv, err := cel.NewEnv(\n\t\tcel.Variable(\"self\", cel.AnyType),\n\t)\n\n\treturn env, err\n}\n\ntype Expression struct {\n\tCELText string\n\tProgram cel.Program\n\tAST     *cel.Ast\n\tEnv     *cel.Env\n}\n\nfunc NewExpression(env *cel.Env, celExpression string) (*Expression, error) {\n\tast, issues := env.Compile(celExpression)\n\tif issues != nil && issues.Err() != nil {\n\t\treturn nil, fmt.Errorf(\"invalid expression %q: %w\", celExpression, issues.Err())\n\t}\n\tprg, err := env.Program(ast)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid expression %q: %w\", celExpression, err)\n\t}\n\treturn &Expression{\n\t\tCELText: celExpression,\n\t\tAST:     ast,\n\t\tProgram: prg,\n\t\tEnv:     env,\n\t}, nil\n}\n\nfunc (x *Expression) Eval(ctx context.Context, self *unstructured.Unstructured) (ref.Val, error) {\n\tlog := klog.FromContext(ctx)\n\tinputs := x.buildInputs(self)\n\n\tout, details, err := x.Program.Eval(inputs)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"evaluating CEL expression: %w\", err)\n\t}\n\tlog.V(2).Info(\"evaluated CEL expression\", \"out\", out, \"details\", details)\n\treturn out, nil\n}\n\nfunc (x *Expression) buildInputs(self *unstructured.Unstructured) map[string]any {\n\tinputs := map[string]any{\n\t\t\"self\": celtypes.NewDynamicMap(&unstructuredToCELAdapter{}, self.Object),\n\t}\n\treturn inputs\n}\n\ntype unstructuredToCELAdapter struct {\n}\n\nfunc (a *unstructuredToCELAdapter) NativeToValue(value any) ref.Val {\n\tswitch value := value.(type) {\n\tcase string:\n\t\treturn celtypes.String(value)\n\tcase int:\n\t\treturn celtypes.Int(value)\n\tcase int64:\n\t\treturn celtypes.Int(value)\n\tcase map[string]any:\n\t\treturn celtypes.NewDynamicMap(a, value)\n\tdefault:\n\t\tklog.Fatalf(\"unhandled type %T\", value)\n\t\treturn nil\n\t}\n}\n"
  },
  {
    "path": "kubectl-utils/pkg/kel/info.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kel\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/google/cel-go/cel\"\n\texprpb \"google.golang.org/genproto/googleapis/api/expr/v1alpha1\"\n\t\"google.golang.org/protobuf/proto\"\n\t\"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured\"\n\t\"k8s.io/klog/v2\"\n)\n\ntype InfoFunction func(ctx context.Context, self *unstructured.Unstructured) string\n\n// BuildStatusPrinter returns an InfoFunction that attempts to report important values from the evaluation of the CEL expression\nfunc (x *Expression) BuildStatusPrinter(ctx context.Context) (InfoFunction, error) {\n\tlog := klog.FromContext(ctx)\n\n\tcheckedExpr, err := cel.AstToCheckedExpr(x.AST)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing CEL ast: %w\", err)\n\t}\n\n\tv := checkedExpr.Expr.ExprKind\n\tswitch v := v.(type) {\n\tcase *exprpb.Expr_CallExpr:\n\t\tprintFunction := \"\"\n\t\tswitch v.CallExpr.Function {\n\t\tcase \"_==_\":\n\t\t\tprintFunction = \"=\"\n\t\tcase \"_>=_\":\n\t\t\tprintFunction = \">=\"\n\t\tcase \"_<=_\":\n\t\t\tprintFunction = \"<=\"\n\t\tcase \"_>_\":\n\t\t\tprintFunction = \">\"\n\t\tcase \"_<_\":\n\t\t\tprintFunction = \"<\"\n\t\tdefault:\n\t\t\tklog.Warningf(\"unhandled function %q\", v.CallExpr.Function)\n\t\t\treturn nil, nil\n\t\t}\n\t\tlog.V(2).Info(\"recognized function\", \"function\", printFunction)\n\t\treturn x.buildFunctionPrinterFor(v.CallExpr.Args)\n\n\tdefault:\n\t\tklog.Warningf(\"unhandled expression kind %T\", checkedExpr.Expr.ExprKind)\n\t\treturn nil, nil\n\t}\n}\n\nfunc (x *Expression) buildFunctionPrinterFor(args []*exprpb.Expr) (InfoFunction, error) {\n\tcheckedExpr, err := cel.AstToCheckedExpr(x.AST)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing CEL ast: %w\", err)\n\t}\n\n\ttype debugValue struct {\n\t\tKey     string\n\t\tProgram cel.Program\n\t}\n\tvar debugValues []debugValue\n\n\tfor _, arg := range args {\n\t\tshouldPrint := true\n\n\t\tv := arg.ExprKind\n\t\tswitch v := v.(type) {\n\t\tcase *exprpb.Expr_ConstExpr:\n\t\t\t// Don't print constants, 2=2 is not informative\n\t\t\tshouldPrint = false\n\t\tcase *exprpb.Expr_SelectExpr:\n\t\t\tshouldPrint = true\n\n\t\tdefault:\n\t\t\tklog.Warningf(\"unhandled expression kind %T\", v)\n\t\t}\n\n\t\tif !shouldPrint {\n\t\t\tcontinue\n\t\t}\n\n\t\tcheckedArg := proto.Clone(checkedExpr).(*exprpb.CheckedExpr)\n\t\tcheckedArg.Expr = arg\n\n\t\tast := cel.CheckedExprToAst(checkedArg)\n\t\tcelExpression, err := cel.AstToString(ast)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting expression to string: %w\", err)\n\t\t}\n\n\t\tcompiled, issues := x.Env.Compile(celExpression)\n\t\tif issues != nil && issues.Err() != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid expression %q: %w\", celExpression, issues.Err())\n\t\t}\n\t\tprg, err := x.Env.Program(compiled)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"invalid expression %q: %w\", celExpression, err)\n\t\t}\n\n\t\tdebugValues = append(debugValues, debugValue{\n\t\t\tKey:     celExpression,\n\t\t\tProgram: prg,\n\t\t})\n\t}\n\n\tif len(debugValues) == 0 {\n\t\treturn nil, nil\n\t}\n\n\treturn func(ctx context.Context, self *unstructured.Unstructured) string {\n\t\tlog := klog.FromContext(ctx)\n\n\t\tinputs := x.buildInputs(self)\n\n\t\tvar values []string\n\t\tfor _, debugValue := range debugValues {\n\t\t\ts := \"\"\n\t\t\tout, details, err := debugValue.Program.Eval(inputs)\n\t\t\tlog.V(2).Info(\"evaluated CEL expression\", \"out\", out, \"details\", details, \"error\", err)\n\t\t\tif err == nil {\n\t\t\t\ts = fmt.Sprintf(\"%s=%v\", debugValue.Key, out.Value())\n\t\t\t} else {\n\t\t\t\ts = fmt.Sprintf(\"%s=%v\", debugValue.Key, \"???\")\n\t\t\t}\n\t\t\tvalues = append(values, s)\n\t\t}\n\n\t\treturn strings.Join(values, \"; \")\n\t}, nil\n}\n"
  },
  {
    "path": "kubectl-utils/pkg/kube/client.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kube\n\nimport (\n\t\"fmt\"\n\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/client-go/discovery\"\n\t\"k8s.io/client-go/dynamic\"\n\t\"k8s.io/client-go/rest\"\n\t\"k8s.io/client-go/tools/clientcmd\"\n)\n\n// Client is a facade around the various kube interfaces\ntype Client struct {\n\tclientConfig    clientcmd.ClientConfig\n\tDyanmicClient   dynamic.Interface\n\tDiscoveryClient discovery.DiscoveryInterface\n}\n\nfunc NewClient(kubeconfig string) (*Client, error) {\n\tclientConfig, err := loadKubeconfig(kubeconfig)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\trestConfig, err := clientConfig.ClientConfig()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"building kubernetes API configuration: %w\", err)\n\t}\n\n\thttpClient, err := rest.HTTPClientFor(restConfig)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"building http client for rest config: %w\", err)\n\t}\n\tdynamicClient, err := dynamic.NewForConfigAndClient(restConfig, httpClient)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"building dynamic client: %w\", err)\n\t}\n\tdiscoveryClient, err := buildDiscoveryClient(restConfig, httpClient)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &Client{\n\t\tclientConfig:    clientConfig,\n\t\tDyanmicClient:   dynamicClient,\n\t\tDiscoveryClient: discoveryClient,\n\t}, nil\n}\n\nfunc loadKubeconfig(kubeconfigPath string) (clientcmd.ClientConfig, error) {\n\trules := clientcmd.NewDefaultClientConfigLoadingRules()\n\tif kubeconfigPath != \"\" {\n\t\trules.ExplicitPath = kubeconfigPath\n\t}\n\tclientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(\n\t\trules,\n\t\t&clientcmd.ConfigOverrides{},\n\t)\n\treturn clientConfig, nil\n}\n\nfunc (c *Client) DefaultNamespace() (string, error) {\n\tns, _, err := c.clientConfig.Namespace()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"getting namespace from kubeconfig: %w\", err)\n\t}\n\tnamespace := ns\n\tif namespace == \"\" {\n\t\tnamespace = \"default\"\n\t}\n\treturn namespace, nil\n}\n\n// ForGVR returns a dynamic client for the specified GroupVersionResource and namespace\nfunc (c *Client) ForGVR(gvr schema.GroupVersionResource, namespace string) dynamic.ResourceInterface {\n\tvar client dynamic.ResourceInterface\n\tif namespace != \"\" {\n\t\tclient = c.DyanmicClient.Resource(gvr).Namespace(namespace)\n\t} else {\n\t\tclient = c.DyanmicClient.Resource(gvr)\n\t}\n\treturn client\n}\n"
  },
  {
    "path": "kubectl-utils/pkg/kube/discovery.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage kube\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/http\"\n\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/runtime/schema\"\n\t\"k8s.io/client-go/discovery\"\n\t\"k8s.io/client-go/rest\"\n)\n\nfunc buildDiscoveryClient(restConfig *rest.Config, httpClient *http.Client) (discovery.DiscoveryInterface, error) {\n\t// TODO: share cache with kubectl?\n\tclient, err := discovery.NewDiscoveryClientForConfigAndClient(restConfig, httpClient)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"building discovery client: %w\", err)\n\t}\n\treturn client, nil\n}\n\nfunc (c *Client) FindResource(ctx context.Context, name string) (*metav1.APIResource, error) {\n\tvar matches []metav1.APIResource\n\tresourceLists, err := c.DiscoveryClient.ServerPreferredResources()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"doing server discovery: %w\", err)\n\t}\n\tfor _, resourceList := range resourceLists {\n\t\tgv, err := schema.ParseGroupVersion(resourceList.GroupVersion)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"parsing group version %q: %w\", resourceList.GroupVersion, err)\n\t\t}\n\t\tfor _, resource := range resourceList.APIResources {\n\t\t\tif resource.Kind == name {\n\t\t\t\tif resource.Group == \"\" {\n\t\t\t\t\tresource.Group = gv.Group\n\t\t\t\t}\n\t\t\t\tif resource.Version == \"\" {\n\t\t\t\t\tresource.Version = gv.Version\n\t\t\t\t}\n\t\t\t\tmatches = append(matches, resource)\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(matches) == 0 {\n\t\treturn nil, fmt.Errorf(\"no match for resource %q\", name)\n\t}\n\tif len(matches) > 1 {\n\t\treturn nil, fmt.Errorf(\"found multiple matches for resource %q\", name)\n\t}\n\tresource := matches[0]\n\treturn &resource, nil\n}\n"
  },
  {
    "path": "makefile",
    "content": "# Makefile for kubectl-ai\n#\n# This Makefile provides a set of commands to build, test, run,\n# and manage the kubectl-ai project.\n\n# Default target to run when no target is specified.\n.DEFAULT_GOAL := help\n\n# --- Variables ---\n# Define common variables to avoid repetition and ease maintenance.\nBIN_DIR      := ./bin\nCMD_DIR      := ./cmd\nBINARY_NAME  := kubectl-ai\nBINARY_PATH  := $(BIN_DIR)/$(BINARY_NAME)\n\n# Attempt to determine GOPATH/bin for installation.\n# Fallback to a common default if `go env GOPATH` fails or is empty.\nGOPATH_BIN   := $(shell go env GOPATH)/bin\nifeq ($(GOPATH_BIN),/bin)\n\tGOPATH_BIN := $(HOME)/go/bin\nendif\n\n# --- Environment Variables from .env ---\n# If a .env file exists, include it. This makes variables defined in .env\n# (e.g., API_KEY=123) available as Make variables.\n# Then, export these variables so they are available in the environment\n# for shell commands executed by Make recipes.\nifneq ($(wildcard .env),)\n\tinclude .env\n\t# Extract variable names from .env and export them.\n\t# This assumes .env contains lines like VAR=value.\n\tENV_VARS_TO_EXPORT := $(shell awk -F= '{print $$1}' .env | xargs)\n\texport $(ENV_VARS_TO_EXPORT)\nendif\n\n# --- Help Target ---\n# Displays a list of available targets and their descriptions.\n# Descriptions are extracted from comments following '##'.\nhelp:\n\t@echo \"kubectl-ai Makefile\"\n\t@echo \"-------------------\"\n\t@echo \"Available targets:\"\n\t@awk 'BEGIN {FS = \":.*?## \"} /^[a-zA-Z0-9_-]+:.*?## / {printf \"  %-20s %s\\n\", $$1, $$2}' $(MAKEFILE_LIST)\n\n# --- Build Tasks ---\nbuild-recursive: ## Build the binary using dev script (recursive for all modules)\n\t@echo \"λ Building all modules (recursive using dev script)...\"\n\tmkdir -p $(BIN_DIR)\n\t./dev/ci/presubmits/go-build.sh\n\nbuild: ## Build single binary for the current platform\n\t@echo \"λ Building $(BINARY_NAME) for current platform...\"\n\tmkdir -p $(BIN_DIR)\n\tgo build -o $(BINARY_PATH) $(CMD_DIR)\n\n# --- Run Tasks ---\nrun: ## Run the application\n\t@echo \"λ Running $(BINARY_NAME) from source...\"\n\tgo run $(CMD_DIR)\n\nrun-html: ## Run with HTML UI\n\t@echo \"λ Running $(BINARY_NAME) with HTML UI from source...\"\n\tgo run $(CMD_DIR) --ui-type web\n\n# --- Code Quality Tasks (using dev scripts) ---\nfmt: ## Format code using dev script\n\t@echo \"λ Formatting code (using dev script)...\"\n\t./dev/tasks/format.sh\n\nvet: ## Run go vet using dev script\n\t@echo \"λ Running go vet (using dev script)...\"\n\t./dev/ci/presubmits/go-vet.sh\n\ntidy: ## Tidy go modules using dev script\n\t@echo \"λ Tidying go modules (using dev script)...\"\n\t./dev/tasks/gomod.sh\n\n# --- Verification Tasks (CI-style checks using dev scripts) ---\nverify-format: ## Verify code formatting\n\t@echo \"λ Verifying code formatting...\"\n\t./dev/ci/presubmits/verify-format.sh\n\nverify-gomod: ## Verify go.mod files are tidy\n\t@echo \"λ Verifying go.mod files...\"\n\t./dev/ci/presubmits/verify-gomod.sh\n\nverify-autogen: ## Verify auto-generated files are up to date\n\t@echo \"λ Verifying auto-generated files...\"\n\t./dev/ci/presubmits/verify-autogen.sh\n\ngenerate:\n\tgo generate ./internal/mocks\n\nverify-mocks:\n\t@echo \"λ Verifying mocks...\"\n\t./dev/ci/presubmits/verify-mocks.sh\n# --- Generation Tasks ---\ngenerate-actions: ## Generate GitHub Actions workflows\n\t@echo \"λ Generating GitHub Actions workflows...\"\n\t./dev/tasks/generate-github-actions.sh\n\n# --- Evaluation Tasks ---\nrun-evals: ## Run evaluations (periodic task)\n\t@echo \"λ Running evaluations...\"\n\t./dev/ci/periodics/run-evals.sh\n\nanalyze-evals: ## Analyze evaluations (periodic task)\n\t@echo \"λ Analyzing evaluations...\"\n\t./dev/ci/periodics/analyze-evals.sh $(ARGS)\n\n# --- Combined Tasks ---\n# 'check' depends on other verification tasks. They will run as prerequisites.\ncheck: verify-format verify-gomod verify-autogen build-recursive vet ## Run all verification checks (presubmit-style)\n\t@echo \"λ All checks completed.\"\n\n# --- Development Workflow ---\n# 'dev' and 'dev-html' depend on the 'build' target.\ndev: build ## Development mode - build and run\n\t@echo \"λ Starting $(BINARY_NAME) in dev mode...\"\n\t$(BINARY_PATH)\n\ndev-html: build ## Development mode - build and run with HTML UI\n\t@echo \"λ Starting $(BINARY_NAME) with HTML UI in dev mode...\"\n\t$(BINARY_PATH) --ui-type web\n\n# --- Maintenance Tasks ---\nclean: ## Clean build artifacts and coverage files\n\t@echo \"λ Cleaning build artifacts...\"\n\trm -rf $(BIN_DIR)\n\trm -f coverage.out coverage.html\n\ndeps: ## Download Go module dependencies\n\t@echo \"λ Downloading Go module dependencies...\"\n\tgo mod download\n\nupdate-deps: ## Update Go module dependencies and then tidy\n\t@echo \"λ Updating Go module dependencies...\"\n\tgo get -u ./...\n\t@echo \"λ Tidying modules after update...\"\n\t$(MAKE) tidy\n\n# --- Installation ---\n# 'install' depends on the 'build' target.\ninstall: build ## Install the binary to $(GOPATH_BIN)\n\t@echo \"λ Installing $(BINARY_NAME) to $(GOPATH_BIN)...\"\n\tcp $(BINARY_PATH) $(GOPATH_BIN)/\n\t@echo \"$(BINARY_NAME) installed.\"\n\n# --- Testing ---\ntest: ## Run tests\n\t@echo \"λ Running tests...\"\n\tgo test ./...\n\ntest-verbose: ## Run tests with verbose output\n\t@echo \"λ Running tests (verbose)...\"\n\tgo test -v ./...\n\ntest-coverage: ## Run tests with coverage and generate HTML report\n\t@echo \"λ Running tests with coverage...\"\n\tgo test -coverprofile=coverage.out ./...\n\t@echo \"λ Generating coverage HTML report...\"\n\tgo tool cover -html=coverage.out -o coverage.html\n\t@echo \"Coverage report generated: coverage.html\"\n\n"
  },
  {
    "path": "modelserving/.gitignore",
    "content": ".build\n.cache\n"
  },
  {
    "path": "modelserving/README.md",
    "content": "# Model Serving\n\nThis directory provides components to build and deploy Large Language Model (LLM) serving endpoints.\n\n- [`k8s/`](k8s/): Kubernetes manifests for model serving components.\n- [`images/`](images/): Dockerfiles for building model serving container images.\n- [`dev/tasks`](dev/tasks): Development-related scripts for model serving.\n  - `download-model`: fetch the required model weights (e.g., Gemma 3 12B IT).\n  - `build-images`: runs `download-model`, and then build the Docker image using the provided Dockerfile in `images/`.\n  - `deploy-to-gke` or `dev/tasks/deploy-to-kind`: runs `build-images`, and then deploy the model serving Kubernetes manifests to Google Kubernetes Engine (GKE) or a local KinD cluster. Once deployed, the model server will be accessible via a Kubernetes Service defined in the manifest. You can use `kubectl get svc` to find the service details and access its endpoint.\n  - `run-local`: run the model server locally for testing purposes, bypassing Kubernetes."
  },
  {
    "path": "modelserving/dev/tasks/build-images",
    "content": "#!/usr/bin/env bash\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nSRC_DIR=${REPO_ROOT}/modelserving\ncd \"${SRC_DIR}\"\n\nif [[ -z \"${IMAGE_PREFIX:-}\" ]]; then\n  IMAGE_PREFIX=\"\"\nfi\necho \"Building images with prefix ${IMAGE_PREFIX}\"\n\nif [[ -z \"${TAG:-}\" ]]; then\n  TAG=latest\nfi\n\nif [[ -z \"${ARCHITECTURES:-}\" ]]; then\n  ARCHITECTURES=cpu,cuda\nfi\necho \"Building for architectures: ${ARCHITECTURES}\"\n\nLLAMACPP_TAG=b4957\necho \"Building llama.cpp version ${LLAMACPP_TAG}\"\n\nfunction build_for_architecture() {\n  a=\",${ARCHITECTURES:-},\"\n  if [[ \"${a}\" =~ \",${1},\" ]]; then\n    return 0\n  fi\n  return 1\n}\n\nif [[ -z \"${BUILDX_ARGS:-}\" ]]; then\n  BUILDX_ARGS=\"--load\"\nfi\n\ndev/tasks/download-model\n\n# Note we do not push or load the \"base\" llama-server images (we do not pass BUILDX_ARGS)\n# This is because this is only an intermediate image (e.g. used for the gemma3-12b-it image)\nif build_for_architecture cpu; then\n  docker buildx build \\\n    -f images/llamacpp-server/Dockerfile \\\n    --target llamacpp-server \\\n    -t llamacpp-server-cpu:${TAG} \\\n    --build-arg BASE_IMAGE=debian:latest \\\n    --build-arg BUILDER_IMAGE=debian:latest \\\n    --build-arg \"CMAKE_ARGS=-DGGML_RPC=ON\" \\\n    --progress=plain .\nfi\n\n# We're running distributed now, so the \"worker\" nodes need CUDA (rpc-server image), the \"head\" nodes do not (llamacpp-server image).\n#   # -DCMAKE_EXE_LINKER_FLAGS=-Wl,--allow-shlib-undefined allows us to build in a container without all the CUDA libraries present\n#   # These flags mirror the flags in the llama.cpp github-action: https://github.com/ggml-org/llama.cpp/blob/master/.github/workflows/build.yml\n\n#   docker buildx build \\\n#     -f images/llamacpp-server/Dockerfile \\\n#     --target llamacpp-server \\\n#     -t llamacpp-server-cuda:${TAG} \\\n#     --build-arg BASE_IMAGE=nvidia/cuda:12.6.2-runtime-ubuntu24.04 \\\n#     --build-arg BUILDER_IMAGE=nvidia/cuda:12.6.2-devel-ubuntu24.04 \\\n#     --build-arg \"CMAKE_ARGS=-DGGML_RPC=ON -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=all -DCMAKE_EXE_LINKER_FLAGS=-Wl,--allow-shlib-undefined\" \\\n#     --progress=plain .\n# fi\n\n# Build a head node that embeds gemma3\nif build_for_architecture cpu; then\n  docker buildx build ${BUILDX_ARGS} \\\n    -f images/llamacpp-gemma3-12b-it/Dockerfile \\\n    -t ${IMAGE_PREFIX}llamacpp-gemma3-12b-it-cpu:${TAG} \\\n    --build-arg BASE_IMAGE=llamacpp-server-cpu:${TAG} \\\n    --progress=plain .\nfi\n\n\n# Build a worker node that runs rpc-server with CPU support\nif build_for_architecture cpu; then\n  docker buildx build ${BUILDX_ARGS} \\\n    -f images/llamacpp-server/Dockerfile \\\n    --target rpc-server \\\n    -t ${IMAGE_PREFIX}rpc-server-cpu:${TAG} \\\n    --build-arg BASE_IMAGE=debian:latest \\\n    --build-arg BUILDER_IMAGE=debian:latest \\\n    --build-arg \"CMAKE_ARGS=-DGGML_RPC=ON\" \\\n    --progress=plain .\nfi\n\n# Build a worker node that runs rpc-server with CUDA support\nif build_for_architecture cuda; then\n  # -DCMAKE_EXE_LINKER_FLAGS=-Wl,--allow-shlib-undefined allows us to build in a container without all the CUDA libraries present\n  # These flags mirror the flags in the llama.cpp github-action: https://github.com/ggml-org/llama.cpp/blob/master/.github/workflows/build.yml\n\n  docker buildx build ${BUILDX_ARGS} \\\n    -f images/llamacpp-server/Dockerfile \\\n    --target rpc-server \\\n    -t ${IMAGE_PREFIX}rpc-server-cuda:${TAG} \\\n    --build-arg BASE_IMAGE=nvidia/cuda:12.6.2-runtime-ubuntu24.04 \\\n    --build-arg BUILDER_IMAGE=nvidia/cuda:12.6.2-devel-ubuntu24.04 \\\n    --build-arg \"CMAKE_ARGS=-DGGML_RPC=ON -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=all -DCMAKE_EXE_LINKER_FLAGS=-Wl,--allow-shlib-undefined\" \\\n    --progress=plain .\nfi\n"
  },
  {
    "path": "modelserving/dev/tasks/deploy-to-gke",
    "content": "#!/usr/bin/env bash\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nSRC_DIR=${REPO_ROOT}/modelserving\ncd \"${SRC_DIR}\"\n\n\nif [[ -z \"${GCP_PROJECT_ID:-}\" ]]; then\n  GCP_PROJECT_ID=$(gcloud config get project)\nfi\necho \"Using GCP_PROJECT_ID=${GCP_PROJECT_ID}\"\n\nif [[ -z \"${KUBE_CONTEXT:-}\" ]]; then\n  echo \"Listing GKE clusters in project ${GCP_PROJECT_ID}:\"\n  gcloud container clusters list --project=${GCP_PROJECT_ID}\n  echo \"\"\n  echo \"Please set CONTEXT to kubectl context to use\"\n  exit 1\nfi\n\n# Pick a probably-unique tag\nexport TAG=`date +%Y%m%d%H%M%S`\n\n# Build the image\necho \"Building images\"\nexport IMAGE_PREFIX=gcr.io/${GCP_PROJECT_ID}/\nARCHITECTURES=cpu,cuda BUILDX_ARGS=--push dev/tasks/build-images\n\n# TODO: support cpu on GKE?\nMODEL_IMAGE=\"${IMAGE_PREFIX}llamacpp-gemma3-12b-it-cpu:${TAG}\"\nRPCSERVER_IMAGE=\"${IMAGE_PREFIX:-}rpc-server-cuda:${TAG}\"\n\n# Deploy manifests\necho \"Deploying manifests\"\ncat k8s/llm-server-rpc.yaml | sed s@llamacpp-gemma3-12b-it-cpu:latest@${MODEL_IMAGE}@g | \\\n  kubectl apply --context=${KUBE_CONTEXT} --server-side -f -\n\ncat k8s/rpc-server-cuda.yaml | sed s@rpc-server-cuda:latest@${RPCSERVER_IMAGE}@g | \\\n  kubectl apply --context=${KUBE_CONTEXT} --server-side -f -\n"
  },
  {
    "path": "modelserving/dev/tasks/deploy-to-kind",
    "content": "#!/usr/bin/env bash\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nSRC_DIR=${REPO_ROOT}/modelserving\ncd \"${SRC_DIR}\"\n\n# Pick a probably-unique tag\nexport TAG=`date +%Y%m%d%H%M%S`\n\n# If we're building for kind, default to only building on cpu\nif [[ -z \"${ARCHITECTURES:-}\" ]]; then\n  ARCHITECTURES=cpu\n  export ARCHITECTURES\nfi\n\nif [[ -z \"${KUBE_CONTEXT:-}\" ]]; then\n  KUBE_CONTEXT=kind-kind\n  echo \"Defaulting to kube context: ${KUBE_CONTEXT}\"\nfi\n\n# Build the image\necho \"Building images\"\nexport IMAGE_PREFIX=fake.registry/\nBUILDX_ARGS=--load dev/tasks/build-images\n\nMODEL_IMAGE=\"${IMAGE_PREFIX:-}llamacpp-gemma3-12b-it-cpu:${TAG}\"\nRPCSERVER_IMAGE=\"${IMAGE_PREFIX:-}rpc-server-cpu:${TAG}\"\n\n# Load the image into kind\necho \"Loading images into kind: ${MODEL_IMAGE}, ${RPCSERVER_IMAGE}\"\nkind load docker-image ${MODEL_IMAGE} ${RPCSERVER_IMAGE}\n\n# Deploy manifests\necho \"Deploying manifests\"\ncat k8s/llm-server-cpu.yaml | sed s@llamacpp-gemma3-12b-it-cpu:latest@${MODEL_IMAGE}@g | \\\n  kubectl apply --context=${KUBE_CONTEXT} --server-side -f -\n\ncat k8s/rpc-server-cpu.yaml | sed s@rpc-server-cpu:latest@${RPCSERVER_IMAGE}@g | \\\n  kubectl apply --context=${KUBE_CONTEXT} --server-side -f -\n\ncat k8s/llm-server-rpc.yaml | sed s@llamacpp-gemma3-12b-it-cpu:latest@${MODEL_IMAGE}@g | \\\n  kubectl apply --context=${KUBE_CONTEXT} --server-side -f -\n"
  },
  {
    "path": "modelserving/dev/tasks/download-model",
    "content": "#!/usr/bin/env bash\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nSRC_DIR=${REPO_ROOT}/modelserving\ncd \"${SRC_DIR}\"\n\nmkdir -p .cache\n\nMODEL_NAME=\"gemma-3-12b-it-Q4_K_M.gguf\"\nMODEL_PATH=\".cache/${MODEL_NAME}\"\nMODEL_REPO=\"unsloth/gemma-3-12b-it-GGUF\"\nMODEL_URL=\"https://huggingface.co/${MODEL_REPO}/resolve/main/${MODEL_NAME}\"\n\n# Fetch SHA-256 checksum from Hugging Face API\nfetch_expected_checksum() {\n  echo \"Fetching expected checksum for ${MODEL_NAME} from Hugging Face...\"\n  EXPECTED_SHA256=$(curl -s \"https://huggingface.co/api/models/${MODEL_REPO}\" | \\\n    jq -r \".siblings[] | select(.rfilename == \\\"${MODEL_NAME}\\\") | .sha256\")\n\n  if [[ -z \"${EXPECTED_SHA256}\" || \"${EXPECTED_SHA256}\" == \"null\" ]]; then\n    echo \"Failed to retrieve expected SHA256 checksum from Hugging Face\"\n    exit 1\n  fi\n\n  echo \"Expected SHA256: ${EXPECTED_SHA256}\"\n}\n\ndownload_model() {\n  echo \"Downloading ${MODEL_NAME}...\"\n  wget \"${MODEL_URL}\" -O \"${MODEL_PATH}\"\n}\n\nverify_checksum() {\n  echo \"Verifying checksum for ${MODEL_NAME}...\"\n  local actual_hash\n  actual_hash=$(sha256sum \"${MODEL_PATH}\" | awk '{print $1}')\n\n  if [[ \"${actual_hash}\" != \"${EXPECTED_SHA256}\" ]]; then\n    echo \"Checksum mismatch\"\n    echo \"Expected: ${EXPECTED_SHA256}\"\n    echo \"Actual:   ${actual_hash}\"\n    rm -f \"${MODEL_PATH}\"\n    exit 1\n  fi\n\n  echo \"Checksum verified\"\n}\n\n# Main logic\nfetch_expected_checksum\n\nif [[ ! -f \"${MODEL_PATH}\" ]]; then\n  download_model\n  verify_checksum\nelse\n  echo \"${MODEL_NAME} already exists. Verifying checksum...\"\n  verify_checksum\nfi"
  },
  {
    "path": "modelserving/dev/tasks/run-local",
    "content": "#!/usr/bin/env bash\n\nset -o errexit\nset -o nounset\nset -o pipefail\n\nREPO_ROOT=\"$(git rev-parse --show-toplevel)\"\nSRC_DIR=${REPO_ROOT}/modelserving\ncd \"${SRC_DIR}\"\n\n\nexport ARCHITECTURES=cpu # TODO: we could support cuda locally, but it is slow to build..\n\n# Build and export the docker image; so we are consistent in how we build\n[[ -x dev/tasks/build-images ]] || {\n  echo \"ERROR: build-images script not found or not executable\"\n  exit 1\n}\n\nmkdir -p .build/llamacpp-server-cpu\nBUILDX_ARGS=\"--output type=local,dest=.build/llamacpp-server-cpu\" dev/tasks/build-images\n\n# Default model\nexport LLAMA_ARG_MODEL=${SRC_DIR}/.cache/gemma-3-12b-it-Q4_K_M.gguf\n\n# Bigger context size (though not too large given memory)\nexport LLAMA_ARG_CTX_SIZE=16384\n\nLD_LIBRARY_PATH=.build/llamacpp-server-cpu/lib/ .build/llamacpp-server-cpu/llama-server --jinja -fa\n"
  },
  {
    "path": "modelserving/images/llamacpp-gemma3-12b-it/Dockerfile",
    "content": "ARG BASE_IMAGE\nFROM ${BASE_IMAGE}\n\n# TODO: Add checksum\nCOPY .cache/gemma-3-12b-it-Q4_K_M.gguf /gemma-3-12b-it-Q4_K_M.gguf\n\n# Default model\nENV LLAMA_ARG_MODEL=/gemma-3-12b-it-Q4_K_M.gguf\n\n# Bigger context size (though not too large given memory)\nENV LLAMA_ARG_CTX_SIZE=16384\n\nENTRYPOINT [ \"/llama-server\" ]\n"
  },
  {
    "path": "modelserving/images/llamacpp-server/Dockerfile",
    "content": "ARG BUILDER_IMAGE\nARG BASE_IMAGE\n\nFROM ${BUILDER_IMAGE} AS builder\n\nARG CMAKE_ARGS\nARG LLAMACPP_TAG\n\nRUN apt-get update\nRUN apt-get install -y g++ git cmake libcurl4-openssl-dev\n\nWORKDIR /src\nRUN git clone https://github.com/ggml-org/llama.cpp\n\nWORKDIR /src/llama.cpp\nRUN git checkout ${LLAMACPP_TAG}\n\nRUN cmake -DCMAKE_BUILD_TYPE=Release ${CMAKE_ARGS} .\nRUN cmake --build . -j16 --config Release --target llama-server --target rpc-server\n\nRUN ldd /src/llama.cpp/bin/llama-server\n\nFROM ${BASE_IMAGE} AS rpc-server\n\nRUN apt-get update && apt-get install --yes libgomp1\n\nCOPY --from=builder /src/llama.cpp/bin/rpc-server /rpc-server\nCOPY --from=builder /src/llama.cpp/bin/lib*.so /lib/\n\nENTRYPOINT [ \"/rpc-server\" ]\n\nFROM ${BASE_IMAGE} AS llamacpp-server\n\nRUN apt-get update && apt-get install --yes libgomp1\n\nCOPY --from=builder /src/llama.cpp/bin/llama-server /llama-server\nCOPY --from=builder /src/llama.cpp/bin/lib*.so /lib/\n\nENTRYPOINT [ \"/llama-server\" ]\n"
  },
  {
    "path": "modelserving/k8s/llm-server-cpu.yaml",
    "content": "kind: Deployment\napiVersion: apps/v1\nmetadata:\n  name: llm-server\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: llm-server\n  template:\n    metadata:\n      labels:\n        app: llm-server\n    spec:\n      serviceAccountName: llm-server\n      containers:\n      - name: llm-server\n        image: llamacpp-gemma3-12b-it-cpu:latest # placeholder value, replaced by deployment scripts\n        env:\n        - name: LLAMA_ARG_FLASH_ATTN\n          value: \"yes\"\n        args:\n        - --jinja # Needed for tool use, no env var\n\n---\n\nkind: ServiceAccount\napiVersion: v1\nmetadata:\n  name: llm-server\n\n---\n\nkind: Service\napiVersion: v1\nmetadata:\n  name: llm-server\n  labels:\n    app: llm-server\nspec:\n  selector:\n    app: llm-server\n  ports:\n  - port: 80\n    targetPort: 8080\n    protocol: TCP\n"
  },
  {
    "path": "modelserving/k8s/llm-server-rpc.yaml",
    "content": "kind: Deployment\napiVersion: apps/v1\nmetadata:\n  name: llm-server-rpc\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: llm-server-rpc\n  template:\n    metadata:\n      labels:\n        app: llm-server-rpc\n    spec:\n      serviceAccountName: llm-server-rpc\n      containers:\n      - name: llm-server-rpc\n        image: llamacpp-gemma3-12b-it-cpu:latest # placeholder value, replaced by deployment scripts\n        env:\n        - name: LLAMA_ARG_N_GPU_LAYERS\n          value: \"99\"\n        - name: LLAMA_ARG_FLASH_ATTN\n          value: \"yes\"\n        # - name: SERVERS\n        #   value: rpc-server-0,rpc-server-1,rpc-server-2,rpc-server-3\n        args:\n        - --jinja # Needed for tool use, no env var\n        - --rpc\n        - rpc-server-0.rpc-server:50052,rpc-server-1.rpc-server:50052,rpc-server-2.rpc-server:50052,rpc-server-3.rpc-server:50052\n\n---\n\nkind: ServiceAccount\napiVersion: v1\nmetadata:\n  name: llm-server-rpc\n\n---\n\nkind: Service\napiVersion: v1\nmetadata:\n  name: llm-server-rpc\n  labels:\n    app: llm-server-rpc\nspec:\n  selector:\n    app: llm-server-rpc\n  ports:\n  - name: http\n    port: 80\n    targetPort: 8080\n    protocol: TCP\n"
  },
  {
    "path": "modelserving/k8s/llm-server.yaml",
    "content": "kind: Deployment\napiVersion: apps/v1\nmetadata:\n  name: llm-server\nspec:\n  replicas: 1\n  selector:\n    matchLabels:\n      app: llm-server\n  template:\n    metadata:\n      labels:\n        app: llm-server\n    spec:\n      serviceAccountName: llm-server\n      containers:\n      - name: llm-server\n        image: llamacpp-gemma3-12b-it-cuda:latest # placeholder value, replaced by deployment scripts\n        env:\n        - name: LLAMA_ARG_N_GPU_LAYERS\n          value: \"99\"\n        - name: LLAMA_ARG_FLASH_ATTN\n          value: \"yes\"\n        args:\n        - --jinja # Needed for tool use, no env var\n        resources:\n          limits:\n            nvidia.com/gpu: \"1\"\n          requests:\n            nvidia.com/gpu: \"1\"\n      nodeSelector:\n        cloud.google.com/gke-accelerator: nvidia-l4\n\n---\n\nkind: ServiceAccount\napiVersion: v1\nmetadata:\n  name: llm-server\n\n---\n\nkind: Service\napiVersion: v1\nmetadata:\n  name: llm-server\n  labels:\n    app: llm-server\nspec:\n  selector:\n    app: llm-server\n  ports:\n  - port: 80\n    targetPort: 8080\n    protocol: TCP\n"
  },
  {
    "path": "modelserving/k8s/rpc-server-cpu.yaml",
    "content": "kind: ServiceAccount\napiVersion: v1\nmetadata:\n  name: rpc-server\n\n---\n\nkind: Service\napiVersion: v1\nmetadata:\n  name: rpc-server\n  labels:\n    app: rpc-server\nspec:\n  clusterIP: None\n  selector:\n    app: rpc-server\n\n---\n\nkind: StatefulSet\napiVersion: apps/v1\nmetadata:\n  name: rpc-server\nspec:\n  podManagementPolicy: \"Parallel\"\n  replicas: 4\n  selector:\n    matchLabels:\n      app: rpc-server\n  serviceName: rpc-server\n  template:\n    metadata:\n      labels:\n        app: rpc-server\n    spec:\n      serviceAccountName: rpc-server\n      containers:\n      - name: rpc-server\n        image: rpc-server-cpu:latest # placeholder value, replaced by deployment scripts\n        args:\n        - --host\n        - 0.0.0.0\n        - --mem\n        - \"4192\"\n"
  },
  {
    "path": "modelserving/k8s/rpc-server-cuda.yaml",
    "content": "kind: ServiceAccount\napiVersion: v1\nmetadata:\n  name: rpc-server\n\n---\n\nkind: Service\napiVersion: v1\nmetadata:\n  name: rpc-server\n  labels:\n    app: rpc-server\nspec:\n  clusterIP: None\n  selector:\n    app: rpc-server\n\n---\n\nkind: StatefulSet\napiVersion: apps/v1\nmetadata:\n  name: rpc-server\nspec:\n  podManagementPolicy: \"Parallel\"\n  replicas: 4\n  selector:\n    matchLabels:\n      app: rpc-server\n  serviceName: rpc-server\n  template:\n    metadata:\n      labels:\n        app: rpc-server\n    spec:\n      serviceAccountName: rpc-server\n      containers:\n      - name: rpc-server\n        image: rpc-server-cuda:latest # placeholder value, replaced by deployment scripts\n        args:\n        - --host\n        - 0.0.0.0\n        resources:\n          limits:\n            nvidia.com/gpu: \"1\"\n          requests:\n            nvidia.com/gpu: \"1\"\n      nodeSelector:\n        cloud.google.com/gke-accelerator: nvidia-l4\n"
  },
  {
    "path": "pkg/agent/agent_e2e_test.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage agent\n\nimport (\n\t\"context\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/internal/mocks\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sessions\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/tools\"\n\t\"go.uber.org/mock/gomock\"\n)\n\nfunc recvMsg(t *testing.T, ctx context.Context, ch <-chan any) *api.Message {\n\tt.Helper()\n\tselect {\n\tcase v := <-ch:\n\t\tm, ok := v.(*api.Message)\n\t\tif !ok {\n\t\t\tt.Fatalf(\"recvMsg: expected *api.Message, got %T\", v)\n\t\t\treturn nil\n\t\t}\n\t\treturn m\n\tcase <-ctx.Done():\n\t\tt.Fatalf(\"timed out waiting for message: %v\", ctx.Err())\n\t\treturn nil\n\t}\n}\n\nfunc recvUntil(t *testing.T, ctx context.Context, ch <-chan any, pred func(*api.Message) bool) *api.Message {\n\tt.Helper()\n\tfor {\n\t\tselect {\n\t\tcase v := <-ch:\n\t\t\tm, ok := v.(*api.Message)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"recvUntil: expected *api.Message, got %T\", v)\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tif pred(m) {\n\t\t\t\treturn m\n\t\t\t}\n\t\tcase <-ctx.Done():\n\t\t\tt.Fatalf(\"timed out waiting for matching message: %v\", ctx.Err())\n\t\t}\n\t}\n}\n\ntype fakePart struct {\n\ttext  string\n\tcalls []gollm.FunctionCall\n}\n\nfunc (p fakePart) AsText() (string, bool) {\n\tif p.text != \"\" {\n\t\treturn p.text, true\n\t}\n\treturn \"\", false\n}\n\nfunc (p fakePart) AsFunctionCalls() ([]gollm.FunctionCall, bool) {\n\tif p.calls != nil {\n\t\treturn p.calls, true\n\t}\n\treturn nil, false\n}\n\ntype fakeCandidate struct{ parts []gollm.Part }\n\nfunc (c fakeCandidate) String() string      { return \"\" }\nfunc (c fakeCandidate) Parts() []gollm.Part { return c.parts }\n\ntype fakeChatResponse struct{ candidate gollm.Candidate }\n\nfunc (r fakeChatResponse) UsageMetadata() any            { return nil }\nfunc (r fakeChatResponse) Candidates() []gollm.Candidate { return []gollm.Candidate{r.candidate} }\n\nfunc fCalls(name string, args map[string]any) gollm.Part {\n\treturn fakePart{calls: []gollm.FunctionCall{{ID: \"1\", Name: name, Arguments: args}}}\n}\n\nfunc fText(s string) gollm.Part { return fakePart{text: s} }\n\nfunc chatWith(parts ...gollm.Part) gollm.ChatResponse {\n\treturn fakeChatResponse{candidate: fakeCandidate{parts: parts}}\n}\n\nfunc TestAgentEndToEndToolExecution(t *testing.T) {\n\tctrl := gomock.NewController(t)\n\tdefer ctrl.Finish()\n\n\tctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\tdefer cancel()\n\n\tstore := sessions.NewInMemoryChatStore()\n\n\tclient := mocks.NewMockClient(ctrl)\n\tchat := mocks.NewMockChat(ctrl)\n\n\tclient.EXPECT().StartChat(gomock.Any(), \"test-model\").Return(chat)\n\tchat.EXPECT().Initialize(gomock.Any()).Return(nil)\n\tchat.EXPECT().SetFunctionDefinitions(gomock.Any()).Return(nil)\n\n\tfirstResp := chatWith(fCalls(\"mocktool\", map[string]any{\"command\": \"do\"}))\n\tsecondResp := chatWith(fText(\"all done\"))\n\n\tfirstIter := gollm.ChatResponseIterator(func(yield func(gollm.ChatResponse, error) bool) {\n\t\tyield(firstResp, nil)\n\t})\n\tsecondIter := gollm.ChatResponseIterator(func(yield func(gollm.ChatResponse, error) bool) {\n\t\tyield(secondResp, nil)\n\t})\n\n\tgomock.InOrder(\n\t\tchat.EXPECT().SendStreaming(gomock.Any(), gomock.Any()).Return(firstIter, nil),\n\t\tchat.EXPECT().SendStreaming(gomock.Any(), gomock.Any()).Return(secondIter, nil),\n\t)\n\n\ttool := mocks.NewMockTool(ctrl)\n\ttool.EXPECT().Name().Return(\"mocktool\").AnyTimes()\n\ttool.EXPECT().Description().Return(\"mock tool\").AnyTimes()\n\ttool.EXPECT().FunctionDefinition().Return(&gollm.FunctionDefinition{Name: \"mocktool\"}).AnyTimes()\n\ttool.EXPECT().IsInteractive(gomock.Any()).Return(false, nil).AnyTimes()\n\ttool.EXPECT().CheckModifiesResource(gomock.Any()).Return(\"yes\").AnyTimes()\n\ttool.EXPECT().Run(gomock.Any(), gomock.Any()).Return(map[string]any{\"result\": \"ok\"}, nil)\n\n\tvar toolset tools.Tools\n\ttoolset.Init()\n\ttoolset.RegisterTool(tool)\n\n\ta := &Agent{\n\t\tChatMessageStore: store,\n\t\tLLM:              client,\n\t\tModel:            \"test-model\",\n\t\tTools:            toolset,\n\t\tMaxIterations:    4,\n\t\tSession: &api.Session{\n\t\t\tID:               \"test-session\",\n\t\t\tChatMessageStore: store,\n\t\t\tAgentState:       api.AgentStateIdle,\n\t\t},\n\t}\n\n\tif err := a.Init(ctx); err != nil {\n\t\tt.Fatalf(\"init: %v\", err)\n\t}\n\tif err := a.Run(ctx, \"\"); err != nil {\n\t\tt.Fatalf(\"run: %v\", err)\n\t}\n\n\t// Expect prompt (UI-driven startup, no greeting message)\n\tm1 := recvMsg(t, ctx, a.Output)\n\tif m1.Type != api.MessageTypeUserInputRequest {\n\t\tt.Fatalf(\"expected user-input-request, got %v\", m1.Type)\n\t}\n\n\t// Send a query (UI -> Agent)\n\ta.Input <- &api.UserInputResponse{Query: \"test\"}\n\n\t// Wait for choice request indicating state waiting for input.\n\tchoiceMsg := recvUntil(t, ctx, a.Output, func(m *api.Message) bool {\n\t\treturn m.Type == api.MessageTypeUserChoiceRequest\n\t})\n\tif choiceMsg == nil {\n\t\tt.Fatalf(\"did not receive choice request\")\n\t}\n\tif st := a.AgentState(); st != api.AgentStateWaitingForInput {\n\t\tt.Fatalf(\"expected waiting-for-input state, got %s\", st)\n\t}\n\n\t// Approve tool execution (UI -> Agent)\n\ta.Input <- &api.UserChoiceResponse{Choice: 1}\n\n\t// Expect tool invocation messages and final response.\n\tsawToolReq, sawToolResp, sawFinal := false, false, false\n\tfor !(sawToolReq && sawToolResp && sawFinal) {\n\t\tselect {\n\t\tcase v := <-a.Output:\n\t\t\tm, ok := v.(*api.Message)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"expected *api.Message on output, got %T\", v)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tswitch m.Type {\n\t\t\tcase api.MessageTypeToolCallRequest:\n\t\t\t\tsawToolReq = true\n\t\t\tcase api.MessageTypeToolCallResponse:\n\t\t\t\tsawToolResp = true\n\t\t\tcase api.MessageTypeText:\n\t\t\t\tif m.Source == api.MessageSourceModel {\n\t\t\t\t\tsawFinal = true\n\t\t\t\t}\n\t\t\t}\n\t\tcase <-ctx.Done():\n\t\t\tt.Fatalf(\"timeout before complete tool execution flow: req=%v resp=%v final=%v\", sawToolReq, sawToolResp, sawFinal)\n\t\t}\n\t}\n\n\t// After final model text, the agent may either prompt for more input (UI loop)\n\t// or declare Done depending on configuration. Accept either behavior.\n\tselect {\n\tcase v := <-a.Output:\n\t\tif m, ok := v.(*api.Message); ok {\n\t\t\tif m.Type != api.MessageTypeUserInputRequest && m.Type != api.MessageTypeText {\n\t\t\t\tt.Fatalf(\"unexpected message after final model text: type=%v\", m.Type)\n\t\t\t}\n\t\t}\n\tdefault:\n\t\tif st := a.AgentState(); st != api.AgentStateDone && st != api.AgentStateWaitingForInput {\n\t\t\tt.Fatalf(\"unexpected state after tool run: %s (want Done or WaitingForInput)\", st)\n\t\t}\n\t}\n}\n\nfunc TestAgentEndToEndMetaClear(t *testing.T) {\n\tctrl := gomock.NewController(t)\n\tdefer ctrl.Finish()\n\n\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)\n\tdefer cancel()\n\n\tstore := sessions.NewInMemoryChatStore()\n\tstore.AddChatMessage(&api.Message{ID: \"u1\", Source: api.MessageSourceUser, Type: api.MessageTypeText, Payload: \"hi\"})\n\tstore.AddChatMessage(&api.Message{ID: \"a1\", Source: api.MessageSourceAgent, Type: api.MessageTypeText, Payload: \"hello\"})\n\n\tclient := mocks.NewMockClient(ctrl)\n\tchat := mocks.NewMockChat(ctrl)\n\n\tclient.EXPECT().StartChat(gomock.Any(), \"test-model\").Return(chat)\n\tchat.EXPECT().Initialize(gomock.Any()).Return(nil).Times(2) // second init after clear\n\tchat.EXPECT().SetFunctionDefinitions(gomock.Any()).Return(nil)\n\n\tvar toolset tools.Tools\n\ttoolset.Init()\n\n\ta := &Agent{\n\t\tChatMessageStore: store,\n\t\tLLM:              client,\n\t\tModel:            \"test-model\",\n\t\tTools:            toolset,\n\t\tSession: &api.Session{\n\t\t\tID:               \"test-session\",\n\t\t\tChatMessageStore: store,\n\t\t\tAgentState:       api.AgentStateIdle,\n\t\t},\n\t}\n\n\tif err := a.Init(ctx); err != nil {\n\t\tt.Fatalf(\"init: %v\", err)\n\t}\n\tif err := a.Run(ctx, \"\"); err != nil {\n\t\tt.Fatalf(\"run: %v\", err)\n\t}\n\n\t// Expect startup prompt (no greeting message, UserInputRequest not stored)\n\tm1 := recvMsg(t, ctx, a.Output)\n\tif m1.Type != api.MessageTypeUserInputRequest {\n\t\tt.Fatalf(\"expected user-input-request, got %v\", m1.Type)\n\t}\n\n\t// Only pre-seeded messages should be in store (UserInputRequest is not stored)\n\tif got := len(store.ChatMessages()); got != 2 {\n\t\tt.Fatalf(\"precondition: expected 2 messages before clear, got %d\", got)\n\t}\n\n\t// UI sends the meta command\n\ta.Input <- &api.UserInputResponse{Query: \"clear\"}\n\n\tsawClear, sawPrompt := false, false\n\tfor !(sawClear && sawPrompt) {\n\t\tselect {\n\t\tcase v := <-a.Output:\n\t\t\tm, ok := v.(*api.Message)\n\t\t\tif !ok {\n\t\t\t\tt.Fatalf(\"expected *api.Message on output, got %T\", v)\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif m.Type == api.MessageTypeText && m.Payload == \"Cleared the conversation.\" {\n\t\t\t\tsawClear = true\n\t\t\t}\n\t\t\tif sawClear && m.Type == api.MessageTypeUserInputRequest {\n\t\t\t\tsawPrompt = true\n\t\t\t}\n\t\tcase <-ctx.Done():\n\t\t\tt.Fatalf(\"timeout waiting for clear confirmation and prompt: %v\", ctx.Err())\n\t\t}\n\t}\n\n\t// Only the clear confirmation should be stored (UserInputRequest is not stored)\n\tmsgs := store.ChatMessages()\n\tif len(msgs) != 1 {\n\t\tt.Fatalf(\"expected 1 message after clear, got %d\", len(msgs))\n\t}\n\tif msgs[0].Payload != \"Cleared the conversation.\" {\n\t\tt.Fatalf(\"first message after clear = %q, want %q\", msgs[0].Payload, \"Cleared the conversation.\")\n\t}\n}\n"
  },
  {
    "path": "pkg/agent/conversation.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage agent\n\nimport (\n\t\"context\"\n\t_ \"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"io\"\n\t\"os\"\n\t\"runtime\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/journal\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/mcp\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sandbox\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sessions\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/tools\"\n\t\"github.com/google/uuid\"\n\t\"k8s.io/klog/v2\"\n)\n\n//go:embed systemprompt_template_default.txt\nvar defaultSystemPromptTemplate string\n\ntype Agent struct {\n\t// Input is the channel to receive user input.\n\tInput chan any\n\n\t// Output is the channel to send messages to the UI.\n\tOutput chan any\n\n\t// RunOnce indicates if the agent should run only once.\n\t// If true, the agent will run only once and then exit.\n\t// If false, the agent will run in a loop until the context is done.\n\tRunOnce bool\n\n\t// InitialQuery is the initial query to the agent.\n\t// If provided, the agent will run only once and then exit.\n\tInitialQuery string\n\n\t// tool calls that are pending execution\n\t// These will typically be all the tool calls suggested by the LLM in the\n\t// previous iteration of the agentic loop.\n\tpendingFunctionCalls []ToolCallAnalysis\n\n\t// currChatContent tracks chat content that needs to be sent\n\t// to the LLM in the current iteration of the agentic loop.\n\tcurrChatContent []any\n\n\t// currIteration tracks the current iteration of the agentic loop.\n\tcurrIteration int\n\n\tLLM gollm.Client\n\n\t// PromptTemplateFile allows specifying a custom template file\n\tPromptTemplateFile string\n\t// ExtraPromptPaths allows specifying additional prompt templates\n\t// to be combined with PromptTemplateFile\n\tExtraPromptPaths []string\n\tModel            string\n\tProvider         string\n\n\tRemoveWorkDir bool\n\n\tMaxIterations int\n\n\t// Kubeconfig is the path to the kubeconfig file.\n\tKubeconfig string\n\t// Sandbox indicates whether to execute tools in a sandbox environment\n\tSandbox string\n\n\t// SandboxImage is the container image to use for the sandbox\n\tSandboxImage string\n\n\tSkipPermissions bool\n\n\tTools tools.Tools\n\n\tEnableToolUseShim bool\n\n\t// MCPClientEnabled indicates whether MCP client mode is enabled\n\tMCPClientEnabled bool\n\n\t// Recorder captures events for diagnostics\n\tRecorder journal.Recorder\n\n\tllmChat gollm.Chat\n\n\tworkDir string\n\n\t// executor is the executor for tool execution\n\texecutor sandbox.Executor\n\n\t// Session optionally provides a session to use.\n\t// This is used by the UI to track the state of the agent and the conversation.\n\tSession *api.Session\n\n\t// protects session from concurrent access\n\tsessionMu sync.Mutex\n\n\t// cached list of available models\n\tavailableModels []string\n\n\t// mcpManager manages MCP client connections\n\tmcpManager *mcp.Manager\n\n\t// ChatMessageStore is the underlying session persistence layer.\n\tChatMessageStore api.ChatMessageStore\n\n\t// SessionBackend is the configured backend for session persistence (e.g., memory, filesystem).\n\tSessionBackend string\n\n\t// lastErr is the most recent error run into, for use across the stack\n\tlastErr error\n\n\t// cancel is the function to cancel the agent's context\n\tcancel context.CancelFunc\n}\n\n// Assert InMemoryChatStore implements ChatMessageStore\nvar _ api.ChatMessageStore = &sessions.InMemoryChatStore{}\n\nfunc (s *Agent) GetSession() *api.Session {\n\ts.sessionMu.Lock()\n\tdefer s.sessionMu.Unlock()\n\n\t// Create a shallow copy of the session struct. The Messages slice header\n\t// is also copied, providing the caller with a snapshot of the messages\n\t// at this point in time. The UI should treat the messages as read-only\n\t// to avoid race conditions.\n\tsessionCopy := *s.Session\n\treturn &sessionCopy\n}\n\n// addMessage creates a new message, adds it to the session, and sends it to the output channel\nfunc (c *Agent) addMessage(source api.MessageSource, messageType api.MessageType, payload any) *api.Message {\n\tc.sessionMu.Lock()\n\tdefer c.sessionMu.Unlock()\n\tmessage := &api.Message{\n\t\tID:        uuid.New().String(),\n\t\tSource:    source,\n\t\tType:      messageType,\n\t\tPayload:   payload,\n\t\tTimestamp: time.Now(),\n\t}\n\n\t// Don't store UI control signals - they're not part of the conversation\n\tif messageType != api.MessageTypeUserInputRequest {\n\t\tc.Session.ChatMessageStore.AddChatMessage(message)\n\t\tc.Session.LastModified = time.Now()\n\t}\n\tc.Output <- message\n\treturn message\n}\n\n// setAgentState updates the agent state and ensures LastModified is updated\nfunc (c *Agent) setAgentState(newState api.AgentState) {\n\tc.sessionMu.Lock()\n\tdefer c.sessionMu.Unlock()\n\tcurrentState := c.agentState()\n\tif currentState != newState {\n\t\tklog.Infof(\"Agent state changing from %s to %s\", currentState, newState)\n\t\tc.Session.AgentState = newState\n\t\tc.Session.LastModified = time.Now()\n\t}\n}\nfunc (c *Agent) AgentState() api.AgentState {\n\tc.sessionMu.Lock()\n\tdefer c.sessionMu.Unlock()\n\treturn c.agentState()\n}\n\n// agentState returns the agent state without locking.\n// The caller is responsible for locking.\nfunc (c *Agent) agentState() api.AgentState {\n\treturn c.Session.AgentState\n}\n\nfunc (s *Agent) Init(ctx context.Context) error {\n\tlog := klog.FromContext(ctx)\n\n\ts.Input = make(chan any, 10)\n\ts.Output = make(chan any, 10)\n\ts.currIteration = 0\n\t// when we support session, we will need to initialize this with the\n\t// current history of the conversation.\n\ts.currChatContent = []any{}\n\n\tif s.InitialQuery == \"\" && s.RunOnce {\n\t\treturn fmt.Errorf(\"RunOnce mode requires an initial query to be provided\")\n\t}\n\n\tif s.Session != nil {\n\t\tif s.Session.ChatMessageStore == nil {\n\t\t\ts.Session.ChatMessageStore = sessions.NewInMemoryChatStore()\n\t\t}\n\t\ts.ChatMessageStore = s.Session.ChatMessageStore\n\t\tif s.Session.ID == \"\" {\n\t\t\ts.Session.ID = uuid.New().String()\n\t\t}\n\t\tif s.Session.CreatedAt.IsZero() {\n\t\t\ts.Session.CreatedAt = time.Now()\n\t\t}\n\t\tif s.Session.LastModified.IsZero() {\n\t\t\ts.Session.LastModified = time.Now()\n\t\t}\n\t\ts.Session.Messages = s.Session.ChatMessageStore.ChatMessages()\n\n\t} else {\n\t\treturn fmt.Errorf(\"agent requires a session to be provided\")\n\t}\n\n\t// Create a temporary working directory\n\tworkDir, err := os.MkdirTemp(\"\", \"agent-workdir-*\")\n\tif err != nil {\n\t\tlog.Error(err, \"Failed to create temporary working directory\")\n\t\treturn err\n\t}\n\n\tlog.Info(\"Created temporary working directory\", \"workDir\", workDir)\n\n\tswitch s.Sandbox {\n\tcase \"k8s\":\n\t\tsandboxName := fmt.Sprintf(\"kubectl-ai-sandbox-%s\", uuid.New().String()[:8])\n\n\t\t// Use default image if not specified\n\t\tsandboxImage := s.SandboxImage\n\t\tif sandboxImage == \"\" {\n\t\t\tsandboxImage = \"bitnami/kubectl:latest\"\n\t\t}\n\n\t\t// Create sandbox with kubeconfig\n\t\tsb, err := sandbox.NewKubernetesSandbox(sandboxName,\n\t\t\tsandbox.WithKubeconfig(s.Kubeconfig),\n\t\t\tsandbox.WithImage(sandboxImage),\n\t\t)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to create sandbox: %w\", err)\n\t\t}\n\n\t\ts.executor = sb\n\t\tlog.Info(\"Created sandbox\", \"name\", sandboxName, \"image\", sandboxImage)\n\n\tcase \"seatbelt\":\n\t\tif runtime.GOOS != \"darwin\" {\n\t\t\treturn fmt.Errorf(\"seatbelt sandbox is only supported on macOS\")\n\t\t}\n\t\ts.executor = sandbox.NewSeatbeltExecutor()\n\t\tlog.Info(\"Using Seatbelt executor\")\n\n\tcase \"\":\n\t\t// No sandbox, use local executor\n\t\ts.executor = sandbox.NewLocalExecutor()\n\n\tdefault:\n\t\treturn fmt.Errorf(\"unknown sandbox type: %s\", s.Sandbox)\n\t}\n\n\ts.workDir = workDir\n\n\t// Register tools with executor if none registered yet\n\t// We clone existing tools (e.g. custom tools) to ensure we have a fresh map\n\t// This avoids polluting the global default tools and ensures thread safety.\n\ts.Tools = s.Tools.CloneWithExecutor(s.executor)\n\n\ts.Tools.RegisterTool(tools.NewBashTool(s.executor))\n\ts.Tools.RegisterTool(tools.NewKubectlTool(s.executor))\n\n\tsystemPrompt, err := s.generatePrompt(ctx, defaultSystemPromptTemplate, PromptData{\n\t\tTools:             s.Tools,\n\t\tEnableToolUseShim: s.EnableToolUseShim,\n\t\t// RunOnce is a good proxy to indicate the agentic session is non-interactive mode.\n\t\tSessionIsInteractive: !s.RunOnce,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"generating system prompt: %w\", err)\n\t}\n\n\t// Start a new chat session\n\ts.llmChat = gollm.NewRetryChat(\n\t\ts.LLM.StartChat(systemPrompt, s.Model),\n\t\tgollm.RetryConfig{\n\t\t\tMaxAttempts:    3,\n\t\t\tInitialBackoff: 10 * time.Second,\n\t\t\tMaxBackoff:     60 * time.Second,\n\t\t\tBackoffFactor:  2,\n\t\t\tJitter:         true,\n\t\t},\n\t)\n\terr = s.llmChat.Initialize(s.Session.ChatMessageStore.ChatMessages())\n\tif err != nil {\n\t\treturn fmt.Errorf(\"initializing chat session: %w\", err)\n\t}\n\n\tif s.MCPClientEnabled {\n\t\tif err := s.InitializeMCPClient(ctx); err != nil {\n\t\t\tklog.Errorf(\"Failed to initialize MCP client: %v\", err)\n\t\t\treturn fmt.Errorf(\"failed to initialize MCP client: %w\", err)\n\t\t}\n\n\t\t// Update MCP status in session\n\t\tif err := s.UpdateMCPStatus(ctx, s.MCPClientEnabled); err != nil {\n\t\t\tklog.Warningf(\"Failed to update MCP status: %v\", err)\n\t\t}\n\t}\n\n\tif !s.EnableToolUseShim {\n\t\tvar functionDefinitions []*gollm.FunctionDefinition\n\t\tfor _, tool := range s.Tools.AllTools() {\n\t\t\tfunctionDefinitions = append(functionDefinitions, tool.FunctionDefinition())\n\t\t}\n\t\t// Sort function definitions to help KV cache reuse\n\t\tsort.Slice(functionDefinitions, func(i, j int) bool {\n\t\t\treturn functionDefinitions[i].Name < functionDefinitions[j].Name\n\t\t})\n\t\tif err := s.llmChat.SetFunctionDefinitions(functionDefinitions); err != nil {\n\t\t\treturn fmt.Errorf(\"setting function definitions: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (c *Agent) Close() error {\n\tif c.workDir != \"\" {\n\t\tif c.RemoveWorkDir {\n\t\t\tif err := os.RemoveAll(c.workDir); err != nil {\n\t\t\t\tklog.Warningf(\"error cleaning up directory %q: %v\", c.workDir, err)\n\t\t\t}\n\t\t}\n\t}\n\t// Close MCP client connections\n\tif err := c.CloseMCPClient(); err != nil {\n\t\tklog.Warningf(\"error closing MCP client: %v\", err)\n\t}\n\n\t// Close sandbox if enabled\n\t// Close executor if it exists\n\tif c.executor != nil {\n\t\tctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)\n\t\tdefer cancel()\n\t\tif err := c.executor.Close(ctx); err != nil {\n\t\t\tklog.Warningf(\"error cleaning up executor: %v\", err)\n\t\t} else {\n\t\t\tklog.Info(\"Executor cleaned up successfully\")\n\t\t}\n\t}\n\t// Cancel the agent's context\n\tif c.cancel != nil {\n\t\tc.cancel()\n\t}\n\t// Close the LLM client\n\tif c.LLM != nil {\n\t\tif err := c.LLM.Close(); err != nil {\n\t\t\tklog.Warningf(\"error closing LLM client: %v\", err)\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (c *Agent) LastErr() error {\n\treturn c.lastErr\n}\n\nfunc (c *Agent) Run(ctx context.Context, initialQuery string) error {\n\tlog := klog.FromContext(ctx)\n\n\tif c.Recorder != nil {\n\t\tctx = journal.ContextWithRecorder(ctx, c.Recorder)\n\t}\n\n\t// Save unexpected error and return it in for RunOnce mode\n\tlog.Info(\"Starting agent loop\", \"initialQuery\", initialQuery, \"runOnce\", c.RunOnce)\n\tgo func() {\n\t\t// If initialQuery is empty, try to use the one from the struct\n\t\tif initialQuery == \"\" {\n\t\t\tinitialQuery = c.InitialQuery\n\t\t}\n\n\t\tif initialQuery != \"\" {\n\t\t\tc.addMessage(api.MessageSourceUser, api.MessageTypeText, initialQuery)\n\t\t\tanswer, handled, err := c.handleMetaQuery(ctx, initialQuery)\n\t\t\tif err != nil {\n\t\t\t\tlog.Error(err, \"error handling meta query\")\n\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, \"Error: \"+err.Error())\n\t\t\t} else if handled {\n\t\t\t\t// initialQuery is the 'exit' or 'quit' metaquery\n\t\t\t\tif c.AgentState() == api.AgentStateExited {\n\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeText, answer)\n\t\t\t\t\tclose(c.Output)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\t// we handled the meta query, so we don't need to run the agentic loop\n\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeText, answer)\n\t\t\t} else {\n\t\t\t\t// Start the agentic loop with the initial query\n\t\t\t\tc.setAgentState(api.AgentStateRunning)\n\t\t\t\tc.currIteration = 0\n\t\t\t\tc.currChatContent = []any{initialQuery}\n\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t}\n\t\t}\n\t\tc.lastErr = nil\n\t\tfor {\n\t\t\tvar userInput any\n\t\t\tlog.Info(\"Agent loop iteration\", \"state\", c.AgentState())\n\t\t\tswitch c.AgentState() {\n\t\t\tcase api.AgentStateIdle, api.AgentStateDone:\n\t\t\t\t// In RunOnce mode, we are done, so exit\n\t\t\t\tif c.RunOnce {\n\t\t\t\t\tlog.Info(\"RunOnce mode, exiting agent loop\")\n\t\t\t\t\tc.setAgentState(api.AgentStateExited)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tlog.Info(\"initiating user input\")\n\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeUserInputRequest, \">>>\")\n\t\t\t\tselect {\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\tlog.Info(\"Agent loop done\")\n\t\t\t\t\treturn\n\t\t\t\tcase userInput = <-c.Input:\n\t\t\t\t\tlog.Info(\"Received input from channel\", \"userInput\", userInput)\n\t\t\t\t\tif userInput == io.EOF {\n\t\t\t\t\t\tlog.Info(\"Agent loop done, EOF received\")\n\t\t\t\t\t\tc.setAgentState(api.AgentStateExited)\n\t\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeText, \"It has been a pleasure assisting you. Have a great day!\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tif sessionPickerResp, ok := userInput.(*api.SessionPickerResponse); ok {\n\t\t\t\t\t\tif sessionPickerResp.Cancelled {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif err := c.LoadSession(sessionPickerResp.SessionID); err != nil {\n\t\t\t\t\t\t\tlog.Error(err, \"error loading session\")\n\t\t\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, \"Error loading session: \"+err.Error())\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeText, fmt.Sprintf(\"Switched to session %s\", sessionPickerResp.SessionID))\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tquery, ok := userInput.(*api.UserInputResponse)\n\t\t\t\t\tif !ok {\n\t\t\t\t\t\tlog.Error(nil, \"Received unexpected input from channel\", \"userInput\", userInput)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tif strings.TrimSpace(query.Query) == \"\" {\n\t\t\t\t\t\tlog.Info(\"No query provided, skipping agentic loop\")\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tc.addMessage(api.MessageSourceUser, api.MessageTypeText, query.Query)\n\t\t\t\t\t// we don't need the agentic loop for meta queries\n\t\t\t\t\t// for ex. model, tools, etc.\n\t\t\t\t\tanswer, handled, err := c.handleMetaQuery(ctx, query.Query)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.Error(err, \"error handling meta query\")\n\t\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, \"Error: \"+err.Error())\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tif handled {\n\t\t\t\t\t\t// metaquery set the state to 'Exited', so we should exit\n\t\t\t\t\t\tif c.AgentState() == api.AgentStateExited {\n\t\t\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeText, answer)\n\t\t\t\t\t\t\tclose(c.Output)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// metaquery set up an interactive picker, wait for response\n\t\t\t\t\t\tif c.AgentState() == api.AgentStateWaitingForInput {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// we handled the meta query, so we don't need to run the agentic loop\n\t\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\t\tif answer != \"\" {\n\t\t\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeText, answer)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tc.setAgentState(api.AgentStateRunning)\n\t\t\t\t\tc.currIteration = 0\n\t\t\t\t\tc.currChatContent = []any{query.Query}\n\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\tlog.Info(\"Set agent state to running, will process agentic loop\", \"currIteration\", c.currIteration, \"currChatContent\", len(c.currChatContent))\n\t\t\t\t}\n\t\t\tcase api.AgentStateWaitingForInput:\n\t\t\t\t// In RunOnce mode, if we need user choice, exit with error\n\t\t\t\tif c.RunOnce {\n\t\t\t\t\tlog.Error(nil, \"RunOnce mode cannot handle user choice requests\")\n\t\t\t\t\tc.setAgentState(api.AgentStateExited)\n\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, \"Error: RunOnce mode cannot handle user choice requests\")\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tselect {\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\tlog.Info(\"Agent loop done\")\n\t\t\t\t\treturn\n\t\t\t\tcase userInput = <-c.Input:\n\t\t\t\t\tif userInput == io.EOF {\n\t\t\t\t\t\tlog.Info(\"Agent loop done, EOF received\")\n\t\t\t\t\t\tc.setAgentState(api.AgentStateExited)\n\t\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeText, \"It has been a pleasure assisting you. Have a great day!\")\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tswitch response := userInput.(type) {\n\t\t\t\t\tcase *api.SessionPickerResponse:\n\t\t\t\t\t\tif response.Cancelled {\n\t\t\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif err := c.LoadSession(response.SessionID); err != nil {\n\t\t\t\t\t\t\tlog.Error(err, \"error loading session\")\n\t\t\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, \"Error loading session: \"+err.Error())\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeText, fmt.Sprintf(\"Switched to session %s\", response.SessionID))\n\t\t\t\t\t\t}\n\t\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\t\tcontinue\n\n\t\t\t\t\tcase *api.UserChoiceResponse:\n\t\t\t\t\t\tdispatchToolCalls := c.handleChoice(ctx, response)\n\t\t\t\t\t\tif dispatchToolCalls {\n\t\t\t\t\t\t\tif err := c.DispatchToolCalls(ctx); err != nil {\n\t\t\t\t\t\t\t\tlog.Error(err, \"error dispatching tool calls\")\n\t\t\t\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\t\t\t\tc.Session.LastModified = time.Now()\n\t\t\t\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, \"Error: \"+err.Error())\n\t\t\t\t\t\t\t\t// In RunOnce mode, exit on tool execution error\n\t\t\t\t\t\t\t\tif c.RunOnce {\n\t\t\t\t\t\t\t\t\tc.setAgentState(api.AgentStateExited)\n\t\t\t\t\t\t\t\t\tc.lastErr = err\n\t\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t// Clear pending function calls after execution\n\t\t\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\t\t\tc.setAgentState(api.AgentStateRunning)\n\t\t\t\t\t\t\tc.currIteration = c.currIteration + 1\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// if user has declined, we are done with this iteration\n\t\t\t\t\t\t\tc.currIteration = c.currIteration + 1\n\t\t\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\t\t\tc.setAgentState(api.AgentStateRunning)\n\t\t\t\t\t\t\tc.Session.LastModified = time.Now()\n\t\t\t\t\t\t}\n\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tlog.Error(nil, \"Received unexpected input from channel\", \"userInput\", userInput)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tcase api.AgentStateRunning:\n\t\t\t\t// Agent is running, don't wait for input, just continue to process the agentic loop\n\t\t\t\tlog.Info(\"Agent is in running state, processing agentic loop\")\n\t\t\tcase api.AgentStateExited:\n\t\t\t\tlog.Info(\"Agent exited in RunOnce mode\")\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif c.AgentState() == api.AgentStateRunning {\n\t\t\t\tlog.Info(\"Processing agentic loop\", \"currIteration\", c.currIteration, \"maxIterations\", c.MaxIterations, \"currChatContentLen\", len(c.currChatContent))\n\n\t\t\t\tif c.currIteration >= c.MaxIterations {\n\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeText, \"Maximum number of iterations reached.\")\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// we run the agentic loop for one iteration\n\t\t\t\tstream, err := c.llmChat.SendStreaming(ctx, c.currChatContent...)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Error(err, \"error sending streaming LLM response\")\n\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\tc.lastErr = err\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// Clear our \"response\" now that we sent the last response\n\t\t\t\tc.currChatContent = nil\n\n\t\t\t\tif c.EnableToolUseShim {\n\t\t\t\t\t// convert the candidate response into a gollm.ChatResponse\n\t\t\t\t\tstream, err = candidateToShimCandidate(stream)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\n\t\t\t\t\t\t// In RunOnce mode, exit on shim conversion error\n\t\t\t\t\t\tif c.RunOnce {\n\t\t\t\t\t\t\tc.setAgentState(api.AgentStateExited)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// Process each part of the response\n\t\t\t\tvar functionCalls []gollm.FunctionCall\n\n\t\t\t\t// accumulator for streamed text\n\t\t\t\tvar streamedText string\n\t\t\t\tvar llmError error\n\n\t\t\t\tfor response, err := range stream {\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.Error(err, \"error reading streaming LLM response\")\n\t\t\t\t\t\tllmError = err\n\t\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\t\tc.lastErr = llmError\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tif response == nil {\n\t\t\t\t\t\t// end of streaming response\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\t// klog.Infof(\"response: %+v\", response)\n\n\t\t\t\t\tif len(response.Candidates()) == 0 {\n\t\t\t\t\t\tllmError = fmt.Errorf(\"no candidates in response\")\n\t\t\t\t\t\tlog.Error(nil, \"No candidates in response\")\n\t\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\n\t\t\t\t\tcandidate := response.Candidates()[0]\n\n\t\t\t\t\tfor _, part := range candidate.Parts() {\n\t\t\t\t\t\t// Check if it's a text response\n\t\t\t\t\t\tif text, ok := part.AsText(); ok {\n\t\t\t\t\t\t\tlog.Info(\"text response\", \"text\", text)\n\t\t\t\t\t\t\tstreamedText += text\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Check if it's a function call\n\t\t\t\t\t\tif calls, ok := part.AsFunctionCalls(); ok && len(calls) > 0 {\n\t\t\t\t\t\t\tlog.Info(\"function calls\", \"calls\", calls)\n\t\t\t\t\t\t\tfunctionCalls = append(functionCalls, calls...)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif llmError != nil {\n\t\t\t\t\tlog.Error(llmError, \"error streaming LLM response\")\n\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, \"Error: \"+llmError.Error())\n\t\t\t\t\tc.lastErr = llmError\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tlog.Info(\"streamedText\", \"streamedText\", streamedText)\n\n\t\t\t\tif streamedText != \"\" {\n\t\t\t\t\tc.addMessage(api.MessageSourceModel, api.MessageTypeText, streamedText)\n\t\t\t\t}\n\t\t\t\t// If no function calls to be made, we're done\n\t\t\t\tif len(functionCalls) == 0 {\n\t\t\t\t\tlog.Info(\"No function calls to be made, so most likely the task is completed, so we're done.\")\n\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\tc.currChatContent = []any{}\n\t\t\t\t\tc.currIteration = 0\n\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\tlog.Info(\"Agent task completed, transitioning to done state\")\n\t\t\t\t\tif streamedText == \"\" {\n\t\t\t\t\t\t// If no tool calls to be made and we do not have a response from the LLM\n\t\t\t\t\t\t// we should let the user know for better diagnostics.\n\t\t\t\t\t\t// IMPORTANT: This also prevents UIs from getting blocked on reading from the output channel.\n\t\t\t\t\t\tlog.Info(\"Empty response with no tool calls from LLM.\")\n\t\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeText, \"Empty response from LLM\")\n\t\t\t\t\t}\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\ttoolCallAnalysisResults, err := c.analyzeToolCalls(ctx, functionCalls)\n\t\t\t\tif err != nil {\n\t\t\t\t\tlog.Error(err, \"error analyzing tool calls\")\n\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\tc.Session.LastModified = time.Now()\n\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, \"Error: \"+err.Error())\n\t\t\t\t\tc.lastErr = err\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// mark the tools for dispatching\n\t\t\t\tc.pendingFunctionCalls = toolCallAnalysisResults\n\n\t\t\t\tinteractiveToolCallIndex := -1\n\t\t\t\tmodifiesResourceToolCallIndex := -1\n\t\t\t\tfor i, result := range toolCallAnalysisResults {\n\t\t\t\t\tif result.ModifiesResourceStr != \"no\" {\n\t\t\t\t\t\tmodifiesResourceToolCallIndex = i\n\t\t\t\t\t}\n\t\t\t\t\tif result.IsInteractive {\n\t\t\t\t\t\tinteractiveToolCallIndex = i\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif interactiveToolCallIndex >= 0 {\n\t\t\t\t\t// Show error block for both shim enabled and disabled modes\n\t\t\t\t\terrorMessage := fmt.Sprintf(\"  %s\\n\", toolCallAnalysisResults[interactiveToolCallIndex].IsInteractiveError.Error())\n\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, errorMessage)\n\n\t\t\t\t\tif c.EnableToolUseShim {\n\t\t\t\t\t\t// Add the error as an observation\n\t\t\t\t\t\tobservation := fmt.Sprintf(\"Result of running %q:\\n%v\",\n\t\t\t\t\t\t\ttoolCallAnalysisResults[interactiveToolCallIndex].FunctionCall.Name,\n\t\t\t\t\t\t\ttoolCallAnalysisResults[interactiveToolCallIndex].IsInteractiveError.Error())\n\t\t\t\t\t\tc.currChatContent = append(c.currChatContent, observation)\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// For models with tool-use support (shim disabled), use proper FunctionCallResult\n\t\t\t\t\t\t// Note: This assumes the model supports sending FunctionCallResult\n\t\t\t\t\t\tc.currChatContent = append(c.currChatContent, gollm.FunctionCallResult{\n\t\t\t\t\t\t\tID:     toolCallAnalysisResults[interactiveToolCallIndex].FunctionCall.ID,\n\t\t\t\t\t\t\tName:   toolCallAnalysisResults[interactiveToolCallIndex].FunctionCall.Name,\n\t\t\t\t\t\t\tResult: map[string]any{\"error\": toolCallAnalysisResults[interactiveToolCallIndex].IsInteractiveError.Error()},\n\t\t\t\t\t\t})\n\t\t\t\t\t}\n\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{} // reset pending function calls\n\t\t\t\t\tc.currIteration = c.currIteration + 1\n\t\t\t\t\tcontinue // Skip execution for interactive commands\n\t\t\t\t}\n\n\t\t\t\tif !c.SkipPermissions && modifiesResourceToolCallIndex >= 0 {\n\t\t\t\t\t// In RunOnce mode, exit with error if permission is required\n\t\t\t\t\tif c.RunOnce {\n\t\t\t\t\t\tvar commandDescriptions []string\n\t\t\t\t\t\tfor _, call := range c.pendingFunctionCalls {\n\t\t\t\t\t\t\tcommandDescriptions = append(commandDescriptions, call.ParsedToolCall.Description())\n\t\t\t\t\t\t}\n\t\t\t\t\t\terrorMessage := \"RunOnce mode cannot handle permission requests. The following commands require approval:\\n* \" + strings.Join(commandDescriptions, \"\\n* \")\n\t\t\t\t\t\terrorMessage += \"\\nUse --skip-permissions flag to bypass permission checks in RunOnce mode.\"\n\n\t\t\t\t\t\tlog.Error(nil, \"RunOnce mode cannot handle permission requests\", \"commands\", commandDescriptions)\n\t\t\t\t\t\tc.setAgentState(api.AgentStateExited)\n\t\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, errorMessage)\n\t\t\t\t\t\tc.lastErr = fmt.Errorf(\"%s\", errorMessage)\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\n\t\t\t\t\tvar commandDescriptions []string\n\t\t\t\t\tfor _, call := range c.pendingFunctionCalls {\n\t\t\t\t\t\tcommandDescriptions = append(commandDescriptions, call.ParsedToolCall.Description())\n\t\t\t\t\t}\n\t\t\t\t\tconfirmationPrompt := \"The following commands require your approval to run:\\n* \" + strings.Join(commandDescriptions, \"\\n* \")\n\t\t\t\t\tconfirmationPrompt += \"\\n\\nDo you want to proceed ?\"\n\n\t\t\t\t\tchoiceRequest := &api.UserChoiceRequest{\n\t\t\t\t\t\tPrompt: confirmationPrompt,\n\t\t\t\t\t\tOptions: []api.UserChoiceOption{\n\t\t\t\t\t\t\t{Value: \"yes\", Label: \"Yes\"},\n\t\t\t\t\t\t\t{Value: \"yes_and_dont_ask_me_again\", Label: \"Yes, and don't ask me again\"},\n\t\t\t\t\t\t\t{Value: \"no\", Label: \"No\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t\tc.setAgentState(api.AgentStateWaitingForInput)\n\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeUserChoiceRequest, choiceRequest)\n\t\t\t\t\t// Request input from the user by sending a message on the output channel.\n\t\t\t\t\t// Remaining part of the loop will be now resumed when we receive a choice input\n\t\t\t\t\t// from the user.\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// we are here means we are in the clear to dispatch the tool calls\n\t\t\t\tif err := c.DispatchToolCalls(ctx); err != nil {\n\t\t\t\t\tlog.Error(err, \"error dispatching tool calls\")\n\t\t\t\t\tc.setAgentState(api.AgentStateDone)\n\t\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\t\tc.Session.LastModified = time.Now()\n\t\t\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, \"Error: \"+err.Error())\n\t\t\t\t\tc.lastErr = err\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tc.currIteration = c.currIteration + 1\n\t\t\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\t\t\tlog.Info(\"Tool calls dispatched successfully\", \"currIteration\", c.currIteration, \"currChatContentLen\", len(c.currChatContent), \"agentState\", c.AgentState())\n\t\t\t}\n\t\t}\n\t}()\n\n\treturn nil\n}\n\nfunc (c *Agent) handleMetaQuery(ctx context.Context, query string) (answer string, handled bool, err error) {\n\tswitch query {\n\tcase \"clear\", \"reset\":\n\t\tc.sessionMu.Lock()\n\t\t// TODO: Remove this check when session persistence is default\n\t\tif err := c.Session.ChatMessageStore.ClearChatMessages(); err != nil {\n\t\t\treturn \"Failed to clear the conversation\", false, err\n\t\t}\n\t\tc.llmChat.Initialize(c.Session.ChatMessageStore.ChatMessages())\n\t\tc.sessionMu.Unlock()\n\t\treturn \"Cleared the conversation.\", true, nil\n\tcase \"exit\", \"quit\":\n\t\tc.setAgentState(api.AgentStateExited)\n\t\treturn \"It has been a pleasure assisting you. Have a great day!\", true, nil\n\tcase \"model\":\n\t\treturn \"Current model is `\" + c.Model + \"`\", true, nil\n\tcase \"models\":\n\t\tmodels, err := c.listModels(ctx)\n\t\tif err != nil {\n\t\t\treturn \"\", false, fmt.Errorf(\"listing models: %w\", err)\n\t\t}\n\t\treturn \"Available models:\\n\\n  - \" + strings.Join(models, \"\\n  - \") + \"\\n\\n\", true, nil\n\tcase \"tools\":\n\t\treturn \"Available tools:\\n\\n  - \" + strings.Join(c.Tools.Names(), \"\\n  - \") + \"\\n\\n\", true, nil\n\tcase \"session\":\n\t\tif c.SessionBackend != \"filesystem\" {\n\t\t\treturn \"Ephemeral session (memory backed). No persistent info available.\", true, nil\n\t\t}\n\t\treturn fmt.Sprintf(\"Current session:\\n\\n%s\", c.Session.String()), true, nil\n\n\tcase \"save-session\":\n\t\tsavedSessionID, err := c.SaveSession()\n\t\tif err != nil {\n\t\t\treturn \"\", false, fmt.Errorf(\"failed to save session: %w\", err)\n\t\t}\n\t\treturn \"Saved session as \" + savedSessionID, true, nil\n\n\tcase \"sessions\":\n\t\tsessions, err := c.ListSessions()\n\t\tif err != nil {\n\t\t\treturn \"\", false, err\n\t\t}\n\t\tif len(sessions) == 0 {\n\t\t\treturn \"No sessions found.\", true, nil\n\t\t}\n\t\t// Add ```text so markdown doesn't wreck the format\n\t\tavailableSessions := \"```text\"\n\t\tavailableSessions += \"Available sessions:\\n\\n\"\n\t\tavailableSessions += \"ID\\t\\t\\tCreated\\t\\t\\tLast Accessed\\t\\tModel\\t\\tProvider\\n\"\n\t\tavailableSessions += \"--\\t\\t\\t-------\\t\\t\\t-------------\\t\\t-----\\t\\t--------\\n\"\n\n\t\tfor _, session := range sessions {\n\t\t\tavailableSessions += fmt.Sprintf(\"%s\\t%s\\t%s\\t%s\\t%s\\n\",\n\t\t\t\tsession.ID,\n\t\t\t\tsession.CreatedAt.Format(\"2006-01-02 15:04\"),\n\t\t\t\tsession.LastModified.Format(\"2006-01-02 15:04\"),\n\t\t\t\tsession.ModelID,\n\t\t\t\tsession.ProviderID)\n\t\t}\n\t\t// close the ```text box\n\t\tavailableSessions += \"```\"\n\t\treturn availableSessions, true, nil\n\t}\n\n\tif strings.HasPrefix(query, \"resume-session\") {\n\t\tparts := strings.Split(query, \" \")\n\t\tif len(parts) != 2 {\n\t\t\treturn \"Invalid command. Usage: resume-session <session_id>\", true, nil\n\t\t}\n\t\tsessionID := parts[1]\n\t\tif err := c.LoadSession(sessionID); err != nil {\n\t\t\treturn \"\", false, err\n\t\t}\n\t\treturn fmt.Sprintf(\"Resumed session %s.\", sessionID), true, nil\n\t}\n\n\treturn \"\", false, nil\n}\n\nfunc (c *Agent) NewSession() (string, error) {\n\tif _, err := c.SaveSession(); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to save current session: %w\", err)\n\t}\n\n\tmanager, err := sessions.NewSessionManager(c.SessionBackend)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create session manager: %w\", err)\n\t}\n\n\tmetadata := sessions.Metadata{\n\t\tModelID:    c.Model,\n\t\tProviderID: c.Provider,\n\t}\n\n\tnewSession, err := manager.NewSession(metadata)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create new session: %w\", err)\n\t}\n\n\t// If we are using a sandbox, we should spin up a new one for the new session\n\tif c.Sandbox == \"k8s\" {\n\t\tsandboxName := fmt.Sprintf(\"kubectl-ai-sandbox-%s\", uuid.New().String()[:8])\n\t\tsandboxImage := c.SandboxImage\n\n\t\tsb, err := sandbox.NewKubernetesSandbox(sandboxName,\n\t\t\tsandbox.WithKubeconfig(c.Kubeconfig),\n\t\t\tsandbox.WithImage(sandboxImage),\n\t\t)\n\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"failed to create new sandbox: %w\", err)\n\t\t}\n\n\t\tc.sessionMu.Lock()\n\t\tif c.executor != nil {\n\t\t\tctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)\n\t\t\tif err := c.executor.Close(ctx); err != nil {\n\t\t\t\tklog.Warningf(\"error closing old executor: %v\", err)\n\t\t\t}\n\t\t\tcancel()\n\t\t}\n\n\t\tc.executor = sb\n\t\tklog.Info(\"Created new sandbox for new session\", \"name\", sandboxName)\n\n\t\t// Re-bind all tools to the new executor\n\t\tc.Tools = c.Tools.CloneWithExecutor(c.executor)\n\n\t\tc.Tools.RegisterTool(tools.NewBashTool(c.executor))\n\t\tc.Tools.RegisterTool(tools.NewKubectlTool(c.executor))\n\t\tc.sessionMu.Unlock()\n\t}\n\n\tif err := c.LoadSession(newSession.ID); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to load new session: %w\", err)\n\t}\n\n\treturn newSession.ID, nil\n}\n\nfunc (c *Agent) SaveSession() (string, error) {\n\tc.sessionMu.Lock()\n\tdefer c.sessionMu.Unlock()\n\n\tmanager, err := sessions.NewSessionManager(c.SessionBackend)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create session manager: %w\", err)\n\t}\n\n\tif c.Session != nil {\n\t\tfoundSession, _ := manager.FindSessionByID(c.Session.ID)\n\t\tif foundSession != nil {\n\t\t\treturn foundSession.ID, nil\n\t\t}\n\t}\n\n\tmetadata := sessions.Metadata{\n\t\tCreatedAt:    c.Session.CreatedAt,\n\t\tLastAccessed: time.Now(),\n\t\tModelID:      c.Model,\n\t\tProviderID:   c.Provider,\n\t}\n\n\tnewSession, err := manager.NewSession(metadata)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to create new session: %w\", err)\n\t}\n\n\tmessages := c.ChatMessageStore.ChatMessages()\n\tif err := newSession.ChatMessageStore.SetChatMessages(messages); err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to save chat messages to new session: %w\", err)\n\t}\n\n\tc.ChatMessageStore = newSession.ChatMessageStore\n\tc.Session = newSession\n\tc.Session.Messages = messages\n\n\tif c.llmChat != nil {\n\t\t_ = c.llmChat.Initialize(c.Session.ChatMessageStore.ChatMessages())\n\t}\n\n\treturn newSession.ID, nil\n}\n\n// LoadSession loads a session by ID (or latest), updates the agent's state, and re-initializes the chat.\nfunc (c *Agent) LoadSession(sessionID string) error {\n\tmanager, err := sessions.NewSessionManager(c.SessionBackend)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to create session manager: %w\", err)\n\t}\n\n\tvar session *api.Session\n\tif sessionID == \"\" || sessionID == \"latest\" {\n\t\ts, err := manager.GetLatestSession()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get latest session: %w\", err)\n\t\t}\n\t\tif s == nil {\n\t\t\treturn fmt.Errorf(\"no sessions found to resume\")\n\t\t}\n\t\tsession = s\n\t} else {\n\t\ts, err := manager.FindSessionByID(sessionID)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to get session %q: %w\", sessionID, err)\n\t\t}\n\t\tsession = s\n\t}\n\n\tc.sessionMu.Lock()\n\tdefer c.sessionMu.Unlock()\n\n\tif session.ChatMessageStore == nil {\n\t\tsession.ChatMessageStore = sessions.NewInMemoryChatStore()\n\t}\n\n\tc.Session = session\n\tc.ChatMessageStore = session.ChatMessageStore\n\tc.Session.Messages = session.ChatMessageStore.ChatMessages()\n\tc.Session.LastModified = time.Now()\n\n\t// Reset state if it was left running (e.g. from a crash)\n\tif c.Session.AgentState == api.AgentStateRunning || c.Session.AgentState == api.AgentStateInitializing {\n\t\tc.Session.AgentState = api.AgentStateIdle\n\t}\n\n\tif err := manager.UpdateLastAccessed(session); err != nil {\n\t\treturn fmt.Errorf(\"failed to update session metadata: %w\", err)\n\t}\n\n\tif c.llmChat != nil {\n\t\tif err := c.llmChat.Initialize(c.Session.ChatMessageStore.ChatMessages()); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to re-initialize chat with new session: %w\", err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// ListSessions returns available sessions for UI pickers\nfunc (c *Agent) ListSessions() ([]api.SessionInfo, error) {\n\tmanager, err := sessions.NewSessionManager(c.SessionBackend)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to create session manager: %w\", err)\n\t}\n\n\tsessionList, err := manager.ListSessions()\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to list sessions: %w\", err)\n\t}\n\n\tsessionInfos := make([]api.SessionInfo, len(sessionList))\n\tfor i, session := range sessionList {\n\t\tmsgCount := 0\n\t\tif session.ChatMessageStore != nil {\n\t\t\tmsgCount = len(session.ChatMessageStore.ChatMessages())\n\t\t}\n\t\tsessionInfos[i] = api.SessionInfo{\n\t\t\tID:           session.ID,\n\t\t\tName:         session.Name,\n\t\t\tModelID:      session.ModelID,\n\t\t\tProviderID:   session.ProviderID,\n\t\t\tCreatedAt:    session.CreatedAt,\n\t\t\tLastModified: session.LastModified,\n\t\t\tMessageCount: msgCount,\n\t\t}\n\t}\n\treturn sessionInfos, nil\n}\n\nfunc (c *Agent) listModels(ctx context.Context) ([]string, error) {\n\tif c.availableModels == nil {\n\t\tmodelNames, err := c.LLM.ListModels(ctx)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"listing models: %w\", err)\n\t\t}\n\t\tc.availableModels = modelNames\n\t}\n\treturn c.availableModels, nil\n}\n\nfunc (c *Agent) DispatchToolCalls(ctx context.Context) error {\n\tlog := klog.FromContext(ctx)\n\t// execute all pending function calls\n\tfor _, call := range c.pendingFunctionCalls {\n\t\t// Only show \"Running\" message and proceed with execution for non-interactive commands\n\t\ttoolDescription := call.ParsedToolCall.Description()\n\n\t\tc.addMessage(api.MessageSourceModel, api.MessageTypeToolCallRequest, toolDescription)\n\n\t\toutput, err := call.ParsedToolCall.InvokeTool(ctx, tools.InvokeToolOptions{\n\t\t\tKubeconfig: c.Kubeconfig,\n\t\t\tWorkDir:    c.workDir,\n\t\t\tExecutor:   c.executor,\n\t\t})\n\n\t\tif err != nil {\n\t\t\tlog.Error(err, \"error executing action\", \"output\", output)\n\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeToolCallResponse, err.Error())\n\t\t\treturn err\n\t\t}\n\n\t\t// Handle timeout message using UI blocks\n\t\tif execResult, ok := output.(*sandbox.ExecResult); ok && execResult != nil && execResult.StreamType == \"timeout\" {\n\t\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, \"\\nTimeout reached after 7 seconds\\n\")\n\t\t}\n\t\t// Add the tool call result to maintain conversation flow\n\t\tvar payload any\n\t\tif c.EnableToolUseShim {\n\t\t\t// Add the error as an observation\n\t\t\tobservation := fmt.Sprintf(\"Result of running %q:\\n%v\",\n\t\t\t\tcall.FunctionCall.Name,\n\t\t\t\toutput)\n\t\t\tc.currChatContent = append(c.currChatContent, observation)\n\t\t\tpayload = observation\n\t\t} else {\n\t\t\t// If shim is disabled, convert the result to a map and append FunctionCallResult\n\t\t\tresult, err := tools.ToolResultToMap(output)\n\t\t\tif err != nil {\n\t\t\t\tlog.Error(err, \"error converting tool result to map\", \"output\", output)\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tpayload = result\n\t\t\tc.currChatContent = append(c.currChatContent, gollm.FunctionCallResult{\n\t\t\t\tID:     call.FunctionCall.ID,\n\t\t\t\tName:   call.FunctionCall.Name,\n\t\t\t\tResult: result,\n\t\t\t})\n\t\t}\n\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeToolCallResponse, payload)\n\t}\n\treturn nil\n}\n\n// The key idea is to treat all tool calls to be executed atomically or not\n// If all tool calls are readonly call, it is straight forward\n// if some of the tool calls are not readonly, then the interesting question is should the permission\n// be asked for each of the tool call or only once for all the tool calls.\n// I think treating all tool calls as atomic is the right thing to do.\n\ntype ToolCallAnalysis struct {\n\tFunctionCall        gollm.FunctionCall\n\tParsedToolCall      *tools.ToolCall\n\tIsInteractive       bool\n\tIsInteractiveError  error\n\tModifiesResourceStr string\n}\n\nfunc (c *Agent) analyzeToolCalls(ctx context.Context, toolCalls []gollm.FunctionCall) ([]ToolCallAnalysis, error) {\n\ttoolCallAnalysis := make([]ToolCallAnalysis, len(toolCalls))\n\tfor i, call := range toolCalls {\n\t\ttoolCallAnalysis[i].FunctionCall = call\n\t\ttoolCall, err := c.Tools.ParseToolInvocation(ctx, call.Name, call.Arguments)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"error parsing tool call: %w\", err)\n\t\t}\n\t\ttoolCallAnalysis[i].IsInteractive, err = toolCall.GetTool().IsInteractive(call.Arguments)\n\t\tif err != nil {\n\t\t\ttoolCallAnalysis[i].IsInteractiveError = err\n\t\t}\n\t\ttoolCallAnalysis[i].ModifiesResourceStr = toolCall.GetTool().CheckModifiesResource(call.Arguments)\n\t\ttoolCallAnalysis[i].ParsedToolCall = toolCall\n\t}\n\treturn toolCallAnalysis, nil\n}\n\nfunc (c *Agent) handleChoice(ctx context.Context, choice *api.UserChoiceResponse) (dispatchToolCalls bool) {\n\tlog := klog.FromContext(ctx)\n\t// if user input is a choice and use has declined the operation,\n\t// we need to abort all pending function calls.\n\t// update the currChatContent with the choice and keep the agent loop running.\n\n\t// Normalize the input\n\tswitch choice.Choice {\n\tcase 1:\n\t\tdispatchToolCalls = true\n\tcase 2:\n\t\tc.SkipPermissions = true\n\t\tdispatchToolCalls = true\n\tcase 3:\n\t\tc.currChatContent = append(c.currChatContent, gollm.FunctionCallResult{\n\t\t\tID:   c.pendingFunctionCalls[0].FunctionCall.ID,\n\t\t\tName: c.pendingFunctionCalls[0].FunctionCall.Name,\n\t\t\tResult: map[string]any{\n\t\t\t\t\"error\":     \"User declined to run this operation.\",\n\t\t\t\t\"status\":    \"declined\",\n\t\t\t\t\"retryable\": false,\n\t\t\t},\n\t\t})\n\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\tdispatchToolCalls = false\n\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, \"Operation was skipped. User declined to run this operation.\")\n\tdefault:\n\t\t// This case should technically not be reachable due to AskForConfirmation loop\n\t\terr := fmt.Errorf(\"invalid confirmation choice: %q\", choice.Choice)\n\t\tlog.Error(err, \"Invalid choice received from AskForConfirmation\")\n\t\tc.pendingFunctionCalls = []ToolCallAnalysis{}\n\t\tdispatchToolCalls = false\n\t\tc.addMessage(api.MessageSourceAgent, api.MessageTypeError, \"Invalid choice received. Cancelling operation.\")\n\t}\n\treturn dispatchToolCalls\n}\n\n// generateFromTemplate generates a prompt for LLM. It uses the prompt from the provides template file or default.\nfunc (a *Agent) generatePrompt(_ context.Context, defaultPromptTemplate string, data PromptData) (string, error) {\n\tpromptTemplate := defaultPromptTemplate\n\tif a.PromptTemplateFile != \"\" {\n\t\tcontent, err := os.ReadFile(a.PromptTemplateFile)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"error reading template file: %v\", err)\n\t\t}\n\t\tpromptTemplate = string(content)\n\t}\n\n\tfor _, extraPromptPath := range a.ExtraPromptPaths {\n\t\tcontent, err := os.ReadFile(extraPromptPath)\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"error reading extra prompt path: %v\", err)\n\t\t}\n\t\tpromptTemplate += \"\\n\" + string(content)\n\t}\n\n\ttmpl, err := template.New(\"promptTemplate\").Parse(promptTemplate)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"building template for prompt: %w\", err)\n\t}\n\n\tvar result strings.Builder\n\terr = tmpl.Execute(&result, &data)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"evaluating template for prompt: %w\", err)\n\t}\n\treturn result.String(), nil\n}\n\n// PromptData represents the structure of the data to be filled into the template.\ntype PromptData struct {\n\tQuery string\n\tTools tools.Tools\n\n\tEnableToolUseShim    bool\n\tSessionIsInteractive bool\n}\n\nfunc (a *PromptData) ToolsAsJSON() string {\n\tvar toolDefinitions []*gollm.FunctionDefinition\n\n\tfor _, tool := range a.Tools.AllTools() {\n\t\ttoolDefinitions = append(toolDefinitions, tool.FunctionDefinition())\n\t}\n\n\tjson, err := json.MarshalIndent(toolDefinitions, \"\", \"  \")\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn string(json)\n}\n\nfunc (a *PromptData) ToolNames() string {\n\treturn strings.Join(a.Tools.Names(), \", \")\n}\n\ntype ReActResponse struct {\n\tThought string  `json:\"thought\"`\n\tAnswer  string  `json:\"answer,omitempty\"`\n\tAction  *Action `json:\"action,omitempty\"`\n}\n\ntype Action struct {\n\tName             string `json:\"name\"`\n\tReason           string `json:\"reason\"`\n\tCommand          string `json:\"command\"`\n\tModifiesResource string `json:\"modifies_resource\"`\n}\n\nfunc extractJSON(s string) (string, bool) {\n\tconst jsonBlockMarker = \"```json\"\n\n\tfirst := strings.Index(s, jsonBlockMarker)\n\tlast := strings.LastIndex(s, \"```\")\n\tif first == -1 || last == -1 || first == last {\n\t\treturn \"\", false\n\t}\n\tdata := s[first+len(jsonBlockMarker) : last]\n\n\treturn data, true\n}\n\n// parseReActResponse parses the LLM response into a ReActResponse struct\n// This function assumes the input contains exactly one JSON code block\n// formatted with ```json and ``` markers. The JSON block is expected to\n// contain a valid ReActResponse object.\nfunc parseReActResponse(input string) (*ReActResponse, error) {\n\tcleaned, found := extractJSON(input)\n\tif !found {\n\t\treturn nil, fmt.Errorf(\"no JSON code block found in %q\", cleaned)\n\t}\n\n\tcleaned = strings.ReplaceAll(cleaned, \"\\n\", \"\")\n\tcleaned = strings.TrimSpace(cleaned)\n\n\tvar reActResp ReActResponse\n\tif err := json.Unmarshal([]byte(cleaned), &reActResp); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing JSON %q: %w\", cleaned, err)\n\t}\n\treturn &reActResp, nil\n}\n\n// toMap converts the value to a map, going via JSON\nfunc toMap(v any) (map[string]any, error) {\n\tj, err := json.Marshal(v)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting %T to json: %w\", v, err)\n\t}\n\tm := make(map[string]any)\n\tif err := json.Unmarshal(j, &m); err != nil {\n\t\treturn nil, fmt.Errorf(\"converting json to map: %w\", err)\n\t}\n\treturn m, nil\n}\n\nfunc candidateToShimCandidate(iterator gollm.ChatResponseIterator) (gollm.ChatResponseIterator, error) {\n\treturn func(yield func(gollm.ChatResponse, error) bool) {\n\t\tbuffer := \"\"\n\t\tfor response, err := range iterator {\n\t\t\tif err != nil {\n\t\t\t\tyield(nil, err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif len(response.Candidates()) == 0 {\n\t\t\t\tyield(nil, fmt.Errorf(\"no candidates in LLM response\"))\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tcandidate := response.Candidates()[0]\n\n\t\t\tfor _, part := range candidate.Parts() {\n\t\t\t\tif text, ok := part.AsText(); ok {\n\t\t\t\t\tbuffer += text\n\t\t\t\t\tklog.Infof(\"text is %q\", text)\n\t\t\t\t} else {\n\t\t\t\t\tyield(nil, fmt.Errorf(\"no text part found in candidate\"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif buffer == \"\" {\n\t\t\tyield(nil, nil)\n\t\t\treturn\n\t\t}\n\n\t\tparsedReActResp, err := parseReActResponse(buffer)\n\t\tif err != nil {\n\t\t\tyield(nil, fmt.Errorf(\"parsing ReAct response %q: %w\", buffer, err))\n\t\t\treturn\n\t\t}\n\t\tbuffer = \"\" // TODO: any trailing text?\n\t\tyield(&ShimResponse{candidate: parsedReActResp}, nil)\n\t}, nil\n}\n\ntype ShimResponse struct {\n\tcandidate *ReActResponse\n}\n\nfunc (r *ShimResponse) UsageMetadata() any {\n\treturn nil\n}\n\nfunc (r *ShimResponse) Candidates() []gollm.Candidate {\n\treturn []gollm.Candidate{&ShimCandidate{candidate: r.candidate}}\n}\n\ntype ShimCandidate struct {\n\tcandidate *ReActResponse\n}\n\nfunc (c *ShimCandidate) String() string {\n\treturn fmt.Sprintf(\"Thought: %s\\nAnswer: %s\\nAction: %s\", c.candidate.Thought, c.candidate.Answer, c.candidate.Action)\n}\n\nfunc (c *ShimCandidate) Parts() []gollm.Part {\n\tvar parts []gollm.Part\n\tif c.candidate.Thought != \"\" {\n\t\tparts = append(parts, &ShimPart{text: c.candidate.Thought})\n\t}\n\tif c.candidate.Answer != \"\" {\n\t\tparts = append(parts, &ShimPart{text: c.candidate.Answer})\n\t}\n\tif c.candidate.Action != nil {\n\t\tparts = append(parts, &ShimPart{action: c.candidate.Action})\n\t}\n\treturn parts\n}\n\ntype ShimPart struct {\n\ttext   string\n\taction *Action\n}\n\nfunc (p *ShimPart) AsText() (string, bool) {\n\treturn p.text, p.text != \"\"\n}\n\nfunc (p *ShimPart) AsFunctionCalls() ([]gollm.FunctionCall, bool) {\n\tif p.action != nil {\n\t\tfunctionCallArgs, err := toMap(p.action)\n\t\tif err != nil {\n\t\t\treturn nil, false\n\t\t}\n\t\tdelete(functionCallArgs, \"name\") // passed separately\n\t\t// delete(functionCallArgs, \"reason\")\n\t\t// delete(functionCallArgs, \"modifies_resource\")\n\t\treturn []gollm.FunctionCall{\n\t\t\t{\n\t\t\t\tName:      p.action.Name,\n\t\t\t\tArguments: functionCallArgs,\n\t\t\t},\n\t\t}, true\n\t}\n\treturn nil, false\n}\n"
  },
  {
    "path": "pkg/agent/conversation_test.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage agent\n\nimport (\n\t\"context\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/internal/mocks\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sessions\"\n\t\"go.uber.org/mock/gomock\"\n)\n\nfunc TestHandleMetaQuery(t *testing.T) {\n\tctx := context.Background()\n\n\ttests := []struct {\n\t\tname         string\n\t\tquery        string\n\t\texpectations func(t *testing.T) *Agent\n\t\tverify       func(t *testing.T, a *Agent, answer string)\n\t\texpect       string\n\t}{\n\t\t{\n\t\t\tname:   \"clear (shows store before/after with mocked model + tool outputs)\",\n\t\t\tquery:  \"clear\",\n\t\t\texpect: \"Cleared the conversation.\",\n\t\t\texpectations: func(t *testing.T) *Agent {\n\t\t\t\tctrl := gomock.NewController(t)\n\t\t\t\tt.Cleanup(ctrl.Finish)\n\n\t\t\t\tstore := sessions.NewInMemoryChatStore()\n\n\t\t\t\tchat := mocks.NewMockChat(ctrl)\n\t\t\t\tchat.EXPECT().Initialize([]*api.Message{}).Times(1)\n\n\t\t\t\tmt := mocks.NewMockTool(ctrl)\n\t\t\t\tmt.EXPECT().Name().Return(\"mock namespace tool\").AnyTimes()\n\t\t\t\tmt.EXPECT().FunctionDefinition().Return(&gollm.FunctionDefinition{\n\t\t\t\t\tName:        \"mock namespace tool\",\n\t\t\t\t\tDescription: \"Inspect current Kubernetes namespace\",\n\t\t\t\t}).AnyTimes()\n\n\t\t\t\tconst toolResult = `{\"namespace\":\"test-namespace\"}`\n\n\t\t\t\tmt.EXPECT().Run(gomock.Any(), gomock.Any()).\n\t\t\t\t\tReturn(toolResult, nil).Times(1)\n\n\t\t\t\tconst modelText = \"The current namespace is test-namespace.\"\n\n\t\t\t\t// user message\n\t\t\t\t_ = store.AddChatMessage(&api.Message{\n\t\t\t\t\tID:      \"u1\",\n\t\t\t\t\tSource:  api.MessageSourceUser,\n\t\t\t\t\tType:    api.MessageTypeText,\n\t\t\t\t\tPayload: \"What's my current namespace?\",\n\t\t\t\t})\n\n\t\t\t\t// model response\n\t\t\t\t_ = store.AddChatMessage(&api.Message{\n\t\t\t\t\tID:      \"a1\",\n\t\t\t\t\tSource:  api.MessageSourceAgent,\n\t\t\t\t\tType:    api.MessageTypeText,\n\t\t\t\t\tPayload: modelText,\n\t\t\t\t})\n\n\t\t\t\t// tool call result\n\t\t\t\tif out, err := mt.Run(ctx, map[string]any{}); err == nil {\n\t\t\t\t\t_ = store.AddChatMessage(&api.Message{\n\t\t\t\t\t\tID:      \"t1\",\n\t\t\t\t\t\tSource:  api.MessageSourceAgent,\n\t\t\t\t\t\tType:    api.MessageTypeText,\n\t\t\t\t\t\tPayload: out,\n\t\t\t\t\t})\n\t\t\t\t} else {\n\t\t\t\t\tt.Fatalf(\"mock tool run failed: %v\", err)\n\t\t\t\t}\n\n\t\t\t\tif got := len(store.ChatMessages()); got != 3 {\n\t\t\t\t\tt.Fatalf(\"precondition: expected 3 messages before clear, got %d\", got)\n\t\t\t\t}\n\n\t\t\t\ta := &Agent{llmChat: chat}\n\t\t\t\ta.Session = &api.Session{ChatMessageStore: store}\n\n\t\t\t\treturn a\n\t\t\t},\n\t\t\tverify: func(t *testing.T, a *Agent, _ string) {\n\t\t\t\tif got := len(a.Session.ChatMessageStore.ChatMessages()); got != 0 {\n\t\t\t\t\tt.Fatalf(\"expected store to be empty after clear, got %d\", got)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"exit\",\n\t\t\tquery:  \"exit\",\n\t\t\texpect: \"It has been a pleasure assisting you. Have a great day!\",\n\t\t\texpectations: func(t *testing.T) *Agent {\n\t\t\t\ta := &Agent{}\n\t\t\t\ta.Session = &api.Session{}\n\t\t\t\treturn a\n\t\t\t},\n\t\t\tverify: func(t *testing.T, a *Agent, _ string) {\n\t\t\t\tif a.AgentState() != api.AgentStateExited {\n\t\t\t\t\tt.Fatalf(\"expected agent to exit\")\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"model\",\n\t\t\tquery:  \"model\",\n\t\t\texpect: \"Current model is `test-model`\",\n\t\t\texpectations: func(t *testing.T) *Agent {\n\t\t\t\ta := &Agent{Model: \"test-model\"}\n\t\t\t\ta.Session = &api.Session{}\n\t\t\t\treturn a\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"models\",\n\t\t\tquery:  \"models\",\n\t\t\texpect: \"Available models:\\n\\n  - a\\n  - b\\n\\n\",\n\t\t\texpectations: func(t *testing.T) *Agent {\n\t\t\t\tctrl := gomock.NewController(t)\n\t\t\t\tt.Cleanup(ctrl.Finish)\n\t\t\t\tllm := mocks.NewMockClient(ctrl)\n\t\t\t\tllm.EXPECT().ListModels(ctx).Return([]string{\"a\", \"b\"}, nil)\n\n\t\t\t\ta := &Agent{LLM: llm}\n\t\t\t\ta.Session = &api.Session{}\n\t\t\t\treturn a\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"tools\",\n\t\t\tquery:  \"tools\",\n\t\t\texpect: \"Available tools:\",\n\t\t\texpectations: func(t *testing.T) *Agent {\n\t\t\t\tctrl := gomock.NewController(t)\n\t\t\t\tt.Cleanup(ctrl.Finish)\n\n\t\t\t\tmt := mocks.NewMockTool(ctrl)\n\t\t\t\tmt.EXPECT().Name().Return(\"mocktool\").AnyTimes()\n\t\t\t\tmt.EXPECT().FunctionDefinition().Return(&gollm.FunctionDefinition{\n\t\t\t\t\tName:        \"mocktool\",\n\t\t\t\t\tDescription: \"Mocked tool for tests\",\n\t\t\t\t}).AnyTimes()\n\n\t\t\t\ta := &Agent{}\n\n\t\t\t\ta.Tools.Init()\n\t\t\t\ta.Tools.RegisterTool(mt)\n\t\t\t\ta.Session = &api.Session{}\n\t\t\t\treturn a\n\t\t\t},\n\t\t\tverify: func(t *testing.T, _ *Agent, answer string) {\n\t\t\t\tif !strings.Contains(answer, \"mocktool\") {\n\t\t\t\t\tt.Fatalf(\"expected kubectl tool in output: %q\", answer)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"session\",\n\t\t\tquery:  \"session\",\n\t\t\texpect: \"Session ID:\",\n\t\t\texpectations: func(t *testing.T) *Agent {\n\t\t\t\toldHome := os.Getenv(\"HOME\")\n\t\t\t\tt.Cleanup(func() { os.Setenv(\"HOME\", oldHome) })\n\t\t\t\thome := t.TempDir()\n\t\t\t\tos.Setenv(\"HOME\", home)\n\n\t\t\t\tmanager, err := sessions.NewSessionManager(\"memory\")\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"creating session manager: %v\", err)\n\t\t\t\t}\n\t\t\t\tsess, err := manager.NewSession(sessions.Metadata{ProviderID: \"p\", ModelID: \"m\"})\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"creating session: %v\", err)\n\t\t\t\t}\n\t\t\t\ta := &Agent{ChatMessageStore: sess.ChatMessageStore, SessionBackend: \"filesystem\"}\n\t\t\t\ta.Session = sess\n\t\t\t\treturn a\n\t\t\t},\n\t\t\tverify: func(t *testing.T, _ *Agent, answer string) {\n\t\t\t\tif !strings.Contains(answer, \"ID:\") {\n\t\t\t\t\tt.Fatalf(\"expected session info, got %q\", answer)\n\t\t\t\t}\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:   \"sessions\",\n\t\t\tquery:  \"sessions\",\n\t\t\texpect: \"Available sessions:\",\n\t\t\texpectations: func(t *testing.T) *Agent {\n\t\t\t\toldHome := os.Getenv(\"HOME\")\n\t\t\t\tt.Cleanup(func() { os.Setenv(\"HOME\", oldHome) })\n\t\t\t\thome := t.TempDir()\n\t\t\t\tos.Setenv(\"HOME\", home)\n\n\t\t\t\tmanager, err := sessions.NewSessionManager(\"memory\")\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Fatalf(\"creating session manager: %v\", err)\n\t\t\t\t}\n\t\t\t\tif _, err := manager.NewSession(sessions.Metadata{ProviderID: \"p1\", ModelID: \"m1\"}); err != nil {\n\t\t\t\t\tt.Fatalf(\"creating session: %v\", err)\n\t\t\t\t}\n\n\t\t\t\ta := &Agent{SessionBackend: \"memory\"}\n\t\t\t\ta.Session = &api.Session{ChatMessageStore: sessions.NewInMemoryChatStore()}\n\t\t\t\treturn a\n\t\t\t},\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ta := tt.expectations(t)\n\t\t\tans, handled, err := a.handleMetaQuery(ctx, tt.query)\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"handleMetaQuery returned error: %v\", err)\n\t\t\t}\n\t\t\tif !handled {\n\t\t\t\tt.Fatalf(\"expected query %q to be handled\", tt.query)\n\t\t\t}\n\t\t\tif tt.expect != \"\" && !strings.Contains(ans, tt.expect) {\n\t\t\t\tt.Fatalf(\"expected %q to contain %q\", ans, tt.expect)\n\t\t\t}\n\t\t\tif tt.verify != nil {\n\t\t\t\ttt.verify(t, a, ans)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestAgent_NewSession(t *testing.T) {\n\t// Setup\n\tmanager, err := sessions.NewSessionManager(\"memory\")\n\tif err != nil {\n\t\tt.Fatalf(\"creating session manager: %v\", err)\n\t}\n\n\t// Create initial session\n\tsess1, err := manager.NewSession(sessions.Metadata{})\n\tif err != nil {\n\t\tt.Fatalf(\"creating session 1: %v\", err)\n\t}\n\n\ta := &Agent{\n\t\tSessionBackend: \"memory\",\n\t}\n\ta.Session = sess1\n\n\t// Call NewSession\n\tnewID, err := a.NewSession()\n\tif err != nil {\n\t\tt.Fatalf(\"NewSession failed: %v\", err)\n\t}\n\n\tif newID == sess1.ID {\n\t\tt.Fatalf(\"expected new session ID to be different from old one\")\n\t}\n\n\tif a.Session.ID != newID {\n\t\tt.Fatalf(\"agent session ID mismatch: got %s, want %s\", a.Session.ID, newID)\n\t}\n}\n\nfunc TestAgent_LoadSession_ResetsState(t *testing.T) {\n\t// Setup\n\tmanager, err := sessions.NewSessionManager(\"memory\")\n\tif err != nil {\n\t\tt.Fatalf(\"creating session manager: %v\", err)\n\t}\n\n\t// Create a session in \"running\" state\n\tsess1, err := manager.NewSession(sessions.Metadata{})\n\tif err != nil {\n\t\tt.Fatalf(\"creating session 1: %v\", err)\n\t}\n\tsess1.AgentState = api.AgentStateRunning\n\tif err := manager.UpdateLastAccessed(sess1); err != nil {\n\t\tt.Fatalf(\"updating session: %v\", err)\n\t}\n\n\ta := &Agent{\n\t\tSessionBackend: \"memory\",\n\t}\n\n\t// Load the session\n\tif err := a.LoadSession(sess1.ID); err != nil {\n\t\tt.Fatalf(\"LoadSession failed: %v\", err)\n\t}\n\n\t// Verify state is reset to idle\n\tif a.Session.AgentState != api.AgentStateIdle {\n\t\tt.Errorf(\"expected agent state to be idle, got %s\", a.Session.AgentState)\n\t}\n}\n\nfunc TestAgent_Init_CreatesSessionInStore(t *testing.T) {\n\tctrl := gomock.NewController(t)\n\tdefer ctrl.Finish()\n\n\tmockClient := mocks.NewMockClient(ctrl)\n\tmockChat := mocks.NewMockChat(ctrl)\n\n\t// Expect StartChat to be called\n\tmockClient.EXPECT().StartChat(gomock.Any(), gomock.Any()).Return(mockChat)\n\t// Expect Initialize to be called\n\tmockChat.EXPECT().Initialize(gomock.Any()).Return(nil)\n\t// Expect SetFunctionDefinitions to be called\n\tmockChat.EXPECT().SetFunctionDefinitions(gomock.Any()).Return(nil)\n\n\t// Setup\n\tsession := &api.Session{\n\t\tID:               \"test-session\",\n\t\tAgentState:       api.AgentStateIdle,\n\t\tChatMessageStore: sessions.NewInMemoryChatStore(),\n\t}\n\n\ta := &Agent{\n\t\tSessionBackend: \"memory\",\n\t\t// Init requires these\n\t\tInput:   make(chan any),\n\t\tOutput:  make(chan any),\n\t\tLLM:     mockClient,\n\t\tSession: session,\n\t}\n\n\tif err := a.Init(context.Background()); err != nil {\n\t\tt.Fatalf(\"Init failed: %v\", err)\n\t}\n\n\tif a.Session != session {\n\t\tt.Errorf(\"expected agent to use provided session\")\n\t}\n}\n\nfunc TestAgent_NewSession_NoDeadlock(t *testing.T) {\n\tctrl := gomock.NewController(t)\n\tdefer ctrl.Finish()\n\n\tmockClient := mocks.NewMockClient(ctrl)\n\tmockChat := mocks.NewMockChat(ctrl)\n\n\t// Expect StartChat to be called for initial session only\n\tmockClient.EXPECT().StartChat(gomock.Any(), gomock.Any()).Return(mockChat).Times(1)\n\t// Expect Initialize to be called for initial session AND new session (and maybe more?)\n\tmockChat.EXPECT().Initialize(gomock.Any()).Return(nil).AnyTimes()\n\t// Expect SetFunctionDefinitions to be called for initial session only\n\tmockChat.EXPECT().SetFunctionDefinitions(gomock.Any()).Return(nil).Times(1)\n\n\t// Setup\n\tsession := &api.Session{\n\t\tID:               \"initial-session\",\n\t\tAgentState:       api.AgentStateIdle,\n\t\tChatMessageStore: sessions.NewInMemoryChatStore(),\n\t}\n\n\ta := &Agent{\n\t\tSessionBackend: \"memory\",\n\t\tInput:          make(chan any),\n\t\tOutput:         make(chan any),\n\t\tLLM:            mockClient,\n\t\tSession:        session,\n\t}\n\n\t// Init\n\tif err := a.Init(context.Background()); err != nil {\n\t\tt.Fatalf(\"Init failed: %v\", err)\n\t}\n\n\t// Create new session\n\t// This should not deadlock\n\tdone := make(chan struct{})\n\tgo func() {\n\t\tif _, err := a.NewSession(); err != nil {\n\t\t\tt.Errorf(\"NewSession failed: %v\", err)\n\t\t}\n\t\tclose(done)\n\t}()\n\n\tselect {\n\tcase <-done:\n\t\t// Success\n\tcase <-time.After(2 * time.Second):\n\t\tt.Fatal(\"NewSession timed out (potential deadlock)\")\n\t}\n}\n"
  },
  {
    "path": "pkg/agent/manager.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage agent\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sessions\"\n\t\"k8s.io/klog/v2\"\n)\n\n// Factory is a function that creates a new Agent instance.\ntype Factory func(context.Context) (*Agent, error)\n\n// AgentManager manages the lifecycle of agents and their sessions.\ntype AgentManager struct {\n\tfactory        Factory\n\tsessionManager *sessions.SessionManager\n\tagents         map[string]*Agent // sessionID -> agent\n\tmu             sync.RWMutex\n\tonAgentCreated func(*Agent)\n}\n\n// NewAgentManager creates a new Manager.\nfunc NewAgentManager(factory Factory, sessionManager *sessions.SessionManager) *AgentManager {\n\treturn &AgentManager{\n\t\tfactory:        factory,\n\t\tsessionManager: sessionManager,\n\t\tagents:         make(map[string]*Agent),\n\t}\n}\n\n// SetAgentCreatedCallback sets the callback to be called when a new agent is created.\n// It also calls the callback immediately for all currently active agents.\nfunc (sm *AgentManager) SetAgentCreatedCallback(cb func(*Agent)) {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\tsm.onAgentCreated = cb\n\tfor _, agent := range sm.agents {\n\t\tcb(agent)\n\t}\n}\n\n// GetAgent returns the agent for the given session ID, loading it if necessary.\nfunc (sm *AgentManager) GetAgent(ctx context.Context, sessionID string) (*Agent, error) {\n\tsm.mu.RLock()\n\tagent, ok := sm.agents[sessionID]\n\tsm.mu.RUnlock()\n\n\tif ok {\n\t\treturn agent, nil\n\t}\n\n\tsession, err := sm.sessionManager.FindSessionByID(sessionID)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"session not found: %w\", err)\n\t}\n\n\tnewAgent, err := sm.factory(ctx)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"creating agent: %w\", err)\n\t}\n\n\treturn sm.startAgent(ctx, session, newAgent)\n}\n\n// Close closes all active agents.\nfunc (sm *AgentManager) Close() error {\n\tsm.mu.Lock()\n\tdefer sm.mu.Unlock()\n\n\tfor id, agent := range sm.agents {\n\t\tklog.Infof(\"Closing agent for session %s\", id)\n\t\tif err := agent.Close(); err != nil {\n\t\t\tklog.Errorf(\"Error closing agent %s: %v\", id, err)\n\t\t}\n\t}\n\t// Clear the map\n\tsm.agents = make(map[string]*Agent)\n\treturn nil\n}\n\n// ListSessions delegates to the underlying store.\nfunc (sm *AgentManager) ListSessions() ([]*api.Session, error) {\n\treturn sm.sessionManager.ListSessions()\n}\n\n// FindSessionByID delegates to the underlying store.\nfunc (sm *AgentManager) FindSessionByID(id string) (*api.Session, error) {\n\treturn sm.sessionManager.FindSessionByID(id)\n}\n\n// DeleteSession delegates to the underlying store and closes the active agent if any.\nfunc (sm *AgentManager) DeleteSession(id string) error {\n\tsm.mu.Lock()\n\tif agent, ok := sm.agents[id]; ok {\n\t\tagent.Close()\n\t\tdelete(sm.agents, id)\n\t}\n\tsm.mu.Unlock()\n\treturn sm.sessionManager.DeleteSession(id)\n}\n\n// UpdateLastAccessed delegates to the underlying store.\nfunc (sm *AgentManager) UpdateLastAccessed(session *api.Session) error {\n\treturn sm.sessionManager.UpdateLastAccessed(session)\n}\n\nfunc (sm *AgentManager) startAgent(ctx context.Context, session *api.Session, agent *Agent) (*Agent, error) {\n\tagent.Session = session\n\n\tif err := agent.Init(ctx); err != nil {\n\t\treturn nil, fmt.Errorf(\"initializing agent: %w\", err)\n\t}\n\n\tagentCtx, cancel := context.WithCancel(context.Background())\n\tagent.cancel = cancel\n\n\tif err := agent.Run(agentCtx, \"\"); err != nil {\n\t\tcancel()\n\t\treturn nil, fmt.Errorf(\"starting agent loop: %w\", err)\n\t}\n\n\tsm.mu.Lock()\n\tsm.agents[session.ID] = agent\n\tif sm.onAgentCreated != nil {\n\t\tsm.onAgentCreated(agent)\n\t}\n\tsm.mu.Unlock()\n\n\treturn agent, nil\n}\n"
  },
  {
    "path": "pkg/agent/mcp_client.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//      http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage agent\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/mcp\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/tools\"\n\t\"k8s.io/klog/v2\"\n)\n\n// InitializeMCPClient initializes MCP client functionality for the agent.\n// It connects to servers and registers discovered tools with the kubectl-ai tool system.\nfunc (a *Agent) InitializeMCPClient(ctx context.Context) error {\n\t// Initialize the MCP manager\n\tmanager, err := mcp.InitializeManager()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to initialize MCP manager: %w\", err)\n\t}\n\n\t// Connect to servers and register tools\n\terr = manager.RegisterWithToolSystem(ctx, func(serverName string, toolInfo mcp.Tool) error {\n\t\t// Create schema for the tool\n\t\tschema, err := tools.ConvertToolToGollm(&toolInfo)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// Create an MCPTool wrapper first to get the unique name\n\t\tmcpTool := tools.NewMCPTool(serverName, toolInfo.Name, toolInfo.Description, schema, manager)\n\n\t\t// Update schema with unique name and better description to avoid conflicts\n\t\tschema.Name = mcpTool.UniqueToolName()\n\t\tschema.Description = fmt.Sprintf(\"%s (from %s)\", toolInfo.Description, serverName)\n\n\t\t// Create and register MCP tool wrapper\n\t\ttools.RegisterTool(mcpTool)\n\t\treturn nil\n\t})\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to register MCP tools: %w\", err)\n\t}\n\n\t// Store the manager for later use\n\ta.mcpManager = manager\n\n\treturn nil\n}\n\n// UpdateMCPStatus updates the MCP status in the agent's session\nfunc (a *Agent) UpdateMCPStatus(ctx context.Context, mcpClientEnabled bool) error {\n\tif a.mcpManager == nil && !mcpClientEnabled {\n\t\t// No MCP functionality requested\n\t\treturn nil\n\t}\n\n\tstatus, err := a.getMCPStatus(ctx, mcpClientEnabled)\n\tif err != nil {\n\t\tklog.Errorf(\"Failed to get MCP server status: %v\", err)\n\t\treturn err\n\t}\n\n\t// Update the session with MCP status\n\ta.sessionMu.Lock()\n\tdefer a.sessionMu.Unlock()\n\ta.Session.MCPStatus = status\n\n\treturn nil\n}\n\n// getMCPStatus retrieves the current MCP status\nfunc (a *Agent) getMCPStatus(ctx context.Context, mcpClientEnabled bool) (*api.MCPStatus, error) {\n\tvar mcpStatus *mcp.MCPStatus\n\tvar err error\n\n\tif mcpClientEnabled && a.mcpManager != nil {\n\t\t// In client mode, use the provided manager\n\t\tmcpStatus, err = a.mcpManager.GetStatus(ctx, mcpClientEnabled)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t} else {\n\t\t// Create minimal status\n\t\tmcpStatus = &mcp.MCPStatus{\n\t\t\tClientEnabled: mcpClientEnabled,\n\t\t}\n\t}\n\n\t// Convert from mcp.MCPStatus to api.MCPStatus\n\treturn a.convertMCPStatus(mcpStatus), nil\n}\n\n// convertMCPStatus converts from mcp.MCPStatus to api.MCPStatus\nfunc (a *Agent) convertMCPStatus(mcpStatus *mcp.MCPStatus) *api.MCPStatus {\n\tif mcpStatus == nil {\n\t\treturn nil\n\t}\n\n\tapiStatus := &api.MCPStatus{\n\t\tTotalServers:   mcpStatus.TotalServers,\n\t\tConnectedCount: mcpStatus.ConnectedCount,\n\t\tFailedCount:    mcpStatus.FailedCount,\n\t\tTotalTools:     mcpStatus.TotalTools,\n\t\tClientEnabled:  mcpStatus.ClientEnabled,\n\t}\n\n\t// Convert server connection info\n\tfor _, server := range mcpStatus.ServerInfoList {\n\t\tapiServerInfo := api.ServerConnectionInfo{\n\t\t\tName:        server.Name,\n\t\t\tCommand:     server.Command,\n\t\t\tIsLegacy:    server.IsLegacy,\n\t\t\tIsConnected: server.IsConnected,\n\t\t}\n\n\t\t// Convert tools\n\t\tfor _, tool := range server.AvailableTools {\n\t\t\tapiTool := api.MCPTool{\n\t\t\t\tName:        tool.Name,\n\t\t\t\tDescription: tool.Description,\n\t\t\t\tServer:      tool.Server,\n\t\t\t}\n\t\t\tapiServerInfo.AvailableTools = append(apiServerInfo.AvailableTools, apiTool)\n\t\t}\n\n\t\tapiStatus.ServerInfoList = append(apiStatus.ServerInfoList, apiServerInfo)\n\t}\n\n\treturn apiStatus\n}\n\n// GetMCPStatusText returns a formatted text representation of the MCP status\n// This can be used by UIs that want to display the status as text\nfunc (a *Agent) GetMCPStatusText() string {\n\ta.sessionMu.Lock()\n\tdefer a.sessionMu.Unlock()\n\tif a.Session.MCPStatus == nil {\n\t\treturn \"\"\n\t}\n\n\tvar statusText strings.Builder\n\n\tstatus := a.Session.MCPStatus\n\n\t// Add summary text\n\tif status.ClientEnabled && status.ConnectedCount > 0 {\n\t\tstatusText.WriteString(fmt.Sprintf(\"Successfully connected to %d MCP server(s) (%d tools discovered)\\n\\n\",\n\t\t\tstatus.ConnectedCount, status.TotalTools))\n\t} else if status.ClientEnabled {\n\t\tstatusText.WriteString(\"No MCP servers connected\\n\\n\")\n\t} else if status.TotalServers > 0 {\n\t\tstatusText.WriteString(fmt.Sprintf(\"%d MCP servers configured (client mode disabled)\\n\\n\",\n\t\t\tstatus.TotalServers))\n\t} else {\n\t\tstatusText.WriteString(\"No MCP servers configured\\n\\n\")\n\t}\n\n\t// Add server details\n\tfor _, server := range status.ServerInfoList {\n\t\tconnectionStatus := \"Disconnected\"\n\t\tif server.IsConnected {\n\t\t\tconnectionStatus = \"Connected\"\n\t\t}\n\n\t\t// Get tool names if available\n\t\tvar toolNames []string\n\t\tfor _, tool := range server.AvailableTools {\n\t\t\ttoolNames = append(toolNames, tool.Name)\n\t\t}\n\n\t\t// Format server details\n\t\tstatusText.WriteString(\"    • \") // Bullet point with indentation\n\t\tstatusText.WriteString(fmt.Sprintf(\"%s (%s) - %s\",\n\t\t\tserver.Name,\n\t\t\textractCommandName(server.Command),\n\t\t\tconnectionStatus))\n\n\t\tif len(toolNames) > 0 {\n\t\t\tstatusText.WriteString(fmt.Sprintf(\", Tools: %s\", strings.Join(toolNames, \", \")))\n\t\t}\n\n\t\tstatusText.WriteString(\"\\n\")\n\t}\n\n\treturn statusText.String()\n}\n\n// extractCommandName gets the base command from a command string\nfunc extractCommandName(command string) string {\n\tif command == \"\" {\n\t\treturn \"remote\" // Return 'remote' for HTTP-based servers\n\t}\n\n\tparts := strings.Fields(command)\n\tif len(parts) > 0 {\n\t\treturn parts[0]\n\t}\n\n\treturn command\n}\n\n// CloseMCPClient closes the MCP client connections\nfunc (a *Agent) CloseMCPClient() error {\n\tif a.mcpManager != nil {\n\t\terr := a.mcpManager.Close()\n\t\ta.mcpManager = nil\n\t\treturn err\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/agent/systemprompt_template_default.txt",
    "content": "You are `kubectl-ai`, an AI assistant with expertise in operating and performing actions against a kubernetes cluster. Your task is to assist with kubernetes-related questions, debugging, performing actions on user's kubernetes cluster.\n\n{{if .EnableToolUseShim }}\n## Available tools\n<tools>\n{{.ToolsAsJSON}}\n</tools>\n\n## Instructions:\n1. Analyze the query, previous reasoning steps, and observations.\n2. Reflect on 5-7 different ways to solve the given query or task. Think carefully about each solution before picking the best one. If you haven't solved the problem completely, and have an option to explore further, or require input from the user, try to proceed without user's input because you are an autonomous agent.\n3. Decide on the next action: use a tool or provide a final answer and respond in the following JSON format:\n\nIf you need to use a tool:\n```json\n{\n    \"thought\": \"Your detailed reasoning about what to do next\",\n    \"action\": {\n        \"name\": \"Tool name ({{.ToolNames}})\",\n        \"reason\": \"Explanation of why you chose this tool (not more than 100 words)\",\n        \"command\": \"Complete command to be executed. For example, 'kubectl get pods', 'kubectl get ns'\",\n        \"modifies_resource\": \"Whether the command modifies a kubernetes resource. Possible values are 'yes' or 'no' or 'unknown'\"\n    }\n}\n```\n\nIf you have enough information to answer the query:\n```json\n{\n    \"thought\": \"Your final reasoning process\",\n    \"answer\": \"Your comprehensive answer to the query\"\n}\n```\n{{else}}\n## Instructions:\n- Examine current state of kubernetes resources relevant to user's query.\n- Analyze the query, previous reasoning steps, and observations.\n- Reflect on 5-7 different ways to solve the given query or task. Think carefully about each solution before picking the best one. If you haven't solved the problem completely, and have an option to explore further, or require input from the user, try to proceed without user's input because you are an autonomous agent.\n- Decide on the next action: use a tool or provide a final answer.\n{{end}}\n\n## Command Structuring Guidelines:\n**IMPORTANT:**\n- When generating kubectl commands, ALWAYS place the verb (e.g., get, apply, delete) immediately after `kubectl`.\n- Example:\n  - ✅ Correct: `kubectl get pods`\n  - ✅ Correct: `kubectl get pods --all-namespaces`\n  - ❌ Incorrect: `get pods`\n  - ❌ Incorrect: `get pods --all-namespaces`\n- Do NOT place flags or options before the verb.\n- Example:\n  - ✅ Correct: `kubectl get pods --namespace=default`\n  - ❌ Incorrect: `kubectl --namespace=default get pods`\n- This ensures commands are properly recognized and filtered by the system.\n- Prefer the command that does not require any interactive input.\n\n\n{{if .SessionIsInteractive}}\n## Resource Manifest Generation Guidelines:\n**CRITICAL**: NEVER generate or create Kubernetes manifests without FIRST gathering ALL required specifics from the user and cluster state. This is a MANDATORY step that cannot be skipped.\n\n### MANDATORY Information Collection Process:\nBefore creating ANY manifest, you MUST:\n\n1. **Check Cluster State**:\n   - Run `kubectl get namespaces` to show available namespaces\n   - Run `kubectl get nodes` to understand cluster capacity\n   - Run `kubectl get storageclass` if storage is involved\n   - Check existing resources with relevant `kubectl get` commands\n\n2. **Ask User for Missing Specifics** (DO NOT assume defaults):\n   - **Namespace**: \"Which namespace should I deploy this to?\" (show available options)\n   - **Container Images**: \"Which specific image version should I use?\" (e.g., postgres:14, postgres:15, postgres:latest)\n   - **Storage Size**: \"How much storage do you need?\" (if persistent storage required)\n   - **Resource Limits**: \"What CPU/memory limits should I set?\"\n   - **Service Exposure**: \"How should this be exposed?\" (ClusterIP, NodePort, LoadBalancer)\n   - **Environment Variables**: \"Do you need any specific environment variables or configurations?\"\n   - **Security**: \"Do you need specific passwords, secrets, or service accounts?\"\n\n3. **Present Summary for Confirmation**:\n   After gathering details, present a summary like:\n   ```\n   **Deployment Summary:**\n   - Namespace: [specified namespace]\n   - Image: [specific image:tag]\n   - Storage: [size] with [storage class]\n   - Resources: [CPU/memory limits]\n   - Service: [exposure type]\n   - Security: [password/secret configuration]\n\n   Should I proceed with creating these resources? Please confirm.\n   ```\n\n### STRICT Manifest Creation Rules:\n- **NEVER** generate manifests with assumed defaults without user confirmation\n- **NEVER** skip the information gathering phase\n- **NEVER** proceed without explicit user confirmation of the configuration\n- **ALWAYS** ask specific questions about unclear requirements\n- **ALWAYS** show available options (namespaces, storage classes, etc.)\n- **ALWAYS** confirm the final configuration before creating resources\n\n### Required Information to Collect:\n1. **Namespace**: Check existing namespaces and ask which namespace to use if not specified\n2. **Container Images**:\n   - Verify image availability and tags\n   - Check for specific version requirements\n   - Validate image registry accessibility\n3. **Ports and Services**:\n   - Identify required container ports\n   - Determine service type (ClusterIP, NodePort, LoadBalancer)\n   - Check for existing services that might conflict\n4. **Resource Requirements**:\n   - CPU and memory requests/limits\n   - Storage requirements (PVCs, volumes)\n   - Node selection criteria (selectors, affinity)\n5. **Environment Configuration**:\n   - Required environment variables\n   - ConfigMaps and Secrets needed\n   - Service accounts and RBAC requirements\n6. **Dependencies**:\n   - Check for existing resources that need to be referenced\n   - Verify network policies don't block connections\n   - Ensure required CRDs are installed\n{{end}}\n\n## Remember:\n- Fetch current state of kubernetes resources relevant to user's query.\n- If using a kubectl command ensure that verb is always prefixed by `kubectl`.\n- Prefer the tool usage that does not require any interactive input.\n- For creating new resources, try to create the resource using the tools available. DO NOT ask the user to create the resource.\n- Use tools when you need more information. Do not respond with the instructions on how to use the tools or what commands to run, instead just use the tool.\n- Provide a final answer only when you're confident you have sufficient information.\n- Provide clear, concise, and accurate responses.\n- Feel free to respond with emojis where appropriate.\n"
  },
  {
    "path": "pkg/api/models.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage api\n\nimport (\n\t\"fmt\"\n\t\"time\"\n)\n\ntype Session struct {\n\tID               string\n\tName             string\n\tProviderID       string\n\tModelID          string\n\tMessages         []*Message\n\tAgentState       AgentState\n\tCreatedAt        time.Time\n\tLastModified     time.Time\n\tChatMessageStore ChatMessageStore\n\t// MCP status information\n\tMCPStatus *MCPStatus\n}\n\ntype AgentState string\n\nconst (\n\tAgentStateIdle            AgentState = \"idle\"\n\tAgentStateWaitingForInput AgentState = \"waiting-for-input\"\n\tAgentStateRunning         AgentState = \"running\"\n\tAgentStateInitializing    AgentState = \"initializing\"\n\tAgentStateDone            AgentState = \"done\"\n\tAgentStateExited          AgentState = \"exited\"\n)\n\ntype MessageType string\n\nconst (\n\tMessageTypeText                  MessageType = \"text\"\n\tMessageTypeError                 MessageType = \"error\"\n\tMessageTypeToolCallRequest       MessageType = \"tool-call-request\"\n\tMessageTypeToolCallResponse      MessageType = \"tool-call-response\"\n\tMessageTypeUserInputRequest      MessageType = \"user-input-request\"\n\tMessageTypeUserInputResponse     MessageType = \"user-input-response\"\n\tMessageTypeUserChoiceRequest     MessageType = \"user-choice-request\"\n\tMessageTypeUserChoiceResponse    MessageType = \"user-choice-response\"\n\tMessageTypeSessionPickerRequest  MessageType = \"session-picker-request\"\n\tMessageTypeSessionPickerResponse MessageType = \"session-picker-response\"\n)\n\ntype Message struct {\n\tID        string\n\tSource    MessageSource\n\tType      MessageType\n\tPayload   any\n\tTimestamp time.Time\n}\n\ntype MessageSource string\n\nconst (\n\tMessageSourceUser  MessageSource = \"user\"\n\tMessageSourceAgent MessageSource = \"agent\"\n\tMessageSourceModel MessageSource = \"model\"\n)\n\ntype UserChoiceRequest struct {\n\tPrompt  string\n\tOptions []UserChoiceOption\n}\n\ntype UserChoiceOption struct {\n\tLabel string `json:\"label,omitempty\"`\n\tValue string `json:\"value,omitempty\"`\n}\n\ntype UserChoiceResponse struct {\n\tChoice int `json:\"choice\"`\n}\n\ntype UserInputResponse struct {\n\tQuery string `json:\"query\"`\n}\n\n// SessionPickerRequest is sent to show an interactive session picker\ntype SessionPickerRequest struct {\n\tSessions []SessionInfo `json:\"sessions\"`\n}\n\n// SessionInfo contains display information for a session\ntype SessionInfo struct {\n\tID           string    `json:\"id\"`\n\tName         string    `json:\"name,omitempty\"`\n\tModelID      string    `json:\"modelId,omitempty\"`\n\tProviderID   string    `json:\"providerId,omitempty\"`\n\tCreatedAt    time.Time `json:\"createdAt\"`\n\tLastModified time.Time `json:\"lastModified\"`\n\tMessageCount int       `json:\"messageCount\"`\n}\n\n// SessionPickerResponse is sent when user selects a session\ntype SessionPickerResponse struct {\n\tSessionID string `json:\"sessionId\"`\n\tCancelled bool   `json:\"cancelled\"`\n}\n\n// MCPStatus represents the overall status of MCP servers and tools\ntype MCPStatus struct {\n\tServerInfoList []ServerConnectionInfo `json:\"serverInfoList,omitempty\"`\n\tTotalServers   int                    `json:\"totalServers,omitempty\"`\n\tConnectedCount int                    `json:\"connectedCount,omitempty\"`\n\tFailedCount    int                    `json:\"failedCount,omitempty\"`\n\tTotalTools     int                    `json:\"totalTools,omitempty\"`\n\tClientEnabled  bool                   `json:\"clientEnabled,omitempty\"`\n}\n\n// ServerConnectionInfo holds connection status for a single MCP server\ntype ServerConnectionInfo struct {\n\tName           string    `json:\"name,omitempty\"`\n\tCommand        string    `json:\"command,omitempty\"`\n\tIsLegacy       bool      `json:\"isLegacy,omitempty\"`\n\tIsConnected    bool      `json:\"isConnected,omitempty\"`\n\tAvailableTools []MCPTool `json:\"availableTools,omitempty\"`\n}\n\n// MCPTool represents an MCP tool with basic information\ntype MCPTool struct {\n\tName        string `json:\"name,omitempty\"`\n\tDescription string `json:\"description,omitempty\"`\n\tServer      string `json:\"server,omitempty\"`\n}\n\n// ChatMessageStore defines the interface for managing storage of chat messages of a session.\ntype ChatMessageStore interface {\n\tAddChatMessage(record *Message) error\n\tSetChatMessages(newHistory []*Message) error\n\tChatMessages() []*Message\n\tClearChatMessages() error\n}\n\nfunc (s *Session) AllMessages() []*Message {\n\tif s.ChatMessageStore == nil {\n\t\treturn nil\n\t}\n\treturn s.ChatMessageStore.ChatMessages()\n}\n\nfunc (s *Session) String() string {\n\treturn fmt.Sprintf(\"Session ID: %s\\nProvider: %s\\nModel: %s\\nCreated At: %s\\nLast Modified: %s\\nAgent State: %s\",\n\t\ts.ID, s.ProviderID, s.ModelID, s.CreatedAt.Format(time.RFC3339), s.LastModified.Format(time.RFC3339), s.AgentState)\n}\n"
  },
  {
    "path": "pkg/journal/context.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage journal\n\nimport (\n\t\"context\"\n)\n\ntype contextKey string\n\nconst RecorderKey contextKey = \"journal-recorder\"\n\n// RecorderFromContext extracts the recorder from the given context\nfunc RecorderFromContext(ctx context.Context) Recorder {\n\trecorder, ok := ctx.Value(RecorderKey).(Recorder)\n\tif !ok {\n\t\treturn &LogRecorder{}\n\t}\n\treturn recorder\n}\n\n// ContextWithRecorder adds the recorder to the given context\nfunc ContextWithRecorder(ctx context.Context, recorder Recorder) context.Context {\n\treturn context.WithValue(ctx, RecorderKey, recorder)\n}\n"
  },
  {
    "path": "pkg/journal/loader.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage journal\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\n\t\"sigs.k8s.io/yaml\"\n)\n\n// ParseEventsFromFile will read the events from the given file path\nfunc ParseEventsFromFile(p string) ([]*Event, error) {\n\tf, err := os.Open(p)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opening file %q: %w\", p, err)\n\t}\n\tdefer f.Close()\n\n\treturn ParseEvents(f)\n}\n\n// ParseEvents will read the events from the reader\nfunc ParseEvents(r io.Reader) ([]*Event, error) {\n\tvar events []*Event\n\n\tscanner := bufio.NewScanner(r)\n\tscanner.Split(splitYAML)\n\tfor scanner.Scan() {\n\t\tb := scanner.Bytes()\n\n\t\tevent := &Event{}\n\n\t\tif err := yaml.Unmarshal(b, &event); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"parsing yaml: %w\", err)\n\t\t}\n\n\t\tif event != nil {\n\t\t\tevents = append(events, event)\n\t\t}\n\t}\n\n\treturn events, nil\n}\n\nvar yamlSep = []byte(\"\\n---\\n\")\n\n// splitYAML is a split function for a Scanner that returns each object in a yaml multi-object doc.\nfunc splitYAML(data []byte, atEOF bool) (advance int, token []byte, err error) {\n\tif atEOF && len(data) == 0 {\n\t\treturn 0, nil, nil\n\t}\n\tif i := bytes.Index(data, yamlSep); i >= 0 {\n\t\t// We have a full object.\n\t\treturn i + len(yamlSep), data[0:i], nil\n\t}\n\t// If we're at EOF, we have a final, non-terminated object. Return it.\n\tif atEOF {\n\t\treturn len(data), data, nil\n\t}\n\t// Request more data.\n\treturn 0, nil, nil\n}\n"
  },
  {
    "path": "pkg/journal/log.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage journal\n\nimport (\n\t\"context\"\n\n\t\"k8s.io/klog/v2\"\n)\n\ntype LogRecorder struct {\n}\n\nfunc (r *LogRecorder) Write(ctx context.Context, event *Event) error {\n\tlog := klog.FromContext(ctx)\n\n\tlog.V(2).Info(\"Tracing event\", \"event\", event)\n\treturn nil\n}\n\nfunc (r *LogRecorder) Close() error {\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/journal/recorder.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage journal\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"time\"\n\n\t\"sigs.k8s.io/yaml\"\n)\n\n// Recorder is an interface for recording a structured log of the agent's actions and observations.\ntype Recorder interface {\n\tio.Closer\n\n\t// Write will add an event to the recorder.\n\tWrite(ctx context.Context, event *Event) error\n}\n\n// FileRecorder writes a structured log of the agent's actions and observations to a file.\ntype FileRecorder struct {\n\tf *os.File\n}\n\n// NewFileRecorder creates a new FileRecorder that writes to the given file.\nfunc NewFileRecorder(path string) (*FileRecorder, error) {\n\tfile, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opening file: %w\", err)\n\t}\n\treturn &FileRecorder{\n\t\tf: file,\n\t}, nil\n}\n\n// Close closes the file.\nfunc (r *FileRecorder) Close() error {\n\treturn r.f.Close()\n}\n\nfunc (r *FileRecorder) Write(ctx context.Context, event *Event) error {\n\tif event.Timestamp.IsZero() {\n\t\tevent.Timestamp = time.Now()\n\t}\n\n\tyamlBytes, err := yaml.Marshal(event)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"marshalling event: %w\", err)\n\t}\n\tvar b bytes.Buffer\n\tb.Write(yamlBytes)\n\tb.Write([]byte(\"\\n\\n---\\n\\n\"))\n\t_, err = r.f.Write(b.Bytes())\n\treturn err\n}\n\ntype Event struct {\n\tTimestamp time.Time `json:\"timestamp\"`\n\tAction    string    `json:\"action\"`\n\tPayload   any       `json:\"payload,omitempty\"`\n}\n\nconst (\n\tActionHTTPRequest  = \"http.request\"\n\tActionHTTPResponse = \"http.response\"\n\tActionHTTPError    = \"http.error\"\n)\n\n// ActionUIRender is for an event that indicates we wrote output to the UI\nconst ActionUIRender = \"ui.render\"\n\n// GetString is a helper to get a string value from the Payload\nfunc (e *Event) GetString(key string) (string, bool) {\n\tif e.Payload == nil {\n\t\treturn \"\", false\n\t}\n\tm, ok := e.Payload.(map[string]any)\n\tif !ok {\n\t\treturn \"\", false\n\t}\n\tv, ok := m[key]\n\tif !ok {\n\t\treturn \"\", false\n\t}\n\ts, ok := v.(string)\n\tif !ok {\n\t\treturn \"\", false\n\t}\n\treturn s, true\n}\n"
  },
  {
    "path": "pkg/mcp/README.md",
    "content": "# MCP (Model Context Protocol) Client\n\nThis package provides functionality to interact with MCP (Model Context Protocol) servers from `kubectl-ai`.\n\n## Overview\n\nThe MCP client allows `kubectl-ai` to connect to MCP servers, discover available tools, and execute them. This enables integration with various services and systems that expose their functionality through the MCP protocol.\n\n## Features\n\n- Connect to multiple MCP servers simultaneously\n- Support for both local (stdio-based) and remote (HTTP-based) MCP servers\n- Authentication support for HTTP-based servers (Basic, Bearer Token, API Key)\n- Automatic discovery of available tools from connected servers\n- Execute tools on MCP servers with parameter conversion\n- Configuration-based server management\n- Generic parameter name and type conversion (snake_case → camelCase, intelligent type inference)\n- Synchronous initialization ensuring tools are available before conversation starts\n\n## Configuration\n\nMCP server configurations are stored in `~/.config/kubectl-ai/mcp.yaml`. If this file doesn't exist, a default configuration will be created automatically.\n\n### Default Configuration\n\nBy default, the MCP client is configured with sequential thinking MCP server:\n\n```yaml\nservers:\n  - name: sequential-thinking\n    command: npx\n    args:\n      - -y\n      - \"@modelcontextprotocol/server-sequential-thinking\"\n```\n\n### Configuration Format\n\nThe configuration file uses YAML format and supports both local (stdio-based) and remote (HTTP-based) MCP servers:\n\n#### Local (stdio-based) Server Configuration\n\n```yaml\nservers:\n  - name: server-name\n    command: path-to-server-binary\n    args:\n      - --flag1\n      - value1\n    env:\n      ENV_VAR: value\n```\n\n#### Remote (HTTP-based) Server Configuration\n\n```yaml\nservers:\n  - name: remote-server\n    url: \"https://mcp-server.example.com/\"\n    timeout: 30  # Optional: Timeout in seconds\n    use_streaming: true  # Optional: Use streaming HTTP client\n    # Optional authentication\n    auth:\n      type: \"bearer\"  # Options: \"basic\", \"bearer\", \"api-key\"\n      token: \"${YOUR_ENV_VAR}\"  # Will be read from YOUR_ENV_VAR environment variable\n    # Optional custom headers\n    headers:\n      X-Custom-Header: \"custom-value\"\n      X-API-Version: \"v1\"\n```\n\n### Authentication Options\n\nRemote MCP servers support different authentication methods:\n\n1. **Bearer Token**:\n\n   ```yaml\n   auth:\n     type: \"bearer\"\n     token: \"your-bearer-token\"\n   ```\n\n2. **Basic Authentication**:\n\n   ```yaml\n   auth:\n     type: \"basic\"\n     username: \"username\"\n     password: \"password\"\n   ```\n\n3. **API Key**:\n\n   ```yaml\n   auth:\n     type: \"api-key\"\n     api_key: \"your-api-key\"\n     header_name: \"X-Api-Key\"  # Optional: Defaults to X-Api-Key\n   ```\n\n### Custom Headers\n\nRemote MCP servers support custom HTTP headers for additional configuration or authentication requirements:\n\n```yaml\nservers:\n  - name: remote-server\n    url: \"https://mcp-server.example.com/\"\n    headers:\n      X-Custom-Header: \"custom-value\"\n      X-API-Version: \"v1\"\n      User-Agent: \"kubectl-ai/1.0\"\n      Accept-Language: \"en-US\"\n```\n\n**Key Points:**\n\n- Custom headers are applied to all HTTP requests sent to the MCP server\n- Headers can be combined with authentication methods\n- Authentication headers (e.g., `Authorization`) may override custom headers if both are specified\n- All header values are strings\n- Headers are case-sensitive as per HTTP specification\n\n**Common Use Cases:**\n\n- API versioning headers (e.g., `X-API-Version`)\n- Custom user agent strings\n- Request tracking headers (e.g., `X-Request-ID`)\n- Content negotiation headers (e.g., `Accept`, `Accept-Language`)\n\n### Environment Variable Support\n\nSensitive information like tokens and passwords can be read from environment variables using the `${VAR_NAME}` syntax in the configuration file. You can also set environment variables with the prefix `MCP_SERVER_NAME_` to override configuration values.\n\n## Usage\n\nEnable MCP client functionality with the `--mcp-client` flag:\n\n```bash\nkubectl-ai --mcp-client\n```\n\n### Checking Server Status\n\nWhen you run kubectl-ai with the MCP client enabled, you'll see information about connected servers:\n\n```txt\nMCP Server Status:\n\nSuccessfully connected to 2 MCP server(s) (2 tools discovered)\n\n• sequential-thinking (npx) - Connected, Tools: sequentialthinking\n• fetch (remote) - Connected, Tools: fetch  \n```\n\nMCP servers are automatically discovered and their tools made available to the AI. The system handles:\n\n- **Parameter conversion**: Automatically converts snake_case parameters to camelCase\n- **Type inference**: Intelligently converts string parameters to numbers/booleans based on naming patterns\n- **Error handling**: Graceful fallbacks for connection issues\n\n### Custom Server Examples\n\nTo add custom MCP servers, edit the configuration file at `~/.config/kubectl-ai/mcp.yaml`:\nYou can combine both local and remote servers in your configuration:\n\n```yaml\nservers:\n  - name: sequential-thinking\n    command: npx\n    args:\n      - -y\n      - '@modelcontextprotocol/server-sequential-thinking'\n  - name: cloudflare-documentation\n    url: https://docs.mcp.cloudflare.com/mcp\n```\n\n### Environment Variables\n\nYou can configure the following environment variables to customize MCP client behavior:\n\n- `KUBECTL_AI_MCP_CONFIG`: Override the default configuration file path\n- `MCP_<SERVER_NAME>_<ENV_VAR>`: Set environment variables for specific servers\n\n## Parameter Conversion\n\nThe MCP client automatically handles parameter name and type conversion to ensure compatibility with different MCP servers:\n\n### Name Conversion\n\n- Converts snake_case parameter names to camelCase\n- Example: `thought_number` → `thoughtNumber`\n\n### Type Conversion\n\nParameters are intelligently converted based on naming patterns:\n\n**Numbers:** Parameters containing `number`, `count`, `total`, `max`, `min`, `limit`\n**Booleans:** Parameters starting with `is`, `has`, `needs`, `enable` or containing `required`, `enabled`\n\n### Fallback Behavior\n\n- If type conversion fails, the original value is preserved\n- Unknown servers use generic conversion rules\n- No configuration required - works automatically with any MCP server\n\n## Implementation Details\n\n### Client\n\nThe `Client` struct represents a connection to an MCP server. It provides methods to:\n\n- Connect to the server\n- List available tools\n- Execute tools\n- Close the connection\n\n### Manager\n\nThe `Manager` struct manages multiple MCP client connections. It provides:\n\n- Connection management for multiple servers\n- Tool discovery across all connected servers\n- Thread-safe operations\n\n### Configuration\n\nThe `Config` struct handles loading and saving MCP server configurations from disk. The configuration is automatically loaded from `~/.config/kubectl-ai/mcp.yaml` when needed.\n\n## Integration with kubectl-ai\n\nThe MCP client is integrated with `kubectl-ai` to automatically discover and use tools from configured MCP servers. The system:\n\n1. **Loads configuration** from `~/.config/kubectl-ai/mcp.yaml` on startup\n2. **Connects synchronously** to all configured MCP servers (when `--mcp-client` flag is used)\n3. **Registers tools** before the conversation starts, ensuring they're immediately available\n4. **Converts parameters** automatically using generic snake_case → camelCase conversion\n5. **Handles execution** with proper error handling and result formatting\n6. **Displays status** showing connected servers and available tool counts\n\n📖 **For practical multi-server orchestration examples and security automation workflows, see the [MCP Client Integration Guide](../../docs/mcp-client.md).**\n\n## Security Considerations\n\n- MCP servers can execute arbitrary commands with the same permissions as the `kubectl-ai` process\n- Only connect to trusted MCP servers\n- The configuration file has strict permissions (0600) by default\n- Be cautious when adding environment variables with sensitive information\n\n## Troubleshooting\n\n### Common Issues\n\n**MCP tools are not available:**\n\n- Ensure you're using the `--mcp-client` flag\n- Check that `~/.config/kubectl-ai/mcp.yaml` exists and is valid (created by default)\n- Verify MCP servers are installed (e.g., `npx` commands work)\n\n**Connection failures:**\n\n- Check network connectivity\n- Ensure server commands and paths are correct in configuration\n- Verify environment variables are properly set\n\n**Parameter conversion issues:**\n\n- The system automatically converts snake_case → camelCase\n- String parameters are converted to numbers/booleans based on naming patterns\n- Fallback behavior preserves original values if conversion fails\n\n### Debug Information\n\n- Use `-v=1` for basic MCP operation logging\n- Use `-v=2` for detailed connection and tool discovery info  \n- Check server status in the startup message\n- Tool counts are displayed for each connected server\n"
  },
  {
    "path": "pkg/mcp/client.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage mcp\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"reflect\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\tmcpclient \"github.com/mark3labs/mcp-go/client\"\n\tmcp \"github.com/mark3labs/mcp-go/mcp\"\n\t\"k8s.io/klog/v2\"\n)\n\n// ===================================================================\n// Client Types and Factory Functions\n// ===================================================================\n\n// Client represents an MCP client that can connect to MCP servers.\n// It is a wrapper around the MCPClient interface for backward compatibility.\ntype Client struct {\n\t// Name is a friendly name for this MCP server connection\n\tName string\n\t// The actual client implementation (stdio or HTTP)\n\timpl MCPClient\n\t// client is the underlying MCP library client\n\tclient *mcpclient.Client\n}\n\n// Tool represents an MCP tool with optional server information.\ntype Tool struct {\n\tName        string `json:\"name\"`\n\tDescription string `json:\"description,omitempty\"`\n\tServer      string `json:\"server,omitempty\"`\n\n\tInputSchema *gollm.Schema `json:\"inputSchema,omitempty\"`\n}\n\n// NewClient creates a new MCP client with the given configuration.\n// This function supports both stdio and HTTP-based MCP servers.\nfunc NewClient(config ClientConfig) *Client {\n\n\t// Create the appropriate implementation based on configuration\n\tvar impl MCPClient\n\tif config.URL != \"\" {\n\t\t// HTTP-based client\n\t\timpl = NewHTTPClient(config)\n\t} else {\n\t\t// Stdio-based client\n\t\timpl = NewStdioClient(config)\n\t}\n\n\treturn &Client{\n\t\tName: config.Name,\n\t\timpl: impl,\n\t}\n}\n\n// CreateStdioClient creates a new stdio-based MCP client (for backward compatibility).\nfunc CreateStdioClient(name, command string, args []string, env map[string]string) *Client {\n\t// Convert env map to slice of KEY=value strings\n\tvar envSlice []string\n\tfor k, v := range env {\n\t\tenvSlice = append(envSlice, fmt.Sprintf(\"%s=%s\", k, v))\n\t}\n\n\tconfig := ClientConfig{\n\t\tName:    name,\n\t\tCommand: command,\n\t\tArgs:    args,\n\t\tEnv:     envSlice,\n\t}\n\n\treturn NewClient(config)\n}\n\n// ===================================================================\n// Main Client Interface Methods\n// ===================================================================\n\n// Connect establishes a connection to the MCP server.\n// This delegates to the appropriate implementation (stdio or HTTP).\nfunc (c *Client) Connect(ctx context.Context) error {\n\tklog.V(2).InfoS(\"Connecting to MCP server\", \"name\", c.Name)\n\n\t// Delegate to the implementation\n\tif err := c.impl.Connect(ctx); err != nil {\n\t\treturn err\n\t}\n\n\t// Store the underlying client for backward compatibility\n\tc.client = c.impl.getUnderlyingClient()\n\n\tklog.V(2).InfoS(\"Successfully connected to MCP server\", \"name\", c.Name)\n\treturn nil\n}\n\n// Close closes the connection to the MCP server.\nfunc (c *Client) Close() error {\n\tif c.impl == nil {\n\t\treturn nil // Not initialized\n\t}\n\n\tklog.V(2).InfoS(\"Closing connection to MCP server\", \"name\", c.Name)\n\n\t// Delegate to implementation\n\terr := c.impl.Close()\n\tc.client = nil // Clear reference to underlying client\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"closing MCP client: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// ListTools lists all available tools from the MCP server.\nfunc (c *Client) ListTools(ctx context.Context) ([]Tool, error) {\n\tif err := c.ensureConnected(); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Delegate to implementation\n\ttools, err := c.impl.ListTools(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tklog.V(2).InfoS(\"Listed tools from MCP server\", \"count\", len(tools), \"server\", c.Name)\n\treturn tools, nil\n}\n\n// CallTool calls a tool on the MCP server and returns the result as a string.\n// The arguments should be a map of parameter names to values that will be passed to the tool.\nfunc (c *Client) CallTool(ctx context.Context, toolName string, arguments map[string]interface{}) (string, error) {\n\tklog.V(2).InfoS(\"Calling MCP tool\", \"server\", c.Name, \"tool\", toolName, \"args\", arguments)\n\n\tif err := c.ensureConnected(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Delegate to implementation\n\treturn c.impl.CallTool(ctx, toolName, arguments)\n}\n\n// ===================================================================\n// Tool Factory Functions and Methods\n// ===================================================================\n\n// WithServer returns a copy of the tool with server information added.\nfunc (t Tool) WithServer(server string) Tool {\n\tcopy := t\n\tcopy.Server = server\n\treturn copy\n}\n\n// ID returns a unique identifier for the tool.\nfunc (t Tool) ID() string {\n\tif t.Server != \"\" {\n\t\treturn fmt.Sprintf(\"%s@%s\", t.Name, t.Server)\n\t}\n\treturn t.Name\n}\n\n// String returns a human-readable representation of the tool.\nfunc (t Tool) String() string {\n\tif t.Server != \"\" {\n\t\treturn fmt.Sprintf(\"%s (from %s)\", t.Name, t.Server)\n\t}\n\treturn t.Name\n}\n\n// AsBasicTool returns the tool without server information (for client.ListTools compatibility).\nfunc (t Tool) AsBasicTool() Tool {\n\tcopy := t\n\tcopy.Server = \"\"\n\treturn copy\n}\n\n// IsFromServer checks if the tool belongs to a specific server.\nfunc (t Tool) IsFromServer(server string) bool {\n\treturn t.Server == server\n}\n\n// convertMCPToolsToTools converts MCP library tools to our Tool type.\nfunc convertMCPToolsToTools(mcpTools []mcp.Tool) ([]Tool, error) {\n\ttools := make([]Tool, 0, len(mcpTools))\n\tfor _, mcpTool := range mcpTools {\n\t\ttool := Tool{\n\t\t\tName:        mcpTool.Name,\n\t\t\tDescription: mcpTool.Description,\n\t\t}\n\t\t// TODO: Annotations (give hints about e.g. read-only, destructive, idempotent, open-world)\n\n\t\tif mcpTool.InputSchema.Type != \"\" {\n\t\t\tschema, err := convertMCPInputSchema(&mcpTool.InputSchema)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"converting MCP input schema to tool input schema: %w\", err)\n\t\t\t}\n\t\t\ttool.InputSchema = schema\n\t\t} else {\n\t\t\t// TODO: Use RawInputSchema if available\n\t\t\t// klog.Warningf(\"no input schema for tool %s\", mcpTool.Name)\n\t\t\treturn nil, fmt.Errorf(\"no input schema for tool %s\", mcpTool.Name)\n\t\t}\n\n\t\ttools = append(tools, tool)\n\t}\n\treturn tools, nil\n}\n\nfunc convertMCPInputSchema(mcpInputSchema *mcp.ToolInputSchema) (*gollm.Schema, error) {\n\tgollmSchema := &gollm.Schema{}\n\tswitch mcpInputSchema.Type {\n\tcase \"string\":\n\t\tgollmSchema.Type = gollm.TypeString\n\t// case \"number\":\n\t// \tgollmSchema.Type = gollm.TypeNumber\n\tcase \"boolean\":\n\t\tgollmSchema.Type = gollm.TypeBoolean\n\tcase \"object\":\n\t\tgollmSchema.Type = gollm.TypeObject\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unexpected MCP input schema type: %s\", mcpInputSchema.Type)\n\t}\n\tif mcpInputSchema.Properties != nil {\n\t\tgollmSchema.Properties = make(map[string]*gollm.Schema)\n\t\tfor key, value := range mcpInputSchema.Properties {\n\t\t\tif valueSchema, ok := value.(mcp.ToolInputSchema); ok {\n\t\t\t\tgollmValue, err := convertMCPInputSchema(&valueSchema)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"converting MCP input schema to tool input schema: %w\", err)\n\t\t\t\t}\n\t\t\t\tgollmSchema.Properties[key] = gollmValue\n\t\t\t} else if valueMap, ok := value.(map[string]interface{}); ok && valueMap != nil {\n\t\t\t\tgollmValue, err := convertMCPMapSchema(key, valueMap)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"converting MCP input schema to tool input schema: %w\", err)\n\t\t\t\t}\n\t\t\t\tgollmSchema.Properties[key] = gollmValue\n\t\t\t} else {\n\t\t\t\treturn nil, fmt.Errorf(\"unexpected input schema type for %q: %T %+v\", key, value, value)\n\t\t\t}\n\t\t}\n\t}\n\tgollmSchema.Required = mcpInputSchema.Required\n\treturn gollmSchema, nil\n}\n\nfunc convertMCPMapSchema(key string, schemaMap map[string]interface{}) (*gollm.Schema, error) {\n\tif schemaMap == nil {\n\t\treturn nil, fmt.Errorf(\"schema map is nil for key %q\", key)\n\t}\n\n\tgollmSchema := &gollm.Schema{}\n\n\tif descriptionObj, ok := schemaMap[\"description\"]; ok {\n\t\tdescription, ok := descriptionObj.(string)\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"unexpected description for key %q: %+v\", key, schemaMap)\n\t\t}\n\t\tgollmSchema.Description = description\n\t}\n\n\tmcpType, ok := schemaMap[\"type\"].(string)\n\tif !ok {\n\t\t// Fallback: treat any unrecognized schema as generic object\n\t\tklog.V(2).InfoS(\"Unrecognized schema format, treating as object\", \"key\", key)\n\t\tgollmSchema.Type = gollm.TypeObject\n\t\treturn gollmSchema, nil\n\t}\n\tswitch mcpType {\n\tcase \"string\":\n\t\tgollmSchema.Type = gollm.TypeString\n\tcase \"number\":\n\t\tgollmSchema.Type = gollm.TypeNumber\n\tcase \"integer\":\n\t\tgollmSchema.Type = gollm.TypeNumber\n\tcase \"boolean\":\n\t\tgollmSchema.Type = gollm.TypeBoolean\n\tcase \"array\":\n\t\titems, ok := schemaMap[\"items\"].(map[string]interface{})\n\t\tif !ok {\n\t\t\treturn nil, fmt.Errorf(\"did not find items for array: key %q: %+v\", key, schemaMap)\n\t\t}\n\t\titemsSchema, err := convertMCPMapSchema(key+\".items\", items)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"converting MCP input schema to tool input schema: %w\", err)\n\t\t}\n\t\tgollmSchema.Type = gollm.TypeArray\n\t\tgollmSchema.Items = itemsSchema\n\n\tcase \"object\":\n\t\tgollmSchema.Type = gollm.TypeObject\n\t\tgollmSchema.Properties = make(map[string]*gollm.Schema)\n\t\tif propertiesObj, ok := schemaMap[\"properties\"]; ok && propertiesObj != nil {\n\t\t\tproperties, ok := propertiesObj.(map[string]interface{})\n\t\t\tif !ok {\n\t\t\t\treturn nil, fmt.Errorf(\"properties field is not a map for key %q: %+v\", key, schemaMap)\n\t\t\t}\n\t\t\tfor key, value := range properties {\n\t\t\t\tvalueMap, ok := value.(map[string]interface{})\n\t\t\t\tif !ok {\n\t\t\t\t\treturn nil, fmt.Errorf(\"property value is not a map for key %q: %+v\", key, value)\n\t\t\t\t}\n\t\t\t\tpropertySchema, err := convertMCPMapSchema(key, valueMap)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, fmt.Errorf(\"converting MCP input schema to tool input schema: %w\", err)\n\t\t\t\t}\n\t\t\t\tgollmSchema.Properties[key] = propertySchema\n\t\t\t}\n\t\t}\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unexpected input schema type %q for key %q: %+v\", mcpType, key, schemaMap)\n\t}\n\n\treturn gollmSchema, nil\n}\n\n// ===================================================================\n// Common Functions\n// ===================================================================\n\n// ensureClientConnected checks if the client is connected.\nfunc ensureClientConnected(client *mcpclient.Client) error {\n\tif client == nil {\n\t\treturn fmt.Errorf(\"client not connected\")\n\t}\n\treturn nil\n}\n\n// initializeClientConnection initializes the MCP connection with proper handshake.\nfunc initializeClientConnection(ctx context.Context, client *mcpclient.Client) error {\n\tinitCtx, cancel := context.WithTimeout(ctx, DefaultConnectionTimeout)\n\tdefer cancel()\n\n\t// Create initialize request with the structure expected by v0.31.0\n\tinitReq := mcp.InitializeRequest{\n\t\t// The structure might differ in v0.31.0 - adapt as needed\n\t\t// This is a placeholder that will be updated when the actual API is known\n\t}\n\n\t_, err := client.Initialize(initCtx, initReq)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"initializing MCP client: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// verifyClientConnection verifies the connection works by testing tool listing.\nfunc verifyClientConnection(ctx context.Context, client *mcpclient.Client) error {\n\tverifyCtx, cancel := context.WithTimeout(ctx, DefaultConnectionTimeout)\n\tdefer cancel()\n\n\t// Try to list tools as a basic connectivity test\n\t_, err := client.ListTools(verifyCtx, mcp.ListToolsRequest{})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"listing tools: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// cleanupClient closes the client connection safely.\nfunc cleanupClient(client **mcpclient.Client) {\n\tif *client != nil {\n\t\t_ = (*client).Close() // Ignore errors on cleanup\n\t\t*client = nil\n\t}\n}\n\n// processToolResponse processes a tool call response and extracts the text result.\n// This function works with any MCP response object that has the expected fields.\nfunc processToolResponse(result any) (string, error) {\n\t// Use reflection to safely access fields\n\trv := reflect.ValueOf(result)\n\n\t// Handle pointer to struct\n\tif rv.Kind() == reflect.Ptr {\n\t\trv = rv.Elem()\n\t}\n\n\tif rv.Kind() != reflect.Struct {\n\t\treturn \"\", fmt.Errorf(\"unexpected response type: %T\", result)\n\t}\n\n\t// Check for IsError field\n\tisErrorField := rv.FieldByName(\"IsError\")\n\tif isErrorField.IsValid() && isErrorField.Kind() == reflect.Bool {\n\t\tisError := isErrorField.Bool()\n\n\t\t// Handle error response\n\t\tif isError {\n\t\t\t// Extract error message\n\t\t\terrorMsg := fmt.Sprintf(\"%+v\", result)\n\n\t\t\t// Try to get message from Content field\n\t\t\tcontentField := rv.FieldByName(\"Content\")\n\t\t\tif contentField.IsValid() && contentField.Len() > 0 {\n\t\t\t\tif content := contentField.Index(0).Interface(); content != nil {\n\t\t\t\t\tif textContent, ok := mcp.AsTextContent(content); ok {\n\t\t\t\t\t\terrorMsg = textContent.Text\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Return JSON error data instead of Go error\n\t\t\treturn fmt.Sprintf(`{\"error\": true, \"message\": %q, \"status\": \"failed\"}`, errorMsg), nil\n\t\t}\n\t}\n\n\t// Check for Content field\n\tcontentField := rv.FieldByName(\"Content\")\n\tif contentField.IsValid() && contentField.Len() > 0 {\n\t\t// Let's rely on the AsTextContent method from MCP package\n\t\t// which handles the specific response format\n\t\tcontent := contentField.Index(0).Interface()\n\t\tif textContent, ok := mcp.AsTextContent(content); ok {\n\t\t\treturn textContent.Text, nil\n\t\t}\n\t}\n\n\t// If we couldn't extract text content, return a generic message\n\treturn \"Tool executed successfully, but no text content was returned\", nil\n}\n\n// listClientTools implements the common ListTools functionality shared by both client types.\nfunc listClientTools(ctx context.Context, client *mcpclient.Client, serverName string) ([]Tool, error) {\n\tif err := ensureClientConnected(client); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Call the ListTools method on the MCP server\n\tresult, err := client.ListTools(ctx, mcp.ListToolsRequest{})\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"listing tools: %w\", err)\n\t}\n\n\t// Convert the result using the helper function\n\ttools, err := convertMCPToolsToTools(result.Tools)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing tools from MCP server: %w\", err)\n\t}\n\n\t// Add the server name to each tool\n\tfor i := range tools {\n\t\ttools[i].Server = serverName\n\t}\n\n\treturn tools, nil\n}\n"
  },
  {
    "path": "pkg/mcp/config.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage mcp\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"k8s.io/klog/v2\"\n\t\"sigs.k8s.io/yaml\"\n)\n\n// Config represents the complete MCP client configuration file\ntype Config struct {\n\t// Servers is a list of MCP server configurations\n\tServers []ServerConfig `yaml:\"servers,omitempty\"`\n}\n\n// ServerConfig represents the configuration for a single MCP server\ntype ServerConfig struct {\n\t// Name is a friendly name for this MCP server\n\tName string `yaml:\"name\"`\n\t// Command is the command to execute for stdio-based MCP servers\n\tCommand string `yaml:\"command\"`\n\t// Args are the arguments to pass to the command\n\tArgs []string `yaml:\"args,omitempty\"`\n\t// Env are the environment variables to set for the command\n\tEnv map[string]string `yaml:\"env,omitempty\"`\n\t// URL is the URL for HTTP-based MCP servers\n\tURL string `yaml:\"url,omitempty\"`\n\t// Auth is the authentication configuration for HTTP-based MCP servers\n\tAuth *AuthConfig `yaml:\"auth,omitempty\"`\n\t// OAuthConfig is the OAuth configuration for HTTP-based MCP servers\n\tOAuthConfig *OAuthConfig `yaml:\"oauth,omitempty\"`\n\t// Timeout is the timeout in seconds for HTTP requests\n\tTimeout int `yaml:\"timeout,omitempty\"`\n\t// UseStreaming enables streaming HTTP for better performance\n\tUseStreaming bool `yaml:\"use_streaming,omitempty\"`\n\t// SkipVerify skips TLS certificate verification for HTTPS connections\n\tSkipVerify bool `yaml:\"skip_verify,omitempty\"`\n}\n\n// ===================================================================\n// Configuration loading and management functions\n// ===================================================================\n\n// loadDefaultConfig loads the default configuration from the embedded file\nfunc loadDefaultConfig() (*Config, error) {\n\t// This path is relative to the module root\n\tdefaultConfigPath := filepath.Join(\"pkg\", \"mcp\", \"default_config.yaml\")\n\n\t// Read the file\n\tdata, err := os.ReadFile(defaultConfigPath)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading default config file: %w\", err)\n\t}\n\n\tvar config Config\n\tif err := yaml.Unmarshal(data, &config); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing default config: %w\", err)\n\t}\n\n\treturn &config, nil\n}\n\n// DefaultConfigPath returns the default path to the MCP config file\nfunc DefaultConfigPath() (string, error) {\n\t// Get the home directory first\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"getting user home directory: %w\", err)\n\t}\n\n\tvar configPath string\n\n\t// Handle different operating systems\n\tswitch runtime.GOOS {\n\tcase \"windows\":\n\t\t// On Windows, use %APPDATA%\\kubectl-ai\\mcp.yaml\n\t\tappData := os.Getenv(\"APPDATA\")\n\t\tif appData == \"\" {\n\t\t\tappData = filepath.Join(home, \"AppData\", \"Roaming\")\n\t\t}\n\t\tconfigPath = filepath.Join(appData, \"kubectl-ai\", \"mcp.yaml\")\n\tdefault:\n\t\t// On Unix-like systems, use XDG_CONFIG_HOME/kubectl-ai/mcp.yaml\n\t\tconfigDir := os.Getenv(\"XDG_CONFIG_HOME\")\n\t\tif configDir == \"\" {\n\t\t\tconfigDir = filepath.Join(home, \".config\")\n\t\t}\n\t\tconfigPath = filepath.Join(configDir, \"kubectl-ai\", \"mcp.yaml\")\n\t}\n\n\treturn configPath, nil\n}\n\n// LoadConfig loads the MCP configuration from the given path and applies environment variable overrides\n// If path is empty, the default config path is used\n// If the file doesn't exist, it creates a default configuration file\nfunc LoadConfig(path string) (*Config, error) {\n\tif path == \"\" {\n\t\tvar err error\n\t\tpath, err = DefaultConfigPath()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// If the file doesn't exist, create it with default configuration\n\tif _, err := os.Stat(path); os.IsNotExist(err) {\n\t\t// Create the directory if it doesn't exist\n\t\tdir := filepath.Dir(path)\n\t\tif err := os.MkdirAll(dir, ConfigDirPermissions); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"creating config directory: %w\", err)\n\t\t}\n\n\t\t// Read the default config from the embedded file\n\t\tdefaultConfig, err := loadDefaultConfig()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"loading default config: %w\", err)\n\t\t}\n\n\t\t// Save it to the config path\n\t\tif err := defaultConfig.Save(path); err != nil {\n\t\t\treturn nil, fmt.Errorf(\"saving default config: %w\", err)\n\t\t}\n\n\t\t// Apply environment variable overrides\n\t\tapplyEnvironmentVariables(defaultConfig)\n\n\t\treturn defaultConfig, nil\n\t}\n\n\t// Read the file\n\tdata, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"reading config file: %w\", err)\n\t}\n\n\t// Parse the YAML\n\tvar config Config\n\tif err := yaml.Unmarshal(data, &config); err != nil {\n\t\treturn nil, fmt.Errorf(\"parsing config file: %w\", err)\n\t}\n\n\t// Validate the configuration\n\tif err := config.ValidateConfig(); err != nil {\n\t\treturn nil, fmt.Errorf(\"invalid configuration: %w\", err)\n\t}\n\n\t// Apply environment variable overrides\n\tapplyEnvironmentVariables(&config)\n\n\treturn &config, nil\n}\n\n// Save saves the configuration to the given path using atomic write\nfunc (c *Config) Save(path string) error {\n\tif path == \"\" {\n\t\tvar err error\n\t\tpath, err = DefaultConfigPath()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Ensure directory exists\n\tif err := os.MkdirAll(filepath.Dir(path), ConfigDirPermissions); err != nil {\n\t\treturn fmt.Errorf(\"creating config directory: %w\", err)\n\t}\n\n\t// Marshal the config to YAML\n\tdata, err := yaml.Marshal(c)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"marshaling config: %w\", err)\n\t}\n\n\t// Perform atomic write\n\tif err := atomicWriteFile(path, data, ConfigFilePermissions); err != nil {\n\t\treturn fmt.Errorf(\"writing config file: %w\", err)\n\t}\n\n\tklog.V(2).Info(\"Saved MCP configuration\", \"path\", path)\n\treturn nil\n}\n\n// atomicWriteFile writes data to a file atomically using a temporary file\nfunc atomicWriteFile(path string, data []byte, perm os.FileMode) error {\n\tdir := filepath.Dir(path)\n\n\t// Create temporary file in the same directory\n\ttmpFile, err := os.CreateTemp(dir, \".mcp-config-*\")\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating temporary file: %w\", err)\n\t}\n\ttmpPath := tmpFile.Name()\n\tdefer os.Remove(tmpPath) // Clean up on error\n\n\t// Write data to temporary file\n\tif _, err := tmpFile.Write(data); err != nil {\n\t\ttmpFile.Close()\n\t\treturn fmt.Errorf(\"writing to temporary file: %w\", err)\n\t}\n\n\t// Sync and close\n\tif err := tmpFile.Sync(); err != nil {\n\t\ttmpFile.Close()\n\t\treturn fmt.Errorf(\"syncing temporary file: %w\", err)\n\t}\n\n\tif err := tmpFile.Close(); err != nil {\n\t\treturn fmt.Errorf(\"closing temporary file: %w\", err)\n\t}\n\n\t// Set permissions\n\tif err := os.Chmod(tmpPath, perm); err != nil {\n\t\treturn fmt.Errorf(\"setting file permissions: %w\", err)\n\t}\n\n\t// Atomic rename\n\treturn os.Rename(tmpPath, path)\n}\n\n// ===================================================================\n// Configuration validation functions\n// ===================================================================\n\n// ValidateConfig validates the entire configuration\nfunc (c *Config) ValidateConfig() error {\n\tif len(c.Servers) == 0 {\n\t\treturn fmt.Errorf(\"no servers configured\")\n\t}\n\n\t// Check for duplicate server names\n\tserverNames := make(map[string]bool)\n\tfor i, server := range c.Servers {\n\t\tif err := ValidateServerConfig(server); err != nil {\n\t\t\treturn fmt.Errorf(\"server %d (%s): %w\", i, server.Name, err)\n\t\t}\n\n\t\tif serverNames[server.Name] {\n\t\t\treturn fmt.Errorf(\"duplicate server name: %s\", server.Name)\n\t\t}\n\t\tserverNames[server.Name] = true\n\t}\n\n\treturn nil\n}\n\n// ValidateServerConfig validates a single server configuration\nfunc ValidateServerConfig(config ServerConfig) error {\n\tif config.Name == \"\" {\n\t\treturn fmt.Errorf(\"server name cannot be empty\")\n\t}\n\n\t// URL-based server (HTTP) or Command-based server (stdio)\n\tif config.URL == \"\" && config.Command == \"\" {\n\t\treturn fmt.Errorf(\"either URL or Command must be specified\")\n\t}\n\n\t// Additional validation could be added here:\n\t// - Check if command exists and is executable\n\t// - Validate environment variable format\n\t// - Check argument validity\n\t// - Validate URL format\n\n\treturn nil\n}\n\n// ===================================================================\n// Environment variable handling functions\n// ===================================================================\n\n// applyEnvironmentVariables overrides config with environment variables\nfunc applyEnvironmentVariables(config *Config) {\n\t// Apply MCP server-specific environment variables\n\tfor i := range config.Servers {\n\t\tapplyServerEnvironment(&config.Servers[i])\n\t}\n}\n\n// applyServerEnvironment applies environment variables for a specific MCP server\nfunc applyServerEnvironment(server *ServerConfig) {\n\tprefix := EnvMCPServerPrefix + strings.ToUpper(server.Name) + \"_\"\n\n\t// Process URL for HTTP servers\n\tif url := os.Getenv(prefix + \"URL\"); url != \"\" {\n\t\tserver.URL = url\n\t\tklog.V(2).InfoS(\"Using URL from environment\", \"server\", server.Name, \"url\", url)\n\t}\n\n\t// Process authentication for HTTP servers\n\tif server.URL != \"\" && server.Auth != nil {\n\t\tapplyAuthEnvironmentVariables(server, prefix)\n\t}\n\n\t// Process command and arguments for stdio servers\n\tif server.Command != \"\" {\n\t\tapplyCommandEnvironmentVariables(server, prefix)\n\t}\n}\n\n// applyAuthEnvironmentVariables applies authentication-related environment variables\nfunc applyAuthEnvironmentVariables(server *ServerConfig, prefix string) {\n\t// Process token for bearer auth\n\tif server.Auth.Type == \"bearer\" {\n\t\tif token := os.Getenv(prefix + \"TOKEN\"); token != \"\" {\n\t\t\tserver.Auth.Token = token\n\t\t\tklog.V(2).InfoS(\"Using bearer token from environment\", \"server\", server.Name)\n\t\t}\n\t}\n\n\t// Process API key for API key auth\n\tif server.Auth.Type == \"api-key\" {\n\t\tif apiKey := os.Getenv(prefix + \"API_KEY\"); apiKey != \"\" {\n\t\t\tserver.Auth.ApiKey = apiKey\n\t\t\tklog.V(2).InfoS(\"Using API key from environment\", \"server\", server.Name)\n\t\t}\n\t}\n\n\t// Process basic auth credentials\n\tif server.Auth.Type == \"basic\" {\n\t\tif username := os.Getenv(prefix + \"USERNAME\"); username != \"\" {\n\t\t\tserver.Auth.Username = username\n\t\t}\n\t\tif password := os.Getenv(prefix + \"PASSWORD\"); password != \"\" {\n\t\t\tserver.Auth.Password = password\n\t\t}\n\t}\n}\n\n// applyCommandEnvironmentVariables applies command-related environment variables\nfunc applyCommandEnvironmentVariables(server *ServerConfig, prefix string) {\n\t// Override command\n\tif cmd := os.Getenv(prefix + \"COMMAND\"); cmd != \"\" {\n\t\tserver.Command = cmd\n\t\tklog.V(2).InfoS(\"Using command from environment\", \"server\", server.Name, \"command\", cmd)\n\t}\n\n\t// Process environment variables for the server\n\tfor _, env := range os.Environ() {\n\t\tif strings.HasPrefix(env, prefix) {\n\t\t\tparts := strings.SplitN(env, \"=\", 2)\n\t\t\tif len(parts) == 2 {\n\t\t\t\tvarName := strings.TrimPrefix(parts[0], prefix)\n\t\t\t\t// Skip special variables that we process elsewhere\n\t\t\t\tif varName != \"COMMAND\" && varName != \"URL\" && varName != \"TOKEN\" &&\n\t\t\t\t\tvarName != \"API_KEY\" && varName != \"USERNAME\" && varName != \"PASSWORD\" {\n\t\t\t\t\tif server.Env == nil {\n\t\t\t\t\t\tserver.Env = make(map[string]string)\n\t\t\t\t\t}\n\t\t\t\t\tserver.Env[varName] = parts[1]\n\t\t\t\t\tklog.V(3).InfoS(\"Added environment variable from environment\", \"server\", server.Name, \"var\", varName)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "pkg/mcp/constants.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage mcp\n\nimport \"time\"\n\n// Timeout constants for MCP operations\nconst (\n\t// DefaultConnectionTimeout is the timeout for establishing connections to MCP servers\n\tDefaultConnectionTimeout = 30 * time.Second\n\n\t// DefaultVerificationTimeout is the timeout for verifying server connections\n\tDefaultVerificationTimeout = 10 * time.Second\n\n\t// DefaultPingTimeout is the timeout for ping operations\n\tDefaultPingTimeout = 5 * time.Second\n\n\t// DefaultStabilizationDelay is the delay to allow servers to stabilize after connection\n\tDefaultStabilizationDelay = 2 * time.Second\n)\n\n// Error message templates\nconst (\n\tErrServerConnectionFmt = \"connecting to MCP server %q: %w\"\n\tErrServerCloseFmt      = \"closing MCP client %q: %w\"\n\tErrToolCallFmt         = \"calling tool %q: %w\"\n\tErrPathCheckFmt        = \"checking path %q: %w\"\n)\n\n// Client constants\nconst (\n\tClientName    = \"kubectl-ai-mcp-client\"\n\tClientVersion = \"1.0.0\"\n)\n\n// File permissions\nconst (\n\tConfigFilePermissions = 0600\n\tConfigDirPermissions  = 0755\n)\n\n// Constants for environment variables\nconst (\n\t// EnvMCPServerPrefix is the prefix for MCP server environment variables\n\tEnvMCPServerPrefix = \"MCP_\"\n)\n"
  },
  {
    "path": "pkg/mcp/default_config.yaml",
    "content": "servers:\n  - name: sequential-thinking\n    command: npx\n    args:\n      - -y\n      - \"@modelcontextprotocol/server-sequential-thinking\" "
  },
  {
    "path": "pkg/mcp/http_client.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage mcp\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"time\"\n\n\tmcpclient \"github.com/mark3labs/mcp-go/client\"\n\t\"github.com/mark3labs/mcp-go/client/transport\"\n\tmcp \"github.com/mark3labs/mcp-go/mcp\"\n\t\"k8s.io/klog/v2\"\n)\n\n// ===================================================================\n// HTTP Client Implementation\n// ===================================================================\n\n// httpClient is an MCP client that communicates with HTTP-based MCP servers\ntype httpClient struct {\n\tname         string\n\turl          string\n\tauth         *AuthConfig\n\toauthConfig  *OAuthConfig\n\ttimeout      int\n\tuseStreaming bool\n\tskipVerify   bool\n\theaders      map[string]string\n\tclient       *mcpclient.Client\n}\n\n// NewHTTPClient creates a new HTTP-based MCP client\nfunc NewHTTPClient(config ClientConfig) MCPClient {\n\treturn &httpClient{\n\t\tname:         config.Name,\n\t\turl:          config.URL,\n\t\tauth:         config.Auth,\n\t\toauthConfig:  config.OAuthConfig,\n\t\ttimeout:      config.Timeout,\n\t\tuseStreaming: config.UseStreaming,\n\t\tskipVerify:   config.SkipVerify,\n\t\theaders:      config.Headers,\n\t}\n}\n\n// getUnderlyingClient returns the underlying MCP client.\nfunc (c *httpClient) getUnderlyingClient() *mcpclient.Client {\n\treturn c.client\n}\n\n// ensureConnected makes sure the client is connected.\nfunc (c *httpClient) ensureConnected() error {\n\treturn ensureClientConnected(c.client)\n}\n\n// Name returns the name of this client.\nfunc (c *httpClient) Name() string {\n\treturn c.name\n}\n\n// Connect establishes a connection to the HTTP MCP server.\nfunc (c *httpClient) Connect(ctx context.Context) error {\n\tklog.V(2).InfoS(\"Connecting to HTTP MCP server\", \"name\", c.name, \"url\", c.url)\n\tif c.client != nil {\n\t\treturn nil // Already connected\n\t}\n\n\tvar client *mcpclient.Client\n\tvar err error\n\n\t// Create the appropriate client based on configuration\n\tif c.oauthConfig != nil {\n\t\tclient, err = c.createOAuthClient(ctx)\n\t} else if c.useStreaming {\n\t\tclient, err = c.createStreamingClient()\n\t} else {\n\t\tclient, err = c.createStandardClient()\n\t}\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating HTTP MCP client: %w\", err)\n\t}\n\n\tc.client = client\n\n\t// Initialize the connection\n\tif err := c.initializeConnection(ctx); err != nil {\n\t\tc.cleanup()\n\t\treturn fmt.Errorf(\"initializing connection: %w\", err)\n\t}\n\n\t// Verify connection\n\tif err := c.verifyConnection(ctx); err != nil {\n\t\tc.cleanup()\n\t\treturn fmt.Errorf(\"verifying connection: %w\", err)\n\t}\n\n\tklog.V(2).InfoS(\"Successfully connected to HTTP MCP server\", \"name\", c.name)\n\treturn nil\n}\n\n// createStreamingClient creates a streamable HTTP client for better performance\nfunc (c *httpClient) createStreamingClient() (*mcpclient.Client, error) {\n\t// Set up options for the HTTP client\n\tvar options []transport.StreamableHTTPCOption\n\n\t// Add timeout if specified (only when not using custom client)\n\tif c.timeout > 0 {\n\t\toptions = append(options, transport.WithHTTPTimeout(time.Duration(c.timeout)*time.Second))\n\t}\n\n\tklog.V(2).InfoS(\"WARNING: TLS certificate verification is disabled\", \"server\", c.name)\n\t// Handle TLS verification skip by creating custom HTTP client\n\tif c.skipVerify {\n\t\tklog.V(2).InfoS(\"WARNING: TLS certificate verification is disabled\", \"server\", c.name)\n\n\t\t// Create custom HTTP client with TLS verification disabled\n\t\tcustomClient := &http.Client{\n\t\t\tTransport: &http.Transport{\n\t\t\t\tTLSClientConfig: &tls.Config{\n\t\t\t\t\tInsecureSkipVerify: true,\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\n\t\t// Add timeout to custom client if specified\n\t\tif c.timeout > 0 {\n\t\t\tcustomClient.Timeout = time.Duration(c.timeout) * time.Second\n\t\t}\n\n\t\t// Use the custom HTTP client\n\t\toptions = append(options, transport.WithHTTPBasicClient(customClient))\n\t}\n\n\t// Prepare headers map for authentication and custom headers\n\theaders := make(map[string]string)\n\n\t// Add custom headers from configuration first\n\tfor key, value := range c.headers {\n\t\theaders[key] = value\n\t\tklog.V(3).InfoS(\"Using custom header for HTTP client\", \"server\", c.name, \"header\", key)\n\t}\n\n\t// Add authentication headers if specified (may override custom headers)\n\tif c.auth != nil {\n\t\tswitch c.auth.Type {\n\t\tcase \"basic\":\n\t\t\tauth := \"Basic \" + base64.StdEncoding.EncodeToString([]byte(c.auth.Username+\":\"+c.auth.Password))\n\t\t\theaders[\"Authorization\"] = auth\n\t\t\tklog.V(3).InfoS(\"Using basic auth for HTTP client\", \"server\", c.name)\n\t\tcase \"bearer\":\n\t\t\theaders[\"Authorization\"] = \"Bearer \" + c.auth.Token\n\t\t\tklog.V(3).InfoS(\"Using bearer auth for HTTP client\", \"server\", c.name)\n\t\tcase \"api-key\":\n\t\t\theaderName := \"X-Api-Key\"\n\t\t\tif c.auth.HeaderName != \"\" {\n\t\t\t\theaderName = c.auth.HeaderName\n\t\t\t}\n\t\t\theaders[headerName] = c.auth.ApiKey\n\t\t\tklog.V(3).InfoS(\"Using API key auth for HTTP client\", \"server\", c.name)\n\t\t}\n\t}\n\n\t// Add headers if any were set\n\tif len(headers) > 0 {\n\t\toptions = append(options, transport.WithHTTPHeaders(headers))\n\t}\n\n\tklog.V(4).InfoS(\"Creating streamable HTTP client\", \"server\", c.name, \"url\", c.url)\n\tclient, err := mcpclient.NewStreamableHttpClient(c.url, options...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"creating streamable HTTP client: %w\", err)\n\t}\n\n\treturn client, nil\n}\n\n// createStandardClient creates a standard HTTP client\nfunc (c *httpClient) createStandardClient() (*mcpclient.Client, error) {\n\t// Standard client delegates to streaming client implementation for now\n\t// In the future, they might have different configurations\n\treturn c.createStreamingClient()\n}\n\n// createOAuthClient creates an HTTP client with OAuth authentication\nfunc (c *httpClient) createOAuthClient(ctx context.Context) (*mcpclient.Client, error) {\n\tif c.oauthConfig == nil {\n\t\treturn nil, fmt.Errorf(\"OAuth config required but not provided\")\n\t}\n\n\tklog.V(3).InfoS(\"Creating OAuth HTTP client\", \"server\", c.name, \"client_id\", c.oauthConfig.ClientID)\n\n\t// Set up options for the HTTP client\n\tvar options []transport.StreamableHTTPCOption\n\n\t// Create OAuth configuration for the transport\n\toauthCfg := transport.OAuthConfig{\n\t\tClientID:     c.oauthConfig.ClientID,\n\t\tClientSecret: c.oauthConfig.ClientSecret,\n\t\tScopes:       c.oauthConfig.Scopes,\n\t\tRedirectURI:  c.oauthConfig.RedirectURL,\n\t\t// Use the token URL as the auth server metadata URL if available\n\t\tAuthServerMetadataURL: c.oauthConfig.TokenURL,\n\t}\n\n\t// Add OAuth configuration\n\toptions = append(options, transport.WithHTTPOAuth(oauthCfg))\n\n\t// Add timeout if specified\n\tif c.timeout > 0 {\n\t\toptions = append(options, transport.WithHTTPTimeout(time.Duration(c.timeout)*time.Second))\n\t}\n\n\tklog.V(4).InfoS(\"Creating OAuth streamable HTTP client\", \"server\", c.name, \"url\", c.url)\n\tclient, err := mcpclient.NewStreamableHttpClient(c.url, options...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"creating OAuth HTTP client: %w\", err)\n\t}\n\n\treturn client, nil\n}\n\n// initializeConnection initializes the MCP connection with proper handshake\nfunc (c *httpClient) initializeConnection(ctx context.Context) error {\n\treturn initializeClientConnection(ctx, c.client)\n}\n\n// verifyConnection verifies the connection works by testing tool listing\nfunc (c *httpClient) verifyConnection(ctx context.Context) error {\n\treturn verifyClientConnection(ctx, c.client)\n}\n\n// cleanup closes the client connection and resets the client state\nfunc (c *httpClient) cleanup() {\n\tcleanupClient(&c.client)\n}\n\n// Close closes the connection to the MCP server\nfunc (c *httpClient) Close() error {\n\tif c.client == nil {\n\t\treturn nil // Already closed\n\t}\n\n\tklog.V(2).InfoS(\"Closing connection to HTTP MCP server\", \"name\", c.name)\n\terr := c.client.Close()\n\tc.client = nil\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"closing MCP client: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// ListTools lists all available tools from the MCP server\nfunc (c *httpClient) ListTools(ctx context.Context) ([]Tool, error) {\n\ttools, err := listClientTools(ctx, c.client, c.name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tklog.V(2).InfoS(\"Listed tools from HTTP MCP server\", \"count\", len(tools), \"server\", c.name)\n\treturn tools, nil\n}\n\n// CallTool calls a tool on the MCP server and returns the result as a string\nfunc (c *httpClient) CallTool(ctx context.Context, toolName string, arguments map[string]interface{}) (string, error) {\n\tklog.V(2).InfoS(\"Calling MCP tool via HTTP\", \"server\", c.name, \"tool\", toolName)\n\n\tif err := c.ensureConnected(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Create v0.31.0 compatible request\n\trequest := mcp.CallToolRequest{\n\t\tParams: mcp.CallToolParams{\n\t\t\tName:      toolName,\n\t\t\tArguments: arguments,\n\t\t},\n\t}\n\n\t// Call the tool on the MCP server\n\tresult, err := c.client.CallTool(ctx, request)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error calling tool %s: %w\", toolName, err)\n\t}\n\n\treturn processToolResponse(result)\n}\n"
  },
  {
    "path": "pkg/mcp/interfaces.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage mcp\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\tmcpclient \"github.com/mark3labs/mcp-go/client\"\n)\n\n// MCPClient defines the common interface for all MCP client implementations\ntype MCPClient interface {\n\t// Name returns the name of this client\n\tName() string\n\n\t// Connect establishes a connection to the MCP server\n\tConnect(ctx context.Context) error\n\n\t// Close closes the connection to the MCP server\n\tClose() error\n\n\t// ListTools lists all available tools from the MCP server\n\tListTools(ctx context.Context) ([]Tool, error)\n\n\t// CallTool calls a tool on the MCP server and returns the result as a string\n\tCallTool(ctx context.Context, toolName string, arguments map[string]interface{}) (string, error)\n\n\t// ensureConnected makes sure the client is connected\n\tensureConnected() error\n\n\t// getUnderlyingClient returns the underlying mcpclient.Client\n\tgetUnderlyingClient() *mcpclient.Client\n}\n\n// ClientConfig contains all configuration options for MCP clients\ntype ClientConfig struct {\n\t// Common fields\n\tName string\n\n\t// For stdio-based clients\n\tCommand string\n\tArgs    []string\n\tEnv     []string\n\n\t// For HTTP-based clients\n\tURL          string\n\tAuth         *AuthConfig\n\tOAuthConfig  *OAuthConfig\n\tTimeout      int\n\tUseStreaming bool              // Whether to use streaming HTTP for better performance\n\tSkipVerify   bool              // Whether to skip TLS certificate verification for HTTPS connections\n\tHeaders      map[string]string // Custom headers to include in HTTP requests\n\n\t// No LLM configuration needed - MCP doesn't need to know about LLM models\n}\n\n// AuthConfig represents authentication options for HTTP MCP servers\ntype AuthConfig struct {\n\tType       string // \"none\", \"basic\", \"bearer\", \"api-key\"\n\tUsername   string // For basic auth\n\tPassword   string // For basic auth\n\tToken      string // For bearer auth\n\tApiKey     string // For API key auth\n\tHeaderName string // Custom header name for API key\n}\n\n// OAuthConfig represents OAuth configuration for HTTP MCP servers\ntype OAuthConfig struct {\n\tClientID     string\n\tClientSecret string\n\tTokenURL     string\n\tAuthURL      string\n\tScopes       []string\n\tRedirectURL  string\n}\n\n// NewMCPClient creates a new MCP client with the appropriate implementation based on the config\nfunc NewMCPClient(config ClientConfig) (MCPClient, error) {\n\t// Validate common configuration\n\tif config.Name == \"\" {\n\t\treturn nil, fmt.Errorf(\"client name is required\")\n\t}\n\n\t// Choose the appropriate client implementation\n\tif config.URL != \"\" {\n\t\t// Use HTTP client\n\t\treturn NewHTTPClient(config), nil\n\t}\n\n\t// Default to stdio client\n\tif config.Command == \"\" {\n\t\treturn nil, fmt.Errorf(\"either URL or Command must be specified\")\n\t}\n\treturn NewStdioClient(config), nil\n}\n"
  },
  {
    "path": "pkg/mcp/manager.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage mcp\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"sync\"\n\t\"time\"\n\n\t\"k8s.io/klog/v2\"\n)\n\n// =============================================================================\n// Status Types\n// =============================================================================\n\n// ServerConnectionInfo holds connection status for a single MCP server\ntype ServerConnectionInfo struct {\n\tName           string\n\tCommand        string\n\tIsLegacy       bool\n\tIsConnected    bool\n\tAvailableTools []Tool\n}\n\n// MCPStatus represents the overall status of MCP servers and tools\ntype MCPStatus struct {\n\tServerInfoList []ServerConnectionInfo\n\tTotalServers   int\n\tConnectedCount int\n\tFailedCount    int\n\tTotalTools     int\n\tClientEnabled  bool\n}\n\n// =============================================================================\n// Manager Core\n// =============================================================================\n\n// Manager handles MCP client connections and tool discovery\ntype Manager struct {\n\tconfig  *Config\n\tclients map[string]*Client\n\tmu      sync.RWMutex\n}\n\n// NewManager creates a new MCP manager with the given configuration\nfunc NewManager(config *Config) *Manager {\n\treturn &Manager{\n\t\tconfig:  config,\n\t\tclients: make(map[string]*Client),\n\t}\n}\n\n// InitializeManager creates and initializes the MCP manager\n// with configuration loaded from default paths\nfunc InitializeManager() (*Manager, error) {\n\tklog.V(1).Info(\"Initializing MCP client functionality\")\n\n\tconfig, err := LoadConfig(\"\")\n\tif err != nil {\n\t\tklog.V(2).Info(\"Failed to load MCP config\", \"error\", err)\n\t\treturn nil, err\n\t}\n\n\treturn NewManager(config), nil\n}\n\n// =============================================================================\n// Connection Management\n// =============================================================================\n\n// ConnectAll connects to all configured MCP servers\nfunc (m *Manager) ConnectAll(ctx context.Context) error {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tvar errs []error\n\n\tfor _, serverCfg := range m.config.Servers {\n\t\tif _, exists := m.clients[serverCfg.Name]; exists {\n\t\t\tklog.V(2).Info(\"MCP client already connected\", \"name\", serverCfg.Name)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Convert environment map to slice\n\t\tvar envSlice []string\n\t\tfor k, v := range serverCfg.Env {\n\t\t\tenvSlice = append(envSlice, fmt.Sprintf(\"%s=%s\", k, v))\n\t\t}\n\n\t\t// Create client config with environment map\n\t\tconfig := ClientConfig{\n\t\t\tName:         serverCfg.Name,\n\t\t\tCommand:      serverCfg.Command,\n\t\t\tArgs:         serverCfg.Args,\n\t\t\tAuth:         serverCfg.Auth,\n\t\t\tOAuthConfig:  serverCfg.OAuthConfig,\n\t\t\tEnv:          envSlice,\n\t\t\tURL:          serverCfg.URL,\n\t\t\tTimeout:      serverCfg.Timeout,\n\t\t\tUseStreaming: serverCfg.UseStreaming,\n\t\t\tSkipVerify:   serverCfg.SkipVerify,\n\t\t}\n\n\t\tclient := NewClient(config)\n\t\tif err := client.Connect(ctx); err != nil {\n\t\t\terr := fmt.Errorf(ErrServerConnectionFmt, serverCfg.Name, err)\n\t\t\terrs = append(errs, err)\n\t\t\tklog.Error(err)\n\t\t\tcontinue\n\t\t}\n\n\t\tm.clients[serverCfg.Name] = client\n\t\tklog.V(2).Info(\"Connected to MCP server\", \"name\", serverCfg.Name)\n\t}\n\n\tif len(errs) > 0 {\n\t\treturn fmt.Errorf(\"failed to connect to some MCP servers: %v\", errs)\n\t}\n\n\treturn nil\n}\n\n// Close closes all MCP client connections\nfunc (m *Manager) Close() error {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tvar errs []error\n\n\tfor name, client := range m.clients {\n\t\tif err := client.Close(); err != nil {\n\t\t\terrs = append(errs, fmt.Errorf(ErrServerCloseFmt, name, err))\n\t\t}\n\t\tdelete(m.clients, name)\n\t}\n\n\tif len(errs) > 0 {\n\t\treturn fmt.Errorf(\"errors while closing MCP clients: %v\", errs)\n\t}\n\n\treturn nil\n}\n\n// GetClient returns a connected MCP client by name\nfunc (m *Manager) GetClient(name string) (*Client, bool) {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tclient, exists := m.clients[name]\n\treturn client, exists\n}\n\n// ListClients returns a list of all connected MCP clients\nfunc (m *Manager) ListClients() []*Client {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tvar clients []*Client\n\tfor _, client := range m.clients {\n\t\tclients = append(clients, client)\n\t}\n\n\treturn clients\n}\n\n// =============================================================================\n// Server and Tool Discovery\n// =============================================================================\n\n// DiscoverAndConnectServers connects to all configured servers\n// with a timeout and stabilization delay\nfunc (m *Manager) DiscoverAndConnectServers(ctx context.Context) error {\n\tklog.V(1).Info(\"Connecting to MCP servers\")\n\n\tconnectCtx, connectCancel := context.WithTimeout(ctx, DefaultConnectionTimeout)\n\tdefer connectCancel()\n\n\tif err := m.ConnectAll(connectCtx); err != nil {\n\t\tklog.V(2).Info(\"Failed to connect to some MCP servers during auto-discovery\", \"error\", err)\n\t\t// Continue with partial connections\n\t}\n\n\t// Allow connections to stabilize before tool discovery\n\tklog.V(3).Info(\"Waiting for server connections to stabilize\", \"delay\", DefaultStabilizationDelay)\n\ttime.Sleep(DefaultStabilizationDelay)\n\n\treturn nil\n}\n\n// ListAvailableTools returns tools from all connected servers\n// For retries and more robust handling, use RefreshToolDiscovery\nfunc (m *Manager) ListAvailableTools(ctx context.Context) (map[string][]Tool, error) {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\ttools := make(map[string][]Tool)\n\n\tfor name, client := range m.clients {\n\t\ttoolList, err := client.ListTools(ctx)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"listing tools from MCP server %q: %w\", name, err)\n\t\t}\n\n\t\tvar serverTools []Tool\n\t\tfor _, tool := range toolList {\n\t\t\tserverTools = append(serverTools, tool.WithServer(name))\n\t\t}\n\n\t\ttools[name] = serverTools\n\t}\n\n\treturn tools, nil\n}\n\n// RefreshToolDiscovery discovers tools from all servers with retries\nfunc (m *Manager) RefreshToolDiscovery(ctx context.Context) (map[string][]Tool, error) {\n\tklog.V(1).Info(\"Starting tool discovery from MCP servers with retries\")\n\n\tvar serverTools map[string][]Tool\n\n\tretryConfig := DefaultRetryConfig(\"tool discovery from MCP servers\")\n\terr := RetryOperation(ctx, retryConfig, func() error {\n\t\tvar err error\n\t\tserverTools, err = m.ListAvailableTools(ctx)\n\t\treturn err\n\t})\n\tif err != nil {\n\t\tklog.Warningf(\"Failed to discover tools after retries: %v\", err)\n\t\treturn nil, err\n\t}\n\n\t// Log discovery results\n\ttoolCount := 0\n\tfor serverName, tools := range serverTools {\n\t\tklog.V(1).Info(\"Discovered tools from MCP server\", \"server\", serverName, \"toolCount\", len(tools))\n\t\ttoolCount += len(tools)\n\t}\n\n\tif toolCount > 0 {\n\t\tklog.InfoS(\"Successfully discovered MCP tools\", \"totalTools\", toolCount)\n\t} else {\n\t\tklog.V(1).Info(\"No MCP tools were discovered from connected servers\")\n\t}\n\n\treturn serverTools, nil\n}\n\n// RegisterTools discovers and registers tools from all MCP servers using the provided callback\n// The callback function is responsible for creating and registering tool wrappers\nfunc (m *Manager) RegisterTools(ctx context.Context, registerCallback func(serverName string, tool Tool) error) error {\n\t// Discover tools from connected servers\n\tserverTools, err := m.RefreshToolDiscovery(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\ttoolCount := 0\n\tfor serverName, tools := range serverTools {\n\t\tfor _, toolInfo := range tools {\n\t\t\t// Use the callback to register each tool\n\t\t\tif err := registerCallback(serverName, toolInfo); err != nil {\n\t\t\t\tklog.Warningf(\"Failed to register tool %s from server %s: %v\", toolInfo.Name, serverName, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ttoolCount++\n\t\t}\n\t}\n\n\tif toolCount > 0 {\n\t\tklog.InfoS(\"Registered MCP tools\", \"totalTools\", toolCount)\n\t}\n\n\treturn nil\n}\n\n// =============================================================================\n// Status Reporting\n// =============================================================================\n\n// GetStatus returns status of all MCP servers and their tools\nfunc (m *Manager) GetStatus(ctx context.Context, mcpClientEnabled bool) (*MCPStatus, error) {\n\tstatus := &MCPStatus{\n\t\tClientEnabled: mcpClientEnabled,\n\t}\n\n\tmcpConfigPath, err := DefaultConfigPath()\n\tif err != nil {\n\t\tklog.V(2).Infof(\"Failed to get MCP config path: %v\", err)\n\t\treturn status, nil // Return empty status\n\t}\n\n\tmcpConfig, err := LoadConfig(mcpConfigPath)\n\tif err != nil {\n\t\treturn status, nil // Return empty status\n\t}\n\n\tstatus.TotalServers = len(mcpConfig.Servers)\n\n\tif status.TotalServers == 0 {\n\t\treturn status, nil\n\t}\n\n\tvar serverTools map[string][]Tool\n\tvar connectedClients []*Client\n\n\tif mcpClientEnabled && m != nil {\n\t\tconnectedClients = m.ListClients()\n\t\tstatus.ConnectedCount = len(connectedClients)\n\t\tstatus.FailedCount = status.TotalServers - status.ConnectedCount\n\n\t\ttoolsCtx, cancel := context.WithTimeout(ctx, 5*time.Second)\n\t\tdefer cancel()\n\n\t\tserverTools, err = m.ListAvailableTools(toolsCtx)\n\t\tif err != nil {\n\t\t\tklog.V(2).InfoS(\"Failed to get tools from MCP manager\", \"error\", err)\n\t\t\tserverTools = make(map[string][]Tool)\n\t\t}\n\n\t\tfor _, toolList := range serverTools {\n\t\t\tstatus.TotalTools += len(toolList)\n\t\t}\n\t} else {\n\t\tserverTools = make(map[string][]Tool)\n\t}\n\n\tconnectedServerNames := make(map[string]bool)\n\tif mcpClientEnabled {\n\t\tfor _, client := range connectedClients {\n\t\t\tconnectedServerNames[client.Name] = true\n\t\t}\n\t}\n\n\t// Process all servers\n\tfor _, server := range mcpConfig.Servers {\n\t\tserverInfo := ServerConnectionInfo{\n\t\t\tName:        server.Name,\n\t\t\tCommand:     server.Command,\n\t\t\tIsLegacy:    false,\n\t\t\tIsConnected: connectedServerNames[server.Name],\n\t\t}\n\n\t\tif tools, exists := serverTools[server.Name]; exists {\n\t\t\tserverInfo.AvailableTools = tools\n\t\t}\n\n\t\tstatus.ServerInfoList = append(status.ServerInfoList, serverInfo)\n\t}\n\n\treturn status, nil\n}\n\n// LogConfig logs the MCP configuration summary\n// If mcpConfigPath is empty, uses the Manager's existing config\nfunc (m *Manager) LogConfig(mcpConfigPath string) error {\n\tvar mcpConfig *Config\n\tvar err error\n\n\tif mcpConfigPath == \"\" && m.config != nil {\n\t\tmcpConfig = m.config\n\t} else {\n\t\tmcpConfig, err = LoadConfig(mcpConfigPath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to load MCP config from %s: %w\", mcpConfigPath, err)\n\t\t}\n\t}\n\n\tserverCount := len(mcpConfig.Servers)\n\ttotalServers := serverCount\n\n\tif totalServers > 0 {\n\t\tserverWord := \"server\"\n\t\tif totalServers > 1 {\n\t\t\tserverWord = \"servers\"\n\t\t}\n\n\t\tif mcpConfigPath != \"\" {\n\t\t\tklog.V(2).Infof(\"Loaded %d MCP %s from %s\", totalServers, serverWord, mcpConfigPath)\n\t\t} else {\n\t\t\tklog.V(2).Infof(\"Found %d MCP %s in configuration\", totalServers, serverWord)\n\t\t}\n\n\t\tfor _, server := range mcpConfig.Servers {\n\t\t\tklog.V(2).Infof(\"  - %s: %s\", server.Name, server.Command)\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// =============================================================================\n// Integration Methods\n// =============================================================================\n\n// RegisterWithToolSystem connects to MCP servers and registers discovered tools with an external tool system\n// using the provided callback function. This simplifies integration with kubectl-ai's tool system.\nfunc (m *Manager) RegisterWithToolSystem(ctx context.Context, registerCallback func(serverName string, tool Tool) error) error {\n\tklog.V(1).Info(\"Initializing MCP client functionality and registering tools\")\n\n\t// Connect to all configured servers\n\tif err := m.DiscoverAndConnectServers(ctx); err != nil {\n\t\treturn fmt.Errorf(\"MCP server connection failed: %w\", err)\n\t}\n\n\t// Register all discovered tools using the callback\n\tif err := m.RegisterTools(ctx, registerCallback); err != nil {\n\t\treturn fmt.Errorf(\"MCP tool registration failed: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/mcp/stdio_client.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage mcp\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\tmcpclient \"github.com/mark3labs/mcp-go/client\"\n\tmcp \"github.com/mark3labs/mcp-go/mcp\"\n\t\"k8s.io/klog/v2\"\n)\n\n// ===================================================================\n// Stdio Client Implementation\n// ===================================================================\n\n// stdioClient is an MCP client that communicates via standard I/O\ntype stdioClient struct {\n\tname    string\n\tcommand string\n\targs    []string\n\tenv     []string\n\tclient  *mcpclient.Client\n}\n\n// NewStdioClient creates a new stdio-based MCP client\nfunc NewStdioClient(config ClientConfig) MCPClient {\n\treturn &stdioClient{\n\t\tname:    config.Name,\n\t\tcommand: config.Command,\n\t\targs:    config.Args,\n\t\tenv:     config.Env,\n\t}\n}\n\n// getUnderlyingClient returns the underlying MCP client.\nfunc (c *stdioClient) getUnderlyingClient() *mcpclient.Client {\n\treturn c.client\n}\n\n// ensureConnected makes sure the client is connected.\nfunc (c *stdioClient) ensureConnected() error {\n\treturn ensureClientConnected(c.client)\n}\n\n// Name returns the name of this client.\nfunc (c *stdioClient) Name() string {\n\treturn c.name\n}\n\n// Connect establishes a connection to the stdio MCP server.\nfunc (c *stdioClient) Connect(ctx context.Context) error {\n\tklog.V(2).InfoS(\"Connecting to stdio MCP server\", \"name\", c.name, \"command\", c.command)\n\tif c.client != nil {\n\t\treturn nil // Already connected\n\t}\n\n\t// Expand the command path and prepare the environment\n\texpandedCmd, err := expandPath(c.command)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"expanding command path: %w\", err)\n\t}\n\n\t// Create the stdio MCP client\n\tclient, err := mcpclient.NewStdioMCPClient(expandedCmd, c.env, c.args...)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"creating stdio MCP client: %w\", err)\n\t}\n\n\tc.client = client\n\n\t// Initialize the connection\n\tif err := c.initializeConnection(ctx); err != nil {\n\t\tc.cleanup()\n\t\treturn fmt.Errorf(\"initializing connection: %w\", err)\n\t}\n\n\t// Verify the connection\n\tif err := c.verifyConnection(ctx); err != nil {\n\t\tc.cleanup()\n\t\treturn fmt.Errorf(\"verifying connection: %w\", err)\n\t}\n\n\tklog.V(2).InfoS(\"Successfully connected to stdio MCP server\", \"name\", c.name)\n\treturn nil\n}\n\n// initializeConnection initializes the MCP connection with proper handshake\nfunc (c *stdioClient) initializeConnection(ctx context.Context) error {\n\treturn initializeClientConnection(ctx, c.client)\n}\n\n// verifyConnection verifies the connection works by testing tool listing\nfunc (c *stdioClient) verifyConnection(ctx context.Context) error {\n\treturn verifyClientConnection(ctx, c.client)\n}\n\n// cleanup closes the client connection and resets the client state\nfunc (c *stdioClient) cleanup() {\n\tcleanupClient(&c.client)\n}\n\n// Close closes the connection to the MCP server\nfunc (c *stdioClient) Close() error {\n\tif c.client == nil {\n\t\treturn nil // Already closed\n\t}\n\n\tklog.V(2).InfoS(\"Closing connection to stdio MCP server\", \"name\", c.name)\n\terr := c.client.Close()\n\tc.client = nil\n\n\tif err != nil {\n\t\treturn fmt.Errorf(\"closing MCP client: %w\", err)\n\t}\n\n\treturn nil\n}\n\n// ListTools lists all available tools from the MCP server\nfunc (c *stdioClient) ListTools(ctx context.Context) ([]Tool, error) {\n\ttools, err := listClientTools(ctx, c.client, c.name)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tklog.V(2).InfoS(\"Listed tools from stdio MCP server\", \"count\", len(tools), \"server\", c.name)\n\treturn tools, nil\n}\n\n// CallTool calls a tool on the MCP server and returns the result as a string\nfunc (c *stdioClient) CallTool(ctx context.Context, toolName string, arguments map[string]interface{}) (string, error) {\n\tklog.V(2).InfoS(\"Calling MCP tool via stdio\", \"server\", c.name, \"tool\", toolName)\n\n\tif err := c.ensureConnected(); err != nil {\n\t\treturn \"\", err\n\t}\n\n\t// Create v0.31.0 compatible request\n\trequest := mcp.CallToolRequest{\n\t\tParams: mcp.CallToolParams{\n\t\t\tName:      toolName,\n\t\t\tArguments: arguments,\n\t\t},\n\t}\n\n\t// Call the tool on the MCP server\n\tresult, err := c.client.CallTool(ctx, request)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"error calling tool %s: %w\", toolName, err)\n\t}\n\n\treturn processToolResponse(result)\n}\n"
  },
  {
    "path": "pkg/mcp/utils.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage mcp\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"math\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"k8s.io/klog/v2\"\n)\n\n// RetryConfig defines retry behavior for MCP operations\ntype RetryConfig struct {\n\tMaxRetries  int\n\tBaseDelay   time.Duration\n\tMaxDelay    time.Duration\n\tMultiplier  float64\n\tDescription string\n}\n\n// DefaultRetryConfig returns a sensible default retry configuration\nfunc DefaultRetryConfig(description string) RetryConfig {\n\treturn RetryConfig{\n\t\tMaxRetries:  3,\n\t\tBaseDelay:   1 * time.Second,\n\t\tMaxDelay:    10 * time.Second,\n\t\tMultiplier:  2.0,\n\t\tDescription: description,\n\t}\n}\n\n// RetryOperation executes an operation with exponential backoff retry\nfunc RetryOperation(ctx context.Context, config RetryConfig, operation func() error) error {\n\tvar lastErr error\n\n\tfor attempt := 1; attempt <= config.MaxRetries; attempt++ {\n\t\tklog.V(3).InfoS(\"Attempting operation\",\n\t\t\t\"operation\", config.Description,\n\t\t\t\"attempt\", attempt,\n\t\t\t\"maxRetries\", config.MaxRetries)\n\n\t\tif err := operation(); err == nil {\n\t\t\tif attempt > 1 {\n\t\t\t\tklog.V(2).InfoS(\"Operation succeeded after retry\",\n\t\t\t\t\t\"operation\", config.Description,\n\t\t\t\t\t\"attempt\", attempt)\n\t\t\t}\n\t\t\treturn nil\n\t\t} else {\n\t\t\tlastErr = err\n\n\t\t\tif attempt < config.MaxRetries {\n\t\t\t\tdelay := calculateBackoffDelay(attempt, config)\n\t\t\t\tklog.V(3).InfoS(\"Operation failed, retrying\",\n\t\t\t\t\t\"operation\", config.Description,\n\t\t\t\t\t\"attempt\", attempt,\n\t\t\t\t\t\"error\", err,\n\t\t\t\t\t\"nextRetryIn\", delay)\n\n\t\t\t\tselect {\n\t\t\t\tcase <-ctx.Done():\n\t\t\t\t\treturn fmt.Errorf(\"operation cancelled: %w\", ctx.Err())\n\t\t\t\tcase <-time.After(delay):\n\t\t\t\t\t// Continue to next attempt\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"operation failed after %d attempts: %w\", config.MaxRetries, lastErr)\n}\n\n// calculateBackoffDelay calculates exponential backoff delay with jitter\nfunc calculateBackoffDelay(attempt int, config RetryConfig) time.Duration {\n\tdelay := float64(config.BaseDelay) * math.Pow(config.Multiplier, float64(attempt-1))\n\n\tif time.Duration(delay) > config.MaxDelay {\n\t\treturn config.MaxDelay\n\t}\n\n\treturn time.Duration(delay)\n}\n\n// SanitizeServerName ensures server names are valid identifiers\nfunc SanitizeServerName(name string) string {\n\t// Simple sanitization - replace invalid characters\n\tresult := \"\"\n\tfor _, char := range name {\n\t\tif (char >= 'a' && char <= 'z') ||\n\t\t\t(char >= 'A' && char <= 'Z') ||\n\t\t\t(char >= '0' && char <= '9') ||\n\t\t\tchar == '-' || char == '_' {\n\t\t\tresult += string(char)\n\t\t} else {\n\t\t\tresult += \"_\"\n\t\t}\n\t}\n\n\tif result == \"\" {\n\t\tresult = \"unnamed\"\n\t}\n\n\treturn result\n}\n\n// GroupToolsByServer groups tools by their server name for easier display\nfunc GroupToolsByServer(tools map[string][]Tool) map[string]int {\n\tsummary := make(map[string]int)\n\n\tfor serverName, serverTools := range tools {\n\t\tsummary[serverName] = len(serverTools)\n\t}\n\n\treturn summary\n}\n\n// mergeEnvironmentVariables merges process environment with custom environment variables\nfunc mergeEnvironmentVariables(processEnv, customEnv []string) []string {\n\tenvMap := make(map[string]string)\n\n\t// Parse process environment\n\tfor _, e := range processEnv {\n\t\tif parts := strings.SplitN(e, \"=\", 2); len(parts) == 2 {\n\t\t\tenvMap[parts[0]] = parts[1]\n\t\t}\n\t}\n\n\t// Override with custom environment variables\n\tfor _, env := range customEnv {\n\t\tif parts := strings.SplitN(env, \"=\", 2); len(parts) == 2 {\n\t\t\tenvMap[parts[0]] = parts[1]\n\t\t}\n\t}\n\n\t// Convert back to slice\n\tfinalEnv := make([]string, 0, len(envMap))\n\tfor k, v := range envMap {\n\t\tfinalEnv = append(finalEnv, fmt.Sprintf(\"%s=%s\", k, v))\n\t}\n\n\treturn finalEnv\n}\n\n// expandPath expands the command path, handling ~ and environment variables\n// If the path is just a binary name (no path separators), it looks in $PATH\nfunc expandPath(path string) (string, error) {\n\tif path == \"\" {\n\t\treturn \"\", fmt.Errorf(\"path cannot be empty\")\n\t}\n\n\t// Expand environment variables first\n\texpanded := os.ExpandEnv(path)\n\n\t// If the command contains no path separators, look it up in $PATH first\n\tif !strings.Contains(expanded, string(filepath.Separator)) && !strings.HasPrefix(expanded, \"~\") {\n\t\tklog.V(2).InfoS(\"Attempting PATH lookup for command\", \"command\", expanded)\n\t\t// Try to find the command in $PATH\n\t\tif pathResolved, err := exec.LookPath(expanded); err == nil {\n\t\t\tklog.V(2).InfoS(\"Found command in PATH\", \"command\", expanded, \"resolved\", pathResolved)\n\t\t\treturn pathResolved, nil\n\t\t} else {\n\t\t\tklog.V(2).InfoS(\"Command not found in PATH\", \"command\", expanded, \"error\", err)\n\t\t}\n\t\t// If not found in PATH, continue with the original logic below\n\t\tklog.V(2).InfoS(\"Command not found in PATH, trying relative to current directory\", \"command\", expanded)\n\t} else {\n\t\tklog.V(2).InfoS(\"Skipping PATH lookup\", \"command\", expanded, \"hasPathSeparator\", strings.Contains(expanded, string(filepath.Separator)), \"hasTilde\", strings.HasPrefix(expanded, \"~\"))\n\t}\n\n\t// Handle ~ for home directory\n\tif strings.HasPrefix(expanded, \"~\") {\n\t\thome, err := os.UserHomeDir()\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"getting home directory: %w\", err)\n\t\t}\n\t\texpanded = filepath.Join(home, expanded[1:])\n\t}\n\n\t// Clean the path to remove any . or .. elements\n\texpanded = filepath.Clean(expanded)\n\n\t// Make the path absolute if it's not already\n\tif !filepath.IsAbs(expanded) {\n\t\tcwd, err := os.Getwd()\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"getting current working directory: %w\", err)\n\t\t}\n\t\texpanded = filepath.Clean(filepath.Join(cwd, expanded))\n\t}\n\n\t// Verify the file exists and is executable\n\tinfo, err := os.Stat(expanded)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(ErrPathCheckFmt, expanded, err)\n\t}\n\n\t// Check if it's a regular file and executable\n\tif !info.Mode().IsRegular() {\n\t\treturn \"\", fmt.Errorf(\"path %q is not a regular file\", expanded)\n\t}\n\n\t// Check if the file is executable by the current user\n\tif info.Mode().Perm()&0111 == 0 {\n\t\treturn \"\", fmt.Errorf(\"file %q is not executable\", expanded)\n\t}\n\n\treturn expanded, nil\n}\n\n// =============================================================================\n// Helper Functions to Reduce Redundancy\n// =============================================================================\n\n// withTimeout creates a context with the specified timeout and returns the context and cancel function\nfunc withTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {\n\treturn context.WithTimeout(ctx, timeout)\n}\n\n// ensureConnected checks if the client is connected and returns an error if not\nfunc (c *Client) ensureConnected() error {\n\tif c.client == nil {\n\t\treturn fmt.Errorf(\"not connected to MCP server\")\n\t}\n\treturn nil\n}\n\n// =============================================================================\n// MCP Tool Helper Functions\n// =============================================================================\n\n// FunctionDefinition is an interface representing generic function schema definitions\n// This allows the MCP package to create schemas without directly depending on gollm\ntype FunctionDefinition interface {\n\t// Schema returns a representation of the function schema\n\tSchema() any\n}\n\n// SchemaProperty is an interface representing generic schema properties\ntype SchemaProperty interface {\n\t// Property returns a representation of the schema property\n\tProperty() any\n}\n\n// SchemaBuilder is a function that builds a function definition from a tool\ntype SchemaBuilder func(tool *Tool) (FunctionDefinition, error)\n\n// ConvertArgs handles all argument conversions for MCP tools.\n// It transforms keys from snake_case to camelCase and converts values to appropriate types.\nfunc ConvertArgs(args map[string]any) map[string]any {\n\tif len(args) == 0 {\n\t\treturn args\n\t}\n\n\tresult := make(map[string]any, len(args))\n\n\tfor key, value := range args {\n\t\t// Convert key from snake_case to camelCase\n\t\tcamelKey := SnakeToCamel(key)\n\n\t\t// Convert value based on key name patterns\n\t\tresult[camelKey] = ConvertValue(camelKey, value)\n\t}\n\n\treturn result\n}\n\n// SnakeToCamel converts a snake_case string to camelCase.\nfunc SnakeToCamel(s string) string {\n\tif !strings.Contains(s, \"_\") {\n\t\treturn s\n\t}\n\n\tparts := strings.Split(s, \"_\")\n\tresult := parts[0]\n\n\tfor _, part := range parts[1:] {\n\t\tif len(part) > 0 {\n\t\t\tresult += strings.ToUpper(part[:1]) + part[1:]\n\t\t}\n\t}\n\n\treturn result\n}\n\n// ConvertValue infers and converts a value to an appropriate type based on the parameter name.\nfunc ConvertValue(paramName string, value any) any {\n\t// Already primitive types that don't need conversion\n\tswitch value.(type) {\n\tcase bool, int, int32, int64, float32, float64:\n\t\treturn value\n\t}\n\n\tname := strings.ToLower(paramName)\n\n\t// Number parameter detection\n\tif IsNumberParam(name) {\n\t\tif str, ok := value.(string); ok {\n\t\t\t// Try integer conversion first\n\t\t\tif num, err := strconv.Atoi(str); err == nil {\n\t\t\t\treturn num\n\t\t\t}\n\t\t\t// Then try float conversion\n\t\t\tif num, err := strconv.ParseFloat(str, 64); err == nil {\n\t\t\t\treturn num\n\t\t\t}\n\t\t} else if f, ok := value.(float64); ok && f == float64(int(f)) {\n\t\t\t// Convert whole number floats to int\n\t\t\treturn int(f)\n\t\t}\n\t}\n\n\t// Boolean parameter detection\n\tif IsBoolParam(name) {\n\t\tif str, ok := value.(string); ok {\n\t\t\tif b, err := strconv.ParseBool(str); err == nil {\n\t\t\t\treturn b\n\t\t\t}\n\t\t} else if n, ok := value.(int); ok {\n\t\t\treturn n != 0\n\t\t}\n\t}\n\n\treturn value\n}\n\n// IsNumberParam checks if a parameter name suggests a numeric value.\nfunc IsNumberParam(name string) bool {\n\tnumberPatterns := []string{\"number\", \"count\", \"total\", \"max\", \"min\", \"limit\"}\n\tfor _, pattern := range numberPatterns {\n\t\tif strings.Contains(name, pattern) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// IsBoolParam checks if a parameter name suggests a boolean value.\nfunc IsBoolParam(name string) bool {\n\t// Prefix checks\n\tboolPrefixes := []string{\"is\", \"has\", \"needs\", \"enable\"}\n\tfor _, prefix := range boolPrefixes {\n\t\tif strings.HasPrefix(name, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\t// Contains checks\n\tboolPatterns := []string{\"required\", \"enabled\"}\n\tfor _, pattern := range boolPatterns {\n\t\tif strings.Contains(name, pattern) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "pkg/sandbox/executor.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sandbox\n\nimport (\n\t\"context\"\n\t\"fmt\"\n)\n\n// Executor defines the interface for executing commands.\ntype Executor interface {\n\t// Execute runs a command and returns the result.\n\tExecute(ctx context.Context, command string, env []string, workDir string) (*ExecResult, error)\n\n\t// Close cleans up any resources associated with the executor.\n\tClose(ctx context.Context) error\n}\n\n// ExecResult represents the result of a command execution.\ntype ExecResult struct {\n\tCommand    string `json:\"command,omitempty\"`\n\tError      string `json:\"error,omitempty\"`\n\tStdout     string `json:\"stdout,omitempty\"`\n\tStderr     string `json:\"stderr,omitempty\"`\n\tExitCode   int    `json:\"exit_code,omitempty\"`\n\tStreamType string `json:\"stream_type,omitempty\"`\n}\n\nfunc (e *ExecResult) String() string {\n\treturn fmt.Sprintf(\"Command: %q\\nError: %q\\nStdout: %q\\nStderr: %q\\nExitCode: %d\\nStreamType: %q}\", e.Command, e.Error, e.Stdout, e.Stderr, e.ExitCode, e.StreamType)\n}\n"
  },
  {
    "path": "pkg/sandbox/kubernetes.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package sandbox provides Kubernetes-based sandboxed command execution with an exec.Command-like interface\n// A sandbox represents an isolated execution environment. Currently implemented using Kubernetes pods,\n// but can be extended to support other backends like Docker containers in the future.\npackage sandbox\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n\n\tcorev1 \"k8s.io/api/core/v1\"\n\t\"k8s.io/apimachinery/pkg/api/errors\"\n\tmetav1 \"k8s.io/apimachinery/pkg/apis/meta/v1\"\n\t\"k8s.io/apimachinery/pkg/util/wait\"\n\t\"k8s.io/client-go/kubernetes\"\n\t\"k8s.io/client-go/kubernetes/scheme\"\n\t\"k8s.io/client-go/rest\"\n\t\"k8s.io/client-go/tools/clientcmd\"\n\t\"k8s.io/client-go/tools/remotecommand\"\n)\n\n// KubernetesSandbox represents a Kubernetes-based sandboxed execution environment\ntype KubernetesSandbox struct {\n\tname       string\n\tnamespace  string\n\timage      string\n\tkubeconfig string\n\tclientset  *kubernetes.Clientset\n\tconfig     *rest.Config\n}\n\n// Execute executes the command in the sandbox.\nfunc (s *KubernetesSandbox) Execute(ctx context.Context, command string, env []string, workDir string) (*ExecResult, error) {\n\tfullCommand := command\n\n\t// Ensure kubectl is in the PATH\n\tfullCommand = fmt.Sprintf(\"export PATH=/opt/bitnami/kubectl/bin:$PATH; %s\", fullCommand)\n\n\tif workDir != \"\" {\n\t\tfullCommand = fmt.Sprintf(\"mkdir -p %q && cd %q && %s\", workDir, workDir, fullCommand)\n\t}\n\n\tfor _, envVar := range env {\n\t\tfullCommand = fmt.Sprintf(\"export %s; %s\", envVar, fullCommand)\n\t}\n\n\tcmd := s.CommandContext(ctx, fullCommand)\n\toutput, err := cmd.CombinedOutput()\n\n\tresult := &ExecResult{\n\t\tCommand: command,\n\t\tStdout:  string(output),\n\t}\n\tif err != nil {\n\t\tresult.Error = err.Error()\n\t\tresult.ExitCode = 1\n\t}\n\n\treturn result, nil\n}\n\n// Close cleans up the sandbox resources.\nfunc (s *KubernetesSandbox) Close(ctx context.Context) error {\n\treturn s.Delete(ctx)\n}\n\n// Cmd represents a command to be executed in a sandbox\n// It follows the same interface pattern as exec.Cmd\ntype Cmd struct {\n\tsandbox *KubernetesSandbox\n\tcommand []string\n\tctx     context.Context\n\n\t// Standard streams (similar to exec.Cmd)\n\tStdin  io.Reader\n\tStdout io.Writer\n\tStderr io.Writer\n}\n\n// Option represents a configuration option for KubernetesSandbox\ntype Option func(*KubernetesSandbox) error\n\n// NewKubernetesSandbox creates a new KubernetesSandbox instance with the given name and options\nfunc NewKubernetesSandbox(name string, opts ...Option) (*KubernetesSandbox, error) {\n\ts := &KubernetesSandbox{\n\t\tname:      name,\n\t\tnamespace: \"computer\", // default namespace\n\t}\n\n\t// Apply options\n\tfor _, opt := range opts {\n\t\tif err := opt(s); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\t// Initialize Kubernetes client\n\tconfig, err := clientcmd.BuildConfigFromFlags(\"\", s.kubeconfig)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error building kubeconfig: %v\", err)\n\t}\n\n\tclientset, err := kubernetes.NewForConfig(config)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error creating Kubernetes client: %v\", err)\n\t}\n\n\ts.config = config\n\ts.clientset = clientset\n\n\treturn s, nil\n}\n\n// WithKubeconfig sets the kubeconfig file path\nfunc WithKubeconfig(kubeconfig string) Option {\n\treturn func(s *KubernetesSandbox) error {\n\t\ts.kubeconfig = kubeconfig\n\t\treturn nil\n\t}\n}\n\n// WithName sets the sandbox name (deprecated - use constructor parameter instead)\nfunc WithName(name string) Option {\n\treturn func(s *KubernetesSandbox) error {\n\t\ts.name = name\n\t\treturn nil\n\t}\n}\n\n// WithNamespace sets the namespace\nfunc WithNamespace(namespace string) Option {\n\treturn func(s *KubernetesSandbox) error {\n\t\ts.namespace = namespace\n\t\treturn nil\n\t}\n}\n\n// WithImage sets the container image\nfunc WithImage(image string) Option {\n\treturn func(s *KubernetesSandbox) error {\n\t\ts.image = image\n\t\treturn nil\n\t}\n}\n\n// Command creates a new Cmd to execute the given command in the sandbox\n// This follows the same interface as exec.Command\nfunc (s *KubernetesSandbox) Command(name string, arg ...string) *Cmd {\n\tcmd := &Cmd{\n\t\tsandbox: s,\n\t\tcommand: append([]string{name}, arg...),\n\t\tctx:     context.Background(),\n\t}\n\treturn cmd\n}\n\n// CommandContext creates a new Cmd with a context\nfunc (s *KubernetesSandbox) CommandContext(ctx context.Context, name string, arg ...string) *Cmd {\n\tcmd := &Cmd{\n\t\tsandbox: s,\n\t\tcommand: append([]string{name}, arg...),\n\t\tctx:     ctx,\n\t}\n\treturn cmd\n}\n\n// Delete removes the sandbox pod and its associated resources, waiting for them to be fully terminated.\n// It does not return an error if the resources are already deleted.\nfunc (s *KubernetesSandbox) Delete(ctx context.Context) error {\n\tvar errs []string\n\n\t// 1. Initiate deletion of the Pod with a zero grace period for faster removal.\n\tdeleteOptions := metav1.DeleteOptions{\n\t\tGracePeriodSeconds: new(int64), // 0 seconds\n\t}\n\terr := s.clientset.CoreV1().Pods(s.namespace).Delete(ctx, s.name, deleteOptions)\n\tif err != nil && !errors.IsNotFound(err) {\n\t\terrs = append(errs, fmt.Sprintf(\"failed to initiate pod deletion: %v\", err))\n\t}\n\n\t// 2. Initiate deletion of the ConfigMap.\n\tconfigMapName := s.name + \"-kubeconfig\"\n\tif err := s.deleteKubeconfigMap(ctx, configMapName); err != nil {\n\t\terrs = append(errs, fmt.Sprintf(\"failed to initiate configmap deletion: %v\", err))\n\t}\n\n\t// 3. Wait for the Pod to be fully terminated.\n\tpollErr := wait.PollUntilContextTimeout(ctx, 2*time.Second, 1*time.Minute, true, func(ctx context.Context) (bool, error) {\n\t\t_, getErr := s.clientset.CoreV1().Pods(s.namespace).Get(ctx, s.name, metav1.GetOptions{})\n\t\tif errors.IsNotFound(getErr) {\n\t\t\treturn true, nil // Pod is gone.\n\t\t}\n\t\tif getErr != nil {\n\t\t\treturn false, getErr // Polling failed with an unexpected error.\n\t\t}\n\t\treturn false, nil // Pod still exists, continue polling.\n\t})\n\tif pollErr != nil {\n\t\terrs = append(errs, fmt.Sprintf(\"error waiting for pod deletion: %v\", pollErr))\n\t}\n\n\tif len(errs) > 0 {\n\t\treturn fmt.Errorf(\"errors during sandbox deletion: %s\", strings.Join(errs, \"; \"))\n\t}\n\n\treturn nil\n}\n\n// Run executes the command and waits for it to complete\nfunc (c *Cmd) Run() error {\n\treturn c.execute(nil, nil)\n}\n\n// Output runs the command and returns its standard output\nfunc (c *Cmd) Output() ([]byte, error) {\n\tvar stdout bytes.Buffer\n\terr := c.execute(&stdout, nil)\n\treturn stdout.Bytes(), err\n}\n\n// CombinedOutput runs the command and returns its combined standard output and standard error\nfunc (c *Cmd) CombinedOutput() ([]byte, error) {\n\tvar output bytes.Buffer\n\terr := c.execute(&output, &output)\n\treturn output.Bytes(), err\n}\n\n// execute is the internal method that handles the actual pod execution\nfunc (c *Cmd) execute(stdout, stderr io.Writer) error {\n\tsandbox := c.sandbox\n\n\t// Validate required fields\n\tif sandbox.name == \"\" || sandbox.image == \"\" {\n\t\treturn fmt.Errorf(\"sandbox name and image must be specified\")\n\t}\n\n\t// Check if pod exists and validate its image if it does.\n\texistingPod, err := c.getPod()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error checking for existing sandbox: %w\", err)\n\t}\n\n\tif existingPod != nil {\n\t\t// Sandbox exists. Verify the container image matches.\n\t\tvar existingImage string\n\t\tfor _, container := range existingPod.Spec.Containers {\n\t\t\tif container.Name == \"main\" {\n\t\t\t\texistingImage = container.Image\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif existingImage != \"\" && existingImage != sandbox.image {\n\t\t\treturn fmt.Errorf(\n\t\t\t\t\"existing sandbox '%s' uses image '%s', but new execution requested image '%s'. Please delete the sandbox first\",\n\t\t\t\tsandbox.name,\n\t\t\t\texistingImage,\n\t\t\t\tsandbox.image,\n\t\t\t)\n\t\t}\n\t} else {\n\t\t// Pod doesn't exist, create it.\n\t\tif err := c.createPod(); err != nil {\n\t\t\treturn fmt.Errorf(\"error creating pod: %v\", err)\n\t\t}\n\t}\n\n\t// Wait for pod to be ready\n\tif err := c.waitForPodReady(); err != nil {\n\t\treturn fmt.Errorf(\"error waiting for pod to be ready: %v\", err)\n\t}\n\n\t// Execute command in pod\n\treturn c.executeInPod(stdout, stderr)\n}\n\n// getPod fetches the sandbox pod if it exists. Returns (nil, nil) if not found.\nfunc (c *Cmd) getPod() (*corev1.Pod, error) {\n\tsandbox := c.sandbox\n\tpod, err := sandbox.clientset.CoreV1().Pods(sandbox.namespace).Get(c.ctx, sandbox.name, metav1.GetOptions{})\n\tif err != nil {\n\t\tif errors.IsNotFound(err) {\n\t\t\treturn nil, nil // Not an error, just means we need to create it.\n\t\t}\n\t\treturn nil, err\n\t}\n\treturn pod, nil\n}\n\n// createPod creates a new pod for the sandbox, including its kubeconfig configmap\nfunc (c *Cmd) createPod() error {\n\tsandbox := c.sandbox\n\tconfigMapName := sandbox.name + \"-kubeconfig\"\n\n\t// Create a dedicated kubeconfig for the pod to use.\n\t// This ensures kubectl defaults to the \"default\" namespace.\n\tif err := c.createKubeconfigMap(configMapName); err != nil {\n\t\t// If the configmap already exists, we can proceed.\n\t\tif !errors.IsAlreadyExists(err) {\n\t\t\treturn fmt.Errorf(\"failed to create in-pod kubeconfig: %w\", err)\n\t\t}\n\t}\n\n\tpod := &corev1.Pod{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      sandbox.name,\n\t\t\tNamespace: sandbox.namespace,\n\t\t},\n\t\tSpec: corev1.PodSpec{\n\t\t\tServiceAccountName: \"normal-user\",\n\t\t\tContainers: []corev1.Container{\n\t\t\t\t{\n\t\t\t\t\tName:    \"main\",\n\t\t\t\t\tImage:   sandbox.image,\n\t\t\t\t\tCommand: []string{\"sleep\"},\n\t\t\t\t\tArgs:    []string{\"infinity\"}, // Sleep forever to keep the container running\n\t\t\t\t\tEnv: []corev1.EnvVar{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"KUBECONFIG\",\n\t\t\t\t\t\t\tValue: \"/etc/kube/config\",\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:  \"PATH\",\n\t\t\t\t\t\t\tValue: \"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/bitnami/kubectl/bin\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\tVolumeMounts: []corev1.VolumeMount{\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tName:      \"kubeconfig-volume\",\n\t\t\t\t\t\t\tMountPath: \"/etc/kube\",\n\t\t\t\t\t\t\tReadOnly:  true,\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tVolumes: []corev1.Volume{\n\t\t\t\t{\n\t\t\t\t\tName: \"kubeconfig-volume\",\n\t\t\t\t\tVolumeSource: corev1.VolumeSource{\n\t\t\t\t\t\tConfigMap: &corev1.ConfigMapVolumeSource{\n\t\t\t\t\t\t\tLocalObjectReference: corev1.LocalObjectReference{\n\t\t\t\t\t\t\t\tName: configMapName,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tItems: []corev1.KeyToPath{\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tKey:  \"config\",\n\t\t\t\t\t\t\t\t\tPath: \"config\",\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t\tRestartPolicy: corev1.RestartPolicyNever,\n\t\t},\n\t}\n\n\t_, podCreateErr := sandbox.clientset.CoreV1().Pods(sandbox.namespace).Create(c.ctx, pod, metav1.CreateOptions{})\n\tif podCreateErr != nil {\n\t\t// If pod creation fails, attempt to clean up the configmap we just created.\n\t\tif cleanupErr := sandbox.deleteKubeconfigMap(c.ctx, configMapName); cleanupErr != nil {\n\t\t\treturn fmt.Errorf(\"pod creation failed: %v; ALSO, configmap cleanup failed: %v\", podCreateErr, cleanupErr)\n\t\t}\n\t\treturn fmt.Errorf(\"pod creation failed: %w\", podCreateErr)\n\t}\n\n\treturn nil\n}\n\n// createKubeconfigMap generates a kubeconfig file that uses the pod's service account token\n// and sets the default namespace to \"default\". This is stored in a ConfigMap.\nfunc (c *Cmd) createKubeconfigMap(name string) error {\n\tsandbox := c.sandbox\n\n\t// Use a static string template for the kubeconfig to ensure correctness.\n\tkubeconfigYAML := fmt.Sprintf(`apiVersion: v1\nclusters:\n- cluster:\n    server: https://kubernetes.default.svc\n    certificate-authority: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt\n  name: default\ncontexts:\n- context:\n    cluster: default\n    namespace: %s\n    user: default\n  name: default\ncurrent-context: default\nusers:\n- name: default\n  user:\n    tokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token`, sandbox.namespace)\n\n\t// Create the ConfigMap object.\n\tconfigMap := &corev1.ConfigMap{\n\t\tObjectMeta: metav1.ObjectMeta{\n\t\t\tName:      name,\n\t\t\tNamespace: sandbox.namespace,\n\t\t},\n\t\tData: map[string]string{\n\t\t\t\"config\": kubeconfigYAML,\n\t\t},\n\t}\n\n\t_, err := sandbox.clientset.CoreV1().ConfigMaps(sandbox.namespace).Create(c.ctx, configMap, metav1.CreateOptions{})\n\treturn err\n}\n\n// deleteKubeconfigMap cleans up the ConfigMap created for the pod.\nfunc (s *KubernetesSandbox) deleteKubeconfigMap(ctx context.Context, name string) error {\n\terr := s.clientset.CoreV1().ConfigMaps(s.namespace).Delete(ctx, name, metav1.DeleteOptions{})\n\tif err != nil && !errors.IsNotFound(err) {\n\t\treturn fmt.Errorf(\"failed to delete kubeconfig configmap: %w\", err)\n\t}\n\treturn nil\n}\n\n// waitForPodReady waits for the pod to be ready\nfunc (c *Cmd) waitForPodReady() error {\n\tsandbox := c.sandbox\n\treturn wait.PollUntilContextTimeout(c.ctx, 2*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) {\n\t\tpod, err := sandbox.clientset.CoreV1().Pods(sandbox.namespace).Get(ctx, sandbox.name, metav1.GetOptions{})\n\t\tif err != nil {\n\t\t\treturn false, err\n\t\t}\n\n\t\t// Check if pod is ready\n\t\tfor _, condition := range pod.Status.Conditions {\n\t\t\tif condition.Type == corev1.PodReady && condition.Status == corev1.ConditionTrue {\n\t\t\t\treturn true, nil\n\t\t\t}\n\t\t}\n\n\t\t// Check if pod failed\n\t\tif pod.Status.Phase == corev1.PodFailed {\n\t\t\treturn false, fmt.Errorf(\"pod %s failed\", sandbox.name)\n\t\t}\n\n\t\treturn false, nil\n\t})\n}\n\n// executeInPod executes the command in the pod\nfunc (c *Cmd) executeInPod(stdout, stderr io.Writer) error {\n\tsandbox := c.sandbox\n\n\t// Use provided writers or default to the Cmd's streams\n\tif stdout == nil {\n\t\tstdout = c.Stdout\n\t\tif stdout == nil {\n\t\t\tstdout = os.Stdout\n\t\t}\n\t}\n\tif stderr == nil {\n\t\tstderr = c.Stderr\n\t\tif stderr == nil {\n\t\t\tstderr = os.Stderr\n\t\t}\n\t}\n\n\treq := sandbox.clientset.CoreV1().RESTClient().Post().\n\t\tResource(\"pods\").\n\t\tName(sandbox.name).\n\t\tNamespace(sandbox.namespace).\n\t\tSubResource(\"exec\")\n\n\tcommandStr := strings.Join(c.command, \" \")\n\treq.VersionedParams(&corev1.PodExecOptions{\n\t\tContainer: \"main\",\n\t\tCommand:   []string{\"/bin/sh\", \"-c\", commandStr},\n\t\tStdin:     c.Stdin != nil,\n\t\tStdout:    true,\n\t\tStderr:    true,\n\t\tTTY:       false,\n\t}, scheme.ParameterCodec)\n\n\texec, err := remotecommand.NewSPDYExecutor(sandbox.config, \"POST\", req.URL())\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error creating executor: %v\", err)\n\t}\n\n\terr = exec.StreamWithContext(c.ctx, remotecommand.StreamOptions{\n\t\tStdin:  c.Stdin,\n\t\tStdout: stdout,\n\t\tStderr: stderr,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"error executing command: %v\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sandbox/local.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sandbox\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"os\"\n\t\"os/exec\"\n\t\"runtime\"\n\n\t\"k8s.io/klog/v2\"\n)\n\nconst (\n\tdefaultBashBin = \"/bin/bash\"\n)\n\n// Local executes commands locally.\ntype Local struct{}\n\n// NewLocalExecutor creates a new LocalExecutor.\nfunc NewLocalExecutor() *Local {\n\treturn &Local{}\n}\n\n// Execute executes the command locally.\nfunc (e *Local) Execute(ctx context.Context, command string, env []string, workDir string) (*ExecResult, error) {\n\t// Use the provided context directly\n\tcmdCtx := ctx\n\n\tvar cmd *exec.Cmd\n\tif runtime.GOOS == \"windows\" {\n\t\tcmd = exec.CommandContext(cmdCtx, os.Getenv(\"COMSPEC\"), \"/c\", command)\n\t} else {\n\t\tcmd = exec.CommandContext(cmdCtx, lookupBashBin(), \"-c\", command)\n\t}\n\tcmd.Dir = workDir\n\tcmd.Env = env\n\n\tvar stdoutBuf, stderrBuf bytes.Buffer\n\tcmd.Stdout = &stdoutBuf\n\tcmd.Stderr = &stderrBuf\n\n\terr := cmd.Run()\n\n\tresult := &ExecResult{\n\t\tCommand: command,\n\t\tStdout:  stdoutBuf.String(),\n\t\tStderr:  stderrBuf.String(),\n\t}\n\n\tif err != nil {\n\t\t// If it wasn't a timeout (or not a streaming command), it's a real error\n\t\tif exitError, ok := err.(*exec.ExitError); ok {\n\t\t\tresult.ExitCode = exitError.ExitCode()\n\t\t\tresult.Error = exitError.Error()\n\t\t\t// Stderr is already captured in result.Stderr\n\t\t} else {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn result, nil\n}\n\n// Close is a no-op for Local executor.\nfunc (e *Local) Close(ctx context.Context) error {\n\treturn nil\n}\n\n// Find the bash executable path using exec.LookPath.\nfunc lookupBashBin() string {\n\tactualBashPath, err := exec.LookPath(\"bash\")\n\tif err != nil {\n\t\tklog.Warningf(\"'bash' not found in PATH, defaulting to %s: %v\", defaultBashBin, err)\n\t\treturn defaultBashBin\n\t}\n\treturn actualBashPath\n}\n"
  },
  {
    "path": "pkg/sandbox/seatbelt_executor.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build darwin\n\npackage sandbox\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"fmt\"\n\t\"os/exec\"\n)\n\n// Seatbelt executes commands in a seatbelt sandbox.\ntype Seatbelt struct {\n\tlocal *Local\n}\n\n// NewSeatbeltExecutor creates a new SeatbeltExecutor.\nfunc NewSeatbeltExecutor() *Seatbelt {\n\treturn &Seatbelt{\n\t\tlocal: NewLocalExecutor(),\n\t}\n}\n\n// Execute executes the command in the seatbelt sandbox.\nfunc (e *Seatbelt) Execute(ctx context.Context, command string, env []string, workDir string) (*ExecResult, error) {\n\t// Use the provided context directly\n\tcmdCtx := ctx\n\n\t// This profile allows reading/writing to the working directory and /tmp,\n\t// but denies writing to other system locations by default (implicitly, though 'allow default' is permissive).\n\n\t// Use a basic profile for now.\n\twrappedCommand := fmt.Sprintf(\"sandbox-exec -p %q /bin/bash -c %q\", \"(version 1) (allow default)\", command)\n\tcmd := exec.CommandContext(cmdCtx, \"/bin/bash\", \"-c\", wrappedCommand)\n\tcmd.Dir = workDir\n\tcmd.Env = env\n\n\tvar stdout, stderr bytes.Buffer\n\tcmd.Stdout = &stdout\n\tcmd.Stderr = &stderr\n\n\terr := cmd.Run()\n\n\tresult := &ExecResult{\n\t\tCommand:  command,\n\t\tStdout:   stdout.String(),\n\t\tStderr:   stderr.String(),\n\t\tExitCode: 0,\n\t}\n\n\tif err != nil {\n\t\tresult.Error = err.Error()\n\t\tif exitErr, ok := err.(*exec.ExitError); ok {\n\t\t\tresult.ExitCode = exitErr.ExitCode()\n\t\t} else {\n\t\t\tresult.ExitCode = 1\n\t\t}\n\t}\n\n\treturn result, nil\n}\n\n// Close is a no-op for Seatbelt executor.\nfunc (e *Seatbelt) Close(ctx context.Context) error {\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sandbox/seatbelt_executor_others.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n//go:build !darwin\n\npackage sandbox\n\nimport (\n\t\"context\"\n\t\"fmt\"\n)\n\n// Seatbelt executes commands in a seatbelt sandbox.\ntype Seatbelt struct{}\n\n// NewSeatbeltExecutor creates a new SeatbeltExecutor.\nfunc NewSeatbeltExecutor() *Seatbelt {\n\treturn &Seatbelt{}\n}\n\n// Execute executes the command in the seatbelt sandbox.\nfunc (e *Seatbelt) Execute(ctx context.Context, command string, env []string, workDir string) (*ExecResult, error) {\n\treturn nil, fmt.Errorf(\"seatbelt sandbox is only supported on macOS\")\n}\n\n// Close is a no-op for Seatbelt executor.\nfunc (e *Seatbelt) Close(ctx context.Context) error {\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sessions/filesystem.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sessions\n\nimport (\n\t\"bufio\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"sync\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\t\"sigs.k8s.io/yaml\"\n)\n\ntype filesystemStore struct {\n\tbasePath string\n}\n\nfunc newFilesystemStore(basePath string) Store {\n\treturn &filesystemStore{basePath: basePath}\n}\n\nfunc (f *filesystemStore) GetSession(id string) (*api.Session, error) {\n\tsessionPath := filepath.Join(f.basePath, id)\n\tmetadataPath := filepath.Join(sessionPath, \"metadata.yaml\")\n\n\tmetadataBytes, err := os.ReadFile(metadataPath)\n\tif err != nil {\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\treturn nil, errors.New(\"session not found\")\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tvar meta Metadata\n\tif err := yaml.Unmarshal(metadataBytes, &meta); err != nil {\n\t\treturn nil, err\n\t}\n\n\tchatStore := NewFileChatMessageStore(sessionPath)\n\treturn &api.Session{\n\t\tID:               id,\n\t\tProviderID:       meta.ProviderID,\n\t\tModelID:          meta.ModelID,\n\t\tAgentState:       api.AgentStateIdle,\n\t\tCreatedAt:        meta.CreatedAt,\n\t\tLastModified:     meta.LastAccessed,\n\t\tChatMessageStore: chatStore,\n\t}, nil\n}\n\nfunc (f *filesystemStore) CreateSession(session *api.Session) error {\n\tsessionPath := filepath.Join(f.basePath, session.ID)\n\tif err := os.MkdirAll(sessionPath, 0o755); err != nil {\n\t\treturn err\n\t}\n\n\tchatStore := NewFileChatMessageStore(sessionPath)\n\tsession.ChatMessageStore = chatStore\n\n\tmeta := Metadata{\n\t\tProviderID:   session.ProviderID,\n\t\tModelID:      session.ModelID,\n\t\tCreatedAt:    session.CreatedAt,\n\t\tLastAccessed: session.LastModified,\n\t}\n\n\tdata, err := yaml.Marshal(meta)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn os.WriteFile(filepath.Join(sessionPath, \"metadata.yaml\"), data, 0o644)\n}\n\nfunc (f *filesystemStore) UpdateSession(session *api.Session) error {\n\tsessionPath := filepath.Join(f.basePath, session.ID)\n\tmetadataPath := filepath.Join(sessionPath, \"metadata.yaml\")\n\n\tmetadataBytes, err := os.ReadFile(metadataPath)\n\tif err != nil {\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\treturn errors.New(\"session not found\")\n\t\t}\n\t\treturn err\n\t}\n\n\tvar meta Metadata\n\tif err := yaml.Unmarshal(metadataBytes, &meta); err != nil {\n\t\treturn err\n\t}\n\n\tmeta.ProviderID = session.ProviderID\n\tmeta.ModelID = session.ModelID\n\tmeta.LastAccessed = session.LastModified\n\n\tdata, err := yaml.Marshal(meta)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn os.WriteFile(metadataPath, data, 0o644)\n}\n\nfunc (f *filesystemStore) ListSessions() ([]*api.Session, error) {\n\tentries, err := os.ReadDir(f.basePath)\n\tif err != nil {\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\treturn []*api.Session{}, nil\n\t\t}\n\t\treturn nil, err\n\t}\n\n\tsessions := make([]*api.Session, 0, len(entries))\n\tfor _, entry := range entries {\n\t\tif !entry.IsDir() {\n\t\t\tcontinue\n\t\t}\n\n\t\tsession, err := f.GetSession(entry.Name())\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tsessions = append(sessions, session)\n\t}\n\n\tsort.Slice(sessions, func(i, j int) bool {\n\t\treturn sessions[i].LastModified.After(sessions[j].LastModified)\n\t})\n\n\treturn sessions, nil\n}\n\nfunc (f *filesystemStore) DeleteSession(id string) error {\n\tsessionPath := filepath.Join(f.basePath, id)\n\treturn os.RemoveAll(sessionPath)\n}\n\n// FileChatMessageStore implements api.ChatMessageStore by persisting history to disk.\ntype FileChatMessageStore struct {\n\tPath string\n\tmu   sync.Mutex\n}\n\n// NewFileChatMessageStore creates a new file-backed chat message store.\nfunc NewFileChatMessageStore(path string) *FileChatMessageStore {\n\treturn &FileChatMessageStore{Path: path}\n}\n\n// HistoryPath returns the location of the history file for this session.\nfunc (s *FileChatMessageStore) HistoryPath() string {\n\treturn filepath.Join(s.Path, \"history.json\")\n}\n\n// AddChatMessage appends a message to the existing history on disk.\nfunc (s *FileChatMessageStore) AddChatMessage(record *api.Message) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\t// Ensure directory exists\n\tif err := os.MkdirAll(s.Path, 0o755); err != nil {\n\t\treturn err\n\t}\n\n\tpath := s.HistoryPath()\n\n\t// Check for legacy format and migrate if needed\n\tisLegacy := false\n\tif f, err := os.Open(path); err == nil {\n\t\tbuf := make([]byte, 1)\n\t\tif _, err := f.Read(buf); err == nil && buf[0] == '[' {\n\t\t\tisLegacy = true\n\t\t}\n\t\tf.Close()\n\t}\n\n\tif isLegacy {\n\t\t// Read all messages (handles legacy format)\n\t\tmessages, err := s.readMessages()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tmessages = append(messages, record)\n\t\treturn s.writeMessages(messages)\n\t}\n\n\t// Normal append for JSONL or new files\n\tdata, err := json.Marshal(record)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tf, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tif _, err := f.Write(data); err != nil {\n\t\treturn err\n\t}\n\tif _, err := f.WriteString(\"\\n\"); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// SetChatMessages replaces the history file with the provided messages.\nfunc (s *FileChatMessageStore) SetChatMessages(newHistory []*api.Message) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\treturn s.writeMessages(newHistory)\n}\n\n// ChatMessages returns all persisted chat messages.\nfunc (s *FileChatMessageStore) ChatMessages() []*api.Message {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\tmessages, err := s.readMessages()\n\tif err != nil {\n\t\treturn []*api.Message{}\n\t}\n\treturn messages\n}\n\n// ClearChatMessages truncates the history file, leaving an empty array.\nfunc (s *FileChatMessageStore) ClearChatMessages() error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\n\treturn s.writeMessages([]*api.Message{})\n}\n\nfunc (s *FileChatMessageStore) readMessages() ([]*api.Message, error) {\n\tpath := s.HistoryPath()\n\tf, err := os.Open(path)\n\tif errors.Is(err, os.ErrNotExist) {\n\t\treturn []*api.Message{}, nil\n\t}\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer f.Close()\n\n\t// Check if the file is empty\n\tstat, err := f.Stat()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif stat.Size() == 0 {\n\t\treturn []*api.Message{}, nil\n\t}\n\n\t// Peek at the first byte to determine format\n\t// If it starts with '[', it's a legacy JSON array\n\t// Otherwise, assume JSONL\n\tbuf := make([]byte, 1)\n\tif _, err := f.Read(buf); err != nil {\n\t\treturn nil, err\n\t}\n\t// Reset file pointer\n\tif _, err := f.Seek(0, 0); err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar messages []*api.Message\n\n\tif buf[0] == '[' {\n\t\t// Legacy JSON array format\n\t\tdecoder := json.NewDecoder(f)\n\t\tif err := decoder.Decode(&messages); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn messages, nil\n\t}\n\n\t// JSONL format\n\tscanner := bufio.NewScanner(f)\n\n\tfor scanner.Scan() {\n\t\tline := scanner.Bytes()\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\tvar msg api.Message\n\t\tif err := json.Unmarshal(line, &msg); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tmessages = append(messages, &msg)\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn messages, nil\n}\n\nfunc (s *FileChatMessageStore) writeMessages(messages []*api.Message) error {\n\tif err := os.MkdirAll(s.Path, 0o755); err != nil {\n\t\treturn err\n\t}\n\n\tf, err := os.OpenFile(s.HistoryPath(), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\n\tfor _, msg := range messages {\n\t\tdata, err := json.Marshal(msg)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := f.Write(data); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif _, err := f.WriteString(\"\\n\"); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sessions/manager.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sessions\n\nimport (\n\t\"fmt\"\n\t\"math/rand\"\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n)\n\ntype SessionManager struct {\n\tstore Store\n}\n\nfunc NewSessionManager(backend string) (*SessionManager, error) {\n\tvar store Store\n\tvar err error\n\n\tif backend == \"\" {\n\t\t// Try filesystem first\n\t\tstore, err = NewStore(\"filesystem\")\n\t\tif err != nil {\n\t\t\t// Fallback to memory\n\t\t\tstore, err = NewStore(\"memory\")\n\t\t}\n\t} else {\n\t\tstore, err = NewStore(backend)\n\t}\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn &SessionManager{store: store}, nil\n}\n\nfunc (sm *SessionManager) NewSession(meta Metadata) (*api.Session, error) {\n\tsuffix := fmt.Sprintf(\"%04d\", rand.Intn(10000))\n\tsessionID := time.Now().Format(\"20060102\") + \"-\" + suffix\n\n\tnow := time.Now()\n\tsession := &api.Session{\n\t\tID:           sessionID,\n\t\tName:         \"Session \" + sessionID,\n\t\tProviderID:   meta.ProviderID,\n\t\tModelID:      meta.ModelID,\n\t\tAgentState:   api.AgentStateIdle,\n\t\tCreatedAt:    now,\n\t\tLastModified: now,\n\t}\n\n\tif err := sm.store.CreateSession(session); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn session, nil\n}\n\nfunc (sm *SessionManager) ListSessions() ([]*api.Session, error) {\n\treturn sm.store.ListSessions()\n}\n\nfunc (sm *SessionManager) FindSessionByID(id string) (*api.Session, error) {\n\treturn sm.store.GetSession(id)\n}\n\nfunc (sm *SessionManager) DeleteSession(id string) error {\n\treturn sm.store.DeleteSession(id)\n}\n\nfunc (sm *SessionManager) GetLatestSession() (*api.Session, error) {\n\tsessions, err := sm.store.ListSessions()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(sessions) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tlatest := sessions[0]\n\tfor _, session := range sessions[1:] {\n\t\tif session.LastModified.After(latest.LastModified) {\n\t\t\tlatest = session\n\t\t}\n\t}\n\n\treturn latest, nil\n}\n\nfunc (sm *SessionManager) UpdateLastAccessed(session *api.Session) error {\n\tsession.LastModified = time.Now()\n\treturn sm.store.UpdateSession(session)\n}\n"
  },
  {
    "path": "pkg/sessions/memory.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sessions\n\nimport (\n\t\"errors\"\n\t\"sort\"\n\t\"sync\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n)\n\ntype memoryStore struct {\n\tmu       sync.RWMutex\n\tsessions map[string]*api.Session\n}\n\nfunc newMemoryStore() Store {\n\treturn &memoryStore{sessions: make(map[string]*api.Session)}\n}\n\nfunc (m *memoryStore) GetSession(id string) (*api.Session, error) {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tsession, ok := m.sessions[id]\n\tif !ok {\n\t\treturn nil, errors.New(\"session not found\")\n\t}\n\treturn session, nil\n}\n\nfunc (m *memoryStore) CreateSession(session *api.Session) error {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tif _, exists := m.sessions[session.ID]; exists {\n\t\treturn errors.New(\"session already exists\")\n\t}\n\n\tif session.ChatMessageStore == nil {\n\t\tsession.ChatMessageStore = NewInMemoryChatStore()\n\t}\n\n\tm.sessions[session.ID] = session\n\treturn nil\n}\n\nfunc (m *memoryStore) UpdateSession(session *api.Session) error {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tif _, exists := m.sessions[session.ID]; !exists {\n\t\treturn errors.New(\"session not found\")\n\t}\n\n\tm.sessions[session.ID] = session\n\treturn nil\n}\n\nfunc (m *memoryStore) ListSessions() ([]*api.Session, error) {\n\tm.mu.RLock()\n\tdefer m.mu.RUnlock()\n\n\tsessions := make([]*api.Session, 0, len(m.sessions))\n\tfor _, session := range m.sessions {\n\t\tsessions = append(sessions, session)\n\t}\n\n\tsort.Slice(sessions, func(i, j int) bool {\n\t\treturn sessions[i].LastModified.After(sessions[j].LastModified)\n\t})\n\n\treturn sessions, nil\n}\n\nfunc (m *memoryStore) DeleteSession(id string) error {\n\tm.mu.Lock()\n\tdefer m.mu.Unlock()\n\n\tif _, exists := m.sessions[id]; !exists {\n\t\treturn errors.New(\"session not found\")\n\t}\n\n\tdelete(m.sessions, id)\n\treturn nil\n}\n\n// InMemoryChatStore is an in-memory implementation of the api.ChatMessageStore interface.\n// It stores chat messages in a slice and is safe for concurrent use.\ntype InMemoryChatStore struct {\n\tmu       sync.RWMutex\n\tmessages []*api.Message\n}\n\n// NewInMemoryChatStore creates a new InMemoryChatStore.\nfunc NewInMemoryChatStore() *InMemoryChatStore {\n\treturn &InMemoryChatStore{\n\t\tmessages: make([]*api.Message, 0),\n\t}\n}\n\n// AddChatMessage adds a message to the store.\nfunc (s *InMemoryChatStore) AddChatMessage(record *api.Message) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.messages = append(s.messages, record)\n\treturn nil\n}\n\n// SetChatMessages replaces the entire chat history with a new one.\nfunc (s *InMemoryChatStore) SetChatMessages(newHistory []*api.Message) error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.messages = newHistory\n\treturn nil\n}\n\n// ChatMessages returns all chat messages from the store.\nfunc (s *InMemoryChatStore) ChatMessages() []*api.Message {\n\ts.mu.RLock()\n\tdefer s.mu.RUnlock()\n\tmessageCopy := make([]*api.Message, len(s.messages))\n\tcopy(messageCopy, s.messages)\n\treturn messageCopy\n}\n\n// ClearChatMessages removes all messages from the store.\nfunc (s *InMemoryChatStore) ClearChatMessages() error {\n\ts.mu.Lock()\n\tdefer s.mu.Unlock()\n\ts.messages = make([]*api.Message, 0)\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/sessions/store.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage sessions\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n)\n\nconst sessionsDirName = \"sessions\"\n\ntype Metadata struct {\n\tProviderID   string    `json:\"providerID\"`\n\tModelID      string    `json:\"modelID\"`\n\tCreatedAt    time.Time `json:\"createdAt\"`\n\tLastAccessed time.Time `json:\"lastAccessed\"`\n}\n\nvar defaultMemoryStore Store = newMemoryStore()\n\ntype Store interface {\n\tGetSession(id string) (*api.Session, error)\n\tCreateSession(session *api.Session) error\n\tUpdateSession(session *api.Session) error\n\tListSessions() ([]*api.Session, error)\n\tDeleteSession(id string) error\n}\n\nfunc NewStore(backend string) (Store, error) {\n\tswitch backend {\n\tcase \"memory\":\n\t\treturn defaultMemoryStore, nil\n\tcase \"filesystem\":\n\t\tbasePath, err := defaultFilesystemBasePath()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif err := os.MkdirAll(basePath, 0o755); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn newFilesystemStore(basePath), nil\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unsupported sessions backend: %s\", backend)\n\t}\n}\n\nfunc defaultFilesystemBasePath() (string, error) {\n\thome, err := os.UserHomeDir()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn filepath.Join(home, \".kubectl-ai\", sessionsDirName), nil\n}\n"
  },
  {
    "path": "pkg/tools/bash_tool.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sandbox\"\n)\n\nconst (\n\tdefaultBashBin = \"/bin/bash\"\n)\n\n// expandShellVar expands shell variables and syntax using bash\nfunc expandShellVar(value string) (string, error) {\n\tif strings.Contains(value, \"~\") {\n\t\tif len(value) >= 2 && value[0] == '~' && os.IsPathSeparator(value[1]) {\n\t\t\tif runtime.GOOS == \"windows\" {\n\t\t\t\tvalue = filepath.Join(os.Getenv(\"USERPROFILE\"), value[2:])\n\t\t\t} else {\n\t\t\t\tvalue = filepath.Join(os.Getenv(\"HOME\"), value[2:])\n\t\t\t}\n\t\t}\n\t}\n\treturn os.ExpandEnv(value), nil\n}\n\ntype BashTool struct {\n\texecutor sandbox.Executor\n}\n\nfunc NewBashTool(executor sandbox.Executor) *BashTool {\n\treturn &BashTool{executor: executor}\n}\n\nfunc (t *BashTool) Name() string {\n\treturn \"bash\"\n}\n\nfunc (t *BashTool) Description() string {\n\treturn \"Executes a bash command. Use this tool only when you need to execute a shell command.\"\n}\n\nfunc (t *BashTool) FunctionDefinition() *gollm.FunctionDefinition {\n\treturn &gollm.FunctionDefinition{\n\t\tName:        t.Name(),\n\t\tDescription: t.Description(),\n\t\tParameters: &gollm.Schema{\n\t\t\tType: gollm.TypeObject,\n\t\t\tProperties: map[string]*gollm.Schema{\n\t\t\t\t\"command\": {\n\t\t\t\t\tType:        gollm.TypeString,\n\t\t\t\t\tDescription: `The bash command to execute.`,\n\t\t\t\t},\n\t\t\t\t\"modifies_resource\": {\n\t\t\t\t\tType: gollm.TypeString,\n\t\t\t\t\tDescription: `Whether the command modifies a kubernetes resource.\nPossible values:\n- \"yes\" if the command modifies a resource\n- \"no\" if the command does not modify a resource\n- \"unknown\" if the command's effect on the resource is unknown\n`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc (t *BashTool) Run(ctx context.Context, args map[string]any) (any, error) {\n\tkubeconfig := ctx.Value(KubeconfigKey).(string)\n\tworkDir := ctx.Value(WorkDirKey).(string)\n\tcommand := args[\"command\"].(string)\n\n\tif err := validateCommand(command); err != nil {\n\t\treturn &sandbox.ExecResult{Command: command, Error: err.Error()}, nil\n\t}\n\n\t// Prepare environment\n\tenv := os.Environ()\n\tif kubeconfig != \"\" {\n\t\tkubeconfig, err := expandShellVar(kubeconfig)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tenv = append(env, \"KUBECONFIG=\"+kubeconfig)\n\t}\n\n\treturn ExecuteWithStreamingHandling(ctx, t.executor, command, workDir, env, DetectKubectlStreaming)\n}\n\nfunc validateCommand(command string) error {\n\tif strings.Contains(command, \"kubectl edit\") {\n\t\treturn fmt.Errorf(\"interactive mode not supported for kubectl, please use non-interactive commands\")\n\t}\n\tif strings.Contains(command, \"kubectl port-forward\") {\n\t\treturn fmt.Errorf(\"port-forwarding is not allowed because assistant is running in an unattended mode, please try some other alternative\")\n\t}\n\treturn nil\n}\n\nfunc (t *BashTool) IsInteractive(args map[string]any) (bool, error) {\n\tcommandVal, ok := args[\"command\"]\n\tif !ok || commandVal == nil {\n\t\treturn false, nil\n\t}\n\n\tcommand, ok := commandVal.(string)\n\tif !ok {\n\t\treturn false, nil\n\t}\n\n\treturn IsInteractiveCommand(command)\n}\n\n// CheckModifiesResource determines if the command modifies kubernetes resources\n// This is used for permission checks before command execution\n// Returns \"yes\", \"no\", or \"unknown\"\nfunc (t *BashTool) CheckModifiesResource(args map[string]any) string {\n\tcommand, ok := args[\"command\"].(string)\n\tif !ok {\n\t\treturn \"unknown\"\n\t}\n\n\tif strings.Contains(command, \"kubectl\") {\n\t\treturn kubectlModifiesResource(command)\n\t}\n\n\treturn \"unknown\"\n}\n"
  },
  {
    "path": "pkg/tools/custom_tool.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sandbox\"\n\t\"mvdan.cc/sh/v3/syntax\"\n)\n\n// CustomToolConfig defines the structure for configuring a custom tool.\ntype CustomToolConfig struct {\n\tName          string `yaml:\"name\"`\n\tDescription   string `yaml:\"description\"`\n\tCommand       string `yaml:\"command\"`\n\tCommandDesc   string `yaml:\"command_desc\"`\n\tIsInteractive bool   `yaml:\"is_interactive\"`\n}\n\n// CustomTool implements the Tool interface for external commands.\ntype CustomTool struct {\n\tconfig   CustomToolConfig\n\texecutor sandbox.Executor\n}\n\n// NewCustomTool creates a new CustomTool instance.\nfunc NewCustomTool(config CustomToolConfig) (*CustomTool, error) {\n\tif config.Name == \"\" {\n\t\treturn nil, fmt.Errorf(\"custom tool name cannot be empty\")\n\t}\n\tif len(config.Command) == 0 {\n\t\treturn nil, fmt.Errorf(\"custom tool command cannot be empty for tool %q\", config.Name)\n\t}\n\n\treturn &CustomTool{config: config}, nil\n}\n\n// Name returns the tool's name.\nfunc (t *CustomTool) Name() string {\n\treturn t.config.Name\n}\n\n// Description returns the tool's description from its function definition.\nfunc (t *CustomTool) Description() string {\n\treturn t.config.Description\n}\n\n// FunctionDefinition returns the tool's function definition.\nfunc (t *CustomTool) FunctionDefinition() *gollm.FunctionDefinition {\n\treturn &gollm.FunctionDefinition{\n\t\tName:        t.Name(),\n\t\tDescription: t.Description(),\n\t\tParameters: &gollm.Schema{\n\t\t\tType: gollm.TypeObject,\n\t\t\tProperties: map[string]*gollm.Schema{\n\t\t\t\t\"command\": {\n\t\t\t\t\tType:        gollm.TypeString,\n\t\t\t\t\tDescription: t.config.CommandDesc,\n\t\t\t\t},\n\t\t\t\t\"modifies_resource\": {\n\t\t\t\t\tType: gollm.TypeString,\n\t\t\t\t\tDescription: `Whether the command modifies a resource.\nPossible values:\n- \"yes\" if the command modifies a resource\n- \"no\" if the command does not modify a resource\n- \"unknown\" if the command's effect on the resource is unknown\n`,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n\n// addCommandPrefix adds the tool's command prefix to the input command if needed.\n// It only adds the prefix if the command is a simple command (no pipes, etc.)\n// and doesn't already start with the prefix.\n// TODO(droot): This will not be needed when models improve on following instructions\n// and specify the complete command to execute.\nfunc (t *CustomTool) addCommandPrefix(inputCmd string) (string, error) {\n\t// Parse the command to check if it's a simple command\n\tparser := syntax.NewParser()\n\tprog, err := parser.Parse(strings.NewReader(inputCmd), \"\")\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"failed to parse command: %w\", err)\n\t}\n\n\t// Check if it's a simple command (no pipes, redirects, etc.)\n\tif len(prog.Stmts) != 1 {\n\t\treturn inputCmd, nil\n\t}\n\tstmt := prog.Stmts[0]\n\tif stmt.Background || stmt.Coprocess || stmt.Negated || len(stmt.Redirs) > 0 {\n\t\treturn inputCmd, nil\n\t}\n\n\t// Check if it's a simple call expression\n\tif _, ok := stmt.Cmd.(*syntax.CallExpr); !ok {\n\t\treturn inputCmd, nil\n\t}\n\n\t// If we get here, it's a simple command without the prefix\n\tif strings.HasPrefix(inputCmd, t.config.Command) {\n\t\treturn inputCmd, nil\n\t}\n\n\treturn t.config.Command + \" \" + inputCmd, nil\n}\n\n// Run executes the external command defined for the custom tool.\nfunc (t *CustomTool) Run(ctx context.Context, args map[string]any) (any, error) {\n\tvar command string\n\tcmdVal, ok := args[\"command\"]\n\tif !ok {\n\t\treturn nil, fmt.Errorf(\"command not found in args\")\n\t}\n\tcommand = cmdVal.(string)\n\n\tcommand, err := t.addCommandPrefix(command)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to process command: %w\", err)\n\t}\n\n\tworkDir := ctx.Value(WorkDirKey).(string)\n\tenv := os.Environ()\n\n\t// Use the injected executor, or fallback to local if not set (e.g. for global instance)\n\texecutor := t.executor\n\tif executor == nil {\n\t\texecutor = sandbox.NewLocalExecutor()\n\t}\n\n\t// Execute the command\n\treturn executor.Execute(ctx, command, env, workDir)\n}\n\n// CheckModifiesResource determines if the command modifies resources\n// For custom tools, we'll conservatively assume they might modify resources\n// unless we have specific knowledge otherwise\n// Returns \"yes\", \"no\", or \"unknown\"\nfunc (t *CustomTool) CheckModifiesResource(args map[string]any) string {\n\t// For custom tools, we'll conservatively use \"unknown\" since we can't\n\treturn \"unknown\"\n}\n\n// CloneWithExecutor creates a copy of the CustomTool with the given executor.\n// This is used to create a session-specific instance of the tool.\nfunc (t *CustomTool) CloneWithExecutor(executor sandbox.Executor) *CustomTool {\n\treturn &CustomTool{\n\t\tconfig:   t.config,\n\t\texecutor: executor,\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/custom_tool_test.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage tools\n\nimport (\n\t\"context\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sandbox\"\n)\n\nfunc TestCustomTool_AddCommandPrefix(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tconfigCommand  string\n\t\tinputCommand   string\n\t\texpectedOutput string\n\t\texpectError    bool\n\t}{\n\t\t{\n\t\t\tname:           \"simple command without prefix\",\n\t\t\tconfigCommand:  \"gcloud\",\n\t\t\tinputCommand:   \"compute instances list\",\n\t\t\texpectedOutput: \"gcloud compute instances list\",\n\t\t\texpectError:    false,\n\t\t},\n\t\t{\n\t\t\tname:           \"simple command with prefix\",\n\t\t\tconfigCommand:  \"gcloud\",\n\t\t\tinputCommand:   \"gcloud compute instances list\",\n\t\t\texpectedOutput: \"gcloud compute instances list\",\n\t\t\texpectError:    false,\n\t\t},\n\t\t{\n\t\t\tname:           \"command with pipe\",\n\t\t\tconfigCommand:  \"gcloud\",\n\t\t\tinputCommand:   \"compute instances list | grep test\",\n\t\t\texpectedOutput: \"compute instances list | grep test\",\n\t\t\texpectError:    false,\n\t\t},\n\t\t{\n\t\t\tname:           \"command with redirect\",\n\t\t\tconfigCommand:  \"gcloud\",\n\t\t\tinputCommand:   \"compute instances list > instances.txt\",\n\t\t\texpectedOutput: \"compute instances list > instances.txt\",\n\t\t\texpectError:    false,\n\t\t},\n\t\t{\n\t\t\tname:           \"command with background\",\n\t\t\tconfigCommand:  \"gcloud\",\n\t\t\tinputCommand:   \"compute instances list &\",\n\t\t\texpectedOutput: \"compute instances list &\",\n\t\t\texpectError:    false,\n\t\t},\n\t\t{\n\t\t\tname:           \"command with subshell\",\n\t\t\tconfigCommand:  \"gcloud\",\n\t\t\tinputCommand:   \"(compute instances list)\",\n\t\t\texpectedOutput: \"(compute instances list)\",\n\t\t\texpectError:    false,\n\t\t},\n\t\t{\n\t\t\tname:           \"command with multiple statements\",\n\t\t\tconfigCommand:  \"gcloud\",\n\t\t\tinputCommand:   \"compute instances list; compute disks list\",\n\t\t\texpectedOutput: \"compute instances list; compute disks list\",\n\t\t\texpectError:    false,\n\t\t},\n\t\t{\n\t\t\tname:           \"invalid shell syntax\",\n\t\t\tconfigCommand:  \"gcloud\",\n\t\t\tinputCommand:   \"compute instances list |\",\n\t\t\texpectedOutput: \"\",\n\t\t\texpectError:    true,\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\ttool := &CustomTool{\n\t\t\t\tconfig: CustomToolConfig{\n\t\t\t\t\tCommand: tt.configCommand,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\toutput, err := tool.addCommandPrefix(tt.inputCommand)\n\t\t\tif tt.expectError {\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Errorf(\"expected error but got none\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif output != tt.expectedOutput {\n\t\t\t\tt.Errorf(\"expected %q, got %q\", tt.expectedOutput, output)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// MockExecutor implements sandbox.Executor for testing\ntype MockExecutor struct {\n\tCapturedCommand string\n\tCapturedEnv     []string\n\tCapturedWorkDir string\n}\n\nfunc (m *MockExecutor) Execute(ctx context.Context, command string, env []string, workDir string) (*sandbox.ExecResult, error) {\n\tm.CapturedCommand = command\n\tm.CapturedEnv = env\n\tm.CapturedWorkDir = workDir\n\treturn &sandbox.ExecResult{Stdout: \"executed\"}, nil\n}\n\nfunc (m *MockExecutor) Close(ctx context.Context) error {\n\treturn nil\n}\n\nfunc TestCustomTool_CloneWithExecutor(t *testing.T) {\n\tconfig := CustomToolConfig{\n\t\tName:    \"test-tool\",\n\t\tCommand: \"echo\",\n\t}\n\n\ttool, err := NewCustomTool(config)\n\tif err != nil {\n\t\tt.Fatalf(\"failed to create tool: %v\", err)\n\t}\n\n\tmockExec := &MockExecutor{}\n\tclonedTool := tool.CloneWithExecutor(mockExec)\n\n\tctx := context.WithValue(context.Background(), WorkDirKey, \"/tmp\")\n\targs := map[string]any{\n\t\t\"command\": \"hello\",\n\t}\n\n\tresult, err := clonedTool.Run(ctx, args)\n\tif err != nil {\n\t\tt.Fatalf(\"tool run failed: %v\", err)\n\t}\n\n\texecResult, ok := result.(*sandbox.ExecResult)\n\tif !ok {\n\t\tt.Fatalf(\"expected *sandbox.ExecResult, got %T\", result)\n\t}\n\tif execResult.Stdout != \"executed\" {\n\t\tt.Errorf(\"expected Stdout 'executed', got %q\", execResult.Stdout)\n\t}\n\n\tif mockExec.CapturedCommand != \"echo hello\" {\n\t\tt.Errorf(\"expected command 'echo hello', got %q\", mockExec.CapturedCommand)\n\t}\n\tif !strings.Contains(strings.Join(mockExec.CapturedEnv, \"\\n\"), \"PATH\") {\n\t\tt.Errorf(\"expected captured environment to contain PATH\")\n\t}\n\tif mockExec.CapturedWorkDir != \"/tmp\" {\n\t\tt.Errorf(\"expected workdir '/tmp', got %q\", mockExec.CapturedWorkDir)\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/interfaces.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage tools\n\nimport (\n\t\"context\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n)\n\ntype Tool interface {\n\t// Name is the identifier for the tool; we pass this to the LLM.\n\t// The LLM uses this name when it wants to invoke the tool.\n\t// It should be meaningful and (we think) camel_case as (we think) that works better with most LLMs.\n\tName() string\n\n\t// Description is an additional description that gives the LLM instructions on when to use the tool.\n\tDescription() string\n\n\t// FunctionDefinition provides the full schema for the parameters to be used when invoking the tool.\n\t// The Description fields provides hints that the LLM may use to use the tool more effectively/correctly.\n\tFunctionDefinition() *gollm.FunctionDefinition\n\n\t// Run invokes the tool, the agent calls this when the LLM requests tool invocation.\n\tRun(ctx context.Context, args map[string]any) (any, error)\n\n\t// IsInteractive checks if a command is interactive\n\t// If the command is interactive, we need to handle it differently in the agent\n\t// Returns true if interactive, with an error explaining why it's interactive\n\tIsInteractive(args map[string]any) (bool, error)\n\n\t// CheckModifiesResource determines if the command modifies resources\n\t// This is used for permission checks before command execution\n\t// Returns \"yes\", \"no\", or \"unknown\"\n\tCheckModifiesResource(args map[string]any) string\n}\n"
  },
  {
    "path": "pkg/tools/kubectl_filter.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage tools\n\nimport (\n\t\"strings\"\n\n\t\"k8s.io/klog/v2\"\n\t\"mvdan.cc/sh/v3/syntax\"\n)\n\n// Package-level constants for kubectl operations\nvar (\n\treadOnlyOps = map[string]bool{\n\t\t\"get\": true, \"describe\": true, \"explain\": true, \"top\": true,\n\t\t\"logs\": true, \"api-resources\": true, \"api-versions\": true,\n\t\t\"version\": true, \"config\": true, \"cluster-info\": true,\n\t\t\"wait\": true, \"auth\": true, \"diff\": true, \"kustomize\": true,\n\t\t\"help\": true, \"options\": true, \"proxy\": true,\n\t\t\"completion\": true, \"convert\": true, \"events\": true,\n\t\t\"port-forward\": true, \"can-i\": true, \"whoami\": true,\n\t}\n\n\twriteOps = map[string]bool{\n\t\t\"create\": true, \"apply\": true, \"edit\": true, \"delete\": true,\n\t\t\"patch\": true, \"replace\": true, \"scale\": true, \"autoscale\": true,\n\t\t\"expose\": true, \"run\": true, \"exec\": true, \"set\": true,\n\t\t\"label\": true, \"annotate\": true, \"taint\": true, \"drain\": true,\n\t\t\"cordon\": true, \"uncordon\": true, \"debug\": true, \"attach\": true,\n\t\t\"cp\": true, \"reconcile\": true, \"approve\": true, \"deny\": true,\n\t\t\"certificate\": true,\n\t}\n\n\treadOnlySubOps = map[string]map[string]bool{\n\t\t\"rollout\": {\n\t\t\t\"history\": true,\n\t\t\t\"status\":  true,\n\t\t},\n\t}\n\n\twriteSubOps = map[string]map[string]bool{\n\t\t\"rollout\": {\n\t\t\t\"pause\":   true,\n\t\t\t\"restart\": true,\n\t\t\t\"resume\":  true,\n\t\t\t\"undo\":    true,\n\t\t},\n\t}\n)\n\n// KubectlModifiesResource analyzes a kubectl command to determine if it modifies resources\nfunc kubectlModifiesResource(command string) string {\n\tparser := syntax.NewParser()\n\tfile, err := parser.Parse(strings.NewReader(command), \"\")\n\tif err != nil {\n\t\tklog.Errorf(\"Failed to parse kubectl command: %v, command: %q\", err, command)\n\t\treturn \"unknown\"\n\t}\n\n\thasReadCommand := false\n\tfoundWrite := false\n\tnumCmds := 0\n\n\t// Single pass through all command calls\n\tsyntax.Walk(file, func(node syntax.Node) bool {\n\t\tif call, ok := node.(*syntax.CallExpr); ok {\n\t\t\tresult := analyzeCall(call)\n\n\t\t\t// If we find any write operation, mark it and stop\n\t\t\tif result == \"yes\" {\n\t\t\t\tfoundWrite = true\n\t\t\t\treturn false // Stop walking\n\t\t\t}\n\n\t\t\t// Track if we found any read operations\n\t\t\tif result == \"no\" {\n\t\t\t\thasReadCommand = true\n\t\t\t}\n\t\t\tnumCmds++\n\t\t\tif numCmds > 1 {\n\t\t\t\treturn false // Stop walking if more then one command is found\n\t\t\t}\n\t\t}\n\t\treturn true\n\t})\n\n\tif numCmds > 1 {\n\t\t// if it's a composite bash command, we should err on the side of caution and return unknown\n\t\t// to prevent exfilteration attacks https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/\n\t\tklog.Infof(\"KubectlModifiesResource result: unknown for command: %q, multiple commands (%d) found\", command, numCmds)\n\t\treturn \"unknown\"\n\t}\n\n\t// Return results based on what we found\n\tif foundWrite {\n\t\tklog.Infof(\"KubectlModifiesResource result: yes (write operation found) for command: %q\", command)\n\t\treturn \"yes\"\n\t}\n\n\tif hasReadCommand {\n\t\tklog.Infof(\"KubectlModifiesResource result: no (read-only) for command: %q\", command)\n\t\treturn \"no\"\n\t}\n\n\t// Default to unknown if no recognized kubectl commands found\n\tklog.Infof(\"KubectlModifiesResource result: unknown for command: %q\", command)\n\treturn \"unknown\"\n}\n\nfunc analyzeCall(call *syntax.CallExpr) string {\n\tif call == nil || len(call.Args) == 0 {\n\t\tklog.Warning(\"analyzeCall: call is nil or has no args\")\n\t\treturn \"unknown\"\n\t}\n\n\t// Extract command and arguments\n\tvar args []string\n\tfor _, arg := range call.Args {\n\t\tlit := arg.Lit()\n\t\tif lit == \"\" {\n\t\t\tvar sb strings.Builder\n\t\t\tsyntax.NewPrinter().Print(&sb, arg)\n\t\t\tlit = strings.Trim(sb.String(), \"'\\\"\")\n\t\t}\n\t\tif lit != \"\" {\n\t\t\targs = append(args, lit)\n\t\t}\n\t}\n\n\tif len(args) == 0 {\n\t\tklog.Warning(\"analyzeCall: no arguments extracted from call\")\n\t\treturn \"unknown\"\n\t}\n\n\t// Check if first argument is kubectl\n\tfirstArg := args[0]\n\n\t// Reject quoted arguments (e.g., '\"/path/kubectl\"')\n\tif (strings.HasPrefix(firstArg, \"'\") && strings.HasSuffix(firstArg, \"'\")) || (strings.HasPrefix(firstArg, \"\\\"\") && strings.HasSuffix(firstArg, \"\\\"\")) {\n\t\tklog.V(2).Infof(\"analyzeCall: first arg is quoted: %q\", firstArg)\n\t\treturn \"unknown\"\n\t}\n\n\t// Check if this is kubectl\n\tif !strings.Contains(firstArg, \"kubectl\") {\n\t\tklog.V(2).Infof(\"analyzeCall: first arg does not contain kubectl: %q\", firstArg)\n\t\treturn \"unknown\"\n\t}\n\n\tklog.V(2).Infof(\"analyzeCall: found kubectl: %q\", firstArg)\n\n\t// Check for boolean or spaced key-value flags before the verb\n\tfor _, arg := range args[1:] {\n\t\tif !strings.HasPrefix(arg, \"-\") {\n\t\t\tbreak\n\t\t}\n\t\t// If flag does not contain '=', it's boolean or spaced key-value\n\t\tif !strings.Contains(arg, \"=\") {\n\t\t\tklog.Warningf(\"analyzeCall: boolean or spaced key-value flag before verb: %q\", arg)\n\t\t\treturn \"unknown\"\n\t\t}\n\t}\n\n\t// Parse kubectl arguments to extract verb, subverb, and flags\n\tverb, subVerb, hasDryRun := parseKubectlArgs(args[1:])\n\tif verb == \"\" {\n\t\tklog.Warningf(\"analyzeCall: no verb found after kubectl in args: %v\", args)\n\t\treturn \"unknown\"\n\t}\n\n\t// Check standard operations - write operations first (prioritize immediate detection)\n\tif (writeOps[verb] || writeSubOps[verb][subVerb]) && !hasDryRun {\n\t\tklog.V(1).Infof(\"analyzeCall: write op for verb=%q subVerb=%q\", verb, subVerb)\n\t\treturn \"yes\"\n\t}\n\n\t// Check read-only operations or dry-run write operations\n\tif (readOnlyOps[verb] || readOnlySubOps[verb][subVerb]) || ((writeOps[verb] || writeSubOps[verb][subVerb]) && hasDryRun) {\n\t\tklog.V(1).Infof(\"analyzeCall: read op for verb=%q subVerb=%q (dry-run=%v)\", verb, subVerb, hasDryRun)\n\t\treturn \"no\"\n\t}\n\n\tklog.V(1).Infof(\"analyzeCall: unknown op for verb=%q subVerb=%q\", verb, subVerb)\n\treturn \"unknown\"\n}\n\n// parseKubectlArgs extracts verb, subverb, and dry-run flag from kubectl arguments\nfunc parseKubectlArgs(args []string) (verb, subVerb string, hasDryRun bool) {\n\tfor _, arg := range args {\n\t\tif strings.HasPrefix(arg, \"--dry-run\") {\n\t\t\thasDryRun = true\n\t\t}\n\t\tif !strings.HasPrefix(arg, \"-\") {\n\t\t\tif verb == \"\" {\n\t\t\t\tverb = arg\n\t\t\t} else if subVerb == \"\" {\n\t\t\t\tsubVerb = arg\n\t\t\t}\n\t\t}\n\t}\n\treturn verb, subVerb, hasDryRun\n}\n"
  },
  {
    "path": "pkg/tools/kubectl_filter_test.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage tools\n\nimport (\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"mvdan.cc/sh/v3/syntax\"\n)\n\nfunc TestKubectlModifiesResource(t *testing.T) {\n\t// Group test cases by category\n\ttestCases := map[string][]struct {\n\t\tname     string\n\t\tcommand  string\n\t\texpected string\n\t}{\n\t\t\"read-only commands\": {\n\t\t\t{\"Get pods\", \"kubectl get pods\", \"no\"},\n\t\t\t{\"Describe pod\", \"kubectl describe pod nginx\", \"no\"},\n\t\t\t{\"Port-forward\", \"kubectl port-forward pod/nginx 8080:80\", \"no\"},\n\t\t\t{\"Port-forward with service\", \"kubectl port-forward svc/nginx 8080:80\", \"no\"},\n\t\t\t{\"Port-forward complex\", \"kubectl port-forward deployment/myapp 8080:8080 9000:9000\", \"no\"},\n\t\t\t{\"Port-forward background\", \"kubectl port-forward svc/nginx 8080:80 &\", \"no\"},\n\t\t\t{\"Get with output\", \"kubectl get pods -o yaml\", \"no\"},\n\t\t\t{\"Get with output redirection\", \"kubectl get pods > pods.txt\", \"no\"},\n\t\t\t{\"Get with name\", \"kubectl get pod nginx\", \"no\"},\n\t\t\t{\"Config view\", \"kubectl config view\", \"no\"},\n\t\t\t{\"Version\", \"kubectl version\", \"no\"},\n\t\t\t{\"API resources\", \"kubectl api-resources\", \"no\"},\n\t\t\t{\"Explain\", \"kubectl explain pods\", \"no\"},\n\t\t\t{\"Logs\", \"kubectl logs nginx\", \"no\"},\n\t\t\t{\"Logs with follow\", \"kubectl logs nginx -f\", \"no\"},\n\t\t\t{\"Watch pods\", \"kubectl get pods --watch\", \"no\"},\n\t\t\t{\"Watch pods short\", \"kubectl get pods -w\", \"no\"},\n\t\t\t{\"Rollout status\", \"kubectl rollout status deployment nginx\", \"no\"},\n\t\t\t{\"Diff\", \"kubectl diff -f deployment.yaml\", \"no\"},\n\t\t\t{\"Can-i\", \"kubectl auth can-i create pods\", \"no\"},\n\t\t\t{\"Kustomize\", \"kubectl kustomize ./\", \"no\"},\n\t\t\t{\"Convert\", \"kubectl convert -f pod.yaml --output-version=v1\", \"no\"},\n\t\t\t{\"Events\", \"kubectl events\", \"no\"},\n\t\t\t{\"Alpha debug\", \"kubectl alpha debug pod/nginx\", \"unknown\"},\n\t\t\t{\"Auth whoami\", \"kubectl auth whoami\", \"no\"},\n\t\t},\n\t\t\"modifying commands\": {\n\t\t\t{\"Create pod\", \"kubectl create -f pod.yaml\", \"yes\"},\n\t\t\t{\"Apply deployment\", \"kubectl apply -f deployment.yaml\", \"yes\"},\n\t\t\t{\"Delete pod\", \"kubectl delete pod nginx\", \"yes\"},\n\t\t\t{\"Scale deployment\", \"kubectl scale deployment nginx --replicas=3\", \"yes\"},\n\t\t\t{\"Edit deployment\", \"kubectl edit deployment nginx\", \"yes\"},\n\t\t\t{\"Patch service\", \"kubectl patch svc nginx -p '{\\\"spec\\\":{\\\"type\\\":\\\"NodePort\\\"}}'\", \"yes\"},\n\t\t\t{\"Label pod\", \"kubectl label pod nginx app=web\", \"yes\"},\n\t\t\t{\"Annotate\", \"kubectl annotate pods nginx description='my nginx'\", \"yes\"},\n\t\t\t{\"Rollout restart\", \"kubectl rollout restart deployment nginx\", \"yes\"},\n\t\t\t{\"Set image\", \"kubectl set image deployment/nginx nginx=nginx:latest\", \"yes\"},\n\t\t\t{\"Exec into pod\", \"kubectl exec -n demo tgi-pod -- nvidia-smi\", \"yes\"},\n\t\t\t{\"Taint node\", \"kubectl taint nodes node1 key=value:NoSchedule\", \"yes\"},\n\t\t\t{\"Run pod\", \"kubectl run nginx --image=nginx\", \"yes\"},\n\t\t\t{\"Config set-context\", \"kubectl config set-context my-context\", \"no\"},\n\t\t\t{\"Exec command\", \"kubectl exec nginx -- rm -rf /\", \"yes\"},\n\t\t\t{\"Cordon node\", \"kubectl cordon node1\", \"yes\"},\n\t\t\t{\"Uncordon node\", \"kubectl uncordon node1\", \"yes\"},\n\t\t\t{\"Drain node\", \"kubectl drain node1\", \"yes\"},\n\t\t\t{\"Certificate approve\", \"kubectl certificate approve csr-12345\", \"yes\"},\n\t\t},\n\t\t\"special cases\": {\n\t\t\t{\"Dry run create\", \"kubectl create -f pod.yaml --dry-run=client\", \"no\"},\n\t\t\t{\"Dry run apply\", \"kubectl apply -f deployment.yaml --dry-run\", \"no\"},\n\t\t\t{\"Apply with server dry-run\", \"kubectl apply -f pod.yaml --dry-run=server\", \"no\"},\n\t\t\t{\"Delete with dry-run\", \"kubectl delete pod nginx --dry-run client\", \"no\"},\n\t\t},\n\t\t\"edge cases\": {\n\t\t\t{\"Command with pipe\", \"kubectl get pods | grep nginx\", \"unknown\"},\n\t\t\t{\"Command with backticks\", \"kubectl get pod `cat podname.txt`\", \"unknown\"},\n\t\t\t{\"Complex path\", \"\\\"/path with spaces/kubectl\\\" get pods\", \"no\"},\n\t\t\t{\"Command with env var\", \"KUBECONFIG=/path/to/config kubectl get pods\", \"no\"},\n\n\t\t\t{\"Not kubectl command\", \"ls -la\", \"unknown\"},\n\t\t\t{\"Multiple spaces\", \"kubectl  get   pods\", \"no\"},\n\t\t\t{\"Complex command with variables\", \"kubectl get pods -l app=$APP_NAME -n $NAMESPACE\", \"no\"},\n\t\t\t{\"Command with quotes\", \"kubectl get pods -l \\\"app=my app\\\"\", \"no\"},\n\t\t\t{\"Command with escaped quotes\", \"kubectl patch configmap my-config --patch \\\"{\\\\\\\"data\\\\\\\":{\\\\\\\"key\\\\\\\":\\\\\\\"new-value\\\\\\\"}}\\\"\", \"yes\"},\n\t\t\t{\"Complex env vars\", \"KUBECONFIG=/path/to/config NS=default kubectl get pods -n $NS\", \"no\"},\n\t\t\t{\"Command with multiple env vars\", \"KUBECONFIG=/config KUBECTL_EXTERNAL_DIFF=\\\"diff -u\\\" kubectl diff -f file.yaml\", \"no\"},\n\t\t\t{\"Sequential commands with semicolon\", \"kubectl get ns; kubectl create ns test\", \"yes\"},\n\t\t\t{\"Multiple safe commands\", \"kubectl get pods; kubectl get deployments\", \"unknown\"},\n\t\t\t{\"Mix safe and unsafe with result\", \"kubectl get pods && kubectl delete pod bad-pod\", \"yes\"},\n\t\t\t{\"Mix with initial unsafe\", \"kubectl delete pod bad-pod && kubectl get pods\", \"yes\"},\n\t\t\t{\"Kubectl alias k\", \"k get pods\", \"unknown\"},\n\t\t\t{\"Full path with arguments\", \"/usr/local/custom/kubectl --kubeconfig=/path/config get pods\", \"no\"},\n\t\t\t{\"Complex jsonpath\", \"kubectl get pods -o=jsonpath='{range .items[*]}{.metadata.name}{\\\"\\\\t\\\"}{.status.phase}{\\\"\\\\n\\\"}{end}'\", \"no\"},\n\t\t\t{\"Custom columns\", \"kubectl get pods -o=custom-columns=NAME:.metadata.name,STATUS:.status.phase\", \"no\"},\n\t\t\t{\"Impersonation\", \"kubectl get pods --as=system:serviceaccount:default:deployer\", \"no\"},\n\t\t\t{\"With token\", \"kubectl --token=eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9... get pods\", \"no\"},\n\t\t\t{\"Weird spacing\", \"kubectl     get    pods   -n   default\", \"no\"},\n\t\t\t{\"kubectl in shell script with line continuation\", \"kubectl get pods \\\\\\n  --namespace=production\", \"no\"},\n\t\t\t{\"kubectl command split across lines in CI/CD\", \"kubectl delete pod \\\\\\n  my-pod-name \\\\\\n  --grace-period=30\", \"yes\"},\n\t\t\t{\"kubectl with multiline YAML pipe\", \"echo 'apiVersion: v1\\nkind: Pod' | kubectl apply -f -\", \"yes\"},\n\t\t\t{\"kubectl logs with line breaks in shell\", \"kubectl logs \\\\\\n  deployment/my-app \\\\\\n  --follow\", \"no\"},\n\t\t\t{\"Watch with selector\", \"kubectl get pods --selector app=nginx --watch\", \"no\"},\n\t\t\t{\"Negative watch timeout\", \"kubectl get pods --watch-only --timeout=10s\", \"no\"},\n\t\t\t{\"Flags after name\", \"kubectl delete pod mypod --now --grace-period=0\", \"yes\"},\n\t\t\t{\"Server-side apply\", \"kubectl apply -f deploy.yaml --server-side\", \"yes\"},\n\t\t\t{\"Field manager\", \"kubectl apply -f deploy.yaml --field-manager=controller\", \"yes\"},\n\t\t\t{\"Create service account\", \"kubectl create serviceaccount jenkins\", \"yes\"},\n\t\t\t{\"Create role binding\", \"kubectl create rolebinding admin --clusterrole=admin --user=user1 --namespace=default\", \"yes\"},\n\t\t\t{\"Versioned kubectl\", \"kubectl.1.24 get pods\", \"no\"},\n\t\t\t{\"Config set credentials\", \"kubectl config set-credentials cluster-admin --token=secret\", \"no\"},\n\t\t\t{\"Config view with flatten\", \"kubectl config view --flatten\", \"no\"},\n\t\t\t{\"Config view with output\", \"kubectl config view -o json\", \"no\"},\n\t\t\t{\"Config use-context\", \"kubectl config use-context production\", \"no\"},\n\t\t\t{\"Label with special characters\", \"kubectl label pod nginx 'app.kubernetes.io/name=nginx-controller'\", \"yes\"},\n\t\t\t{\"Jsonpath with quotes\", \"kubectl get pods -o jsonpath='{.items[0].metadata.name}'\", \"no\"},\n\t\t\t{\"Command with grep\", \"kubectl get pods | grep -v Completed\", \"unknown\"},\n\t\t\t{\"Command with awk\", \"kubectl get pods | awk '{print $1}'\", \"unknown\"},\n\t\t\t{\"Delete with force\", \"kubectl delete pod stuck-pod --force --grace-period=0\", \"yes\"},\n\t\t\t{\"Custom resource get\", \"kubectl get virtualmachines\", \"no\"},\n\t\t\t{\"Custom resource apply\", \"kubectl apply -f vm-instance.yaml\", \"yes\"},\n\t\t\t{\"Multiple input files\", \"kubectl delete -f file1.yaml -f file2.yaml\", \"yes\"},\n\t\t\t{\"URL as input file\", \"kubectl apply -f https://example.com/manifest.yaml\", \"yes\"},\n\t\t\t{\"Input from stdin\", \"cat file.yaml | kubectl apply -f -\", \"yes\"},\n\t\t\t{\"Proxy command\", \"kubectl proxy --port=8080\", \"no\"},\n\t\t\t{\"Attach command\", \"kubectl attach mypod -i\", \"yes\"},\n\t\t\t{\"Copy files\", \"kubectl cp mypod:/tmp/foo /tmp/bar\", \"yes\"},\n\t\t\t{\"Rollout status with flags\", \"kubectl rollout --recursive=false status --timeout=0s deployment -w nginx\", \"no\"},\n\t\t},\n\t}\n\n\tfor category, cases := range testCases {\n\t\tt.Run(category, func(t *testing.T) {\n\t\t\tfor _, tt := range cases {\n\t\t\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t\t\tresult := kubectlModifiesResource(tt.command)\n\t\t\t\t\tif result != tt.expected {\n\t\t\t\t\t\tt.Errorf(\"KubectlModifiesResource(%q) = %q, want %q\",\n\t\t\t\t\t\t\ttt.command, result, tt.expected)\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestKubectlAnalyzerComponents tests the internal helper functions used by KubectlModifiesResource\nfunc TestKubectlAnalyzerComponents(t *testing.T) {\n\tt.Run(\"parseKubectlArgs detection\", func(t *testing.T) {\n\t\ttests := []struct {\n\t\t\tcommand           string\n\t\t\tverbExpected      string\n\t\t\tsubverbExpected   string\n\t\t\thasDryRunExpected bool\n\t\t}{\n\t\t\t{\"kubectl apply -f deploy.yaml --dry-run=client\", \"apply\", \"deploy.yaml\", true},\n\t\t\t{\"kubectl apply -f deploy.yaml --dry-run\", \"apply\", \"deploy.yaml\", true},\n\t\t\t{\"kubectl delete pod nginx --dry-run client\", \"delete\", \"pod\", true},\n\t\t\t{\"kubectl delete pod nginx --dry-run=server\", \"delete\", \"pod\", true},\n\t\t\t{\"kubectl apply -f deploy.yaml\", \"apply\", \"deploy.yaml\", false},\n\t\t\t{\"kubectl get pods --dry\", \"get\", \"pods\", false}, // Not a valid dry-run flag\n\t\t\t{\"echo --dry-run\", \"\", \"\", true},                 // The current implementation doesn't check if it's kubectl\n\t\t\t{\"kubectl rollout status deployment nginx\", \"rollout\", \"status\", false},\n\t\t}\n\n\t\tfor _, tt := range tests {\n\t\t\tverb, subVerb, hasDryRun := parseKubectlArgs(strings.Split(tt.command, \" \")[1:]) // Skip the first arg (kubectl)\n\t\t\tif verb != tt.verbExpected {\n\t\t\t\tt.Errorf(\"parseKubectlArgs(%q) verb = %q, want %q\", tt.command, verb, tt.verbExpected)\n\t\t\t}\n\t\t\tif subVerb != tt.subverbExpected {\n\t\t\t\tt.Errorf(\"parseKubectlArgs(%q) subVerb = %q, want %q\", tt.command, subVerb, tt.subverbExpected)\n\t\t\t}\n\t\t\tif hasDryRun != tt.hasDryRunExpected {\n\t\t\t\tt.Errorf(\"parseKubectlArgs(%q) hasDryRun = %v, want %v\", tt.command, hasDryRun, tt.hasDryRunExpected)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"command parsing\", func(t *testing.T) {\n\t\ttests := []struct {\n\t\t\tcommand     string\n\t\t\texpectedRes string\n\t\t}{\n\t\t\t{\"kubectl get pods\", \"no\"},\n\t\t\t{\"kubectl apply -f deploy.yaml\", \"yes\"},\n\t\t\t{\"ls -la\", \"unknown\"},      // Not kubectl\n\t\t\t{\"kubectl\", \"unknown\"},     // Incomplete command\n\t\t\t{\"kubectl; ls\", \"unknown\"}, // Multiple commands\n\t\t}\n\n\t\tfor _, tt := range tests {\n\t\t\tresult := kubectlModifiesResource(tt.command)\n\t\t\tif result != tt.expectedRes {\n\t\t\t\tt.Errorf(\"KubectlModifiesResource(%q) = %q, want %q\",\n\t\t\t\t\ttt.command, result, tt.expectedRes)\n\t\t\t}\n\t\t}\n\t})\n}\n\n// TestKubectlCommandParsing tests kubectl command parsing focusing on realistic scenarios\nfunc TestKubectlCommandParsing(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tcommand  string\n\t\texpected string\n\t\tdesc     string\n\t}{\n\t\t// Basic kubectl detection\n\t\t{\"literal kubectl\", \"kubectl get pods\", \"no\", \"standard kubectl command\"},\n\t\t{\"kubectl.exe\", \"kubectl.exe get pods\", \"no\", \"Windows executable\"},\n\t\t{\"Unix path\", \"/usr/bin/kubectl get pods\", \"no\", \"Full Unix path\"},\n\t\t{\"relative path\", \"./kubectl get services\", \"no\", \"relative path\"},\n\t\t{\"nested path\", \"../bin/kubectl describe pod nginx\", \"no\", \"nested relative path\"},\n\n\t\t// Windows paths with forward slashes (works cross-platform)\n\t\t{\"Windows forward slash\", \"C:/tools/kubectl.exe delete pod nginx\", \"yes\", \"Windows path with forward slash\"},\n\n\t\t// macOS/Homebrew paths\n\t\t{\"macOS Homebrew\", \"/opt/homebrew/bin/kubectl get nodes\", \"no\", \"macOS Homebrew path\"},\n\t\t{\"macOS Intel Homebrew\", \"/usr/local/bin/kubectl create namespace test\", \"yes\", \"macOS Intel Homebrew path\"},\n\t\t{\"macOS Applications\", \"/Applications/Docker.app/Contents/Resources/bin/kubectl get all\", \"no\", \"macOS Docker Desktop kubectl\"},\n\n\t\t// Non-kubectl commands\n\t\t{\"not kubectl\", \"k get pods\", \"unknown\", \"kubectl alias\"},\n\t\t{\"kubectl suffix\", \"kubectl-1.28 get pods\", \"no\", \"kubectl with version suffix\"},\n\t\t{\"kubectl prefix\", \"kubectl-proxy --port=8080\", \"unknown\", \"kubectl with additional suffix\"},\n\t\t{\"different command\", \"kubectx production\", \"unknown\", \"different k8s tool\"},\n\n\t\t// Environment variables\n\t\t{\"env var prefix\", \"KUBECONFIG=/path/config kubectl get pods\", \"no\", \"environment variable prefix\"},\n\t\t{\"multiple env vars\", \"KUBECONFIG=/config NS=default kubectl apply -f deploy.yaml --dry-run\", \"no\", \"multiple environment variables\"},\n\n\t\t// Complex scenarios\n\t\t{\"long path\", \"/very/long/path/to/kubectl get pods\", \"no\", \"very long path\"},\n\t\t{\"flags before verb\", \"kubectl --context=prod --namespace=app get pods\", \"no\", \"global flags before verb\"},\n\t\t{\"flags before verb mutating\", \"kubectl --replicas=3 scale deployment/nginx-deployment\", \"yes\", \"global flags before verb mutating\"},\n\t\t{\"flags before verb without equals\", \"kubectl --context prod --namespace app get pods\", \"unknown\", \"global flags before verb without equals\"},\n\t\t{\"no verb\", \"kubectl --help\", \"unknown\", \"kubectl with only flags\"},\n\t\t{\"boolean flag before verb\", \"kubectl --verbose get pods\", \"unknown\", \"boolean flag before verb\"},\n\t\t{\"boolean flag before verb mutating\", \"kubectl --force delete pod nginx\", \"unknown\", \"boolean flag before verb mutating\"},\n\t\t{\"mixed flags before verb\", \"kubectl --context=prod --namespace app get pods\", \"unknown\", \"mixed non-spaced and spaced flags before verb\"},\n\t\t{\"non-spaced key-value before verb non-mutating\", \"kubectl --namespace=default get pods\", \"no\", \"non-spaced key-value before verb non-mutating\"},\n\t\t{\"non-spaced key-value before verb mutating\", \"kubectl --namespace=default delete pod nginx\", \"yes\", \"non-spaced key-value before verb mutating\"},\n\t\t{\"flag after verb spaced\", \"kubectl get pods --context prod\", \"no\", \"spaced key-value flag after verb\"},\n\t\t{\"flag after verb boolean\", \"kubectl get pods --verbose\", \"no\", \"boolean flag after verb\"},\n\t\t{\"flag after verb mutating\", \"kubectl delete pod nginx --force\", \"yes\", \"boolean flag after verb mutating\"},\n\t\t{\"flag with equals empty value before verb\", \"kubectl --namespace= get pods\", \"no\", \"non-spaced key-value with empty value before verb\"},\n\t\t{\"unexpected arg before verb\", \"kubectl something get pods\", \"unknown\", \"unexpected arg before verb\"},\n\t\t{\"multiple boolean flags before verb\", \"kubectl --verbose --debug get pods\", \"unknown\", \"multiple boolean flags before verb\"},\n\t\t{\"multiple spaced flags before verb\", \"kubectl --context prod --namespace app get pods\", \"unknown\", \"multiple spaced flags before verb\"},\n\t\t{\"multiple non-spaced flags before verb mutating\", \"kubectl --namespace=default --force=true delete pod nginx\", \"yes\", \"multiple non-spaced flags before verb mutating\"},\n\t\t{\"multiple non-spaced flags before verb non-mutating\", \"kubectl --namespace=default --verbose=true get pods\", \"no\", \"multiple non-spaced flags before verb non-mutating\"},\n\n\t\t// Dry run scenarios\n\t\t{\"dry run create\", \"/usr/bin/kubectl create -f pod.yaml --dry-run=client\", \"no\", \"dry run with path\"},\n\t\t{\"dry run apply\", \"kubectl.exe apply -f deploy.yaml --dry-run\", \"no\", \"Windows dry run\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := kubectlModifiesResource(tt.command)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"KubectlModifiesResource(%q) = %q, want %q\\nDescription: %s\",\n\t\t\t\t\ttt.command, result, tt.expected, tt.desc)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestKubectlPathHandling tests the OS-agnostic path handling specifically\nfunc TestKubectlPathHandling(t *testing.T) {\n\ttests := []struct {\n\t\tname        string\n\t\tbinaryPath  string\n\t\tshouldMatch bool\n\t\tdescription string\n\t}{\n\t\t// Basic cases\n\t\t{\"Standard kubectl\", \"kubectl\", true, \"Standard kubectl binary name\"},\n\t\t{\"Windows kubectl.exe\", \"kubectl.exe\", true, \"Windows kubectl executable\"},\n\t\t{\"Unix full path\", \"/usr/bin/kubectl\", true, \"Full Unix path to kubectl\"},\n\t\t{\"Windows forward slash\", \"C:/tools/kubectl.exe\", true, \"Windows path with forward slashes\"},\n\t\t{\"Relative path\", \"./kubectl\", true, \"Relative path to kubectl\"},\n\t\t{\"macOS Homebrew\", \"/opt/homebrew/bin/kubectl\", true, \"macOS Homebrew path\"},\n\n\t\t// Non-kubectl binaries\n\t\t{\"Not kubectl\", \"k\", false, \"Short alias should not match\"},\n\t\t{\"kubectl with suffix\", \"kubectl-1.28\", false, \"kubectl with version suffix\"},\n\t\t{\"kubectl prefix\", \"kubectl-proxy\", false, \"kubectl with additional suffix\"},\n\t\t{\"Other binary\", \"kubectx\", false, \"Different binary altogether\"},\n\t\t{\"Empty path\", \"\", false, \"Empty binary path\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\t// Test the filepath.Base logic used in the actual function\n\t\t\tbasename := filepath.Base(tt.binaryPath)\n\t\t\tisKubectl := (basename == \"kubectl\" || basename == \"kubectl.exe\")\n\n\t\t\tif isKubectl != tt.shouldMatch {\n\t\t\t\tt.Errorf(\"filepath.Base(%q) = %q, kubectl detection = %v, want %v\\nDescription: %s\",\n\t\t\t\t\ttt.binaryPath, basename, isKubectl, tt.shouldMatch, tt.description)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestKubectlDetectionLogic tests the core kubectl detection logic\nfunc TestKubectlDetectionLogic(t *testing.T) {\n\t// Simulate the kubectl detection logic from analyzeCall\n\ttestKubectlDetection := func(arg string) bool {\n\t\t// Reject quoted arguments\n\t\tif (strings.HasPrefix(arg, \"'\") && strings.HasSuffix(arg, \"'\")) || (strings.HasPrefix(arg, \"\\\"\") && strings.HasSuffix(arg, \"\\\"\")) {\n\t\t\treturn false\n\t\t}\n\t\t// Check if this is kubectl using OS-agnostic path handling\n\t\tbasename := filepath.Base(arg)\n\t\treturn basename == \"kubectl\" || basename == \"kubectl.exe\"\n\t}\n\n\ttestCases := []struct {\n\t\tinput    string\n\t\texpected bool\n\t\tdesc     string\n\t}{\n\t\t{\"kubectl\", true, \"literal kubectl\"},\n\t\t{\"kubectl.exe\", true, \"Windows executable\"},\n\t\t{\"/usr/bin/kubectl\", true, \"Unix path\"},\n\t\t{\"C:/tools/kubectl.exe\", true, \"Windows path with forward slash\"},\n\t\t{\"./kubectl\", true, \"relative path\"},\n\t\t{\"../bin/kubectl\", true, \"relative path with parent dir\"},\n\t\t{\"/opt/homebrew/bin/kubectl\", true, \"macOS Homebrew path\"},\n\t\t{\"'kubectl'\", false, \"quoted kubectl\"},\n\t\t{\"\\\"/usr/bin/kubectl\\\"\", false, \"quoted path\"},\n\t\t{\"not-kubectl\", false, \"different command\"},\n\t\t{\"/usr/bin/k\", false, \"different command in path\"},\n\t\t{\"kubectl-something\", false, \"kubectl with suffix\"},\n\t\t{\"kubectl-1.28\", false, \"kubectl with version suffix\"},\n\t\t{\"k\", false, \"kubectl alias\"},\n\t\t{\"kubectx\", false, \"different k8s tool\"},\n\t\t{\"\", false, \"empty string\"},\n\t}\n\n\tfor _, tc := range testCases {\n\t\tt.Run(tc.desc, func(t *testing.T) {\n\t\t\tresult := testKubectlDetection(tc.input)\n\t\t\tif result != tc.expected {\n\t\t\t\tt.Errorf(\"testKubectlDetection(%q) = %t, want %t (%s)\",\n\t\t\t\t\ttc.input, result, tc.expected, tc.desc)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestShellParserBehavior tests how the shell parser handles different command structures\n// This helps us understand if we can simplify the kubectl detection logic\nfunc TestShellParserBehavior(t *testing.T) {\n\ttestCommands := []struct {\n\t\tname     string\n\t\tcommand  string\n\t\texpected [][]string // expected args for each CallExpr\n\t}{\n\t\t{\n\t\t\tname:    \"simple kubectl\",\n\t\t\tcommand: \"kubectl get pods\",\n\t\t\texpected: [][]string{\n\t\t\t\t{\"kubectl\", \"get\", \"pods\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    \"kubectl with env var\",\n\t\t\tcommand: \"KUBECONFIG=/path/config kubectl get pods\",\n\t\t\texpected: [][]string{\n\t\t\t\t{\"kubectl\", \"get\", \"pods\"}, // env vars are handled separately\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    \"sequential commands\",\n\t\t\tcommand: \"kubectl get pods; kubectl create pod\",\n\t\t\texpected: [][]string{\n\t\t\t\t{\"kubectl\", \"get\", \"pods\"},\n\t\t\t\t{\"kubectl\", \"create\", \"pod\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    \"kubectl with path\",\n\t\t\tcommand: \"/usr/bin/kubectl get pods\",\n\t\t\texpected: [][]string{\n\t\t\t\t{\"/usr/bin/kubectl\", \"get\", \"pods\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:    \"not kubectl\",\n\t\t\tcommand: \"ls -la\",\n\t\t\texpected: [][]string{\n\t\t\t\t{\"ls\", \"-la\"},\n\t\t\t},\n\t\t},\n\t}\n\n\tparser := syntax.NewParser()\n\n\tfor _, tt := range testCommands {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tfile, err := parser.Parse(strings.NewReader(tt.command), \"\")\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Parse error for %q: %v\", tt.command, err)\n\t\t\t}\n\n\t\t\tvar actualCalls [][]string\n\t\t\tsyntax.Walk(file, func(node syntax.Node) bool {\n\t\t\t\tif call, ok := node.(*syntax.CallExpr); ok {\n\t\t\t\t\tvar args []string\n\t\t\t\t\tfor _, arg := range call.Args {\n\t\t\t\t\t\tlit := arg.Lit()\n\t\t\t\t\t\tif lit == \"\" {\n\t\t\t\t\t\t\tvar sb strings.Builder\n\t\t\t\t\t\t\tsyntax.NewPrinter().Print(&sb, arg)\n\t\t\t\t\t\t\tlit = strings.Trim(sb.String(), `\"'`)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif lit != \"\" {\n\t\t\t\t\t\t\targs = append(args, lit)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tactualCalls = append(actualCalls, args)\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\n\t\t\tif len(actualCalls) != len(tt.expected) {\n\t\t\t\tt.Errorf(\"Expected %d CallExpr, got %d for command %q\",\n\t\t\t\t\tlen(tt.expected), len(actualCalls), tt.command)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Debug output to understand parser behavior\n\t\t\tt.Logf(\"Command: %q\", tt.command)\n\t\t\tfor i, call := range actualCalls {\n\t\t\t\tt.Logf(\"  CallExpr %d: %v\", i, call)\n\t\t\t\tif len(call) > 0 {\n\t\t\t\t\tt.Logf(\"    args[0] = %q\", call[0])\n\t\t\t\t\tif strings.Contains(call[0], \"kubectl\") {\n\t\t\t\t\t\tt.Logf(\"    -> Contains kubectl!\")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor i, expectedArgs := range tt.expected {\n\t\t\t\tif len(actualCalls[i]) != len(expectedArgs) {\n\t\t\t\t\tt.Errorf(\"CallExpr %d: expected %d args, got %d for command %q\",\n\t\t\t\t\t\ti, len(expectedArgs), len(actualCalls[i]), tt.command)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tfor j, expectedArg := range expectedArgs {\n\t\t\t\t\tif actualCalls[i][j] != expectedArg {\n\t\t\t\t\t\tt.Errorf(\"CallExpr %d arg %d: expected %q, got %q for command %q\",\n\t\t\t\t\t\t\ti, j, expectedArg, actualCalls[i][j], tt.command)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestSimplifiedKubectlDetection tests a simplified approach to kubectl detection\nfunc TestSimplifiedKubectlDetection(t *testing.T) {\n\t// Simplified kubectl detection logic\n\tisKubectl := func(args []string) bool {\n\t\tif len(args) == 0 {\n\t\t\treturn false\n\t\t}\n\n\t\t// Get the first argument (the command)\n\t\tcmd := args[0]\n\n\t\t// Reject quoted arguments\n\t\tif (strings.HasPrefix(cmd, \"'\") && strings.HasSuffix(cmd, \"'\")) ||\n\t\t\t(strings.HasPrefix(cmd, \"\\\"\") && strings.HasSuffix(cmd, \"\\\"\")) {\n\t\t\treturn false\n\t\t}\n\n\t\t// Check if this is kubectl using OS-agnostic path handling\n\t\tbasename := filepath.Base(cmd)\n\t\treturn basename == \"kubectl\" || basename == \"kubectl.exe\"\n\t}\n\n\ttests := []struct {\n\t\tname     string\n\t\targs     []string\n\t\texpected bool\n\t}{\n\t\t{\"empty args\", []string{}, false},\n\t\t{\"kubectl\", []string{\"kubectl\", \"get\", \"pods\"}, true},\n\t\t{\"kubectl.exe\", []string{\"kubectl.exe\", \"get\", \"pods\"}, true},\n\t\t{\"path to kubectl\", []string{\"/usr/bin/kubectl\", \"get\", \"pods\"}, true},\n\t\t{\"Windows path\", []string{\"C:/tools/kubectl.exe\", \"delete\", \"pod\"}, true},\n\t\t{\"quoted kubectl\", []string{\"'kubectl'\", \"get\", \"pods\"}, false},\n\t\t{\"quoted path\", []string{\"\\\"/usr/bin/kubectl\\\"\", \"get\", \"pods\"}, false},\n\t\t{\"not kubectl\", []string{\"ls\", \"-la\"}, false},\n\t\t{\"kubectl with suffix\", []string{\"kubectl-1.28\", \"get\", \"pods\"}, false},\n\t\t{\"k alias\", []string{\"k\", \"get\", \"pods\"}, false},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := isKubectl(tt.args)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"isKubectl(%v) = %v, want %v\", tt.args, result, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestKubectlAlwaysAtPosition0 confirms that kubectl is always at args[0] in a CallExpr\nfunc TestKubectlAlwaysAtPosition0(t *testing.T) {\n\t// Test different kubectl commands to confirm kubectl is always at position 0\n\ttestCommands := []string{\n\t\t\"kubectl get pods\",\n\t\t\"/usr/bin/kubectl get pods\",\n\t\t\"kubectl.exe get pods\",\n\t\t\"kubectl --context=prod get pods\",\n\t\t\"KUBECONFIG=/path/config kubectl get pods\",\n\t}\n\n\tparser := syntax.NewParser()\n\n\tfor _, cmd := range testCommands {\n\t\tt.Run(cmd, func(t *testing.T) {\n\t\t\tfile, err := parser.Parse(strings.NewReader(cmd), \"\")\n\t\t\tif err != nil {\n\t\t\t\tt.Fatalf(\"Parse error: %v\", err)\n\t\t\t}\n\n\t\t\tsyntax.Walk(file, func(node syntax.Node) bool {\n\t\t\t\tif call, ok := node.(*syntax.CallExpr); ok {\n\t\t\t\t\tif len(call.Args) == 0 {\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\n\t\t\t\t\t// Extract first argument\n\t\t\t\t\tfirstArg := call.Args[0].Lit()\n\t\t\t\t\tif firstArg == \"\" {\n\t\t\t\t\t\tvar sb strings.Builder\n\t\t\t\t\t\tsyntax.NewPrinter().Print(&sb, call.Args[0])\n\t\t\t\t\t\tfirstArg = strings.Trim(sb.String(), `\"'`)\n\t\t\t\t\t}\n\n\t\t\t\t\t// Check if first argument is kubectl (using same logic as main code)\n\t\t\t\t\tbasename := filepath.Base(firstArg)\n\t\t\t\t\tisKubectl := basename == \"kubectl\" || basename == \"kubectl.exe\"\n\n\t\t\t\t\tif !isKubectl {\n\t\t\t\t\t\tt.Errorf(\"Expected kubectl at args[0], got %q (basename: %q)\", firstArg, basename)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn true\n\t\t\t})\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "pkg/tools/kubectl_tool.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sandbox\"\n)\n\ntype Kubectl struct {\n\texecutor sandbox.Executor\n}\n\nfunc NewKubectlTool(executor sandbox.Executor) *Kubectl {\n\treturn &Kubectl{executor: executor}\n}\n\nfunc (t *Kubectl) Name() string {\n\treturn \"kubectl\"\n}\n\nfunc (t *Kubectl) Description() string {\n\treturn `Executes a kubectl command against the user's Kubernetes cluster. Use this tool only when you need to query or modify the state of the user's Kubernetes cluster.\n\nIMPORTANT: Interactive commands are not supported in this environment. This includes:\n- kubectl exec with -it flag (use non-interactive exec instead)\n- kubectl edit (use kubectl get -o yaml, kubectl patch, or kubectl apply instead)\n- kubectl port-forward (use alternative methods like NodePort or LoadBalancer)\n\nFor interactive operations, please use these non-interactive alternatives:\n- Instead of 'kubectl edit', use 'kubectl get -o yaml' to view, 'kubectl patch' for targeted changes, or 'kubectl apply' to apply full changes\n- Instead of 'kubectl exec -it', use 'kubectl exec' with a specific command\n- Instead of 'kubectl port-forward', use service types like NodePort or LoadBalancer`\n}\n\nfunc (t *Kubectl) FunctionDefinition() *gollm.FunctionDefinition {\n\treturn &gollm.FunctionDefinition{\n\t\tName:        t.Name(),\n\t\tDescription: t.Description(),\n\t\tParameters: &gollm.Schema{\n\t\t\tType: gollm.TypeObject,\n\t\t\tProperties: map[string]*gollm.Schema{\n\t\t\t\t\"command\": {\n\t\t\t\t\tType: gollm.TypeString,\n\t\t\t\t\tDescription: `The complete kubectl command to execute. Prefer to use heredoc syntax for multi-line commands. Please include the kubectl prefix as well.\n\nIMPORTANT: Do not use interactive commands. Instead:\n- Use 'kubectl get -o yaml', 'kubectl patch', or 'kubectl apply' instead of 'kubectl edit'\n- Use 'kubectl exec' with specific commands instead of 'kubectl exec -it'\n- Use service types like NodePort or LoadBalancer instead of 'kubectl port-forward'\n\nExamples:\nuser: what pods are running in the cluster?\nassistant: kubectl get pods\n\nuser: what is the status of the pod my-pod?\nassistant: kubectl get pod my-pod -o jsonpath='{.status.phase}'\n\nuser: I need to edit the pod configuration\nassistant: # Option 1: Using patch for targeted changes\nkubectl patch pod my-pod --patch '{\"spec\":{\"containers\":[{\"name\":\"main\",\"image\":\"new-image\"}]}}'\n\n# Option 2: Using get and apply for full changes\nkubectl get pod my-pod -o yaml > pod.yaml\n# Edit pod.yaml locally\nkubectl apply -f pod.yaml\n\nuser: I need to execute a command in the pod\nassistant: kubectl exec my-pod -- /bin/sh -c \"your command here\"`,\n\t\t\t\t},\n\t\t\t\t\"modifies_resource\": {\n\t\t\t\t\tType: gollm.TypeString,\n\t\t\t\t\tDescription: `Whether the command modifies a kubernetes resource.\nPossible values:\n- \"yes\" if the command modifies a resource\n- \"no\" if the command does not modify a resource\n- \"unknown\" if the command's effect on the resource is unknown`},\n\t\t\t},\n\t\t},\n\t}\n}\n\nfunc (t *Kubectl) Run(ctx context.Context, args map[string]any) (any, error) {\n\tkubeconfig := ctx.Value(KubeconfigKey).(string)\n\tworkDir := ctx.Value(WorkDirKey).(string)\n\n\t// Add nil check for command\n\tcommandVal, ok := args[\"command\"]\n\tif !ok || commandVal == nil {\n\t\treturn &sandbox.ExecResult{Command: \"\", Error: \"kubectl command not provided or is nil\"}, nil\n\t}\n\n\tcommand, ok := commandVal.(string)\n\tif !ok {\n\t\treturn &sandbox.ExecResult{Command: command, Error: \"kubectl command must be a string\"}, nil\n\t}\n\n\t// Check for interactive commands before proceeding\n\tif err := validateKubectlCommand(command); err != nil {\n\t\treturn &sandbox.ExecResult{Command: command, Error: err.Error()}, nil\n\t}\n\n\t// Prepare environment\n\tenv := os.Environ()\n\tif kubeconfig != \"\" {\n\t\tkubeconfig, err := ExpandShellVar(kubeconfig)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tenv = append(env, \"KUBECONFIG=\"+kubeconfig)\n\t}\n\n\treturn ExecuteWithStreamingHandling(ctx, t.executor, command, workDir, env, DetectKubectlStreaming)\n}\n\n// DetectKubectlStreaming checks if a kubectl command is a streaming command\nfunc DetectKubectlStreaming(command string) (bool, string) {\n\tisWatch := strings.Contains(command, \" get \") && strings.Contains(command, \" -w\")\n\tisLogs := strings.Contains(command, \" logs \") && strings.Contains(command, \" -f\")\n\tisAttach := strings.Contains(command, \" attach \")\n\n\tif isWatch {\n\t\treturn true, \"watch\"\n\t}\n\tif isLogs {\n\t\treturn true, \"logs\"\n\t}\n\tif isAttach {\n\t\treturn true, \"attach\"\n\t}\n\treturn false, \"\"\n}\n\nfunc (t *Kubectl) IsInteractive(args map[string]any) (bool, error) {\n\tcommandVal, ok := args[\"command\"]\n\tif !ok || commandVal == nil {\n\t\treturn false, nil\n\t}\n\n\tcommand, ok := commandVal.(string)\n\tif !ok {\n\t\treturn false, nil\n\t}\n\n\treturn IsInteractiveCommand(command)\n}\n\n// CheckModifiesResource determines if the command modifies kubernetes resources\n// This is used for permission checks before command execution\n// Returns \"yes\", \"no\", or \"unknown\"\nfunc (t *Kubectl) CheckModifiesResource(args map[string]any) string {\n\tcommand, ok := args[\"command\"].(string)\n\tif !ok {\n\t\treturn \"unknown\"\n\t}\n\n\treturn kubectlModifiesResource(command)\n}\n\nfunc validateKubectlCommand(command string) error {\n\tif strings.Contains(command, \"kubectl edit\") {\n\t\treturn fmt.Errorf(\"interactive mode not supported for kubectl, please use non-interactive commands\")\n\t}\n\tif strings.Contains(command, \"kubectl port-forward\") {\n\t\treturn fmt.Errorf(\"port-forwarding is not allowed because assistant is running in an unattended mode, please try some other alternative\")\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "pkg/tools/mcp_tool.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\n// Package tools implements the kubectl-ai tool system.\npackage tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/gollm\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/mcp\"\n\t\"k8s.io/klog/v2\"\n)\n\n// =============================================================================\n// Schema Conversion Functions (kubectl-ai specific)\n// =============================================================================\n\n// ConvertToolToGollm converts an MCP tool to gollm.FunctionDefinition with a simple schema\nfunc ConvertToolToGollm(mcpTool *mcp.Tool) (*gollm.FunctionDefinition, error) {\n\tdef := &gollm.FunctionDefinition{\n\t\tName:        mcpTool.Name,\n\t\tDescription: mcpTool.Description,\n\t\tParameters:  mcpTool.InputSchema,\n\t}\n\treturn def, nil\n}\n\n// =============================================================================\n// MCP Tool Implementation\n// =============================================================================\n\n// MCPTool wraps an MCP server tool to implement the Tool interface.\n// It serves as an adapter between MCP-based tools and kubectl-ai's tool system.\ntype MCPTool struct {\n\tserverName  string\n\ttoolName    string\n\tdescription string\n\tschema      *gollm.FunctionDefinition\n\tmanager     *mcp.Manager\n}\n\n// NewMCPTool creates a new MCP tool wrapper.\nfunc NewMCPTool(serverName, toolName, description string, schema *gollm.FunctionDefinition, manager *mcp.Manager) *MCPTool {\n\treturn &MCPTool{\n\t\tserverName:  serverName,\n\t\ttoolName:    toolName,\n\t\tdescription: description,\n\t\tschema:      schema,\n\t\tmanager:     manager,\n\t}\n}\n\n// Name returns the tool name.\nfunc (t *MCPTool) Name() string {\n\treturn t.toolName\n}\n\nfunc (t *MCPTool) UniqueToolName() string {\n\treturn fmt.Sprintf(\"%s_%s\", t.serverName, t.toolName)\n}\n\n// ServerName returns the MCP server name.\nfunc (t *MCPTool) ServerName() string {\n\treturn t.serverName\n}\n\n// Description returns the tool description.\nfunc (t *MCPTool) Description() string {\n\treturn t.description\n}\n\n// FunctionDefinition returns the tool's function definition.\nfunc (t *MCPTool) FunctionDefinition() *gollm.FunctionDefinition {\n\treturn t.schema\n}\n\n// TODO(tuannvm): This is a placeholder implementation. Need to implement detection of interactive MCP tools.\n// IsInteractive checks if the tool requires interactive input.\nfunc (t *MCPTool) IsInteractive(args map[string]any) (bool, error) {\n\treturn false, nil\n}\n\n// CheckModifiesResource determines if the command modifies kubernetes resources\n// For MCP tools, we'll conservatively assume they might modify resources\n// since we can't easily determine this for arbitrary external tools\n// Returns \"yes\", \"no\", or \"unknown\"\nfunc (t *MCPTool) CheckModifiesResource(args map[string]any) string {\n\t// Since MCP tools can be arbitrary external tools and we don't have a way to know\n\t// if they modify resources, we'll conservatively return \"unknown\"\n\treturn \"unknown\"\n}\n\n// Run executes the MCP tool by calling the appropriate MCP server.\nfunc (t *MCPTool) Run(ctx context.Context, args map[string]any) (any, error) {\n\tlog := klog.FromContext(ctx)\n\n\t// Get MCP client for the server\n\tclient, exists := t.manager.GetClient(t.serverName)\n\tif !exists {\n\t\treturn nil, fmt.Errorf(\"MCP server %q not connected\", t.serverName)\n\t}\n\n\t// // Convert arguments to proper types for MCP server using the MCP package's functions\n\t// args = mcp.ConvertArgs(args)\n\n\t// Execute tool on MCP server\n\tresult, err := client.CallTool(ctx, t.toolName, args)\n\tif err != nil {\n\t\tlog.Info(\"tool info\", \"name\", t.toolName, \"schema\", t.schema)\n\t\tlog.Info(\"call info\", \"args\", args)\n\t\treturn nil, fmt.Errorf(\"calling MCP tool %q on server %q: %w\", t.toolName, t.serverName, err)\n\t}\n\n\treturn result, nil\n}\n"
  },
  {
    "path": "pkg/tools/streaming.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage tools\n\nimport (\n\t\"context\"\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sandbox\"\n)\n\n// StreamDetector determines if a command is a streaming command and returns the stream type.\n// It returns (true, streamType) if it is a streaming command, and (false, \"\") otherwise.\ntype StreamDetector func(command string) (isStreaming bool, streamType string)\n\n// ExecuteWithStreamingHandling executes a command using the provided executor,\n// handling streaming commands (watch, logs -f, attach) by applying a timeout\n// and capturing partial output.\nfunc ExecuteWithStreamingHandling(ctx context.Context, executor sandbox.Executor, command string, workDir string, env []string, detector StreamDetector) (*sandbox.ExecResult, error) {\n\tisStreaming, streamType := false, \"\"\n\tif detector != nil {\n\t\tisStreaming, streamType = detector(command)\n\t}\n\n\tvar cmdCtx context.Context\n\tvar cancel context.CancelFunc\n\n\tif isStreaming {\n\t\t// Create a context with timeout for streaming commands\n\t\tcmdCtx, cancel = context.WithTimeout(ctx, 7*time.Second)\n\t\tdefer cancel()\n\t} else {\n\t\t// Use the provided context directly\n\t\tcmdCtx = ctx\n\t\tcancel = func() {} // No-op cancel\n\t}\n\n\tresult, err := executor.Execute(cmdCtx, command, env, workDir)\n\n\t// If executor returns nil result on error (it shouldn't, but let's be safe), create one\n\tif result == nil {\n\t\tresult = &sandbox.ExecResult{Command: command}\n\t}\n\n\tif isStreaming {\n\t\tif cmdCtx.Err() == context.DeadlineExceeded {\n\t\t\t// Timeout is expected for streaming commands\n\t\t\tresult.StreamType = \"timeout\"\n\t\t\tresult.Error = \"Timeout reached after 7 seconds\"\n\t\t\t// Clear the error if it was just the timeout\n\t\t\terr = nil\n\t\t\t// Set the detected stream type\n\t\t\tresult.StreamType = streamType\n\t\t\treturn result, nil\n\t\t}\n\t}\n\n\treturn result, err\n}\n"
  },
  {
    "path": "pkg/tools/tools.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage tools\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"maps\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"slices\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/journal\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sandbox\"\n\t\"github.com/google/uuid\"\n\t\"sigs.k8s.io/yaml\"\n)\n\ntype ContextKey string\n\nconst (\n\tKubeconfigKey ContextKey = \"kubeconfig\"\n\tWorkDirKey    ContextKey = \"work_dir\"\n\tExecutorKey   ContextKey = \"executor\"\n)\n\nfunc Lookup(name string) Tool {\n\treturn allTools.Lookup(name)\n}\n\nvar allTools Tools = Tools{\n\ttools: make(map[string]Tool),\n}\n\nfunc Default() Tools {\n\treturn allTools\n}\n\n// RegisterTool makes a tool available to the LLM.\nfunc RegisterTool(tool Tool) {\n\tallTools.RegisterTool(tool)\n}\n\ntype Tools struct {\n\ttools map[string]Tool\n}\n\nfunc (t *Tools) Init() {\n\tt.tools = make(map[string]Tool)\n}\n\nfunc (t *Tools) Lookup(name string) Tool {\n\treturn t.tools[name]\n}\n\nfunc (t *Tools) AllTools() []Tool {\n\treturn slices.Collect(maps.Values(t.tools))\n}\n\nfunc (t *Tools) Names() []string {\n\tnames := make([]string, 0, len(t.tools))\n\tfor name := range t.tools {\n\t\tnames = append(names, name)\n\t}\n\tsort.Strings(names)\n\treturn names\n}\n\nfunc (t *Tools) RegisterTool(tool Tool) {\n\t// if mcp tool use unique name\n\tname := tool.Name()\n\t// For MCP tools, we need to use a unique name to avoid conflicts\n\t// with built-in tools or tools from other MCP servers.\n\tif mcpTool, ok := tool.(*MCPTool); ok {\n\t\tname = mcpTool.UniqueToolName()\n\t}\n\n\tif _, exists := t.tools[name]; exists {\n\t\tpanic(\"tool already registered: \" + name)\n\t}\n\tt.tools[name] = tool\n}\n\n// CloneWithExecutor creates a shallow copy of the Tools collection,\n// but clones any tools that need a session-specific executor (like CustomTool).\nfunc (t *Tools) CloneWithExecutor(executor sandbox.Executor) Tools {\n\tnewTools := Tools{\n\t\ttools: make(map[string]Tool),\n\t}\n\n\tfor name, tool := range t.tools {\n\t\t// If it's a CustomTool, we need to clone it with the session-specific executor\n\t\tif ct, ok := tool.(*CustomTool); ok {\n\t\t\tnewTools.tools[name] = ct.CloneWithExecutor(executor)\n\t\t} else {\n\t\t\t// For other tools (like MCP tools), we reuse the existing instance\n\t\t\tnewTools.tools[name] = tool\n\t\t}\n\t}\n\treturn newTools\n}\n\ntype ToolCall struct {\n\ttool      Tool\n\tname      string\n\targuments map[string]any\n}\n\n// Description returns a description of the tool call.\n// This is used to display the tool call in the UI.\n// It should be human-readable,\n// and should be concise enough that the user can read it quickly,\n// but precise enough that the user can decide whether to invoke the tool.\nfunc (t *ToolCall) Description() string {\n\t// Check if this is an MCP tool and format accordingly\n\tif mcpTool, ok := t.tool.(*MCPTool); ok {\n\t\tif command, ok := t.arguments[\"command\"]; ok {\n\t\t\treturn fmt.Sprintf(\"[MCP: %s] %s\", mcpTool.serverName, command.(string))\n\t\t}\n\t\tvar args []string\n\t\tfor k, v := range t.arguments {\n\t\t\targs = append(args, fmt.Sprintf(\"%s=%v\", k, v))\n\t\t}\n\t\tsort.Strings(args)\n\t\treturn fmt.Sprintf(\"[MCP: %s] %s(%s)\", mcpTool.serverName, t.name, strings.Join(args, \", \"))\n\t}\n\n\t// Default formatting for non-MCP tools\n\tif command, ok := t.arguments[\"command\"]; ok {\n\t\treturn command.(string)\n\t}\n\tvar args []string\n\tfor k, v := range t.arguments {\n\t\targs = append(args, fmt.Sprintf(\"%s=%v\", k, v))\n\t}\n\tsort.Strings(args)\n\treturn fmt.Sprintf(\"%s(%s)\", t.name, strings.Join(args, \", \"))\n}\n\n// ParseToolInvocation parses a request from the LLM into a tool call.\nfunc (t *Tools) ParseToolInvocation(ctx context.Context, name string, arguments map[string]any) (*ToolCall, error) {\n\ttool := t.Lookup(name)\n\tif tool == nil {\n\t\treturn nil, fmt.Errorf(\"tool %q not recognized\", name)\n\t}\n\n\treturn &ToolCall{\n\t\ttool:      tool,\n\t\tname:      name,\n\t\targuments: arguments,\n\t}, nil\n}\n\ntype InvokeToolOptions struct {\n\tWorkDir string\n\n\t// Kubeconfig is the path to the kubeconfig file.\n\tKubeconfig string\n\n\t// Executor is the executor for tool execution\n\tExecutor sandbox.Executor\n}\n\ntype ToolRequestEvent struct {\n\tCallID    string         `json:\"id,omitempty\"`\n\tName      string         `json:\"name,omitempty\"`\n\tArguments map[string]any `json:\"arguments,omitempty\"`\n}\n\ntype ToolResponseEvent struct {\n\tCallID   string `json:\"id,omitempty\"`\n\tResponse any    `json:\"response,omitempty\"`\n\tError    string `json:\"error,omitempty\"`\n}\n\n// InvokeTool handles the execution of a single action\nfunc (t *ToolCall) InvokeTool(ctx context.Context, opt InvokeToolOptions) (any, error) {\n\trecorder := journal.RecorderFromContext(ctx)\n\n\tcallID := uuid.NewString()\n\trecorder.Write(ctx, &journal.Event{\n\t\tTimestamp: time.Now(),\n\t\tAction:    \"tool-request\",\n\t\tPayload: ToolRequestEvent{\n\t\t\tCallID:    callID,\n\t\t\tName:      t.name,\n\t\t\tArguments: t.arguments,\n\t\t},\n\t})\n\n\tctx = context.WithValue(ctx, KubeconfigKey, opt.Kubeconfig)\n\tctx = context.WithValue(ctx, WorkDirKey, opt.WorkDir)\n\tif opt.Executor != nil {\n\t\tctx = context.WithValue(ctx, ExecutorKey, opt.Executor)\n\t}\n\n\tresponse, err := t.tool.Run(ctx, t.arguments)\n\n\t{\n\t\tev := ToolResponseEvent{\n\t\t\tCallID:   callID,\n\t\t\tResponse: response,\n\t\t}\n\t\tif err != nil {\n\t\t\tev.Error = err.Error()\n\t\t}\n\t\trecorder.Write(ctx, &journal.Event{\n\t\t\tTimestamp: time.Now(),\n\t\t\tAction:    \"tool-response\",\n\t\t\tPayload:   ev,\n\t\t})\n\t}\n\n\treturn response, err\n}\n\n// ToolResultToMap converts an arbitrary result to a map[string]any\nfunc ToolResultToMap(result any) (map[string]any, error) {\n\t// Handle simple string results (common with MCP tools)\n\tif str, ok := result.(string); ok {\n\t\treturn map[string]any{\"content\": str}, nil\n\t}\n\n\t// Handle nil results\n\tif result == nil {\n\t\treturn map[string]any{\"content\": \"\"}, nil\n\t}\n\n\t// Try to convert to map via JSON for structured results\n\tb, err := json.Marshal(result)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"converting result to json: %w\", err)\n\t}\n\n\tm := make(map[string]any)\n\tif err := json.Unmarshal(b, &m); err != nil {\n\t\t// If JSON unmarshal fails, wrap the original result\n\t\treturn map[string]any{\"content\": result}, nil\n\t}\n\treturn m, nil\n}\n\n// LoadAndRegisterCustomTools loads tool configurations from a YAML file\n// and registers them.\nfunc LoadAndRegisterCustomTools(configPath string) error {\n\tpathInfo, err := os.Stat(configPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to describe config file %s: %w\", configPath, err)\n\t}\n\n\tif pathInfo.IsDir() {\n\t\tconfigPaths, err := os.ReadDir(configPath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"failed to read config dir %s: %w\", configPath, err)\n\t\t}\n\n\t\tfor _, entry := range configPaths {\n\t\t\tif err := LoadAndRegisterCustomTools(filepath.Join(configPath, entry.Name())); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\treturn nil\n\t}\n\n\tyamlFile, err := os.ReadFile(configPath)\n\tif os.IsNotExist(err) {\n\t\treturn nil\n\t} else if err != nil {\n\t\treturn fmt.Errorf(\"failed to read config file %s: %w\", configPath, err)\n\t}\n\n\tvar configs []CustomToolConfig\n\terr = yaml.Unmarshal(yamlFile, &configs)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to parse YAML config file %s: %w\", configPath, err)\n\t}\n\n\t// Register each custom tool\n\tvar registrationErrors []string\n\tfor _, config := range configs {\n\t\ttool, err := NewCustomTool(config)\n\t\tif err != nil {\n\t\t\tregistrationErrors = append(registrationErrors, fmt.Sprintf(\"failed to create tool %q: %v\", config.Name, err))\n\t\t\tcontinue // Skip registration if creation failed\n\t\t}\n\t\t// Check for duplicate registration attempt\n\t\tif _, exists := allTools.tools[tool.Name()]; exists {\n\t\t\tregistrationErrors = append(registrationErrors, fmt.Sprintf(\"tool %q already registered (possibly built-in), skipping custom definition\", tool.Name()))\n\t\t\tcontinue\n\t\t}\n\t\tRegisterTool(tool)\n\t}\n\n\tif len(registrationErrors) > 0 {\n\t\treturn fmt.Errorf(\"encountered errors during custom tool registration:\\n - %s\", strings.Join(registrationErrors, \"\\n - \"))\n\t}\n\n\treturn nil\n}\n\n// For CustomTool\nfunc (t *CustomTool) IsInteractive(args map[string]any) (bool, error) {\n\t// Custom tools are not interactive by default\n\treturn false, nil\n}\n\n// Add a method to access the tool\nfunc (t *ToolCall) GetTool() Tool {\n\treturn t.tool\n}\n\n// ExpandShellVar expands shell variables and syntax using bash\nfunc ExpandShellVar(value string) (string, error) {\n\tif strings.Contains(value, \"~\") {\n\t\tif len(value) >= 2 && value[0] == '~' && os.IsPathSeparator(value[1]) {\n\t\t\tif runtime.GOOS == \"windows\" {\n\t\t\t\tvalue = filepath.Join(os.Getenv(\"USERPROFILE\"), value[2:])\n\t\t\t} else {\n\t\t\t\tvalue = filepath.Join(os.Getenv(\"HOME\"), value[2:])\n\t\t\t}\n\t\t}\n\t}\n\treturn os.ExpandEnv(value), nil\n}\n\nfunc IsInteractiveCommand(command string) (bool, error) {\n\t// Inline isKubectlCommand logic\n\twords := strings.Fields(command)\n\tif len(words) == 0 {\n\t\treturn false, nil\n\t}\n\tbase := filepath.Base(words[0])\n\tif base != \"kubectl\" {\n\t\treturn false, nil\n\t}\n\n\tisExec := strings.Contains(command, \" exec \") && strings.Contains(command, \" -it\")\n\tisPortForward := strings.Contains(command, \" port-forward \")\n\tisEdit := strings.Contains(command, \" edit \")\n\n\tif isExec || isPortForward || isEdit {\n\t\treturn true, fmt.Errorf(\"interactive mode not supported for kubectl, please use non-interactive commands\")\n\t}\n\treturn false, nil\n}\n"
  },
  {
    "path": "pkg/ui/html/htmlui.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage html\n\nimport (\n\t\"context\"\n\t_ \"embed\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/agent\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/journal\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/sessions\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/ui\"\n\t\"github.com/charmbracelet/glamour\"\n\t\"golang.org/x/sync/errgroup\"\n\t\"k8s.io/klog/v2\"\n)\n\n// Broadcaster manages a set of clients for Server-Sent Events.\ntype Broadcaster struct {\n\tclients   map[chan []byte]bool\n\tnewClient chan chan []byte\n\tdelClient chan chan []byte\n\tmessages  chan []byte\n\tmu        sync.Mutex\n}\n\n// NewBroadcaster creates a new Broadcaster instance.\nfunc NewBroadcaster() *Broadcaster {\n\tb := &Broadcaster{\n\t\tclients:   make(map[chan []byte]bool),\n\t\tnewClient: make(chan (chan []byte)),\n\t\tdelClient: make(chan (chan []byte)),\n\t\tmessages:  make(chan []byte, 10),\n\t}\n\treturn b\n}\n\n// Run starts the broadcaster's event loop.\nfunc (b *Broadcaster) Run(ctx context.Context) {\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\treturn\n\t\tcase client := <-b.newClient:\n\t\t\tb.mu.Lock()\n\t\t\tb.clients[client] = true\n\t\t\tb.mu.Unlock()\n\t\tcase client := <-b.delClient:\n\t\t\tb.mu.Lock()\n\t\t\tdelete(b.clients, client)\n\t\t\tclose(client)\n\t\t\tb.mu.Unlock()\n\t\tcase msg := <-b.messages:\n\t\t\tb.mu.Lock()\n\t\t\tfor client := range b.clients {\n\t\t\t\tselect {\n\t\t\t\tcase client <- msg:\n\t\t\t\tdefault:\n\t\t\t\t\tklog.Warning(\"SSE client buffer full, dropping message.\")\n\t\t\t\t}\n\t\t\t}\n\t\t\tb.mu.Unlock()\n\t\t}\n\t}\n}\n\n// Broadcast sends a message to all connected clients.\nfunc (b *Broadcaster) Broadcast(msg []byte) {\n\tb.messages <- msg\n}\n\ntype HTMLUserInterface struct {\n\thttpServer         *http.Server\n\thttpServerListener net.Listener\n\n\tmanager         *agent.AgentManager\n\tsessionManager  *sessions.SessionManager\n\tjournal         journal.Recorder\n\tdefaultModel    string\n\tdefaultProvider string\n\n\tmarkdownRenderer *glamour.TermRenderer\n\tbroadcasters     map[string]*Broadcaster\n\tbroadcastersMu   sync.Mutex\n\n\tbroadcasterCancels map[string]context.CancelFunc\n\tbaseCtx            context.Context\n}\n\nvar _ ui.UI = &HTMLUserInterface{}\n\nfunc NewHTMLUserInterface(manager *agent.AgentManager, sessionManager *sessions.SessionManager, defaultModel, defaultProvider string, listenAddress string, journal journal.Recorder) (*HTMLUserInterface, error) {\n\tmux := http.NewServeMux()\n\n\tu := &HTMLUserInterface{\n\t\tmanager:            manager,\n\t\tsessionManager:     sessionManager,\n\t\tdefaultModel:       defaultModel,\n\t\tdefaultProvider:    defaultProvider,\n\t\tjournal:            journal,\n\t\tbroadcasters:       make(map[string]*Broadcaster),\n\t\tbroadcasterCancels: make(map[string]context.CancelFunc),\n\t}\n\n\t// Register callback to listen to new agents\n\tmanager.SetAgentCreatedCallback(func(a *agent.Agent) {\n\t\tu.ensureAgentListener(a)\n\t})\n\n\thttpServer := &http.Server{\n\t\tAddr:    listenAddress,\n\t\tHandler: mux,\n\t}\n\n\tmux.HandleFunc(\"GET /\", u.serveIndex)\n\tmux.HandleFunc(\"GET /api/sessions\", u.handleListSessions)\n\tmux.HandleFunc(\"POST /api/sessions\", u.handleCreateSession)\n\tmux.HandleFunc(\"POST /api/sessions/{id}/rename\", u.handleRenameSession)\n\tmux.HandleFunc(\"DELETE /api/sessions/{id}\", u.handleDeleteSession)\n\tmux.HandleFunc(\"GET /api/sessions/{id}/stream\", u.handleSessionStream)\n\tmux.HandleFunc(\"POST /api/sessions/{id}/send-message\", u.handlePOSTSendMessage)\n\tmux.HandleFunc(\"POST /api/sessions/{id}/choose-option\", u.handlePOSTChooseOption)\n\n\thttpServerListener, err := net.Listen(\"tcp\", listenAddress)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"starting http server network listener: %w\", err)\n\t}\n\tendpoint := httpServerListener.Addr()\n\tu.httpServerListener = httpServerListener\n\tu.httpServer = httpServer\n\n\tfmt.Fprintf(os.Stdout, \"listening on http://%s\\n\", endpoint)\n\n\tmdRenderer, err := glamour.NewTermRenderer(\n\t\tglamour.WithAutoStyle(),\n\t\tglamour.WithPreservedNewLines(),\n\t\tglamour.WithEmoji(),\n\t)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error initializing the markdown renderer: %w\", err)\n\t}\n\tu.markdownRenderer = mdRenderer\n\n\treturn u, nil\n}\n\nfunc (u *HTMLUserInterface) Run(ctx context.Context) error {\n\tg, gctx := errgroup.WithContext(ctx)\n\n\tu.baseCtx = gctx\n\n\tg.Go(func() error {\n\t\tif err := u.httpServer.Serve(u.httpServerListener); err != nil && !errors.Is(err, http.ErrServerClosed) {\n\t\t\treturn fmt.Errorf(\"error running http server: %w\", err)\n\t\t}\n\t\treturn nil\n\t})\n\n\tg.Go(func() error {\n\t\t<-gctx.Done()\n\t\tshutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n\t\tdefer cancel()\n\t\tif err := u.httpServer.Shutdown(shutdownCtx); err != nil {\n\t\t\tklog.Errorf(\"HTTP server shutdown error: %v\", err)\n\t\t}\n\t\treturn nil\n\t})\n\n\treturn g.Wait()\n}\n\n//go:embed index.html\nvar indexHTML []byte\n\nfunc (u *HTMLUserInterface) serveIndex(w http.ResponseWriter, req *http.Request) {\n\tw.Header().Set(\"Content-Type\", \"text/html\")\n\tw.Write(indexHTML)\n}\n\nfunc (u *HTMLUserInterface) handleSessionStream(w http.ResponseWriter, req *http.Request) {\n\tctx := req.Context()\n\tlog := klog.FromContext(ctx)\n\n\tid := req.PathValue(\"id\")\n\tif id == \"\" {\n\t\thttp.Error(w, \"missing session id\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tflusher, ok := w.(http.Flusher)\n\tif !ok {\n\t\thttp.Error(w, \"Streaming unsupported!\", http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n\tw.Header().Set(\"Connection\", \"keep-alive\")\n\n\tclientChan := make(chan []byte, 10)\n\tbroadcaster := u.getBroadcaster(id)\n\tbroadcaster.newClient <- clientChan\n\tdefer func() {\n\t\tbroadcaster.delClient <- clientChan\n\t}()\n\n\tlog.Info(\"SSE client connected\", \"sessionID\", id)\n\n\tagent, err := u.manager.GetAgent(ctx, id)\n\tvar initialData []byte\n\tif err != nil {\n\t\tlog.Error(err, \"getting agent for session\")\n\t} else {\n\t\tinitialData, err = u.getSessionStateJSON(agent.Session)\n\t}\n\n\tif err != nil {\n\t\tlog.Error(err, \"getting initial state for SSE client\")\n\t} else {\n\t\tfmt.Fprintf(w, \"data: %s\\n\\n\", initialData)\n\t\tflusher.Flush()\n\t}\n\n\tfor {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tlog.Info(\"SSE client disconnected\")\n\t\t\treturn\n\t\tcase msg := <-clientChan:\n\t\t\tfmt.Fprintf(w, \"data: %s\\n\\n\", msg)\n\t\t\tflusher.Flush()\n\t\t}\n\t}\n}\n\nfunc (u *HTMLUserInterface) handleListSessions(w http.ResponseWriter, req *http.Request) {\n\tctx := req.Context()\n\tlog := klog.FromContext(ctx)\n\n\tsessionsList, err := u.manager.ListSessions()\n\tif err != nil {\n\t\tlog.Error(err, \"listing sessions\")\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tif err := json.NewEncoder(w).Encode(sessionsList); err != nil {\n\t\tlog.Error(err, \"encoding sessions list\")\n\t}\n}\n\nfunc (u *HTMLUserInterface) handleCreateSession(w http.ResponseWriter, req *http.Request) {\n\tctx := req.Context()\n\tlog := klog.FromContext(ctx)\n\n\tmeta := sessions.Metadata{\n\t\tModelID:    u.defaultModel,\n\t\tProviderID: u.defaultProvider,\n\t}\n\n\tsession, err := u.sessionManager.NewSession(meta)\n\tif err != nil {\n\t\tlog.Error(err, \"creating new session\")\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Ensure agent is started/loaded (though mostly for side effect of starting if not started)\n\tif _, err := u.manager.GetAgent(ctx, session.ID); err != nil {\n\t\tlog.Error(err, \"starting agent for new session\")\n\t\t// We don't fail the request here necessarily, but it's good to know.\n\t}\n\n\tw.Header().Set(\"Content-Type\", \"application/json\")\n\tjson.NewEncoder(w).Encode(map[string]string{\"id\": session.ID})\n}\n\nfunc (u *HTMLUserInterface) handleRenameSession(w http.ResponseWriter, req *http.Request) {\n\tctx := req.Context()\n\tlog := klog.FromContext(ctx)\n\n\tid := req.PathValue(\"id\")\n\tif id == \"\" {\n\t\thttp.Error(w, \"missing session id\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif err := req.ParseForm(); err != nil {\n\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\treturn\n\t}\n\tnewName := req.FormValue(\"name\")\n\tif newName == \"\" {\n\t\thttp.Error(w, \"missing name\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tsession, err := u.manager.FindSessionByID(id)\n\tif err != nil {\n\t\thttp.Error(w, \"session not found\", http.StatusNotFound)\n\t\treturn\n\t}\n\n\tsession.Name = newName\n\tif err := u.manager.UpdateLastAccessed(session); err != nil { // UpdateLastAccessed also saves the session\n\t\tlog.Error(err, \"updating session\")\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\tif agent, err := u.manager.GetAgent(ctx, id); err == nil {\n\t\tagent.Session.Name = newName\n\t\t// Broadcast update\n\t\tif data, err := u.getSessionStateJSON(agent.Session); err == nil {\n\t\t\tu.getBroadcaster(id).Broadcast(data)\n\t\t}\n\t}\n\n\tw.WriteHeader(http.StatusOK)\n}\n\nfunc (u *HTMLUserInterface) handleDeleteSession(w http.ResponseWriter, req *http.Request) {\n\tctx := req.Context()\n\tlog := klog.FromContext(ctx)\n\n\tid := req.PathValue(\"id\")\n\tif id == \"\" {\n\t\thttp.Error(w, \"missing session id\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif err := u.manager.DeleteSession(id); err != nil {\n\t\tlog.Error(err, \"deleting session\")\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// If anyone was listening to this session, they should know it's gone.\n\t// We can close the broadcaster.\n\tu.broadcastersMu.Lock()\n\tif cancel, ok := u.broadcasterCancels[id]; ok {\n\t\tcancel()\n\t\tdelete(u.broadcasterCancels, id)\n\t}\n\tdelete(u.broadcasters, id)\n\tu.broadcastersMu.Unlock()\n\n\tw.WriteHeader(http.StatusOK)\n}\n\nfunc (u *HTMLUserInterface) handlePOSTSendMessage(w http.ResponseWriter, req *http.Request) {\n\tctx := req.Context()\n\tlog := klog.FromContext(ctx)\n\n\tid := req.PathValue(\"id\")\n\tif id == \"\" {\n\t\thttp.Error(w, \"missing session id\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif err := req.ParseForm(); err != nil {\n\t\tlog.Error(err, \"parsing form\")\n\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tq := req.FormValue(\"q\")\n\tif q == \"\" {\n\t\thttp.Error(w, \"missing query\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Get the agent for this session\n\tagent, err := u.manager.GetAgent(ctx, id)\n\tif err != nil {\n\t\tlog.Error(err, \"getting agent\")\n\t\thttp.Error(w, err.Error(), http.StatusInternalServerError)\n\t\treturn\n\t}\n\n\t// Send the message to the agent\n\tagent.Input <- &api.UserInputResponse{Query: q}\n\n\tw.WriteHeader(http.StatusOK)\n}\n\nfunc (u *HTMLUserInterface) handlePOSTChooseOption(w http.ResponseWriter, req *http.Request) {\n\tctx := req.Context()\n\tlog := klog.FromContext(ctx)\n\n\tid := req.PathValue(\"id\")\n\tif id == \"\" {\n\t\thttp.Error(w, \"missing session id\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tif err := req.ParseForm(); err != nil {\n\t\tlog.Error(err, \"parsing form\")\n\t\thttp.Error(w, err.Error(), http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tchoice := req.FormValue(\"choice\")\n\tif choice == \"\" {\n\t\thttp.Error(w, \"missing choice\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\tchoiceIndex, err := strconv.Atoi(choice)\n\tif err != nil {\n\t\thttp.Error(w, \"invalid choice\", http.StatusBadRequest)\n\t\treturn\n\t}\n\n\t// Get the agent\n\tagent, err := u.manager.GetAgent(ctx, id)\n\tif err != nil {\n\t\thttp.Error(w, \"agent not found\", http.StatusNotFound)\n\t\treturn\n\t}\n\n\t// Send the choice to the agent\n\tagent.Input <- &api.UserChoiceResponse{Choice: choiceIndex}\n\n\tw.WriteHeader(http.StatusOK)\n}\n\nfunc (u *HTMLUserInterface) Close() error {\n\tvar errs []error\n\tif u.httpServerListener != nil {\n\t\tif err := u.httpServerListener.Close(); err != nil {\n\t\t\terrs = append(errs, err)\n\t\t} else {\n\t\t\tu.httpServerListener = nil\n\t\t}\n\t}\n\n\tu.broadcastersMu.Lock()\n\tfor id, cancel := range u.broadcasterCancels {\n\t\tcancel()\n\t\tdelete(u.broadcasterCancels, id)\n\t}\n\tu.broadcasters = make(map[string]*Broadcaster)\n\tu.broadcastersMu.Unlock()\n\n\treturn errors.Join(errs...)\n}\n\nfunc (u *HTMLUserInterface) ClearScreen() {\n\t// Not applicable for HTML UI\n}\n\nfunc (u *HTMLUserInterface) getSessionStateJSON(session *api.Session) ([]byte, error) {\n\tallMessages := session.AllMessages()\n\t// Create a copy of the messages to avoid race conditions\n\tvar messages []*api.Message\n\tfor _, message := range allMessages {\n\t\tif message.Type == api.MessageTypeUserInputRequest && message.Payload == \">>>\" {\n\t\t\tcontinue\n\t\t}\n\t\tmessages = append(messages, message)\n\t}\n\n\tagentState := session.AgentState\n\n\tdata := map[string]interface{}{\n\t\t\"messages\":   messages,\n\t\t\"agentState\": agentState,\n\t\t\"sessionId\":  session.ID,\n\t}\n\treturn json.Marshal(data)\n}\n\nfunc (u *HTMLUserInterface) getBroadcaster(sessionID string) *Broadcaster {\n\tu.broadcastersMu.Lock()\n\tdefer u.broadcastersMu.Unlock()\n\n\tif b, ok := u.broadcasters[sessionID]; ok {\n\t\treturn b\n\t}\n\n\tb := NewBroadcaster()\n\tu.broadcasters[sessionID] = b\n\n\tparent := u.baseCtx\n\tif parent == nil {\n\t\tparent = context.Background()\n\t}\n\tctx, cancel := context.WithCancel(parent)\n\tu.broadcasterCancels[sessionID] = cancel\n\n\t// Start the broadcaster loop\n\tgo b.Run(ctx)\n\n\treturn b\n}\n\nfunc (u *HTMLUserInterface) ensureAgentListener(a *agent.Agent) {\n\t// Start a goroutine to listen to this agent's output\n\tgo func() {\n\t\tfor range a.Output {\n\t\t\t// Broadcast state\n\t\t\tif a.Session == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tdata, err := u.getSessionStateJSON(a.Session)\n\t\t\tif err != nil {\n\t\t\t\tklog.Errorf(\"Error marshaling state for broadcast: %v\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tb := u.getBroadcaster(a.Session.ID)\n\t\t\tb.Broadcast(data)\n\t\t}\n\t}()\n}\n"
  },
  {
    "path": "pkg/ui/html/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>kubectl-ai</title>\n    <script src=\"https://unpkg.com/react@18/umd/react.development.js\"></script>\n    <script src=\"https://unpkg.com/react-dom@18/umd/react-dom.development.js\"></script>\n    <script src=\"https://unpkg.com/@babel/standalone/babel.min.js\"></script>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\"></script>\n    <script src=\"https://cdn.jsdelivr.net/npm/dompurify@3.0.5/dist/purify.min.js\"></script>\n    <link\n        href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap\"\n        rel=\"stylesheet\">\n    <script>\n        tailwind.config = {\n            darkMode: 'class',\n            theme: {\n                extend: {\n                    fontFamily: {\n                        'sans': ['Inter', 'system-ui', 'sans-serif'],\n                        'mono': ['JetBrains Mono', 'Menlo', 'Monaco', 'Courier New', 'monospace'],\n                    },\n                    colors: {\n                        'brand': {\n                            50: '#f0f9ff',\n                            100: '#e0f2fe',\n                            500: '#0ea5e9',\n                            600: '#0284c7',\n                            700: '#0369a1',\n                            800: '#075985',\n                            900: '#0c4a6e',\n                        }\n                    },\n                    animation: {\n                        'fade-in': 'fadeIn 0.5s ease-in-out',\n                        'slide-up': 'slideUp 0.3s ease-out',\n                        'pulse-soft': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',\n                    },\n                    keyframes: {\n                        fadeIn: {\n                            '0%': { opacity: '0' },\n                            '100%': { opacity: '1' },\n                        },\n                        slideUp: {\n                            '0%': { transform: 'translateY(10px)', opacity: '0' },\n                            '100%': { transform: 'translateY(0)', opacity: '1' },\n                        }\n                    }\n                }\n            }\n        }\n    </script>\n    <style>\n        /* Modern, subtle scrollbar */\n        .custom-scrollbar::-webkit-scrollbar {\n            width: 8px;\n            height: 8px;\n        }\n\n        .custom-scrollbar::-webkit-scrollbar-track {\n            background: transparent;\n        }\n\n        .custom-scrollbar::-webkit-scrollbar-thumb {\n            background: #94a3b8;\n            border-radius: 4px;\n            border: 2px solid transparent;\n            background-clip: content-box;\n        }\n\n        .dark .custom-scrollbar::-webkit-scrollbar-thumb {\n            background: #52525b;\n        }\n\n        .custom-scrollbar::-webkit-scrollbar-thumb:hover {\n            background: #475569;\n        }\n\n        .dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {\n            background: #71717a;\n        }\n\n        .custom-scrollbar {\n            scrollbar-width: thin;\n            scrollbar-color: #94a3b8 transparent;\n        }\n\n        .dark .custom-scrollbar {\n            scrollbar-color: #52525b transparent;\n        }\n\n        /* Dark mode for body background */\n        body.dark {\n            background: linear-gradient(to bottom right, #0f172a, #1e293b);\n        }\n\n        /* Markdown content styles */\n        .prose {\n            max-width: none;\n        }\n\n        .prose p {\n            margin-bottom: 1em;\n            line-height: 1.7;\n        }\n\n        .prose h1,\n        .prose h2,\n        .prose h3,\n        .prose h4,\n        .prose h5,\n        .prose h6 {\n            margin-top: 1.5em;\n            margin-bottom: 0.5em;\n            font-weight: 600;\n            line-height: 1.25;\n        }\n\n        .prose h1 {\n            font-size: 1.5em;\n        }\n\n        .prose h2 {\n            font-size: 1.3em;\n        }\n\n        .prose h3 {\n            font-size: 1.1em;\n        }\n\n        .prose code {\n            background: #f1f5f9;\n            color: #475569;\n            padding: 0.125rem 0.375rem;\n            border-radius: 0.375rem;\n            font-family: 'JetBrains Mono', monospace;\n            font-size: 0.875em;\n            font-weight: 500;\n        }\n\n        .dark .prose code {\n            background: #374151;\n            color: #e5e7eb;\n        }\n\n        .prose pre {\n            background: #0f172a;\n            color: #e2e8f0;\n            padding: 1.25rem;\n            border-radius: 0.75rem;\n            overflow-x: auto;\n            margin: 1.5em 0;\n            border: 1px solid #1e293b;\n        }\n\n        .dark .prose pre {\n            background: #111827;\n            border-color: #374151;\n        }\n\n        .prose pre code {\n            background: transparent;\n            color: inherit;\n            padding: 0;\n            border-radius: 0;\n        }\n\n        .prose ul,\n        .prose ol {\n            margin: 1em 0;\n            padding-left: 1.5em;\n        }\n\n        .prose li {\n            margin-bottom: 0.5em;\n        }\n\n        .prose blockquote {\n            border-left: 4px solid #e2e8f0;\n            padding-left: 1rem;\n            margin: 1.5em 0;\n            color: #64748b;\n            font-style: italic;\n        }\n\n        .dark .prose blockquote {\n            border-left-color: #475569;\n            color: #9ca3af;\n        }\n\n        .prose a {\n            color: #0ea5e9;\n            text-decoration: underline;\n            text-decoration-color: #bae6fd;\n            text-underline-offset: 2px;\n        }\n\n        .prose a:hover {\n            color: #0284c7;\n            text-decoration-color: #0ea5e9;\n        }\n\n        .dark .prose a {\n            color: #38bdf8;\n            text-decoration-color: #0369a1;\n        }\n\n        .dark .prose a:hover {\n            color: #0ea5e9;\n            text-decoration-color: #38bdf8;\n        }\n\n        .prose table {\n            border-collapse: collapse;\n            width: 100%;\n            margin: 1.5em 0;\n            border-radius: 0.5rem;\n            overflow: hidden;\n            border: 1px solid #e2e8f0;\n        }\n\n        .dark .prose table {\n            border-color: #374151;\n        }\n\n        .prose th,\n        .prose td {\n            border: 1px solid #e2e8f0;\n            padding: 0.75rem;\n            text-align: left;\n        }\n\n        .dark .prose th,\n        .dark .prose td {\n            border-color: #374151;\n        }\n\n        .prose th {\n            background: #f8fafc;\n            font-weight: 600;\n            color: #374151;\n        }\n\n        .dark .prose th {\n            background: #1f2937;\n            color: #e5e7eb;\n        }\n\n        /* Message animations */\n        .message-enter {\n            animation: slide-up 0.3s ease-out;\n        }\n\n        /* Choice button hover effects */\n        .choice-button {\n            transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);\n        }\n\n        .choice-button:hover {\n            transform: translateY(-1px);\n            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);\n        }\n\n        /* Status indicator pulse */\n        .status-pulse {\n            animation: pulse-soft 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n        }\n\n        /* Typing indicator animation */\n        .typing-indicator {\n            display: flex;\n            align-items: center;\n            gap: 1px;\n        }\n\n        .typing-dot {\n            width: 8px;\n            height: 8px;\n            border-radius: 50%;\n            background-color: #94a3b8;\n            animation: typing 1.4s infinite ease-in-out;\n        }\n\n        .dark .typing-dot {\n            background-color: #64748b;\n        }\n\n        .typing-dot:nth-child(1) {\n            animation-delay: 0ms;\n        }\n\n        .typing-dot:nth-child(2) {\n            animation-delay: 200ms;\n        }\n\n        .typing-dot:nth-child(3) {\n            animation-delay: 400ms;\n        }\n\n        @keyframes typing {\n\n            0%,\n            60%,\n            100% {\n                transform: translateY(0);\n                opacity: 0.4;\n            }\n\n            30% {\n                transform: translateY(-10px);\n                opacity: 1;\n            }\n        }\n    </style>\n</head>\n\n<body class=\"bg-gradient-to-br from-slate-50 to-blue-50 font-sans\">\n    <div id=\"root\"></div>\n    <script type=\"text/babel\">\n        const { useState, useEffect, useRef } = React;\n\n        function App() {\n            const [messages, setMessages] = useState([]);\n            const [input, setInput] = useState('');\n            const [agentState, setAgentState] = useState('idle');\n            const [sessions, setSessions] = useState([]);\n            const [currentSessionId, setCurrentSessionId] = useState(null);\n            const [isConnected, setIsConnected] = useState(false);\n            const [expandedOutputs, setExpandedOutputs] = useState(new Set());\n            const [isDarkMode, setIsDarkMode] = useState(() => {\n                // Check for saved preference first\n                const saved = localStorage.getItem('kubectl-ai-dark-mode');\n                if (saved !== null) {\n                    return JSON.parse(saved);\n                }\n\n                // If no saved preference, use OS/browser default\n                if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {\n                    return true;\n                }\n\n                // Fallback to light mode\n                return false;\n            });\n            const messagesEndRef = useRef(null);\n            const inputRef = useRef(null);\n\n            // Auto-resize textarea\n            useEffect(() => {\n                if (inputRef.current) {\n                    inputRef.current.style.height = 'auto';\n                    const scrollHeight = inputRef.current.scrollHeight;\n                    inputRef.current.style.height = scrollHeight + 'px';\n                }\n            }, [input]);\n\n            const toggleOutput = (messageIndex) => {\n                const newExpanded = new Set(expandedOutputs);\n                if (newExpanded.has(messageIndex)) {\n                    newExpanded.delete(messageIndex);\n                } else {\n                    newExpanded.add(messageIndex);\n                }\n                setExpandedOutputs(newExpanded);\n            };\n\n            const toggleDarkMode = () => {\n                const newDarkMode = !isDarkMode;\n                setIsDarkMode(newDarkMode);\n                localStorage.setItem('kubectl-ai-dark-mode', JSON.stringify(newDarkMode));\n            };\n\n            // Apply dark mode to document body\n            useEffect(() => {\n                if (isDarkMode) {\n                    document.body.classList.add('dark');\n                } else {\n                    document.body.classList.remove('dark');\n                }\n            }, [isDarkMode]);\n\n            // Listen for OS theme changes\n            useEffect(() => {\n                const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n\n                const handleThemeChange = (e) => {\n                    // Only auto-update if user hasn't manually set a preference\n                    const hasManualPreference = localStorage.getItem('kubectl-ai-dark-mode') !== null;\n                    if (!hasManualPreference) {\n                        setIsDarkMode(e.matches);\n                    }\n                };\n\n                // Add listener for theme changes\n                mediaQuery.addEventListener('change', handleThemeChange);\n\n                // Cleanup\n                return () => {\n                    mediaQuery.removeEventListener('change', handleThemeChange);\n                };\n            }, []);\n\n            const fetchSessions = async () => {\n                try {\n                    const res = await fetch('api/sessions');\n                    if (res.ok) {\n                        const data = await res.json();\n                        data.sort((a, b) => new Date(b.LastModified) - new Date(a.LastModified));\n                        setSessions(data);\n                        if (!currentSessionId && data.length > 0) {\n                            setCurrentSessionId(data[0].ID);\n                        }\n                    }\n                } catch (e) {\n                    console.error(\"Failed to fetch sessions\", e);\n                }\n            };\n\n            useEffect(() => {\n                fetchSessions();\n            }, []);\n\n            const handleNewSession = async () => {\n                try {\n                    const res = await fetch('api/sessions', { method: 'POST' });\n                    if (res.ok) {\n                        const data = await res.json();\n                        if (data.id) {\n                            setCurrentSessionId(data.id);\n                            fetchSessions();\n                        }\n                    }\n                } catch (e) {\n                    console.error(\"Failed to create new session\", e);\n                }\n            };\n\n            const handleSwitchSession = (id) => {\n                if (id !== currentSessionId) {\n                    setCurrentSessionId(id);\n                }\n            };\n\n            const handleDeleteSession = async (id) => {\n                if (!confirm('Are you sure you want to delete this session?')) return;\n                try {\n                    const res = await fetch(`api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });\n                    if (res.ok) {\n                        // Fetch updated list\n                        const sessionsRes = await fetch('api/sessions');\n                        if (sessionsRes.ok) {\n                            const data = await sessionsRes.json();\n                            data.sort((a, b) => new Date(b.LastModified) - new Date(a.LastModified));\n                            setSessions(data);\n\n                            if (id === currentSessionId) {\n                                // If we deleted the active session, switch to the latest one\n                                if (data.length > 0) {\n                                    setCurrentSessionId(data[0].ID);\n                                } else {\n                                    setCurrentSessionId(null);\n                                }\n                            }\n                        }\n                    } else {\n                        const text = await res.text();\n                        alert('Failed to delete session: ' + text);\n                    }\n                } catch (e) {\n                    console.error(\"Failed to delete session\", e);\n                }\n            };\n\n            const scrollToBottom = () => {\n                messagesEndRef.current?.scrollIntoView({ behavior: \"smooth\" });\n            };\n\n            useEffect(() => {\n                scrollToBottom();\n            }, [messages]);\n\n            useEffect(() => {\n                if (!currentSessionId) return;\n\n                const eventSource = new EventSource(`api/sessions/${encodeURIComponent(currentSessionId)}/stream`);\n\n                eventSource.onopen = () => {\n                    setIsConnected(true);\n                    console.log('Connected to kubectl-ai session', currentSessionId);\n                };\n\n                eventSource.onmessage = (event) => {\n                    try {\n                        const data = JSON.parse(event.data);\n                        // Only update if the message belongs to the current session\n                        if (data.sessionId === currentSessionId) {\n                            setMessages(data.messages || []);\n                            setAgentState(data.agentState || 'idle');\n                        }\n                        // Refresh session list if needed (e.g. last modified changed)\n                        // We could optimize this, but fetching is cheap enough for now\n                        fetchSessions();\n                    } catch (error) {\n                        console.error('Error parsing server data:', error);\n                    }\n                };\n\n                eventSource.onerror = () => {\n                    setIsConnected(false);\n                    eventSource.close();\n                };\n\n                return () => {\n                    eventSource.close();\n                };\n            }, [currentSessionId]);\n\n            useEffect(() => {\n                const canSendMessage = agentState === 'idle' || agentState === 'done' || agentState === 'waiting-for-input';\n                const isWaitingForChoice = agentState === 'waiting-for-input' && messages.length > 0 &&\n                    messages[messages.length - 1].Type === 'user-choice-request';\n\n                if (canSendMessage && !isWaitingForChoice && inputRef.current) {\n                    inputRef.current.focus();\n                }\n            }, [agentState, messages]);\n\n            const sendMessage = async (message) => {\n                if (!message.trim() || !currentSessionId) return;\n\n                try {\n                    const response = await fetch(`api/sessions/${encodeURIComponent(currentSessionId)}/send-message`, {\n                        method: 'POST',\n                        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n                        body: 'q=' + encodeURIComponent(message)\n                    });\n\n                    if (response.ok) {\n                        setInput('');\n                    }\n                } catch (error) {\n                    console.error('Error sending message:', error);\n                }\n            };\n\n            const chooseOption = async (optionIndex) => {\n                if (!currentSessionId) return;\n                try {\n                    await fetch(`api/sessions/${encodeURIComponent(currentSessionId)}/choose-option`, {\n                        method: 'POST',\n                        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },\n                        body: 'choice=' + encodeURIComponent(optionIndex)\n                    });\n                } catch (error) {\n                    console.error('Error choosing option:', error);\n                }\n            };\n\n            const handleSubmit = (e) => {\n                e.preventDefault();\n                if (isWaitingForChoice) {\n                    const lowercaseInput = input.toLowerCase().trim();\n                    if (lowercaseInput === 'y' || lowercaseInput === 'yes') {\n                        chooseOption(1);\n                    } else if (lowercaseInput === 'n' || lowercaseInput === 'no') {\n                        chooseOption(3);\n                    } else {\n                        const num = parseInt(lowercaseInput, 10);\n                        if (!isNaN(num) && num > 0 && num <= messages[messages.length - 1].Payload.Options.length) {\n                            chooseOption(num);\n                        }\n                    }\n                    setInput('');\n                } else {\n                    sendMessage(input);\n                }\n            };\n\n            const formatMessage = (message) => {\n                if (!message) return '';\n\n                try {\n                    marked.setOptions({\n                        breaks: true,\n                        gfm: true,\n                        tables: true,\n                        sanitize: false\n                    });\n\n                    const rawHtml = marked.parse(message);\n                    const cleanHtml = DOMPurify.sanitize(rawHtml, {\n                        ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'code', 'pre', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td'],\n                        ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class']\n                    });\n\n                    return cleanHtml;\n                } catch (error) {\n                    console.error('Error formatting message:', error);\n                    return message.replace(/\\n/g, '<br>');\n                }\n            };\n\n            const renderMessage = (message, index) => {\n                const getSourceInfo = (source) => {\n                    switch (source) {\n                        case 'user': return {\n                            name: 'You',\n                            color: isDarkMode ? 'text-blue-200' : 'text-blue-700',\n                            bg: isDarkMode ? 'bg-blue-800/40' : 'bg-blue-50',\n                            avatar: '👤'\n                        };\n                        case 'model': return {\n                            name: 'AI Assistant',\n                            color: isDarkMode ? 'text-emerald-400' : 'text-emerald-700',\n                            bg: isDarkMode ? 'bg-emerald-900/30' : 'bg-emerald-50',\n                            avatar: '🤖'\n                        };\n                        case 'agent': return {\n                            name: 'AI Assistant',\n                            color: isDarkMode ? 'text-emerald-400' : 'text-emerald-700',\n                            bg: isDarkMode ? 'bg-emerald-900/30' : 'bg-emerald-50',\n                            avatar: '🤖'\n                        };\n                        default: return {\n                            name: 'System',\n                            color: isDarkMode ? 'text-gray-400' : 'text-gray-700',\n                            bg: isDarkMode ? 'bg-gray-800' : 'bg-gray-50',\n                            avatar: '⚙️'\n                        };\n                    }\n                };\n\n                const sourceInfo = getSourceInfo(message.Source);\n\n                // Helper function to find the corresponding tool response\n                const findToolResponse = (requestIndex) => {\n                    for (let i = requestIndex + 1; i < messages.length; i++) {\n                        if (messages[i].Type === 'tool-call-response') {\n                            return messages[i];\n                        }\n                        // Stop looking if we hit another request or different message type\n                        if (messages[i].Type === 'tool-call-request' || messages[i].Type === 'text') {\n                            break;\n                        }\n                    }\n                    return null;\n                };\n\n                const MessageWrapper = ({ children, className = \"\" }) => (\n                    <div className={\"message-enter mb-6 \" + className}>\n                        <div className=\"flex items-start space-x-3\">\n                            <div className={\"flex-shrink-0 w-8 h-8 rounded-full \" + sourceInfo.bg + \" flex items-center justify-center text-sm\"}>\n                                {sourceInfo.avatar}\n                            </div>\n                            <div className=\"flex-1 min-w-0\">\n                                <div className={\"text-sm font-medium \" + sourceInfo.color + \" mb-1\"}>\n                                    {sourceInfo.name}\n                                </div>\n                                {children}\n                            </div>\n                        </div>\n                    </div>\n                );\n\n                switch (message.Type) {\n                    case 'text':\n                    case 'user-input-request':\n                        return (\n                            <MessageWrapper key={index}>\n                                <div className={`prose leading-relaxed ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}\n                                    dangerouslySetInnerHTML={{ __html: formatMessage(message.Payload) }} />\n                            </MessageWrapper>\n                        );\n\n                    case 'error':\n                        return (\n                            <MessageWrapper key={index} className=\"error-message\">\n                                <div className={`${isDarkMode ? 'bg-red-900/30 border-red-800' : 'bg-red-50 border-red-200'} border rounded-lg p-4`}>\n                                    <div className=\"flex items-center\">\n                                        <span className={`${isDarkMode ? 'text-red-400' : 'text-red-600'} text-lg mr-2`}>⚠️</span>\n                                        <div className={`${isDarkMode ? 'text-red-300' : 'text-red-800'} font-medium`}>Error</div>\n                                    </div>\n                                    <div className={`${isDarkMode ? 'text-red-400' : 'text-red-700'} mt-1`}>{message.Payload}</div>\n                                </div>\n                            </MessageWrapper>\n                        );\n\n                    case 'tool-call-request':\n                        const toolResponse = findToolResponse(index);\n                        const isCompleted = toolResponse !== null;\n                        const isOutputExpanded = expandedOutputs.has(index);\n\n                        // Extract stdout from the tool response\n                        const getOutputText = (response) => {\n                            if (!response || !response.Payload) return '';\n\n                            let payload = response.Payload;\n                            if (typeof payload === 'string') {\n                                try {\n                                    payload = JSON.parse(payload);\n                                } catch (e) {\n                                    return payload; // Return as-is if not valid JSON\n                                }\n                            }\n\n                            // Try to extract stdout field, fallback to full payload\n                            if (payload && payload.stdout) {\n                                return payload.stdout;\n                            } else if (payload && typeof payload === 'object') {\n                                return JSON.stringify(payload, null, 2);\n                            } else {\n                                return String(payload);\n                            }\n                        };\n\n                        const outputText = isCompleted ? getOutputText(toolResponse) : '';\n                        const hasOutput = outputText && outputText.trim().length > 0;\n\n                        return (\n                            <MessageWrapper key={index}>\n                                <div className={`border rounded-lg p-4 ${isCompleted ? (isDarkMode ? 'border-emerald-700 bg-emerald-900/20' : 'border-emerald-200 bg-emerald-50') : (isDarkMode ? 'border-blue-700 bg-blue-900/20' : 'border-blue-200 bg-blue-50')}`}>\n                                    <div className=\"flex items-center\">\n                                        {isCompleted ? (\n                                            <span className={`${isDarkMode ? 'text-emerald-400' : 'text-emerald-600'} text-lg mr-3`}>✅</span>\n                                        ) : (\n                                            <div className={`animate-spin rounded-full h-4 w-4 border-b-2 ${isDarkMode ? 'border-blue-400' : 'border-blue-600'} mr-3`}></div>\n                                        )}\n                                        <span className={`font-medium ${isCompleted ? (isDarkMode ? 'text-emerald-300' : 'text-emerald-800') : (isDarkMode ? 'text-blue-300' : 'text-blue-800')}`}>\n                                            {isCompleted ? \"Completed\" : \"Executing\"}\n                                        </span>\n                                    </div>\n                                    <div className={`font-mono text-sm mt-2 rounded px-3 py-2 ${isCompleted ? (isDarkMode ? 'text-emerald-300 bg-emerald-900/30' : 'text-emerald-700 bg-emerald-100') : (isDarkMode ? 'text-blue-300 bg-blue-900/30' : 'text-blue-700 bg-blue-100')}`}>\n                                        {message.Payload}\n                                    </div>\n                                    {isCompleted && hasOutput && (\n                                        <div className={`mt-3 pt-3 border-t ${isDarkMode ? 'border-emerald-700' : 'border-emerald-200'}`}>\n                                            <button\n                                                onClick={() => toggleOutput(index)}\n                                                className={`flex items-center space-x-2 ${isDarkMode ? 'text-emerald-400 hover:text-emerald-300' : 'text-emerald-600 hover:text-emerald-700'} focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-1 rounded px-2 py-1 transition-colors`}\n                                            >\n                                                <span className=\"text-xs font-medium\">\n                                                    {isOutputExpanded ? 'Hide output' : 'Show output'}\n                                                </span>\n                                                <svg\n                                                    className={\"w-3 h-3 transition-transform duration-200 \" + (isOutputExpanded ? \"rotate-180\" : \"\")}\n                                                    fill=\"none\"\n                                                    stroke=\"currentColor\"\n                                                    viewBox=\"0 0 24 24\"\n                                                >\n                                                    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M19 9l-7 7-7-7\" />\n                                                </svg>\n                                            </button>\n                                            {isOutputExpanded && (\n                                                <div className={`mt-2 text-sm rounded px-3 py-2 font-mono text-xs overflow-x-auto max-h-96 overflow-y-auto ${isDarkMode ? 'text-emerald-300 bg-emerald-900/30' : 'text-emerald-700 bg-emerald-100'}`}>\n                                                    <pre className=\"whitespace-pre-wrap\">{outputText}</pre>\n                                                </div>\n                                            )}\n                                        </div>\n                                    )}\n                                </div>\n                            </MessageWrapper>\n                        );\n\n                    case 'tool-call-response':\n                        // Skip rendering individual tool responses since they're shown with the request\n                        return null;\n\n                    case 'user-choice-request':\n                        const choiceRequest = message.Payload;\n                        return (\n                            <MessageWrapper key={index}>\n                                <div className={`border rounded-xl p-6 shadow-sm ${isDarkMode ? 'border-amber-700 bg-amber-900/20' : 'border-amber-200 bg-amber-50'}`}>\n                                    <div className=\"flex items-center mb-4\">\n                                        <span className={`${isDarkMode ? 'text-amber-400' : 'text-amber-600'} text-lg mr-2`}>🤔</span>\n                                        <span className={`${isDarkMode ? 'text-amber-300' : 'text-amber-800'} font-semibold`}>Decision Required</span>\n                                    </div>\n                                    <div className={`prose mb-4 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}\n                                        dangerouslySetInnerHTML={{ __html: formatMessage(choiceRequest.Prompt) }} />\n                                    <div className=\"space-y-3\">\n                                        {choiceRequest.Options.map((option, idx) => (\n                                            <button\n                                                key={idx}\n                                                onClick={() => chooseOption(idx + 1)}\n                                                className={`choice-button w-full text-left px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-colors ${isDarkMode\n                                                    ? 'bg-gray-800 border-gray-600 hover:border-brand-500 hover:bg-gray-700'\n                                                    : 'bg-white border-gray-200 hover:border-brand-300 hover:bg-brand-50'\n                                                    }`}\n                                            >\n                                                <div className=\"flex items-center\">\n                                                    <span className={`flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-sm font-medium mr-3 ${isDarkMode\n                                                        ? 'bg-brand-900/50 text-brand-300'\n                                                        : 'bg-brand-100 text-brand-700'\n                                                        }`}>\n                                                        {idx + 1}\n                                                    </span>\n                                                    <span className={`font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>{option.label}</span>\n                                                </div>\n                                            </button>\n                                        ))}\n                                    </div>\n                                </div>\n                            </MessageWrapper>\n                        );\n\n                    default:\n                        return (\n                            <MessageWrapper key={index}>\n                                <div className={`border rounded-lg p-4 ${isDarkMode ? 'bg-yellow-900/20 border-yellow-700' : 'bg-yellow-50 border-yellow-200'}`}>\n                                    <div className={`font-medium mb-2 ${isDarkMode ? 'text-yellow-300' : 'text-yellow-800'}`}>Debug Information</div>\n                                    <pre className={`text-xs overflow-x-auto font-mono ${isDarkMode ? 'text-yellow-400' : 'text-yellow-700'}`}>\n                                        {JSON.stringify(message, null, 2)}\n                                    </pre>\n                                </div>\n                            </MessageWrapper>\n                        );\n                }\n            };\n\n            const canSendMessage = agentState === 'idle' || agentState === 'done' || agentState === 'waiting-for-input';\n            const isWaitingForChoice = agentState === 'waiting-for-input' && messages.length > 0 &&\n                messages[messages.length - 1].Type === 'user-choice-request';\n\n            const getInputPlaceholder = () => {\n                if (isWaitingForChoice) return \"Type yes/no or a number, or click an option above...\";\n                if (canSendMessage) return \"Ask me anything about Kubernetes...\";\n                return \"AI is working...\";\n            };\n\n            // Show typing indicator when AI is working\n            const showTypingIndicator = agentState === 'running';\n\n            // Typing indicator component\n            const TypingIndicator = () => (\n                <div className=\"message-enter mb-6\">\n                    <div className=\"flex items-start space-x-3\">\n                        <div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm ${isDarkMode ? 'bg-emerald-900/30' : 'bg-emerald-50'}`}>\n                            🤖\n                        </div>\n                        <div className=\"flex-1 min-w-0\">\n                            <div className={`text-sm font-medium mb-1 ${isDarkMode ? 'text-emerald-400' : 'text-emerald-700'}`}>\n                                AI Assistant\n                            </div>\n                            <div className=\"flex items-center space-x-2\">\n                                <div className=\"flex items-center space-x-1\">\n                                    <div className=\"typing-dot\"></div>\n                                    <div className=\"typing-dot\"></div>\n                                    <div className=\"typing-dot\"></div>\n                                </div>\n                                <span className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>working on it...</span>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            );\n\n            const getAgentStatusInfo = () => {\n                switch (agentState) {\n                    case 'idle': return { text: 'Ready', color: 'text-emerald-600', bgColor: 'bg-emerald-100', icon: '✅' };\n                    case 'done': return { text: 'Ready', color: 'text-emerald-600', bgColor: 'bg-emerald-100', icon: '✅' };\n                    case 'waiting-for-input': return isWaitingForChoice\n                        ? { text: 'Waiting for choice', color: 'text-amber-600', bgColor: 'bg-amber-100', icon: '🤔' }\n                        : { text: 'Ready', color: 'text-emerald-600', bgColor: 'bg-emerald-100', icon: '✅' };\n                    case 'running': return { text: 'Working', color: 'text-blue-600', bgColor: 'bg-blue-100', icon: '⚡' };\n                    case 'initializing': return { text: 'Starting up', color: 'text-gray-600', bgColor: 'bg-gray-100', icon: '🔄' };\n                    case 'exited': return { text: 'Exited', color: 'text-red-600', bgColor: 'bg-red-100', icon: '🛑' };\n                    default: return { text: agentState, color: 'text-gray-600', bgColor: 'bg-gray-100', icon: '❓' };\n                }\n            };\n\n            const statusInfo = getAgentStatusInfo();\n\n            return (\n                <div className={`flex h-screen ${isDarkMode ? 'bg-gradient-to-br from-slate-900 to-gray-900' : 'bg-gradient-to-br from-slate-50 to-blue-50'}`}>\n                    {/* Sidebar */}\n                    <div className={`w-64 flex flex-col border-r ${isDarkMode ? 'border-gray-700 bg-gray-800/50' : 'border-gray-200 bg-white/50'} backdrop-blur-sm transition-colors duration-200`}>\n                        <div className={`p-4 border-b ${isDarkMode ? 'border-gray-700' : 'border-gray-200'} flex justify-between items-center`}>\n                            <h2 className={`font-semibold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>Sessions</h2>\n                            <button\n                                onClick={handleNewSession}\n                                className=\"p-2 rounded-lg bg-brand-500 hover:bg-brand-600 text-white transition-colors shadow-sm\"\n                                title=\"New Session\"\n                            >\n                                <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M12 4v16m8-8H4\" /></svg>\n                            </button>\n                        </div>\n                        <div className=\"flex-1 overflow-y-auto custom-scrollbar p-2 space-y-2\">\n                            {sessions.map(session => (\n                                <div key={session.ID} className=\"relative group\">\n                                    <button\n                                        onClick={() => handleSwitchSession(session.ID)}\n                                        className={`w-full text-left p-3 rounded-lg transition-all duration-200 pr-8 ${currentSessionId === session.ID\n                                            ? (isDarkMode ? 'bg-brand-800/50 text-white border border-brand-600/50 shadow-sm' : 'bg-brand-50 text-brand-900 border border-brand-200 shadow-sm')\n                                            : (isDarkMode ? 'text-gray-400 hover:bg-gray-800 hover:text-gray-200' : 'text-gray-600 hover:bg-white/60 hover:text-gray-900')\n                                            }`}\n                                    >\n                                        <div className=\"text-sm font-medium truncate mb-1\">{session.Name || session.ID}</div>\n                                        <div className=\"text-xs opacity-70 flex items-center\">\n                                            <span className=\"mr-1\">🕒</span>\n                                            {new Date(session.LastModified).toLocaleString(undefined, {\n                                                month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'\n                                            })}\n                                        </div>\n                                    </button>\n                                    <button\n                                        onClick={(e) => { e.stopPropagation(); handleDeleteSession(session.ID); }}\n                                        className={`absolute right-2 top-3 p-1 rounded hover:bg-red-100 text-gray-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity ${isDarkMode ? 'hover:bg-red-900/30' : ''\n                                            }`}\n                                        title=\"Delete Session\"\n                                    >\n                                        <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\" /></svg>\n                                    </button>\n                                </div>\n                            ))}\n                        </div>\n                    </div>\n\n                    {/* Main Content */}\n                    <div className=\"flex-1 flex flex-col min-w-0\">\n                        {/* Header */}\n                        <div className={`${isDarkMode ? 'bg-gray-800/80' : 'bg-white/80'} backdrop-blur-sm ${isDarkMode ? 'border-gray-700' : 'border-gray-200'} border-b px-6 py-4 shadow-sm`}>\n                            <div className=\"flex items-center justify-between\">\n                                <div className=\"flex items-center space-x-3\">\n                                    <div className=\"w-8 h-8 bg-gradient-to-br from-brand-500 to-brand-600 rounded-lg flex items-center justify-center\">\n                                        <span className=\"text-white font-bold text-sm\">K8</span>\n                                    </div>\n                                    <div>\n                                        <h1 className={`text-xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>kubectl-ai</h1>\n                                        <p className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>Your intelligent Kubernetes assistant</p>\n                                    </div>\n                                </div>\n                                <div className=\"flex items-center space-x-4\">\n                                    <div className=\"flex items-center space-x-2\">\n                                        <div className={\"px-3 py-1 rounded-full text-xs font-medium \" + statusInfo.bgColor + \" \" + statusInfo.color + \" flex items-center space-x-1\"}>\n                                            <span>{statusInfo.icon}</span>\n                                            <span>{statusInfo.text}</span>\n                                        </div>\n                                    </div>\n                                    <div className=\"flex items-center space-x-2\">\n                                        <div className={\"w-2 h-2 rounded-full \" + (isConnected ? 'bg-emerald-500' : 'bg-red-500') + \" \" + (!isConnected ? 'status-pulse' : '')}></div>\n                                        <span className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>\n                                            {isConnected ? 'Connected' : 'Connecting...'}\n                                        </span>\n                                    </div>\n                                    {/* Dark Mode Toggle */}\n                                    <button\n                                        onClick={toggleDarkMode}\n                                        className={`p-2 rounded-lg transition-colors duration-200 ${isDarkMode\n                                            ? 'bg-gray-700 hover:bg-gray-600 text-yellow-400'\n                                            : 'bg-gray-100 hover:bg-gray-200 text-gray-600'\n                                            }`}\n                                        title={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}\n                                    >\n                                        {isDarkMode ? (\n                                            <svg className=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                                                <path fillRule=\"evenodd\" d=\"M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z\" clipRule=\"evenodd\" />\n                                            </svg>\n                                        ) : (\n                                            <svg className=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                                                <path d=\"M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z\" />\n                                            </svg>\n                                        )}\n                                    </button>\n                                </div>\n                            </div>\n                        </div>\n\n                        {/* Messages Area */}\n                        <div className=\"flex-1 overflow-y-auto px-6 py-6 custom-scrollbar\">\n                            <div className=\"max-w-4xl mx-auto\">\n                                {messages.length === 0 ? (\n                                    <div className=\"text-center py-16\">\n                                        <div className=\"w-16 h-16 bg-gradient-to-br from-brand-500 to-brand-600 rounded-2xl flex items-center justify-center mx-auto mb-4\">\n                                            <span className=\"text-white text-2xl\">🚀</span>\n                                        </div>\n                                        <h2 className={`text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'} mb-2`}>Welcome to kubectl-ai</h2>\n                                        <p className={`${isDarkMode ? 'text-gray-300' : 'text-gray-600'} mb-8 max-w-md mx-auto`}>\n                                            I'm your intelligent Kubernetes assistant. I can help you manage deployments,\n                                            troubleshoot issues, and answer questions about your cluster.\n                                        </p>\n                                        <div className=\"grid grid-cols-1 md:grid-cols-3 gap-4 max-w-2xl mx-auto\">\n                                            <div className={`${isDarkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'} rounded-lg p-4 shadow-sm border`}>\n                                                <div className=\"text-2xl mb-2\">⚙️</div>\n                                                <div className={`font-medium ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>Manage Resources</div>\n                                                <div className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>Scale deployments, update configs</div>\n                                            </div>\n                                            <div className={`${isDarkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'} rounded-lg p-4 shadow-sm border`}>\n                                                <div className=\"text-2xl mb-2\">🔍</div>\n                                                <div className={`font-medium ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>Debug Issues</div>\n                                                <div className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>Find and fix problems quickly</div>\n                                            </div>\n                                            <div className={`${isDarkMode ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'} rounded-lg p-4 shadow-sm border`}>\n                                                <div className=\"text-2xl mb-2\">📊</div>\n                                                <div className={`font-medium ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>Get Insights</div>\n                                                <div className={`text-sm ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>Monitor and analyze your cluster</div>\n                                            </div>\n                                        </div>\n                                    </div>\n                                ) : (\n                                    <>\n                                        {messages.map((message, index) => renderMessage(message, index))}\n                                        {showTypingIndicator && <TypingIndicator />}\n                                    </>\n                                )}\n                                <div ref={messagesEndRef} />\n                            </div>\n                        </div>\n\n                        {/* Input Area */}\n                        <div className={`${isDarkMode ? 'bg-gray-800/80' : 'bg-white/80'} backdrop-blur-sm ${isDarkMode ? 'border-gray-700' : 'border-gray-200'} border-t p-6`}>\n                            <div className=\"max-w-4xl mx-auto\">\n                                <form onSubmit={handleSubmit} className=\"flex space-x-3\">\n                                    <div className=\"flex-1 relative\">\n                                        <textarea\n                                            ref={inputRef}\n                                            value={input}\n                                            onChange={(e) => setInput(e.target.value)}\n                                            onKeyDown={(e) => {\n                                                if (e.key === 'Enter' && !e.shiftKey) {\n                                                    e.preventDefault();\n                                                    handleSubmit(e);\n                                                }\n                                            }}\n                                            placeholder={getInputPlaceholder()}\n                                            disabled={!canSendMessage}\n                                            className={`w-full px-4 py-3 pr-12 border rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent transition-colors resize-none overflow-y-auto max-h-40 custom-scrollbar align-bottom ${isDarkMode\n                                                ? 'bg-gray-700 border-gray-600 text-white placeholder-gray-400'\n                                                : 'bg-white border-gray-300 text-gray-900 placeholder-gray-400'\n                                                } ${!canSendMessage ? (isDarkMode ? 'bg-gray-800 text-gray-500' : 'bg-gray-50 text-gray-500') : ''}`}\n                                            rows=\"1\"\n                                        />\n                                        {agentState === 'running' && (\n                                            <div className=\"absolute right-3 top-1/2 transform -translate-y-1/2\">\n                                                <div className=\"animate-spin rounded-full h-5 w-5 border-b-2 border-brand-500\"></div>\n                                            </div>\n                                        )}\n                                    </div>\n                                    <button\n                                        type=\"submit\"\n                                        disabled={!canSendMessage || !input.trim()}\n                                        className=\"px-6 py-3 bg-gradient-to-r from-brand-500 to-brand-600 text-white rounded-xl hover:from-brand-600 hover:to-brand-700 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 font-medium shadow-sm self-end\"\n                                    >\n                                        Send\n                                    </button>\n                                </form>\n                                <div className={`flex items-center justify-center mt-3 text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>\n                                    <span>💡 Try: \"scale nginx to 3 replicas\" or \"show me pod status\"</span>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            );\n        }\n\n        ReactDOM.render(<App />, document.getElementById('root'));\n    </script>\n</body>\n\n</html>"
  },
  {
    "path": "pkg/ui/interfaces.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage ui\n\nimport (\n\t\"context\"\n\t\"fmt\"\n)\n\n// UI is the interface that defines the capabilities of assisant's user interface.\n// Each of the UIs, CLI, TUI, Web, etc. implement this interface.\ntype UI interface {\n\t// ClearScreen clears any output rendered to the screen\n\tClearScreen()\n\n\t// Run starts the UI and blocks until the context is done.\n\tRun(ctx context.Context) error\n}\n\n// Type is the type of user interface.\ntype Type string\n\nconst (\n\tUITypeTerminal Type = \"terminal\"\n\tUITypeWeb      Type = \"web\"\n\tUITypeTUI      Type = \"tui\"\n)\n\n// Implement pflag.Value for UIType\nfunc (u *Type) Set(s string) error {\n\tswitch s {\n\tcase \"terminal\", \"web\", \"tui\":\n\t\t*u = Type(s)\n\t\treturn nil\n\tdefault:\n\t\treturn fmt.Errorf(\"invalid UI type: %s\", s)\n\t}\n}\n\nfunc (u *Type) String() string {\n\treturn string(*u)\n}\n\nfunc (u *Type) Type() string {\n\treturn \"UIType\"\n}\n"
  },
  {
    "path": "pkg/ui/terminal.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage ui\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/agent\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/journal\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/tools\"\n\t\"github.com/charmbracelet/glamour\"\n\t\"github.com/chzyer/readline\"\n\t\"golang.org/x/term\"\n\t\"k8s.io/klog/v2\"\n)\n\ntype computedStyle struct {\n\tForeground     colorValue\n\tRenderMarkdown bool\n}\n\ntype colorValue string\n\nconst (\n\tcolorGreen colorValue = \"green\"\n\tcolorWhite colorValue = \"white\"\n\tcolorRed   colorValue = \"red\"\n)\n\ntype styleOption func(s *computedStyle)\n\nfunc foreground(color colorValue) styleOption {\n\treturn func(s *computedStyle) {\n\t\ts.Foreground = color\n\t}\n}\n\nfunc renderMarkdown() styleOption {\n\treturn func(s *computedStyle) {\n\t\ts.RenderMarkdown = true\n\t}\n}\n\n// TODO: rename this to CLI because the command line interface.\ntype TerminalUI struct {\n\tjournal          journal.Recorder\n\tmarkdownRenderer *glamour.TermRenderer\n\n\t// Input handling fields (initialized once)\n\trlInstance        *readline.Instance // For readline input\n\tttyFile           *os.File           // For TTY input\n\tttyReaderInstance *bufio.Reader      // For TTY input\n\n\t// This is useful in cases where stdin is already been used for providing the input to the agent (caller in this case)\n\t// in such cases, stdin is already consumed and closed and reading input results in IO error.\n\t// In such cases, we open /dev/tty and use it for taking input.\n\tuseTTYForInput bool\n\t// showToolOutput disables truncation of tool output.\n\tshowToolOutput bool\n\n\tagent *agent.Agent\n}\n\nvar _ UI = &TerminalUI{}\n\nfunc getCustomTerminalWidth() int {\n\t// Check for user-configured width via environment variable\n\tif widthStr := os.Getenv(\"KUBECTL_AI_TERM_WIDTH\"); widthStr != \"\" {\n\n\t\tif widthStr == \"auto\" {\n\t\t\twidth, _, err := term.GetSize(int(os.Stdout.Fd()))\n\n\t\t\tif err != nil {\n\t\t\t\tklog.Warningf(\"Failed to get terminal size: %v, using default width\", err)\n\t\t\t\treturn 0\n\t\t\t}\n\n\t\t\treturn width\n\t\t}\n\n\t\tif width, err := strconv.Atoi(widthStr); err == nil && width > 0 {\n\t\t\treturn width\n\t\t}\n\t\tklog.Warningf(\"Invalid KUBECTL_AI_TERM_WIDTH value %q, using default\", widthStr)\n\t}\n\n\t// Return 0 to indicate no custom width should be set (use glamour's default)\n\treturn 0\n}\n\nfunc NewTerminalUI(agent *agent.Agent, useTTYForInput bool, showToolOutput bool, journal journal.Recorder) (*TerminalUI, error) {\n\twidth := getCustomTerminalWidth()\n\n\toptions := []glamour.TermRendererOption{\n\t\tglamour.WithAutoStyle(),\n\t\tglamour.WithPreservedNewLines(),\n\t\tglamour.WithEmoji(),\n\t}\n\n\t// Only add WordWrap if a valid width is configured\n\tif width > 0 {\n\t\toptions = append(options, glamour.WithWordWrap(width))\n\t}\n\n\tmdRenderer, err := glamour.NewTermRenderer(options...)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"error initializing the markdown renderer: %w\", err)\n\t}\n\n\tu := &TerminalUI{\n\t\tmarkdownRenderer: mdRenderer,\n\t\tjournal:          journal,\n\t\tuseTTYForInput:   useTTYForInput, // Store this flag\n\t\tagent:            agent,\n\t\tshowToolOutput:   showToolOutput,\n\t}\n\n\treturn u, nil\n}\n\nfunc (u *TerminalUI) Run(ctx context.Context) error {\n\tsession := u.agent.GetSession()\n\tif len(session.Messages) > 0 {\n\t\tgreeting := \"Welcome back. What can I help you with today?\\n (Don't want to continue your last session? Use --new-session)\"\n\t\t// If it's a persistent session (not memory), print metadata\n\t\tif u.agent.SessionBackend == \"filesystem\" {\n\t\t\tgreeting = fmt.Sprintf(\"%s\\n\\n%s\", greeting, session.String())\n\t\t}\n\t\tout, _ := u.markdownRenderer.Render(greeting)\n\t\tfmt.Printf(\"\\n%s\\n\", out)\n\t}\n\n\t// Channel to signal when the agent has exited\n\tagentExited := make(chan struct{})\n\n\t// Start a goroutine to handle agent output\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase msg, ok := <-u.agent.Output:\n\t\t\t\tif !ok {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tklog.Infof(\"agent output: %+v\", msg)\n\t\t\t\tu.handleMessage(msg.(*api.Message))\n\n\t\t\t\t// Check if agent has exited in RunOnce mode\n\t\t\t\tif u.agent.GetSession().AgentState == api.AgentStateExited {\n\t\t\t\t\tklog.Info(\"Agent has exited, terminating UI\")\n\t\t\t\t\tclose(agentExited)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}()\n\n\t// Block until context is cancelled or agent exits\n\tselect {\n\tcase <-ctx.Done():\n\t\treturn nil\n\tcase <-agentExited:\n\t\treturn u.agent.LastErr()\n\t}\n}\n\nfunc (u *TerminalUI) ttyReader() (*bufio.Reader, error) {\n\tif u.ttyReaderInstance != nil {\n\t\treturn u.ttyReaderInstance, nil\n\t}\n\t// Initialize TTY input\n\ttty, err := os.OpenFile(\"/dev/tty\", os.O_RDWR, 0)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"opening tty for input: %w\", err)\n\t}\n\tu.ttyFile = tty // Store file handle for closing\n\tu.ttyReaderInstance = bufio.NewReader(tty)\n\treturn u.ttyReaderInstance, nil\n}\n\nfunc (u *TerminalUI) readlineInstance() (*readline.Instance, error) {\n\tif u.rlInstance != nil {\n\t\treturn u.rlInstance, nil\n\t}\n\t// Initialize readline input\n\thistoryPath := filepath.Join(os.TempDir(), \"kubectl-ai-history\")\n\trl, err := readline.NewEx(&readline.Config{\n\t\tPrompt:      \">>> \", // Default prompt for main input\n\t\tStdin:       os.Stdin,\n\t\tStdout:      os.Stdout,\n\t\tStderr:      os.Stderr,\n\t\tHistoryFile: historyPath,\n\t\t// History enabled by default\n\t})\n\tif err != nil {\n\t\t// Log warning or fallback if readline init fails?\n\t\tklog.Warningf(\"Failed to initialize readline, input might be limited: %v\", err)\n\t\t// Proceed without readline for now, or return error?\n\t\t// Returning error to make it explicit\n\t\treturn nil, fmt.Errorf(\"creating readline instance: %w\", err)\n\t}\n\tu.rlInstance = rl // Store readline instance\n\treturn u.rlInstance, nil\n}\n\nfunc (u *TerminalUI) Close() error {\n\tvar errs []error\n\n\t// Close the initialized input handler\n\tif u.rlInstance != nil {\n\t\tif err := u.rlInstance.Close(); err != nil {\n\t\t\terrs = append(errs, fmt.Errorf(\"closing readline instance: %w\", err))\n\t\t}\n\t}\n\tif u.ttyFile != nil {\n\t\tif err := u.ttyFile.Close(); err != nil {\n\t\t\terrs = append(errs, fmt.Errorf(\"closing tty file: %w\", err))\n\t\t}\n\t}\n\treturn errors.Join(errs...)\n}\n\nfunc (u *TerminalUI) handleMessage(msg *api.Message) {\n\ttext := \"\"\n\tvar styleOptions []styleOption\n\n\tswitch msg.Type {\n\tcase api.MessageTypeText:\n\t\ttext = msg.Payload.(string)\n\t\tswitch msg.Source {\n\t\tcase api.MessageSourceUser:\n\t\t\t// styleOptions = append(styleOptions, Foreground(ColorWhite))\n\t\t\t// since we print the message as user types, we don't need to print it again\n\t\t\treturn\n\t\tcase api.MessageSourceAgent:\n\t\t\tstyleOptions = append(styleOptions, renderMarkdown(), foreground(colorGreen))\n\t\tcase api.MessageSourceModel:\n\t\t\tstyleOptions = append(styleOptions, renderMarkdown())\n\t\t}\n\tcase api.MessageTypeError:\n\t\tstyleOptions = append(styleOptions, foreground(colorRed))\n\t\ttext = msg.Payload.(string)\n\tcase api.MessageTypeToolCallRequest:\n\t\tstyleOptions = append(styleOptions, foreground(colorGreen))\n\t\ttext = fmt.Sprintf(\"\\n  Running: %s\\n\", msg.Payload.(string))\n\tcase api.MessageTypeToolCallResponse:\n\t\tif !u.showToolOutput {\n\t\t\treturn\n\t\t}\n\t\tstyleOptions = append(styleOptions, renderMarkdown())\n\t\toutput, err := tools.ToolResultToMap(msg.Payload)\n\n\t\tif err != nil {\n\t\t\tklog.Errorf(\"Error converting tool result to map: %v\", err)\n\t\t\tu.agent.Input <- fmt.Errorf(\"error converting tool result to map: %w\", err)\n\t\t\treturn\n\t\t}\n\n\t\tresponseText := formatToolCallResponse(output)\n\t\ttext = fmt.Sprintf(\"%s\\n\", responseText)\n\n\tcase api.MessageTypeUserInputRequest:\n\t\ttext = msg.Payload.(string)\n\t\tklog.Infof(\"Received user input request with payload: %q\", text)\n\n\t\tvar query string\n\t\tif u.useTTYForInput {\n\t\t\ttReader, err := u.ttyReader()\n\t\t\tif err != nil {\n\t\t\t\tklog.Errorf(\"Failed to get TTY reader: %v\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// keep reading input until we get a non-empty query\n\t\t\tfor {\n\t\t\t\tvar err error\n\t\t\t\tfmt.Print(\"\\n>>> \") // Print prompt manually\n\t\t\t\tquery, err = tReader.ReadString('\\n')\n\t\t\t\tif err != nil {\n\t\t\t\t\tklog.Infof(\"TTY read error: %v\", err)\n\t\t\t\t\tif err == io.EOF {\n\t\t\t\t\t\t// Handle Ctrl+D gracefully\n\t\t\t\t\t\tu.agent.Input <- io.EOF\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tklog.Errorf(\"Error reading from TTY: %v\", err)\n\t\t\t\t\tu.agent.Input <- fmt.Errorf(\"error reading from TTY: %w\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif strings.TrimSpace(query) == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tklog.Infof(\"Sending TTY input to agent: %q\", query)\n\t\t\tu.agent.Input <- &api.UserInputResponse{Query: query}\n\t\t} else {\n\t\t\trlInstance, err := u.readlineInstance()\n\t\t\tif err != nil {\n\t\t\t\tklog.Errorf(\"Failed to create readline instance: %v\", err)\n\t\t\t\tu.agent.Input <- fmt.Errorf(\"error creating readline instance: %w\", err)\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// keep reading input until we get a non-empty query\n\t\t\tfor {\n\t\t\t\trlInstance.SetPrompt(\">>> \") // Ensure correct prompt\n\t\t\t\tquery, err = rlInstance.Readline()\n\t\t\t\tif err != nil {\n\t\t\t\t\tklog.Infof(\"Readline error: %v\", err)\n\t\t\t\t\tswitch err {\n\t\t\t\t\tcase readline.ErrInterrupt: // Handle Ctrl+C\n\t\t\t\t\t\tu.agent.Input <- io.EOF\n\t\t\t\t\tcase io.EOF: // Handle Ctrl+D\n\t\t\t\t\t\tu.agent.Input <- io.EOF\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tu.agent.Input <- err\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tif strings.TrimSpace(query) == \"\" {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tklog.Infof(\"Sending readline input to agent: %q\", query)\n\t\t\t\tu.agent.Input <- &api.UserInputResponse{Query: query}\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif query == \"clear\" || query == \"reset\" {\n\t\t\tu.ClearScreen()\n\t\t}\n\t\treturn\n\tcase api.MessageTypeUserChoiceRequest:\n\t\tchoiceRequest := msg.Payload.(*api.UserChoiceRequest)\n\t\tprompt, _ := u.markdownRenderer.Render(choiceRequest.Prompt)\n\t\tfmt.Printf(\"\\n%s\\n\", string(prompt))\n\n\t\tfor i, option := range choiceRequest.Options {\n\t\t\tfmt.Printf(\"  %d. %s\\n\", i+1, option.Label)\n\t\t}\n\t\tfmt.Println()\n\n\t\tvar choice int\n\t\tfor {\n\t\t\tvar line string\n\t\t\tvar err error\n\t\t\tif u.useTTYForInput {\n\t\t\t\ttReader, err := u.ttyReader()\n\t\t\t\tif err != nil {\n\t\t\t\t\tklog.Errorf(\"Failed to get TTY reader: %v\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tfmt.Print(\"Enter your choice: \")\n\t\t\t\tline, err = tReader.ReadString('\\n')\n\t\t\t\tif err != nil {\n\t\t\t\t\tklog.Infof(\"TTY read error: %v\", err)\n\t\t\t\t\tif err == io.EOF {\n\t\t\t\t\t\t// Handle Ctrl+D gracefully\n\t\t\t\t\t\tu.agent.Input <- io.EOF\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t\tklog.Errorf(\"Error reading from TTY: %v\", err)\n\t\t\t\t\tu.agent.Input <- fmt.Errorf(\"error reading from TTY: %w\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\trlInstance, err := u.readlineInstance()\n\t\t\t\tif err != nil {\n\t\t\t\t\tklog.Errorf(\"Failed to create readline instance: %v\", err)\n\t\t\t\t\tu.agent.Input <- fmt.Errorf(\"error creating readline instance: %w\", err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\trlInstance.SetPrompt(\"Enter your choice: \")\n\t\t\t\tline, err = rlInstance.Readline()\n\t\t\t\tif err != nil {\n\t\t\t\t\tklog.Infof(\"Readline error: %v\", err)\n\t\t\t\t\tswitch err {\n\t\t\t\t\tcase readline.ErrInterrupt, io.EOF:\n\t\t\t\t\t\tu.agent.Input <- io.EOF\n\t\t\t\t\t\treturn\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tu.agent.Input <- err\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tinput := strings.TrimSpace(strings.ToLower(line))\n\t\t\tchoice = -1\n\n\t\t\t// Handle special cases for yes/no\n\t\t\tif input == \"y\" || input == \"yes\" {\n\t\t\t\tinput = \"1\"\n\t\t\t}\n\t\t\tif input == \"n\" || input == \"no\" {\n\t\t\t\tinput = \"3\"\n\t\t\t}\n\n\t\t\tchoiceIdx, err := strconv.Atoi(input)\n\t\t\tif err == nil && choiceIdx > 0 && choiceIdx <= len(choiceRequest.Options) {\n\t\t\t\tchoice = choiceIdx\n\t\t\t\tbreak\n\t\t\t}\n\n\t\t\tfmt.Println(\"Invalid choice. Please try again.\")\n\t\t}\n\t\tu.agent.Input <- &api.UserChoiceResponse{Choice: choice}\n\t\treturn\n\tdefault:\n\t\tklog.Warningf(\"unsupported message type: %v\", msg.Type)\n\t\treturn\n\t}\n\n\tcomputedStyle := &computedStyle{}\n\tfor _, opt := range styleOptions {\n\t\topt(computedStyle)\n\t}\n\n\tprintText := text\n\n\tif computedStyle.RenderMarkdown && printText != \"\" {\n\t\tout, err := u.markdownRenderer.Render(printText)\n\t\tif err != nil {\n\t\t\tklog.Errorf(\"Error rendering markdown: %v\", err)\n\t\t} else {\n\t\t\tprintText = out\n\t\t}\n\t}\n\treset := \"\"\n\tswitch computedStyle.Foreground {\n\tcase colorRed:\n\t\tfmt.Printf(\"\\033[31m\")\n\t\treset += \"\\033[0m\"\n\tcase colorGreen:\n\t\tfmt.Printf(\"\\033[32m\")\n\t\treset += \"\\033[0m\"\n\tcase colorWhite:\n\t\tfmt.Printf(\"\\033[37m\")\n\t\treset += \"\\033[0m\"\n\n\tcase \"\":\n\tdefault:\n\t\tklog.Info(\"foreground color not supported by TerminalUI\", \"color\", computedStyle.Foreground)\n\t}\n\n\tfmt.Printf(\"%s%s\", printText, reset)\n}\n\nfunc (u *TerminalUI) ClearScreen() {\n\tfmt.Print(\"\\033[H\\033[2J\")\n}\n\nfunc formatToolCallResponse(payload map[string]any) string {\n\tif payload == nil {\n\t\treturn \"\"\n\t}\n\n\tif v, ok := payload[\"content\"]; ok {\n\t\treturn fmt.Sprint(v)\n\t}\n\n\tif v, ok := payload[\"stdout\"]; ok {\n\t\treturn fmt.Sprint(v)\n\t}\n\n\tif b, err := json.MarshalIndent(payload, \"\", \"  \"); err == nil {\n\t\treturn string(b)\n\t}\n\n\treturn fmt.Sprint(payload)\n}\n"
  },
  {
    "path": "pkg/ui/tui.go",
    "content": "// Copyright 2025 Google LLC\n//\n// Licensed under the Apache License, Version 2.0 (the \"License\");\n// you may not use this file except in compliance with the License.\n// You may obtain a copy of the License at\n//\n//     http://www.apache.org/licenses/LICENSE-2.0\n//\n// Unless required by applicable law or agreed to in writing, software\n// distributed under the License is distributed on an \"AS IS\" BASIS,\n// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n// See the License for the specific language governing permissions and\n// limitations under the License.\n\npackage ui\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/agent\"\n\t\"github.com/GoogleCloudPlatform/kubectl-ai/pkg/api\"\n\t\"github.com/charmbracelet/bubbles/list\"\n\t\"github.com/charmbracelet/bubbles/spinner\"\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/glamour\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"k8s.io/klog/v2\"\n)\n\nconst logo = `\n _          _               _   _             _\n| | ___   _| |__   ___  ___| |_| |       __ _(_)\n| |/ / | | | '_ \\ / _ \\/ __| __| |_____ / _  | |\n|   <| |_| | |_) |  __/ (__| |_| |_____| (_| | |\n|_|\\_\\\\__,_|_.__/ \\___|\\___|\\__|_|      \\__,_|_|\n`\n\n// Color palette - Google Material Design colors\nvar (\n\tcolorPrimary   = lipgloss.Color(\"#8AB4F8\") // Blue 200\n\tcolorSecondary = lipgloss.Color(\"#81C995\") // Green 200\n\tcolorError     = lipgloss.Color(\"#F28B82\") // Red 200\n\tcolorWarning   = lipgloss.Color(\"#FDD663\") // Yellow 200\n\tcolorText      = lipgloss.Color(\"#E8EAED\") // Grey 200\n\tcolorMuted     = lipgloss.Color(\"#9AA0A6\") // Grey 500\n\tcolorDim       = lipgloss.Color(\"#5F6368\") // Grey 700\n\tcolorBgSubtle  = lipgloss.Color(\"#303134\") // Surface variant\n\tcolorBgCode    = lipgloss.Color(\"#1E1E1E\") // Code background\n)\n\n// Styles - consolidated for reuse\nvar (\n\ttextStyle   = lipgloss.NewStyle().Foreground(colorText)\n\tmutedStyle  = lipgloss.NewStyle().Foreground(colorMuted)\n\tdimStyle    = lipgloss.NewStyle().Foreground(colorDim)\n\tprimaryText = lipgloss.NewStyle().Foreground(colorPrimary).Bold(true)\n\tsuccessText = lipgloss.NewStyle().Foreground(colorSecondary).Bold(true)\n\terrorText   = lipgloss.NewStyle().Foreground(colorError).Bold(true)\n\twarnText    = lipgloss.NewStyle().Foreground(colorWarning).Bold(true)\n\n\tstatusBar = lipgloss.NewStyle().Background(colorBgSubtle).Foreground(colorText)\n\n\tuserMsg = lipgloss.NewStyle().\n\t\tBorderLeft(true).BorderStyle(lipgloss.ThickBorder()).\n\t\tBorderForeground(colorPrimary).PaddingLeft(1).MarginBottom(1)\n\tagentMsg = lipgloss.NewStyle().\n\t\t\tBorderLeft(true).BorderStyle(lipgloss.ThickBorder()).\n\t\t\tBorderForeground(colorSecondary).PaddingLeft(1).MarginBottom(1)\n\n\ttoolBox = lipgloss.NewStyle().\n\t\tBorder(lipgloss.RoundedBorder()).BorderForeground(colorSecondary).\n\t\tPadding(0, 1).MarginBottom(1)\n\terrorBox = lipgloss.NewStyle().\n\t\t\tBorder(lipgloss.RoundedBorder()).BorderForeground(colorError).\n\t\t\tPadding(0, 1).MarginBottom(1)\n\tinputBox    = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colorPrimary).Padding(0, 1)\n\tinputBoxDim = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colorDim).Padding(0, 1)\n\tcodeStyle   = lipgloss.NewStyle().Foreground(colorText).Background(colorBgCode).Padding(0, 1)\n)\n\n// List item for choice selection\ntype item string\n\nfunc (i item) FilterValue() string { return \"\" }\n\ntype itemDelegate struct{}\n\nfunc (d itemDelegate) Height() int                             { return 1 }\nfunc (d itemDelegate) Spacing() int                            { return 0 }\nfunc (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }\nfunc (d itemDelegate) Render(w io.Writer, m list.Model, idx int, li list.Item) {\n\ts, ok := li.(item)\n\tif !ok {\n\t\treturn\n\t}\n\tif idx == m.Index() {\n\t\tfmt.Fprint(w, primaryText.Render(\"> \"+string(s)))\n\t} else {\n\t\tfmt.Fprint(w, mutedStyle.PaddingLeft(2).Render(string(s)))\n\t}\n}\n\n// TUI is the terminal user interface for the agent.\ntype TUI struct {\n\tprogram *tea.Program\n\tagent   *agent.Agent\n}\n\nfunc NewTUI(agent *agent.Agent) *TUI {\n\treturn &TUI{\n\t\tprogram: tea.NewProgram(newModel(agent), tea.WithAltScreen(), tea.WithMouseAllMotion()),\n\t\tagent:   agent,\n\t}\n}\n\nfunc (u *TUI) Run(ctx context.Context) error {\n\t// Suppress stderr to prevent klog from breaking TUI\n\tif devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil {\n\t\torig := os.Stderr\n\t\tos.Stderr = devNull\n\t\tdefer func() { os.Stderr = orig; devNull.Close() }()\n\t}\n\tklog.SetOutput(io.Discard)\n\tklog.LogToStderr(false)\n\n\tgo func() {\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn\n\t\t\tcase msg, ok := <-u.agent.Output:\n\t\t\t\tif !ok {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tu.program.Send(msg)\n\t\t\t}\n\t\t}\n\t}()\n\n\t_, err := u.program.Run()\n\treturn err\n}\n\nfunc (u *TUI) ClearScreen() {}\n\ntype sessionListMsg []api.SessionInfo\n\nfunc (m *model) fetchSessions() tea.Msg {\n\tsessions, err := m.agent.ListSessions()\n\tif err != nil {\n\t\treturn api.Message{\n\t\t\tType:    api.MessageTypeError,\n\t\t\tPayload: fmt.Sprintf(\"Failed to list sessions: %v\", err),\n\t\t}\n\t}\n\treturn sessionListMsg(sessions)\n}\n\ntype tickMsg time.Time\n\n// Render cache for markdown\ntype renderCache struct {\n\tmu       sync.RWMutex\n\tcache    map[string]string\n\twidth    int\n\trenderer *glamour.TermRenderer\n}\n\nfunc newRenderCache() *renderCache {\n\treturn &renderCache{cache: make(map[string]string)}\n}\n\nfunc (rc *renderCache) get(id string) (string, bool) {\n\trc.mu.RLock()\n\tdefer rc.mu.RUnlock()\n\tv, ok := rc.cache[id]\n\treturn v, ok\n}\n\nfunc (rc *renderCache) set(id, content string) {\n\trc.mu.Lock()\n\tdefer rc.mu.Unlock()\n\trc.cache[id] = content\n}\n\nfunc (rc *renderCache) getRenderer(width int) (*glamour.TermRenderer, error) {\n\trc.mu.Lock()\n\tdefer rc.mu.Unlock()\n\n\tif rc.width != width {\n\t\trc.cache = make(map[string]string)\n\t\trc.width = width\n\t\trc.renderer = nil\n\t}\n\tif rc.renderer == nil {\n\t\tr, err := glamour.NewTermRenderer(glamour.WithStylePath(\"dark\"), glamour.WithWordWrap(width))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\trc.renderer = r\n\t}\n\treturn rc.renderer, nil\n}\n\n// Model state\ntype model struct {\n\tagent      *agent.Agent\n\tviewport   viewport.Model\n\tinput      textinput.Model\n\tspinner    spinner.Model\n\tlist       list.Model\n\tcache      *renderCache\n\tmessages   []*api.Message\n\twidth      int\n\theight     int\n\tdirty      bool\n\tquitting   bool\n\tthinkStart time.Time\n\t// Choice mode tracking\n\tinChoiceMode   bool\n\tchoicePrompt   string\n\tchoiceOptionID string // Track which choice request we initialized for\n\tchoiceType     string // \"confirm\" or \"session\"\n\tsessionIDs     []string\n}\n\nfunc newModel(agent *agent.Agent) model {\n\tti := textinput.New()\n\tti.Placeholder = \"Ask kubectl-ai anything...\"\n\tti.Focus()\n\tti.Prompt = \"\"\n\tti.CharLimit = 4096\n\tti.Width = 80\n\tti.TextStyle = textStyle\n\tti.PlaceholderStyle = dimStyle\n\tti.Cursor.Style = primaryText\n\n\tsp := spinner.New()\n\tsp.Spinner = spinner.MiniDot\n\tsp.Style = primaryText\n\n\tl := list.New(nil, itemDelegate{}, 40, 5)\n\tl.SetShowStatusBar(false)\n\tl.SetFilteringEnabled(false)\n\tl.SetShowHelp(false)\n\tl.SetShowPagination(false)\n\tl.SetShowTitle(false)\n\n\tvp := viewport.New(80, 20)\n\tvp.MouseWheelEnabled = true\n\n\treturn model{\n\t\tagent:    agent,\n\t\tinput:    ti,\n\t\tviewport: vp,\n\t\tspinner:  sp,\n\t\tlist:     l,\n\t\tcache:    newRenderCache(),\n\t\tdirty:    true,\n\t}\n}\n\nfunc (m model) Init() tea.Cmd {\n\treturn tea.Batch(textinput.Blink, m.spinner.Tick, m.tick())\n}\n\nfunc (m model) tick() tea.Cmd {\n\treturn tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })\n}\n\nfunc (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tswitch msg := msg.(type) {\n\tcase tea.WindowSizeMsg:\n\t\tm.width, m.height = msg.Width, msg.Height\n\t\tm.dirty = true\n\t\tm.resize()\n\t\treturn m, nil\n\n\tcase tea.KeyMsg:\n\t\treturn m.handleKey(msg)\n\n\tcase tea.MouseMsg:\n\t\tswitch msg.Button {\n\t\tcase tea.MouseButtonWheelUp:\n\t\t\tm.viewport.ScrollUp(3)\n\t\tcase tea.MouseButtonWheelDown:\n\t\t\tm.viewport.ScrollDown(3)\n\t\t}\n\t\treturn m, nil\n\n\tcase *api.Message:\n\t\treturn m.handleAgentMsg(msg)\n\n\tcase spinner.TickMsg:\n\t\tvar cmd tea.Cmd\n\t\tm.spinner, cmd = m.spinner.Update(msg)\n\t\treturn m, cmd\n\n\tcase tickMsg:\n\t\treturn m, m.tick()\n\n\tcase sessionListMsg:\n\t\tif len(msg) == 0 {\n\t\t\tm.messages = append(m.messages, &api.Message{\n\t\t\t\tSource:    api.MessageSourceAgent,\n\t\t\t\tType:      api.MessageTypeText,\n\t\t\t\tPayload:   \"No sessions found.\",\n\t\t\t\tTimestamp: time.Now(),\n\t\t\t})\n\t\t\tm.dirty = true\n\t\t\tm.refresh()\n\t\t\tm.viewport.GotoBottom()\n\t\t\treturn m, nil\n\t\t}\n\n\t\titems := make([]list.Item, len(msg))\n\t\tids := make([]string, len(msg))\n\t\tfor i, s := range msg {\n\t\t\tlabel := fmt.Sprintf(\"%s (%s) • %d msgs\", s.ID, s.ModelID, s.MessageCount)\n\t\t\tif s.Name != \"\" {\n\t\t\t\tlabel = fmt.Sprintf(\"%s (%s) • %s • %d msgs\", s.Name, s.ModelID, s.ID, s.MessageCount)\n\t\t\t}\n\t\t\titems[i] = item(label)\n\t\t\tids[i] = s.ID\n\t\t}\n\t\tm.list.SetItems(items)\n\t\tm.list.Select(0)\n\t\tm.inChoiceMode = true\n\t\tm.choicePrompt = \"Select a session to resume\"\n\t\tm.choiceOptionID = \"manual-session-picker\"\n\t\tm.choiceType = \"session\"\n\t\tm.sessionIDs = ids\n\t\tm.dirty = true\n\t\tm.refresh()\n\t\tm.viewport.GotoBottom()\n\t\treturn m, nil\n\t}\n\treturn m, nil\n}\n\nfunc (m *model) resize() {\n\tm.viewport.Width = m.width - 2\n\tm.input.Width = m.width - 6\n\tm.list.SetWidth(m.width - 4)\n\tm.updateViewportHeight()\n\tm.refresh()\n\tm.viewport.GotoBottom()\n}\n\nfunc (m *model) updateViewportHeight() {\n\t// Layout: status(1) + 2 dividers(2) + input(3) + help(1) + bottom padding(1) = 8\n\tcontentH := m.height - 8\n\n\tcontentH = max(contentH, 5)\n\tm.viewport.Height = contentH\n}\n\nfunc (m *model) navigateList(keyType tea.KeyType) tea.Cmd {\n\tvar cmd tea.Cmd\n\tm.list, cmd = m.list.Update(tea.KeyMsg{Type: keyType})\n\tm.dirty = true\n\tm.refresh()\n\treturn cmd\n}\n\nfunc (m *model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {\n\tswitch msg.Type {\n\tcase tea.KeyCtrlC, tea.KeyCtrlD:\n\t\tm.quitting = true\n\t\treturn m, tea.Quit\n\tcase tea.KeyEsc:\n\t\tm.input.Reset()\n\t\treturn m, nil\n\tcase tea.KeyEnter:\n\t\treturn m.handleEnter()\n\tcase tea.KeyUp:\n\t\tif m.inChoiceMode {\n\t\t\treturn m, m.navigateList(tea.KeyUp)\n\t\t}\n\t\tm.viewport.ScrollUp(1)\n\tcase tea.KeyDown:\n\t\tif m.inChoiceMode {\n\t\t\treturn m, m.navigateList(tea.KeyDown)\n\t\t}\n\t\tm.viewport.ScrollDown(1)\n\tcase tea.KeyPgUp:\n\t\tm.viewport.ScrollUp(m.viewport.Height / 2)\n\tcase tea.KeyPgDown:\n\t\tm.viewport.ScrollDown(m.viewport.Height / 2)\n\tdefault:\n\t\tswitch msg.String() {\n\t\tcase \"ctrl+u\":\n\t\t\tm.viewport.ScrollUp(m.viewport.Height / 2)\n\t\tcase \"ctrl+d\":\n\t\t\tm.viewport.ScrollDown(m.viewport.Height / 2)\n\t\tcase \"j\":\n\t\t\tif m.inChoiceMode {\n\t\t\t\treturn m, m.navigateList(tea.KeyDown)\n\t\t\t}\n\t\tcase \"k\":\n\t\t\tif m.inChoiceMode {\n\t\t\t\treturn m, m.navigateList(tea.KeyUp)\n\t\t\t}\n\t\t}\n\t\t// Default: send to text input\n\t\tvar cmd tea.Cmd\n\t\tm.input, cmd = m.input.Update(msg)\n\t\treturn m, cmd\n\t}\n\treturn m, nil\n}\n\nfunc (m *model) handleEnter() (tea.Model, tea.Cmd) {\n\t// Handle choice selection\n\tif m.inChoiceMode {\n\t\tif _, ok := m.list.SelectedItem().(item); ok {\n\t\t\tif m.choiceType == \"session\" {\n\t\t\t\tidx := m.list.Index()\n\t\t\t\tif idx >= 0 && idx < len(m.sessionIDs) {\n\t\t\t\t\tselectedID := m.sessionIDs[idx]\n\t\t\t\t\tm.inChoiceMode = false\n\t\t\t\t\tm.choicePrompt = \"\"\n\t\t\t\t\tm.choiceOptionID = \"\"\n\t\t\t\t\t// Don't reset choiceType/sessionIDs yet or it might race, but actually we are done.\n\t\t\t\t\tm.dirty = true\n\t\t\t\t\tm.refresh()\n\t\t\t\t\treturn m, func() tea.Msg {\n\t\t\t\t\t\tm.agent.Input <- &api.SessionPickerResponse{SessionID: selectedID}\n\t\t\t\t\t\treturn nil\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tchoice := m.list.Index() + 1\n\t\t\t\tm.inChoiceMode = false\n\t\t\t\tm.choicePrompt = \"\"\n\t\t\t\tm.choiceOptionID = \"\"\n\t\t\t\tm.dirty = true\n\t\t\t\tm.refresh()\n\t\t\t\treturn m, func() tea.Msg {\n\t\t\t\t\tm.agent.Input <- &api.UserChoiceResponse{Choice: choice}\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn m, nil\n\t}\n\n\tvalue := strings.TrimSpace(m.input.Value())\n\tif value == \"\" {\n\t\treturn m, nil\n\t}\n\n\t// Add user message\n\tm.messages = append(m.messages, &api.Message{\n\t\tSource:    api.MessageSourceUser,\n\t\tType:      api.MessageTypeText,\n\t\tPayload:   value,\n\t\tTimestamp: time.Now(),\n\t})\n\tm.input.Reset()\n\tm.dirty = true\n\tm.refresh()\n\tm.viewport.GotoBottom()\n\n\t// Intercept \"sessions\" command\n\tif value == \"sessions\" {\n\t\treturn m, m.fetchSessions\n\t}\n\n\tm.thinkStart = time.Now()\n\n\treturn m, func() tea.Msg {\n\t\tm.agent.Input <- &api.UserInputResponse{Query: value}\n\t\treturn nil\n\t}\n}\n\nfunc (m *model) handleAgentMsg(msg *api.Message) (tea.Model, tea.Cmd) {\n\tsession := m.agent.GetSession()\n\tm.messages = session.AllMessages()\n\tm.dirty = true\n\n\t// Check if we're entering choice mode - use the incoming message directly\n\t// to avoid race conditions where the message isn't yet in AllMessages()\n\tif msg.Type == api.MessageTypeUserChoiceRequest {\n\t\tif req, ok := msg.Payload.(*api.UserChoiceRequest); ok {\n\t\t\titems := make([]list.Item, len(req.Options))\n\t\t\tfor i, opt := range req.Options {\n\t\t\t\titems[i] = item(opt.Label)\n\t\t\t}\n\t\t\tm.list.SetItems(items)\n\t\t\tm.list.Select(0)\n\t\t\tm.inChoiceMode = true\n\t\t\tm.choicePrompt = req.Prompt\n\t\t\tm.choiceOptionID = msg.ID\n\t\t\tm.choiceType = \"confirm\"\n\t\t}\n\t} else if msg.Type == api.MessageTypeSessionPickerRequest {\n\t\tif req, ok := msg.Payload.(*api.SessionPickerRequest); ok {\n\t\t\titems := make([]list.Item, len(req.Sessions))\n\t\t\tids := make([]string, len(req.Sessions))\n\t\t\tfor i, s := range req.Sessions {\n\t\t\t\tlabel := fmt.Sprintf(\"%s (%s) • %d msgs\", s.ID, s.ModelID, s.MessageCount)\n\t\t\t\tif s.Name != \"\" {\n\t\t\t\t\tlabel = fmt.Sprintf(\"%s (%s) • %s • %d msgs\", s.Name, s.ModelID, s.ID, s.MessageCount)\n\t\t\t\t}\n\t\t\t\titems[i] = item(label)\n\t\t\t\tids[i] = s.ID\n\t\t\t}\n\t\t\tm.list.SetItems(items)\n\t\t\tm.list.Select(0)\n\t\t\tm.inChoiceMode = true\n\t\t\tm.choicePrompt = \"Select a session to resume\"\n\t\t\tm.choiceOptionID = msg.ID\n\t\t\tm.choiceType = \"session\"\n\t\t\tm.sessionIDs = ids\n\t\t}\n\t} else if session.AgentState == api.AgentStateDone || session.AgentState == api.AgentStateExited {\n\t\t// Clear choice mode if we're done or exited\n\t\tm.inChoiceMode = false\n\t\tm.choicePrompt = \"\"\n\t\tm.choiceOptionID = \"\"\n\t}\n\n\tm.refresh()\n\tm.viewport.GotoBottom()\n\n\tif session.AgentState == api.AgentStateRunning || session.AgentState == api.AgentStateInitializing {\n\t\treturn m, m.spinner.Tick\n\t}\n\treturn m, nil\n}\n\nfunc (m *model) refresh() {\n\tif !m.dirty {\n\t\treturn\n\t}\n\tm.viewport.SetContent(m.renderMessages())\n\tm.dirty = false\n}\n\nfunc (m model) renderMessages() string {\n\tvar sb strings.Builder\n\n\tif len(m.messages) == 0 {\n\t\tsb.WriteString(fmt.Sprintf(\"\\n%s\\n\\n%s\\n%s\\n\",\n\t\t\tprimaryText.Render(logo),\n\t\t\tmutedStyle.PaddingLeft(1).Render(\"Your AI-powered Kubernetes assistant\"),\n\t\t\tdimStyle.PaddingLeft(1).Render(\"Type a message to get started\")))\n\t} else {\n\t\twidth := min(m.viewport.Width-6, 90)\n\t\tif width < 40 {\n\t\t\twidth = 40\n\t\t}\n\n\t\trenderer, err := m.cache.getRenderer(width)\n\t\tif err != nil {\n\t\t\treturn \"Error rendering messages\"\n\t\t}\n\n\t\tfor _, msg := range m.messages {\n\t\t\tif s := m.renderMessage(msg, renderer, width); s != \"\" {\n\t\t\t\tsb.WriteString(s)\n\t\t\t}\n\t\t}\n\t}\n\n\t// Render choice picker inline at the end of messages\n\tif m.inChoiceMode {\n\t\tsb.WriteString(\"\\n\")\n\t\tsb.WriteString(warnText.Render(\"? \" + m.choicePrompt))\n\t\tsb.WriteString(\"\\n\\n\")\n\t\tsb.WriteString(m.list.View())\n\t\tsb.WriteString(\"\\n\")\n\t}\n\n\treturn sb.String()\n}\n\nfunc (m model) renderMessage(msg *api.Message, r *glamour.TermRenderer, w int) string {\n\t// Skip certain message types\n\tif msg.Type == api.MessageTypeUserInputRequest {\n\t\tif p, ok := msg.Payload.(string); ok && p == \">>>\" {\n\t\t\treturn \"\"\n\t\t}\n\t}\n\tif msg.Type == api.MessageTypeToolCallResponse {\n\t\treturn \"\"\n\t}\n\t// Skip choice requests - they're rendered in the input area instead\n\tif msg.Type == api.MessageTypeUserChoiceRequest || msg.Type == api.MessageTypeSessionPickerRequest {\n\t\treturn \"\"\n\t}\n\n\t// Check cache (except tool calls which show status)\n\tif msg.ID != \"\" && msg.Type != api.MessageTypeToolCallRequest {\n\t\tif cached, ok := m.cache.get(msg.ID); ok {\n\t\t\treturn cached\n\t\t}\n\t}\n\n\tvar result string\n\tswitch msg.Type {\n\tcase api.MessageTypeToolCallRequest:\n\t\tresult = m.renderToolCall(msg, w)\n\tcase api.MessageTypeError:\n\t\tresult = m.renderError(msg, w)\n\tdefault:\n\t\tresult = m.renderTextMsg(msg, r, w)\n\t}\n\n\t// Cache result\n\tif msg.ID != \"\" && result != \"\" && msg.Type != api.MessageTypeToolCallRequest {\n\t\tm.cache.set(msg.ID, result)\n\t}\n\treturn result\n}\n\nfunc (m model) renderTextMsg(msg *api.Message, r *glamour.TermRenderer, w int) string {\n\tpayload, ok := msg.Payload.(string)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\n\tts := \"\"\n\tif !msg.Timestamp.IsZero() {\n\t\tts = dimStyle.Italic(true).Render(\" \" + msg.Timestamp.Format(\"15:04\"))\n\t}\n\n\tswitch msg.Source {\n\tcase api.MessageSourceUser:\n\t\tlabel := primaryText.Render(\"You\") + ts\n\t\tcontent := textStyle.Width(w).Render(payload)\n\t\treturn userMsg.Width(w+2).Render(label+\"\\n\"+content) + \"\\n\"\n\tcase api.MessageSourceModel, api.MessageSourceAgent:\n\t\tlabel := successText.Render(\"kubectl-ai\") + ts\n\t\trendered, _ := r.Render(payload)\n\t\treturn agentMsg.Width(w+2).Render(label+\"\\n\"+strings.TrimSpace(rendered)) + \"\\n\"\n\t}\n\treturn \"\"\n}\n\nfunc (m model) renderToolCall(msg *api.Message, w int) string {\n\tpayload, ok := msg.Payload.(string)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\tcontent := successText.Render(\"⚡ Running\") + \"\\n\" + codeStyle.Render(payload)\n\treturn toolBox.Width(w).Render(content) + \"\\n\"\n}\n\nfunc (m model) renderError(msg *api.Message, w int) string {\n\tpayload, ok := msg.Payload.(string)\n\tif !ok {\n\t\treturn \"\"\n\t}\n\tcontent := errorText.Render(\"✗ Error\") + \"\\n\" + errorText.Render(payload)\n\treturn errorBox.Width(w).Render(content) + \"\\n\"\n}\n\nfunc (m model) View() string {\n\tif m.quitting {\n\t\treturn mutedStyle.Padding(1).Render(\"Goodbye!\")\n\t}\n\n\tsession := m.agent.GetSession()\n\treturn lipgloss.JoinVertical(lipgloss.Left,\n\t\tm.viewStatus(session),\n\t\tm.viewDivider(),\n\t\tlipgloss.NewStyle().PaddingLeft(1).Render(m.viewport.View()),\n\t\tm.viewDivider(),\n\t\tm.viewInput(session.AgentState),\n\t\tm.viewHelp(session.AgentState),\n\t)\n}\n\nfunc (m model) viewStatus(session *api.Session) string {\n\tsep := dimStyle.Render(\" | \")\n\n\tname := session.Name\n\tif name == \"\" {\n\t\tname = session.ID\n\t}\n\tleft := primaryText.Render(\"kubectl-ai\") + sep + mutedStyle.Render(name) + sep + m.viewState(session.AgentState)\n\n\tmodel := session.ModelID\n\tif model == \"\" {\n\t\tmodel = \"unknown\"\n\t}\n\tright := lipgloss.NewStyle().Foreground(colorSecondary).Render(model)\n\n\tgap := m.width - lipgloss.Width(left) - lipgloss.Width(right) - 2\n\tif gap < 0 {\n\t\tgap = 0\n\t}\n\treturn statusBar.Width(m.width).Render(\" \" + left + strings.Repeat(\" \", gap) + right + \" \")\n}\n\nfunc (m model) viewState(state api.AgentState) string {\n\tstates := map[api.AgentState]struct {\n\t\ticon, text string\n\t\tstyle      lipgloss.Style\n\t}{\n\t\tapi.AgentStateRunning:         {\"●\", \"Running\", successText},\n\t\tapi.AgentStateInitializing:    {\"\", \"Initializing...\", mutedStyle},\n\t\tapi.AgentStateWaitingForInput: {\"●\", \"Ready\", successText},\n\t\tapi.AgentStateIdle:            {\"○\", \"Idle\", mutedStyle},\n\t\tapi.AgentStateDone:            {\"✓\", \"Done\", successText},\n\t\tapi.AgentStateExited:          {\"○\", \"Exited\", mutedStyle},\n\t}\n\n\tif s, ok := states[state]; ok {\n\t\ttxt := s.style.Render(s.icon + \" \" + s.text)\n\t\tif state == api.AgentStateRunning && !m.thinkStart.IsZero() {\n\t\t\ttxt += mutedStyle.Render(\" \" + formatDuration(time.Since(m.thinkStart)))\n\t\t}\n\t\treturn txt\n\t}\n\treturn mutedStyle.Render(string(state))\n}\n\nfunc (m model) viewDivider() string {\n\treturn dimStyle.Render(strings.Repeat(\"─\", m.width))\n}\n\nfunc (m model) viewInput(state api.AgentState) string {\n\t// Show dimmed input hint when in choice mode (picker is inline above)\n\tif m.inChoiceMode {\n\t\tcontent := mutedStyle.Render(\"Use ↑/↓ to navigate, Enter to select\")\n\t\treturn lipgloss.NewStyle().Padding(0, 1).Render(inputBoxDim.Width(m.width - 4).Render(content))\n\t}\n\n\t// Show spinner or input\n\tif state == api.AgentStateRunning || state == api.AgentStateInitializing {\n\t\telapsed := \"\"\n\t\tif !m.thinkStart.IsZero() {\n\t\t\telapsed = \" \" + formatDuration(time.Since(m.thinkStart))\n\t\t}\n\t\tcontent := primaryText.Render(m.spinner.View()+\" Thinking...\") + mutedStyle.Render(elapsed)\n\t\treturn lipgloss.NewStyle().Padding(0, 1).Render(inputBoxDim.Width(m.width - 4).Render(content))\n\t}\n\n\treturn lipgloss.NewStyle().Padding(0, 1).Render(inputBox.Width(m.width - 4).Render(m.input.View()))\n}\n\nfunc (m model) viewHelp(state api.AgentState) string {\n\tvar hints []string\n\tif m.inChoiceMode {\n\t\thints = []string{\"↑/↓: navigate\", \"Enter: select\", \"Ctrl+C: quit\"}\n\t} else if state == api.AgentStateRunning {\n\t\thints = []string{\"Ctrl+C: cancel\"}\n\t} else {\n\t\thints = []string{\"Enter: send\", \"Esc: clear\", \"Ctrl+C: quit\"}\n\t\tif m.viewport.TotalLineCount() > m.viewport.Height {\n\t\t\thints = append(hints, \"↑/↓: scroll\")\n\t\t}\n\t}\n\treturn dimStyle.Padding(0, 2, 1, 2).Render(strings.Join(hints, \" • \"))\n}\n\nfunc formatDuration(d time.Duration) string {\n\tswitch {\n\tcase d < time.Minute:\n\t\treturn fmt.Sprintf(\"%ds\", int(d.Seconds()))\n\tcase d < time.Hour:\n\t\treturn fmt.Sprintf(\"%dm%ds\", int(d.Minutes()), int(d.Seconds())%60)\n\tdefault:\n\t\treturn fmt.Sprintf(\"%dh%dm\", int(d.Hours()), int(d.Minutes())%60)\n\t}\n}\n"
  }
]