mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-01-11 19:56:44 +00:00
Refactor: Remove Copilot tools and related utilities
Some checks are pending
Build / docker-build-accounts (push) Waiting to run
Build / docker-build-isolated-vm (push) Waiting to run
Build / docker-build-home (push) Waiting to run
Build / docker-build-worker (push) Waiting to run
Build / docker-build-workflow (push) Waiting to run
Build / docker-build-api-reference (push) Waiting to run
Build / docker-build-docs (push) Waiting to run
Build / docker-build-otel-collector (push) Waiting to run
Build / docker-build-app (push) Waiting to run
Build / docker-build-e2e (push) Waiting to run
Build / docker-build-admin-dashboard (push) Waiting to run
Build / docker-build-dashboard (push) Waiting to run
Build / docker-build-probe (push) Waiting to run
Build / docker-build-probe-ingest (push) Waiting to run
Build / docker-build-server-monitor-ingest (push) Waiting to run
Build / docker-build-telemetry (push) Waiting to run
Build / docker-build-incoming-request-ingest (push) Waiting to run
Build / docker-build-status-page (push) Waiting to run
Build / docker-build-test-server (push) Waiting to run
Compile / compile-accounts (push) Waiting to run
Compile / compile-isolated-vm (push) Waiting to run
Compile / compile-common (push) Waiting to run
Compile / compile-app (push) Waiting to run
Compile / compile-home (push) Waiting to run
Compile / compile-worker (push) Waiting to run
Compile / compile-workflow (push) Waiting to run
Compile / compile-api-reference (push) Waiting to run
Compile / compile-docs-reference (push) Waiting to run
Compile / compile-nginx (push) Waiting to run
Compile / compile-infrastructure-agent (push) Waiting to run
Compile / compile-admin-dashboard (push) Waiting to run
Compile / compile-dashboard (push) Waiting to run
Compile / compile-e2e (push) Waiting to run
Compile / compile-probe (push) Waiting to run
Push Test Images to Docker Hub and GitHub Container Registry / telemetry-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / probe-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / dashboard-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / admin-dashboard-docker-image-deploy (push) Blocked by required conditions
CodeQL / Analyze (push) Waiting to run
Common Jobs / helm-lint (push) Waiting to run
Common Jobs / js-lint (push) Waiting to run
Compile / compile-probe-ingest (push) Waiting to run
Compile / compile-server-monitor-ingest (push) Waiting to run
Compile / compile-telemetry (push) Waiting to run
Compile / compile-incoming-request-ingest (push) Waiting to run
Compile / compile-status-page (push) Waiting to run
Compile / compile-test-server (push) Waiting to run
Compile / compile-mcp (push) Waiting to run
OpenAPI Spec Generation / generate-openapi-spec (push) Waiting to run
Terraform Provider Generation / generate-terraform-provider (push) Waiting to run
Push Test Images to Docker Hub and GitHub Container Registry / generate-build-number (push) Waiting to run
Push Test Images to Docker Hub and GitHub Container Registry / read-version (push) Waiting to run
Push Test Images to Docker Hub and GitHub Container Registry / publish-mcp-server (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / nginx-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / e2e-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / test-server-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / otel-collector-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / isolated-vm-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / home-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / status-page-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / test-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / probe-ingest-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / server-monitor-ingest-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / incoming-request-ingest-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / app-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / api-reference-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / accounts-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / worker-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / workflow-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / docs-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / publish-terraform-provider (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / test-helm-chart (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / test-e2e-test-saas (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / test-e2e-test-self-hosted (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / infrastructure-agent-deploy (push) Blocked by required conditions
Common Test / test (push) Waiting to run
Incoming Request Ingest Test / test (push) Waiting to run
Tests / test-app (push) Waiting to run
Tests / test-worker (push) Waiting to run
MCP Server Test / test (push) Waiting to run
ProbeIngest Test / test (push) Waiting to run
Probe Test / test (push) Waiting to run
Telemetry Test / test (push) Waiting to run
Tests / test-home (push) Waiting to run
Some checks are pending
Build / docker-build-accounts (push) Waiting to run
Build / docker-build-isolated-vm (push) Waiting to run
Build / docker-build-home (push) Waiting to run
Build / docker-build-worker (push) Waiting to run
Build / docker-build-workflow (push) Waiting to run
Build / docker-build-api-reference (push) Waiting to run
Build / docker-build-docs (push) Waiting to run
Build / docker-build-otel-collector (push) Waiting to run
Build / docker-build-app (push) Waiting to run
Build / docker-build-e2e (push) Waiting to run
Build / docker-build-admin-dashboard (push) Waiting to run
Build / docker-build-dashboard (push) Waiting to run
Build / docker-build-probe (push) Waiting to run
Build / docker-build-probe-ingest (push) Waiting to run
Build / docker-build-server-monitor-ingest (push) Waiting to run
Build / docker-build-telemetry (push) Waiting to run
Build / docker-build-incoming-request-ingest (push) Waiting to run
Build / docker-build-status-page (push) Waiting to run
Build / docker-build-test-server (push) Waiting to run
Compile / compile-accounts (push) Waiting to run
Compile / compile-isolated-vm (push) Waiting to run
Compile / compile-common (push) Waiting to run
Compile / compile-app (push) Waiting to run
Compile / compile-home (push) Waiting to run
Compile / compile-worker (push) Waiting to run
Compile / compile-workflow (push) Waiting to run
Compile / compile-api-reference (push) Waiting to run
Compile / compile-docs-reference (push) Waiting to run
Compile / compile-nginx (push) Waiting to run
Compile / compile-infrastructure-agent (push) Waiting to run
Compile / compile-admin-dashboard (push) Waiting to run
Compile / compile-dashboard (push) Waiting to run
Compile / compile-e2e (push) Waiting to run
Compile / compile-probe (push) Waiting to run
Push Test Images to Docker Hub and GitHub Container Registry / telemetry-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / probe-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / dashboard-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / admin-dashboard-docker-image-deploy (push) Blocked by required conditions
CodeQL / Analyze (push) Waiting to run
Common Jobs / helm-lint (push) Waiting to run
Common Jobs / js-lint (push) Waiting to run
Compile / compile-probe-ingest (push) Waiting to run
Compile / compile-server-monitor-ingest (push) Waiting to run
Compile / compile-telemetry (push) Waiting to run
Compile / compile-incoming-request-ingest (push) Waiting to run
Compile / compile-status-page (push) Waiting to run
Compile / compile-test-server (push) Waiting to run
Compile / compile-mcp (push) Waiting to run
OpenAPI Spec Generation / generate-openapi-spec (push) Waiting to run
Terraform Provider Generation / generate-terraform-provider (push) Waiting to run
Push Test Images to Docker Hub and GitHub Container Registry / generate-build-number (push) Waiting to run
Push Test Images to Docker Hub and GitHub Container Registry / read-version (push) Waiting to run
Push Test Images to Docker Hub and GitHub Container Registry / publish-mcp-server (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / nginx-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / e2e-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / test-server-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / otel-collector-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / isolated-vm-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / home-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / status-page-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / test-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / probe-ingest-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / server-monitor-ingest-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / incoming-request-ingest-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / app-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / api-reference-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / accounts-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / worker-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / workflow-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / docs-docker-image-deploy (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / publish-terraform-provider (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / test-helm-chart (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / test-e2e-test-saas (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / test-e2e-test-self-hosted (push) Blocked by required conditions
Push Test Images to Docker Hub and GitHub Container Registry / infrastructure-agent-deploy (push) Blocked by required conditions
Common Test / test (push) Waiting to run
Incoming Request Ingest Test / test (push) Waiting to run
Tests / test-app (push) Waiting to run
Tests / test-worker (push) Waiting to run
MCP Server Test / test (push) Waiting to run
ProbeIngest Test / test (push) Waiting to run
Probe Test / test (push) Waiting to run
Telemetry Test / test (push) Waiting to run
Tests / test-home (push) Waiting to run
- Deleted RunCommandTool, SearchWorkspaceTool, WriteFileTool, and their associated interfaces and implementations. - Removed Tool, ToolRegistry, and AgentLogger classes, along with their dependencies. - Eliminated utility functions for workspace path management and secret sanitization. - Cleaned up TypeScript configuration and example environment variables related to Copilot. - Updated Docker Compose files to remove references to Copilot services.
This commit is contained in:
parent
210eb82369
commit
eea9c2788b
36 changed files with 3 additions and 8586 deletions
23
.github/workflows/build.yml
vendored
23
.github/workflows/build.yml
vendored
|
|
@ -220,29 +220,6 @@ jobs:
|
|||
command: sudo docker build --no-cache -f ./App/Dockerfile .
|
||||
|
||||
|
||||
docker-build-copilot:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preinstall
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: npm run prerun
|
||||
|
||||
# build image for accounts service
|
||||
- name: build docker image
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: sudo docker build --no-cache -f ./Copilot/Dockerfile .
|
||||
|
||||
docker-build-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
|
|
|||
17
.github/workflows/compile.yml
vendored
17
.github/workflows/compile.yml
vendored
|
|
@ -162,23 +162,6 @@ jobs:
|
|||
max_attempts: 3
|
||||
command: cd Docs && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-copilot:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
- run: cd Common && npm install
|
||||
- name: Compile Copilot
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: cd Copilot && npm install && npm run compile && npm run dep-check
|
||||
|
||||
compile-nginx:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
|
|
|||
149
.github/workflows/release.yml
vendored
149
.github/workflows/release.yml
vendored
|
|
@ -297,77 +297,6 @@ jobs:
|
|||
path: ./MCP/
|
||||
retention-days: 90
|
||||
|
||||
publish-copilot-agent:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [generate-build-number, read-version, publish-npm-packages]
|
||||
env:
|
||||
NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
|
||||
CI_PIPELINE_ID: ${{ github.run_number }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
Copilot/package-lock.json
|
||||
Common/package-lock.json
|
||||
|
||||
- name: Configure npm authentication
|
||||
run: |
|
||||
rm -f ~/.npmrc
|
||||
{
|
||||
echo "//registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN}"
|
||||
echo "always-auth=true"
|
||||
} >> ~/.npmrc
|
||||
|
||||
- name: Install Common dependencies
|
||||
run: |
|
||||
cd Common
|
||||
npm install
|
||||
|
||||
- name: Install Copilot dependencies
|
||||
run: |
|
||||
cd Copilot
|
||||
npm install
|
||||
|
||||
- name: Build Copilot agent
|
||||
run: |
|
||||
cd Copilot
|
||||
npm run compile
|
||||
|
||||
- name: Update Copilot agent version
|
||||
run: |
|
||||
cd Copilot
|
||||
npm version ${{ needs.read-version.outputs.major_minor }} --no-git-tag-version
|
||||
|
||||
- name: Publish Copilot agent to npm
|
||||
run: |
|
||||
cd Copilot
|
||||
set +e
|
||||
PUBLISH_OUTPUT=$(npm publish --access public 2>&1)
|
||||
PUBLISH_EXIT=$?
|
||||
set -e
|
||||
echo "$PUBLISH_OUTPUT"
|
||||
if [ $PUBLISH_EXIT -ne 0 ]; then
|
||||
if echo "$PUBLISH_OUTPUT" | grep -q "You cannot publish over the previously published versions"; then
|
||||
echo "⚠️ Copilot agent version already published, skipping."
|
||||
else
|
||||
echo "❌ npm publish failed"
|
||||
exit $PUBLISH_EXIT
|
||||
fi
|
||||
else
|
||||
echo "✅ Published @oneuptime/copilot-agent@${{ needs.read-version.outputs.major_minor }}"
|
||||
fi
|
||||
|
||||
nginx-docker-image-deploy:
|
||||
needs: [generate-build-number, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -1531,78 +1460,6 @@ jobs:
|
|||
--git-sha "${{ github.sha }}"
|
||||
|
||||
|
||||
copilot-docker-image-deploy:
|
||||
needs: [generate-build-number, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/copilot
|
||||
ghcr.io/oneuptime/copilot
|
||||
tags: |
|
||||
type=raw,value=release,enable=true
|
||||
type=semver,value=${{needs.read-version.outputs.major_minor}},pattern={{version}},enable=true
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4
|
||||
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: npm run prerun
|
||||
|
||||
# Build and deploy app.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 5
|
||||
max_attempts: 3
|
||||
command: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 5
|
||||
max_attempts: 3
|
||||
command: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
command: |
|
||||
bash ./Scripts/GHA/build_docker_images.sh \
|
||||
--image copilot \
|
||||
--version "${{needs.read-version.outputs.major_minor}}" \
|
||||
--dockerfile ./Copilot/Dockerfile \
|
||||
--context . \
|
||||
--platforms linux/amd64,linux/arm64 \
|
||||
--git-sha "${{ github.sha }}"
|
||||
|
||||
accounts-docker-image-deploy:
|
||||
needs: [generate-build-number, read-version]
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -2083,7 +1940,6 @@ jobs:
|
|||
- admin-dashboard-docker-image-deploy
|
||||
- dashboard-docker-image-deploy
|
||||
- app-docker-image-deploy
|
||||
- copilot-docker-image-deploy
|
||||
- accounts-docker-image-deploy
|
||||
- docs-docker-image-deploy
|
||||
- worker-docker-image-deploy
|
||||
|
|
@ -2113,7 +1969,6 @@ jobs:
|
|||
"admin-dashboard",
|
||||
"dashboard",
|
||||
"app",
|
||||
"copilot",
|
||||
"accounts",
|
||||
"docs",
|
||||
"worker",
|
||||
|
|
@ -2173,7 +2028,7 @@ jobs:
|
|||
|
||||
test-e2e-release-saas:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [telemetry-docker-image-deploy, publish-mcp-server, copilot-docker-image-deploy, docs-docker-image-deploy, api-reference-docker-image-deploy, workflow-docker-image-deploy, accounts-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, read-version, nginx-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
|
||||
needs: [telemetry-docker-image-deploy, publish-mcp-server, docs-docker-image-deploy, api-reference-docker-image-deploy, workflow-docker-image-deploy, accounts-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, read-version, nginx-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
|
|
@ -2261,7 +2116,7 @@ jobs:
|
|||
test-e2e-release-self-hosted:
|
||||
runs-on: ubuntu-latest
|
||||
# After all the jobs runs
|
||||
needs: [telemetry-docker-image-deploy, publish-mcp-server, copilot-docker-image-deploy, incoming-request-ingest-docker-image-deploy, docs-docker-image-deploy, api-reference-docker-image-deploy, workflow-docker-image-deploy, accounts-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, read-version, nginx-docker-image-deploy]
|
||||
needs: [telemetry-docker-image-deploy, publish-mcp-server, incoming-request-ingest-docker-image-deploy, docs-docker-image-deploy, api-reference-docker-image-deploy, workflow-docker-image-deploy, accounts-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, read-version, nginx-docker-image-deploy]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
|
|
|
|||
76
.github/workflows/test-release.yaml
vendored
76
.github/workflows/test-release.yaml
vendored
|
|
@ -1628,80 +1628,6 @@ jobs:
|
|||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
|
||||
copilot-docker-image-deploy:
|
||||
needs: [read-version, generate-build-number]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/copilot
|
||||
ghcr.io/oneuptime/copilot
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=raw,value=${{needs.read-version.outputs.major_minor}}-test,enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v10.0.4
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: npm run prerun
|
||||
|
||||
# Build and deploy accounts.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 5
|
||||
max_attempts: 3
|
||||
command: |
|
||||
echo "${{ secrets.DOCKERHUB_PASSWORD }}" | docker login --username "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 5
|
||||
max_attempts: 3
|
||||
command: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io --username "${{ github.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
command: |
|
||||
bash ./Scripts/GHA/build_docker_images.sh \
|
||||
--image copilot \
|
||||
--version "${{needs.read-version.outputs.major_minor}}-test" \
|
||||
--dockerfile ./Copilot/Dockerfile \
|
||||
--context . \
|
||||
--platforms linux/amd64,linux/arm64 \
|
||||
--git-sha "${{ github.sha }}" \
|
||||
--extra-tags test \
|
||||
--extra-enterprise-tags enterprise-test
|
||||
|
||||
|
||||
workflow-docker-image-deploy:
|
||||
needs: [read-version, generate-build-number]
|
||||
|
|
@ -1868,7 +1794,7 @@ jobs:
|
|||
|
||||
test-helm-chart:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [infrastructure-agent-deploy, publish-mcp-server, publish-terraform-provider, telemetry-docker-image-deploy, copilot-docker-image-deploy, docs-docker-image-deploy, worker-docker-image-deploy, workflow-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, api-reference-docker-image-deploy, test-server-docker-image-deploy, test-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, probe-docker-image-deploy, dashboard-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, accounts-docker-image-deploy, otel-collector-docker-image-deploy, status-page-docker-image-deploy, nginx-docker-image-deploy, e2e-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
|
||||
needs: [infrastructure-agent-deploy, publish-mcp-server, publish-terraform-provider, telemetry-docker-image-deploy, docs-docker-image-deploy, worker-docker-image-deploy, workflow-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, api-reference-docker-image-deploy, test-server-docker-image-deploy, test-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, probe-docker-image-deploy, dashboard-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, accounts-docker-image-deploy, otel-collector-docker-image-deploy, status-page-docker-image-deploy, nginx-docker-image-deploy, e2e-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
|
|
|
|||
14
.vscode/launch.json
vendored
14
.vscode/launch.json
vendored
|
|
@ -19,20 +19,6 @@
|
|||
}
|
||||
],
|
||||
"configurations": [
|
||||
{
|
||||
"address": "127.0.0.1",
|
||||
"localRoot": "${workspaceFolder}/TestServer",
|
||||
"name": "Copilot: Debug with Docker",
|
||||
"port": 9985,
|
||||
"remoteRoot": "/usr/src/app",
|
||||
"request": "attach",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"type": "node",
|
||||
"restart": true,
|
||||
"autoAttachChildProcesses": true
|
||||
},
|
||||
{
|
||||
"name": "Debug Infrastructure Agent",
|
||||
"type": "go",
|
||||
|
|
|
|||
4
Copilot/.gitignore
vendored
4
Copilot/.gitignore
vendored
|
|
@ -1,4 +0,0 @@
|
|||
node_modules
|
||||
build
|
||||
*.log
|
||||
.DS_Store
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
#
|
||||
# OneUptime-copilot Dockerfile
|
||||
#
|
||||
|
||||
# Pull base image nodejs image.
|
||||
FROM public.ecr.aws/docker/library/node:22.3.0
|
||||
RUN mkdir /tmp/npm && chmod 2777 /tmp/npm && chown 1000:1000 /tmp/npm && npm config set cache /tmp/npm --global
|
||||
|
||||
RUN npm config set fetch-retries 5
|
||||
RUN npm config set fetch-retry-mintimeout 100000
|
||||
RUN npm config set fetch-retry-maxtimeout 600000
|
||||
|
||||
|
||||
ARG GIT_SHA
|
||||
ARG APP_VERSION
|
||||
ARG IS_ENTERPRISE_EDITION=false
|
||||
|
||||
ENV GIT_SHA=${GIT_SHA}
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
ENV IS_ENTERPRISE_EDITION=${IS_ENTERPRISE_EDITION}
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
|
||||
|
||||
# IF APP_VERSION is not set, set it to 1.0.0
|
||||
RUN if [ -z "$APP_VERSION" ]; then export APP_VERSION=1.0.0; fi
|
||||
|
||||
|
||||
RUN apt-get update
|
||||
|
||||
# Install bash.
|
||||
RUN apt-get install bash -y && apt-get install curl -y
|
||||
|
||||
# Install python
|
||||
RUN apt-get update && apt-get install -y .gyp python3 make g++
|
||||
|
||||
#Use bash shell by default
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
|
||||
RUN mkdir -p /usr/src
|
||||
|
||||
WORKDIR /usr/src/Common
|
||||
COPY ./Common/package*.json /usr/src/Common/
|
||||
# Set version in ./Common/package.json to the APP_VERSION
|
||||
RUN sed -i "s/\"version\": \".*\"/\"version\": \"$APP_VERSION\"/g" /usr/src/Common/package.json
|
||||
RUN npm install
|
||||
COPY ./Common /usr/src/Common
|
||||
|
||||
|
||||
|
||||
ENV PRODUCTION=true
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install app dependencies
|
||||
COPY ./Copilot/package*.json /usr/src/app/
|
||||
RUN npm install
|
||||
|
||||
# Create /repository/ directory where the app will store the repository
|
||||
RUN mkdir -p /repository
|
||||
|
||||
# Set the stack trace limit to 30 to show longer stack traces
|
||||
ENV NODE_OPTIONS="--stack-trace-limit=30"
|
||||
|
||||
{{ if eq .Env.ENVIRONMENT "development" }}
|
||||
#Run the app
|
||||
CMD [ "npm", "run", "dev" ]
|
||||
{{ else }}
|
||||
# Copy app source
|
||||
COPY ./Copilot /usr/src/app
|
||||
# Bundle app source
|
||||
RUN npm run build
|
||||
# Set permission to write logs and cache in case container run as non root
|
||||
RUN chown -R 1000:1000 "/tmp/npm" && chmod -R 2777 "/tmp/npm"
|
||||
#Run the app
|
||||
CMD [ "npm", "start" ]
|
||||
{{ end }}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
# OneUptime Copilot Agent
|
||||
|
||||
A standalone CLI coding agent that mirrors the autonomous workflows we use inside VS Code Copilot Chat. It connects to LM Studio, Ollama, OpenAI, or Anthropic chat-completion models, inspects a workspace, reasons about the task, and uses a toolbox (file/patch editing, search, terminal commands) to complete coding requests.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- At least one supported LLM provider:
|
||||
- LM Studio exposing a chat completions endpoint (for example `http://localhost:1234/v1/chat/completions`).
|
||||
- Ollama running locally with the OpenAI-compatible HTTP server (default `http://localhost:11434/v1/chat/completions`).
|
||||
- OpenAI API access and an API key with chat-completions enabled.
|
||||
- Anthropic API access and an API key with Messages API enabled.
|
||||
- The workspace you want the agent to modify must already exist locally.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd Copilot
|
||||
npm install
|
||||
npm run build
|
||||
npm link # optional, provides the global oneuptime-copilot command
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### LM Studio (local HTTP endpoint)
|
||||
|
||||
```bash
|
||||
oneuptime-copilot \
|
||||
--prompt "Refactor auth middleware and add unit tests" \
|
||||
--provider lmstudio \
|
||||
--model http://localhost:1234/v1/chat/completions \
|
||||
--model-name openai/gpt-oss-20b \
|
||||
--workspace-path ./
|
||||
```
|
||||
|
||||
### OpenAI (hosted)
|
||||
|
||||
```bash
|
||||
oneuptime-copilot \
|
||||
--prompt "Refactor auth middleware and add unit tests" \
|
||||
--provider openai \
|
||||
--model-name gpt-4o-mini \
|
||||
--api-key "$OPENAI_API_KEY" \
|
||||
--workspace-path ./
|
||||
```
|
||||
|
||||
### Anthropic (hosted)
|
||||
|
||||
```bash
|
||||
oneuptime-copilot \
|
||||
--prompt "Refactor auth middleware and add unit tests" \
|
||||
--provider anthropic \
|
||||
--model-name claude-3-5-sonnet-latest \
|
||||
--api-key "$ANTHROPIC_API_KEY" \
|
||||
--workspace-path ./
|
||||
```
|
||||
|
||||
### Ollama (local OpenAI-compatible endpoint)
|
||||
|
||||
```bash
|
||||
oneuptime-copilot \
|
||||
--prompt "Refactor auth middleware and add unit tests" \
|
||||
--provider ollama \
|
||||
--model-name mistral:7b-instruct \
|
||||
--workspace-path ./
|
||||
```
|
||||
|
||||
### CLI options
|
||||
|
||||
| Flag | Description |
|
||||
| ---- | ----------- |
|
||||
| `--prompt` | Required. Natural language description of the task. |
|
||||
| `--provider` | Selects the LLM backend: `lmstudio` (default), `ollama`, `openai`, or `anthropic`. |
|
||||
| `--model` | Endpoint override. Required for `lmstudio`; optional for other providers (defaults to their hosted or local API). |
|
||||
| `--workspace-path` | Required. Absolute or relative path to the repo the agent should use. |
|
||||
| `--model-name` | Provider-specific model identifier (default `lmstudio`). |
|
||||
| `--temperature` | Sampling temperature (default `0.1`). |
|
||||
| `--max-iterations` | Maximum agent/tool-call loops before stopping (default `100`). |
|
||||
| `--timeout` | LLM HTTP timeout per request in milliseconds (default `120000`). |
|
||||
| `--api-key` | Required for OpenAI/Anthropic; optional bearer token for secured LM Studio/Ollama endpoints. |
|
||||
| `--log-level` | `debug`, `info`, `warn`, or `error` (default `info`). |
|
||||
| `--log-file` | Optional file path. When provided, all logs are appended to this file in addition to stdout. |
|
||||
|
||||
Provider cheatsheet:
|
||||
|
||||
- `lmstudio` – Always pass a full HTTP endpoint via `--model`. API keys are optional.
|
||||
- `ollama` – Defaults to `http://localhost:11434/v1/chat/completions`; override with `--model` when remote tunneling. API keys are optional.
|
||||
- `openai` – Provide `--api-key` and `--model-name` (for example `gpt-4o-mini`). `--model` is optional and defaults to `https://api.openai.com/v1/chat/completions`.
|
||||
- `anthropic` – Provide `--api-key` and `--model-name` (for example `claude-3-5-sonnet-latest`). `--model` falls back to `https://api.anthropic.com/v1/messages` when omitted.
|
||||
|
||||
### Debug logging
|
||||
|
||||
Pass `--log-file` when running the agent to persist verbose debugging output (including `debug` level messages) for later inspection:
|
||||
|
||||
```bash
|
||||
oneuptime-copilot \
|
||||
--prompt "Track flaky jest tests" \
|
||||
--provider lmstudio \
|
||||
--model http://localhost:1234/v1/chat/completions \
|
||||
--workspace-path ./ \
|
||||
--log-file ./logs/copilot-agent-debug.log
|
||||
```
|
||||
|
||||
The agent will create any missing parent directories and continuously append to the specified file while still streaming logs to stdout.
|
||||
|
||||
## Architecture snapshot
|
||||
|
||||
- `src/agent` – Orchestrates the conversation loop, builds the system prompt (inspired by the VS Code Copilot agent), snapshots the workspace, and streams messages to the configured provider.
|
||||
- `src/tools` – Implements the toolbelt (`list_directory`, `read_file`, `search_workspace`, `apply_patch`, `write_file`, `run_command`). These wrap `Common` utilities (`Execute`, `LocalFile`, `Logger`) to stay consistent with other OneUptime services.
|
||||
- `src/llm` – Contains the LM Studio/Ollama/OpenAI-compatible clients plus the native Anthropic adapter, all using `undici` with timeout + error handling.
|
||||
- `src/@types/Common` – Lightweight shim typings so TypeScript consumers get the pieces of `Common` they need without re-compiling that entire package.
|
||||
|
||||
## Development scripts
|
||||
|
||||
```bash
|
||||
npm run build # Compile TypeScript -> build/dist
|
||||
npm run dev # Run with ts-node for quick experiments
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
npm run dev -- --prompt "Write tests for this project. These tests should be in Jest and TypeScript." \
|
||||
--provider lmstudio \
|
||||
--model http://localhost:1234/v1/chat/completions \
|
||||
--model-name deepseek/deepseek-r1-0528-qwen3-8b \
|
||||
--workspace-path ./ \
|
||||
--log-file ./copilot-agent-debug.log
|
||||
```
|
||||
|
||||
The agent intentionally mirrors Copilot’s workflow: it iteratively plans, reads files, edits them through patches or full rewrites, and executes commands/tests via the terminal tool. Logs stream to stdout so you can follow each tool invocation in real time.
|
||||
4269
Copilot/package-lock.json
generated
4269
Copilot/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,36 +0,0 @@
|
|||
{
|
||||
"name": "@oneuptime/copilot",
|
||||
"version": "0.1.0",
|
||||
"description": "Standalone OneUptime Copilot coding agent CLI",
|
||||
"private": false,
|
||||
"bin": {
|
||||
"oneuptime-copilot": "./build/dist/Index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"compile": "tsc",
|
||||
"dep-check": "npm install -g depcheck && depcheck ./ --skip-missing=true",
|
||||
"dev": "ts-node --transpile-only -r tsconfig-paths/register src/Index.ts",
|
||||
"start": "node --enable-source-maps ./build/dist/Index.js",
|
||||
"test": "jest",
|
||||
"clear-modules": "rm -rf node_modules && rm -f package-lock.json && npm install"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^12.1.0",
|
||||
"Common": "file:../../Common",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"undici": "^6.19.8",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^17.0.45",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
import path from "node:path";
|
||||
import LocalFile from "Common/Server/Utils/LocalFile";
|
||||
import { AnthropicClient } from "../LLM/AnthropicClient";
|
||||
import { LLMClient } from "../LLM/LLMClient";
|
||||
import { LMStudioClient } from "../LLM/LMStudioClient";
|
||||
import { OllamaClient } from "../LLM/OllamaClient";
|
||||
import { OpenAIClient } from "../LLM/OpenAIClient";
|
||||
import { buildSystemPrompt } from "./SystemPrompt";
|
||||
import { WorkspaceContextBuilder } from "./WorkspaceContext";
|
||||
import { ToolRegistry } from "../Tools/ToolRegistry";
|
||||
import { ChatMessage, OpenAIToolCall, ToolExecutionResult } from "../Types";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
|
||||
/**
|
||||
* Configuration values that control how the Copilot agent connects to the
|
||||
* model, how many iterations it may run, and which workspace it operates on.
|
||||
*/
|
||||
export type LLMProvider = "lmstudio" | "ollama" | "openai" | "anthropic";
|
||||
|
||||
export interface CopilotAgentOptions {
|
||||
prompt: string;
|
||||
provider: LLMProvider;
|
||||
modelUrl?: string;
|
||||
modelName: string;
|
||||
workspacePath: string;
|
||||
temperature: number;
|
||||
maxIterations: number;
|
||||
requestTimeoutMs: number;
|
||||
apiKey?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates the overall tool-using conversation loop with the target LLM,
|
||||
* including prompt preparation, workspace validation, and tool execution.
|
||||
*/
|
||||
export class CopilotAgent {
|
||||
private readonly options: CopilotAgentOptions;
|
||||
private readonly workspaceRoot: string;
|
||||
private readonly client: LLMClient;
|
||||
private readonly registry: ToolRegistry;
|
||||
|
||||
/**
|
||||
* Creates a new agent instance, wiring up the selected LLM client and tool
|
||||
* registry for the provided workspace.
|
||||
*/
|
||||
public constructor(options: CopilotAgentOptions) {
|
||||
this.options = options;
|
||||
this.workspaceRoot = path.resolve(options.workspacePath);
|
||||
this.client = this.createClient(options);
|
||||
|
||||
this.registry = new ToolRegistry(this.workspaceRoot);
|
||||
AgentLogger.debug("CopilotAgent initialized", {
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
provider: options.provider,
|
||||
modelUrl: options.modelUrl,
|
||||
modelName: options.modelName,
|
||||
temperature: options.temperature,
|
||||
maxIterations: options.maxIterations,
|
||||
timeoutMs: options.requestTimeoutMs,
|
||||
hasApiKey: Boolean(options.apiKey),
|
||||
});
|
||||
}
|
||||
|
||||
private createClient(options: CopilotAgentOptions): LLMClient {
|
||||
switch (options.provider) {
|
||||
case "lmstudio": {
|
||||
if (!options.modelUrl) {
|
||||
throw new Error(
|
||||
"--model must be provided when using the lmstudio provider.",
|
||||
);
|
||||
}
|
||||
|
||||
return new LMStudioClient({
|
||||
endpoint: options.modelUrl,
|
||||
model: options.modelName,
|
||||
temperature: options.temperature,
|
||||
timeoutMs: options.requestTimeoutMs,
|
||||
apiKey: options.apiKey,
|
||||
});
|
||||
}
|
||||
case "ollama": {
|
||||
const endpoint: string | undefined = options.modelUrl;
|
||||
return new OllamaClient({
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
model: options.modelName,
|
||||
temperature: options.temperature,
|
||||
timeoutMs: options.requestTimeoutMs,
|
||||
apiKey: options.apiKey,
|
||||
});
|
||||
}
|
||||
case "openai": {
|
||||
const endpoint: string | undefined = options.modelUrl;
|
||||
return new OpenAIClient({
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
model: options.modelName,
|
||||
temperature: options.temperature,
|
||||
timeoutMs: options.requestTimeoutMs,
|
||||
apiKey: this.requireApiKey("OpenAI"),
|
||||
});
|
||||
}
|
||||
case "anthropic": {
|
||||
const endpoint: string | undefined = options.modelUrl;
|
||||
return new AnthropicClient({
|
||||
...(endpoint ? { endpoint } : {}),
|
||||
model: options.modelName,
|
||||
temperature: options.temperature,
|
||||
timeoutMs: options.requestTimeoutMs,
|
||||
apiKey: this.requireApiKey("Anthropic"),
|
||||
});
|
||||
}
|
||||
default: {
|
||||
const exhaustiveCheck: never = options.provider;
|
||||
throw new Error(`Unsupported provider ${exhaustiveCheck}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requireApiKey(providerName: string): string {
|
||||
if (!this.options.apiKey) {
|
||||
throw new Error(
|
||||
`${providerName} provider requires --api-key to be specified.`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.options.apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the multi-iteration conversation loop until the model responds
|
||||
* without tool calls or the iteration budget is exhausted.
|
||||
*/
|
||||
public async run(): Promise<void> {
|
||||
AgentLogger.debug("Ensuring workspace exists", {
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
});
|
||||
await this.ensureWorkspace();
|
||||
AgentLogger.debug("Workspace verified", {
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
});
|
||||
const contextSnapshot: string = await WorkspaceContextBuilder.buildSnapshot(
|
||||
this.workspaceRoot,
|
||||
);
|
||||
AgentLogger.debug(`Workspace snapshot built:\n${contextSnapshot}`, {
|
||||
snapshotLength: contextSnapshot.length,
|
||||
snapshotContents: contextSnapshot,
|
||||
});
|
||||
|
||||
const messages: Array<ChatMessage> = [
|
||||
{ role: "system", content: buildSystemPrompt() },
|
||||
{
|
||||
role: "user",
|
||||
content: this.composeUserPrompt(this.options.prompt, contextSnapshot),
|
||||
},
|
||||
];
|
||||
AgentLogger.debug(
|
||||
`Initial conversation seeded:\n${this.describeMessages(messages)}`,
|
||||
{
|
||||
messageCount: messages.length,
|
||||
seedMessages: messages,
|
||||
},
|
||||
);
|
||||
|
||||
for (
|
||||
let iteration: number = 0;
|
||||
iteration < this.options.maxIterations;
|
||||
iteration += 1
|
||||
) {
|
||||
AgentLogger.info(`Starting iteration ${iteration + 1}`);
|
||||
AgentLogger.debug(
|
||||
`Sending messages to LLM (iteration ${iteration + 1}):\n${this.describeMessages(messages)}`,
|
||||
{
|
||||
iteration: iteration + 1,
|
||||
messageCount: messages.length,
|
||||
outgoingMessages: messages,
|
||||
},
|
||||
);
|
||||
const response: ChatMessage = await this.client.createChatCompletion({
|
||||
messages,
|
||||
tools: this.registry.getToolDefinitions(),
|
||||
});
|
||||
|
||||
AgentLogger.debug(
|
||||
`LLM response received (iteration ${iteration + 1}):\n${this.describeMessages([response])}`,
|
||||
{
|
||||
iteration: iteration + 1,
|
||||
hasToolCalls: Boolean(response.tool_calls?.length),
|
||||
responseContent: response.content ?? null,
|
||||
responseObject: response,
|
||||
responseToolCalls: response.tool_calls ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.tool_calls?.length) {
|
||||
AgentLogger.info(
|
||||
`Model requested tools: ${response.tool_calls
|
||||
.map((call: OpenAIToolCall) => {
|
||||
return call.function.name;
|
||||
})
|
||||
.join(", ")}`,
|
||||
);
|
||||
messages.push(response);
|
||||
await this.handleToolCalls(response.tool_calls, messages);
|
||||
continue;
|
||||
}
|
||||
|
||||
const finalMessage: string =
|
||||
response.content?.trim() ||
|
||||
"Model ended the conversation without a reply.";
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`\n${finalMessage}`);
|
||||
AgentLogger.debug(
|
||||
`Conversation completed after ${iteration + 1} iterations:\n${finalMessage}`,
|
||||
{
|
||||
iterationsUsed: iteration + 1,
|
||||
finalMessageLength: finalMessage.length,
|
||||
finalMessage,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
AgentLogger.error("Iteration limit reached", {
|
||||
maxIterations: this.options.maxIterations,
|
||||
prompt: this.options.prompt,
|
||||
});
|
||||
throw new Error(
|
||||
`Reached the iteration limit (${this.options.maxIterations}) without a final response.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes every tool call requested by the model and appends the results to
|
||||
* the running conversation so the LLM can observe tool outputs.
|
||||
*/
|
||||
private async handleToolCalls(
|
||||
calls: Array<{
|
||||
id: string;
|
||||
type: "function";
|
||||
function: { name: string; arguments: string };
|
||||
}>,
|
||||
messages: Array<ChatMessage>,
|
||||
): Promise<void> {
|
||||
for (let index: number = 0; index < calls.length; index += 1) {
|
||||
const call:
|
||||
| {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: { name: string; arguments: string };
|
||||
}
|
||||
| undefined = calls[index];
|
||||
if (call === undefined) {
|
||||
AgentLogger.warn("Missing tool call entry", {
|
||||
requestedIndex: index,
|
||||
totalCalls: calls.length,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
AgentLogger.debug("Executing tool", {
|
||||
toolName: call.function.name,
|
||||
callId: call.id,
|
||||
});
|
||||
const result: ToolExecutionResult = await this.registry.execute(call);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`\n# Tool: ${call.function.name}\n${result.output}\n`);
|
||||
AgentLogger.debug(
|
||||
`Tool execution completed (${call.function.name}/${call.id}):\n${result.output}`,
|
||||
{
|
||||
toolName: call.function.name,
|
||||
callId: call.id,
|
||||
isError: result.output.startsWith("ERROR"),
|
||||
outputLength: result.output.length,
|
||||
outputContents: result.output,
|
||||
},
|
||||
);
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: result.output,
|
||||
tool_call_id: result.toolCallId,
|
||||
});
|
||||
AgentLogger.debug(
|
||||
`Tool result appended to conversation (total ${messages.length} messages):\n${this.describeMessages(messages)}`,
|
||||
{
|
||||
totalMessages: messages.length,
|
||||
updatedConversation: messages,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the configured workspace root directory exists before any
|
||||
* commands or tool calls attempt to touch the file system.
|
||||
*/
|
||||
private async ensureWorkspace(): Promise<void> {
|
||||
AgentLogger.debug("Validating workspace directory", {
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
});
|
||||
if (!(await LocalFile.doesDirectoryExist(this.workspaceRoot))) {
|
||||
throw new Error(
|
||||
`Workspace path ${this.workspaceRoot} does not exist or is not a directory.`,
|
||||
);
|
||||
}
|
||||
AgentLogger.debug("Workspace exists", {
|
||||
workspaceRoot: this.workspaceRoot,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the user-facing portion of the chat prompt by combining the task
|
||||
* description with a structured workspace snapshot.
|
||||
*/
|
||||
private composeUserPrompt(task: string, snapshot: string): string {
|
||||
const prompt: string = `# Task\n${task.trim()}\n\n# Workspace snapshot\n${snapshot}\n\nPlease reason step-by-step, gather any missing context with the tools, and keep iterating until the task is complete.`;
|
||||
AgentLogger.debug(`Composed user prompt:\n${prompt}`, {
|
||||
taskLength: task.length,
|
||||
snapshotLength: snapshot.length,
|
||||
promptLength: prompt.length,
|
||||
taskContents: task,
|
||||
snapshotContents: snapshot,
|
||||
promptContents: prompt,
|
||||
});
|
||||
return prompt;
|
||||
}
|
||||
|
||||
private describeMessages(messages: Array<ChatMessage>): string {
|
||||
return messages
|
||||
.map((message: ChatMessage, index: number) => {
|
||||
const headerParts: Array<string> = [
|
||||
`Message ${index + 1}`,
|
||||
`role=${message.role}`,
|
||||
];
|
||||
|
||||
if (message.tool_call_id) {
|
||||
headerParts.push(`tool_call_id=${message.tool_call_id}`);
|
||||
}
|
||||
|
||||
const content: unknown = message.content;
|
||||
const normalizedContent: string =
|
||||
typeof content === "string"
|
||||
? content
|
||||
: content
|
||||
? JSON.stringify(content, null, 2)
|
||||
: "<no content>";
|
||||
|
||||
const toolCalls: string =
|
||||
Array.isArray(message.tool_calls) && message.tool_calls.length
|
||||
? `\nTool calls:\n${JSON.stringify(message.tool_calls, null, 2)}`
|
||||
: "";
|
||||
|
||||
return `${headerParts.join(" | ")}\n${normalizedContent}${toolCalls}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
/**
|
||||
* Returns the static instruction block that tells the LLM how to behave when
|
||||
* operating as the OneUptime Copilot inside a local repository.
|
||||
*/
|
||||
export function buildSystemPrompt(): string {
|
||||
return `You are the OneUptime Copilot Agent, a fully autonomous senior engineer that works inside a local workspace. Your job is to understand the user's request, gather the context you need, modify files with precision, run checks, and stop only when the request is satisfied or truly blocked.
|
||||
|
||||
Core principles:
|
||||
1. Stay focused on the workspace. Read files and inspect folders before editing. Never guess when you can verify.
|
||||
2. Use the provided tools instead of printing raw code or shell commands. read_file/list_directory/search_workspace help you understand; apply_patch/write_file/run_command let you change or validate.
|
||||
3. Break work into short iterations. Form a plan, call tools, review the output, and keep going until the plan is complete.
|
||||
4. Prefer targeted edits (apply_patch) over rewriting entire files. If you must create or replace a whole file, describe why.
|
||||
5. When running commands, capture real output and summarize failures honestly. Do not invent results.
|
||||
6. Reference workspace paths or symbols using Markdown backticks (\`path/to/file.ts\`).
|
||||
7. Keep responses concise and outcome-oriented. Explain what you inspected, what you changed, how you verified it, and what remains.
|
||||
8. If you hit a blocker (missing dependency, failing command, lacking permission), describe the issue and what you tried before asking for help.
|
||||
|
||||
Completion requirements:
|
||||
- Complete the entire task before ending. Do not stop midway through the work.
|
||||
- After making changes, verify them by running relevant commands (e.g., npm test, npm run build).
|
||||
- Only provide a final summary when ALL work is done and verified.
|
||||
|
||||
Always think before acting, gather enough evidence, and prefer high-quality, minimal diffs. The user expects you to proactively explore, implement, and validate fixes without further guidance.`;
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import path from "node:path";
|
||||
import Execute from "Common/Server/Utils/Execute";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
|
||||
/**
|
||||
* Produces human-readable snapshots of the current workspace, including git
|
||||
* metadata and directory listings, so the agent can reason about its
|
||||
* environment.
|
||||
*/
|
||||
export class WorkspaceContextBuilder {
|
||||
/**
|
||||
* Builds a multi-section textual snapshot describing the workspace root,
|
||||
* git branch/status, and top-level entries.
|
||||
*/
|
||||
public static async buildSnapshot(workspaceRoot: string): Promise<string> {
|
||||
const absoluteRoot: string = path.resolve(workspaceRoot);
|
||||
const sections: Array<string> = [`Workspace root: ${absoluteRoot}`];
|
||||
AgentLogger.debug("Building workspace snapshot", {
|
||||
workspaceRoot: absoluteRoot,
|
||||
});
|
||||
|
||||
const branch: string | null = await this.tryGitCommand(
|
||||
["rev-parse", "--abbrev-ref", "HEAD"],
|
||||
absoluteRoot,
|
||||
);
|
||||
if (branch) {
|
||||
sections.push(`Git branch: ${branch.trim()}`);
|
||||
AgentLogger.debug(`Detected git branch: ${branch.trim()}`, {
|
||||
branch: branch.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const status: string | null = await this.tryGitCommand(
|
||||
["status", "-sb"],
|
||||
absoluteRoot,
|
||||
);
|
||||
if (status) {
|
||||
sections.push(`Git status:\n${status.trim()}`);
|
||||
AgentLogger.debug(`Captured git status:\n${status.trim()}`, {
|
||||
statusLength: status.length,
|
||||
statusContents: status.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const entries: Array<string> = await this.listTopLevelEntries(absoluteRoot);
|
||||
sections.push(
|
||||
`Top-level entries (${entries.length}): ${entries.join(", ")}`,
|
||||
);
|
||||
AgentLogger.debug(
|
||||
`Listed top-level entries (${entries.length}): ${entries.join(", ")}`,
|
||||
{
|
||||
entryCount: entries.length,
|
||||
entries,
|
||||
},
|
||||
);
|
||||
|
||||
const snapshot: string = sections.join("\n");
|
||||
AgentLogger.debug(`Workspace snapshot complete:\n${snapshot}`, {
|
||||
sectionCount: sections.length,
|
||||
snapshotLength: snapshot.length,
|
||||
snapshotContents: snapshot,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an ordered, filtered list of top-level files and directories while
|
||||
* hiding dotfiles and heavy folders like node_modules.
|
||||
*/
|
||||
private static async listTopLevelEntries(
|
||||
root: string,
|
||||
): Promise<Array<string>> {
|
||||
try {
|
||||
const dirEntries: Array<Dirent> = await fs.readdir(root, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
return dirEntries
|
||||
.filter((entry: Dirent) => {
|
||||
return !entry.name.startsWith(".") && entry.name !== "node_modules";
|
||||
})
|
||||
.slice(0, 25)
|
||||
.map((entry: Dirent) => {
|
||||
return entry.isDirectory() ? `${entry.name}/` : entry.name;
|
||||
});
|
||||
} catch (error) {
|
||||
AgentLogger.error("Unable to list workspace entries", error as Error);
|
||||
return [];
|
||||
} finally {
|
||||
AgentLogger.debug("listTopLevelEntries completed", {
|
||||
root,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a git command and returns the trimmed output, swallowing errors so
|
||||
* snapshot generation never fails if git is unavailable.
|
||||
*/
|
||||
private static async tryGitCommand(
|
||||
args: Array<string>,
|
||||
cwd: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const output: string = await Execute.executeCommandFile({
|
||||
command: "git",
|
||||
args,
|
||||
cwd,
|
||||
});
|
||||
AgentLogger.debug(
|
||||
`Git command succeeded (${args.join(" ")}):\n${output}`,
|
||||
{
|
||||
args,
|
||||
cwd,
|
||||
outputLength: output.length,
|
||||
outputContents: output,
|
||||
},
|
||||
);
|
||||
return output;
|
||||
} catch (error) {
|
||||
const message: string = (error as Error).message;
|
||||
AgentLogger.debug(
|
||||
`Git command failed (${args.join(" ")}) in ${cwd}: ${message}`,
|
||||
{
|
||||
cwd,
|
||||
args,
|
||||
error: message,
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
CopilotAgent,
|
||||
CopilotAgentOptions,
|
||||
LLMProvider,
|
||||
} from "./Agent/CopilotAgent";
|
||||
import AgentLogger from "./Utils/AgentLogger";
|
||||
import { requiresOpenAIResponsesEndpoint } from "./Utils/OpenAIModel";
|
||||
|
||||
/** CLI harness for configuring and launching the Copilot agent. */
|
||||
const program: Command = new Command();
|
||||
|
||||
program
|
||||
.name("oneuptime-copilot")
|
||||
.description(
|
||||
"Autonomous OneUptime coding agent for LM Studio, Ollama, OpenAI, and Anthropic models",
|
||||
)
|
||||
.requiredOption(
|
||||
"--prompt <text>",
|
||||
"Problem statement or set of tasks for the agent",
|
||||
)
|
||||
.option(
|
||||
"--model <value>",
|
||||
"Provider-specific model endpoint override. Required for lmstudio, optional for other providers.",
|
||||
)
|
||||
.option(
|
||||
"--provider <name>",
|
||||
"llm provider: lmstudio | ollama | openai | anthropic (default lmstudio)",
|
||||
"lmstudio",
|
||||
)
|
||||
.requiredOption(
|
||||
"--workspace-path <path>",
|
||||
"Path to the repository or folder the agent should work inside",
|
||||
)
|
||||
.option(
|
||||
"--model-name <name>",
|
||||
"Model identifier expected by the selected provider",
|
||||
"lmstudio",
|
||||
)
|
||||
.option(
|
||||
"--temperature <value>",
|
||||
"Sampling temperature passed to the model (default 0.1)",
|
||||
"0.1",
|
||||
)
|
||||
.option(
|
||||
"--max-iterations <count>",
|
||||
"Maximum number of tool-calling rounds (default 100)",
|
||||
"100",
|
||||
)
|
||||
.option(
|
||||
"--timeout <ms>",
|
||||
"HTTP timeout for each LLM request in milliseconds (default 120000)",
|
||||
"120000",
|
||||
)
|
||||
.option(
|
||||
"--api-key <token>",
|
||||
"API key for OpenAI/Anthropic or secured LM Studio/Ollama endpoints",
|
||||
)
|
||||
.option(
|
||||
"--log-level <level>",
|
||||
"debug | info | warn | error (default info)",
|
||||
process.env["LOG_LEVEL"] ?? "info",
|
||||
)
|
||||
.option(
|
||||
"--log-file <path>",
|
||||
"Optional file path to append all agent logs for auditing",
|
||||
)
|
||||
.parse(process.argv);
|
||||
|
||||
const PROVIDERS: Array<LLMProvider> = [
|
||||
"lmstudio",
|
||||
"ollama",
|
||||
"openai",
|
||||
"anthropic",
|
||||
];
|
||||
|
||||
function normalizeProvider(value: string | undefined): LLMProvider {
|
||||
const normalized: string = (value ?? "lmstudio").toLowerCase();
|
||||
if (PROVIDERS.includes(normalized as LLMProvider)) {
|
||||
return normalized as LLMProvider;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unsupported provider ${value}. Expected one of: ${PROVIDERS.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveModelUrl(
|
||||
provider: LLMProvider,
|
||||
explicit: string | undefined,
|
||||
modelName: string | undefined,
|
||||
): string | undefined {
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
if (provider === "ollama") {
|
||||
return "http://localhost:11434/v1/chat/completions";
|
||||
}
|
||||
|
||||
if (provider === "openai") {
|
||||
return requiresOpenAIResponsesEndpoint(modelName)
|
||||
? "https://api.openai.com/v1/responses"
|
||||
: "https://api.openai.com/v1/chat/completions";
|
||||
}
|
||||
|
||||
if (provider === "anthropic") {
|
||||
return "https://api.anthropic.com/v1/messages";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Entry point that parses CLI args, configures logging, and runs the agent. */
|
||||
(async () => {
|
||||
const opts: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
workspacePath: string;
|
||||
modelName?: string;
|
||||
temperature: string;
|
||||
maxIterations: string;
|
||||
timeout: string;
|
||||
apiKey?: string;
|
||||
logLevel?: string;
|
||||
logFile?: string;
|
||||
} = program.opts<{
|
||||
prompt: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
workspacePath: string;
|
||||
modelName?: string;
|
||||
temperature: string;
|
||||
maxIterations: string;
|
||||
timeout: string;
|
||||
apiKey?: string;
|
||||
logLevel?: string;
|
||||
logFile?: string;
|
||||
}>();
|
||||
|
||||
const provider: LLMProvider = normalizeProvider(opts.provider);
|
||||
const requestedModelName: string = opts.modelName || "lmstudio";
|
||||
const modelUrl: string | undefined = resolveModelUrl(
|
||||
provider,
|
||||
opts.model,
|
||||
requestedModelName,
|
||||
);
|
||||
|
||||
process.env["LOG_LEVEL"] = opts.logLevel?.toUpperCase() ?? "INFO";
|
||||
await AgentLogger.configure({ logFilePath: opts.logFile });
|
||||
AgentLogger.debug("CLI options parsed", {
|
||||
workspacePath: opts.workspacePath,
|
||||
provider,
|
||||
modelUrl,
|
||||
modelName: requestedModelName,
|
||||
temperature: opts.temperature,
|
||||
maxIterations: opts.maxIterations,
|
||||
timeout: opts.timeout,
|
||||
hasApiKey: Boolean(opts.apiKey),
|
||||
logLevel: process.env["LOG_LEVEL"],
|
||||
logFile: opts.logFile,
|
||||
});
|
||||
|
||||
const config: CopilotAgentOptions = {
|
||||
prompt: opts.prompt,
|
||||
provider,
|
||||
modelName: requestedModelName,
|
||||
workspacePath: path.resolve(opts.workspacePath),
|
||||
temperature: Number(opts.temperature) || 0.1,
|
||||
maxIterations: Number(opts.maxIterations) || 100,
|
||||
requestTimeoutMs: Number(opts.timeout) || 120000,
|
||||
...(modelUrl ? { modelUrl } : {}),
|
||||
...(opts.apiKey ? { apiKey: opts.apiKey } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const agent: CopilotAgent = new CopilotAgent(config);
|
||||
await agent.run();
|
||||
} catch (error) {
|
||||
AgentLogger.error("Agent run failed", error as Error);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Agent failed", error);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
import { JSONObject } from "Common/Types/JSON";
|
||||
import { fetch, Response } from "undici";
|
||||
import { ChatMessage, OpenAIToolCall, ToolDefinition } from "../Types";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
import { LLMClient } from "./LLMClient";
|
||||
|
||||
const DEFAULT_ENDPOINT: string = "https://api.anthropic.com/v1/messages";
|
||||
const DEFAULT_VERSION: string = "2023-06-01";
|
||||
const DEFAULT_MAX_OUTPUT_TOKENS: number = 1024;
|
||||
const DEFAULT_MAX_ATTEMPTS: number = 3;
|
||||
const DEFAULT_RETRY_DELAY_MS: number = 2000;
|
||||
|
||||
type AnthropicRole = "user" | "assistant";
|
||||
|
||||
type AnthropicContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_use"; id: string; name: string; input: JSONObject }
|
||||
| {
|
||||
type: "tool_result";
|
||||
tool_use_id: string;
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
};
|
||||
|
||||
interface AnthropicMessage {
|
||||
role: AnthropicRole;
|
||||
content: Array<AnthropicContentBlock>;
|
||||
}
|
||||
|
||||
interface AnthropicToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: JSONObject;
|
||||
}
|
||||
|
||||
interface AnthropicChatCompletionRequest {
|
||||
model: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
messages: Array<AnthropicMessage>;
|
||||
system?: string;
|
||||
tools?: Array<AnthropicToolDefinition>;
|
||||
tool_choice?: "auto";
|
||||
}
|
||||
|
||||
interface AnthropicResponseBody {
|
||||
content: Array<
|
||||
AnthropicContentBlock | { type: string; [key: string]: unknown }
|
||||
>;
|
||||
}
|
||||
|
||||
export interface AnthropicClientOptions {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
timeoutMs: number;
|
||||
endpoint?: string;
|
||||
version?: string;
|
||||
maxOutputTokens?: number;
|
||||
maxAttempts?: number;
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native Anthropic Messages API client with tool-use support.
|
||||
*/
|
||||
export class AnthropicClient implements LLMClient {
|
||||
private readonly endpoint: string;
|
||||
private readonly version: string;
|
||||
private readonly maxTokens: number;
|
||||
private readonly maxAttempts: number;
|
||||
private readonly retryDelayMs: number;
|
||||
|
||||
public constructor(private readonly options: AnthropicClientOptions) {
|
||||
if (!options.apiKey) {
|
||||
throw new Error(
|
||||
"Anthropic API key is required when using the anthropic provider.",
|
||||
);
|
||||
}
|
||||
|
||||
this.endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
|
||||
this.version = options.version ?? DEFAULT_VERSION;
|
||||
this.maxTokens = options.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
||||
this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
||||
}
|
||||
|
||||
public async createChatCompletion(data: {
|
||||
messages: Array<ChatMessage>;
|
||||
tools?: Array<ToolDefinition>;
|
||||
}): Promise<ChatMessage> {
|
||||
const payload: AnthropicChatCompletionRequest = this.buildPayload(data);
|
||||
return await this.executeWithRetries(payload);
|
||||
}
|
||||
|
||||
private buildPayload(data: {
|
||||
messages: Array<ChatMessage>;
|
||||
tools?: Array<ToolDefinition>;
|
||||
}): AnthropicChatCompletionRequest {
|
||||
const { systemPrompt, messages } = this.mapMessages(data.messages);
|
||||
const toolMetadata:
|
||||
| {
|
||||
tools: Array<AnthropicToolDefinition>;
|
||||
tool_choice: "auto";
|
||||
}
|
||||
| undefined = data.tools?.length
|
||||
? {
|
||||
tools: data.tools.map((tool: ToolDefinition) => {
|
||||
return {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
input_schema: tool.function.parameters,
|
||||
};
|
||||
}),
|
||||
tool_choice: "auto",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const payload: AnthropicChatCompletionRequest = {
|
||||
model: this.options.model,
|
||||
temperature: this.options.temperature,
|
||||
max_tokens: this.maxTokens,
|
||||
messages,
|
||||
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||
...(toolMetadata ?? {}),
|
||||
};
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async executeWithRetries(
|
||||
payload: AnthropicChatCompletionRequest,
|
||||
): Promise<ChatMessage> {
|
||||
let attempt: number = 0;
|
||||
let lastError: unknown;
|
||||
|
||||
while (attempt < this.maxAttempts) {
|
||||
attempt += 1;
|
||||
try {
|
||||
return await this.executeOnce(payload, attempt);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!this.isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (attempt >= this.maxAttempts) {
|
||||
throw this.createTimeoutError(error as Error);
|
||||
}
|
||||
|
||||
const delayMs: number = this.retryDelayMs * attempt;
|
||||
AgentLogger.warn("Anthropic request timed out; retrying", {
|
||||
attempt,
|
||||
maxAttempts: this.maxAttempts,
|
||||
retryDelayMs: delayMs,
|
||||
});
|
||||
await this.delay(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error("Anthropic request failed without a specific error.");
|
||||
}
|
||||
|
||||
private async executeOnce(
|
||||
payload: AnthropicChatCompletionRequest,
|
||||
attempt: number,
|
||||
): Promise<ChatMessage> {
|
||||
const controller: AbortController = new AbortController();
|
||||
const timeout: NodeJS.Timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, this.options.timeoutMs);
|
||||
|
||||
try {
|
||||
AgentLogger.debug("Dispatching Anthropic request", {
|
||||
endpoint: this.endpoint,
|
||||
model: this.options.model,
|
||||
messageCount: payload.messages.length,
|
||||
toolCount: payload.tools?.length ?? 0,
|
||||
temperature: payload.temperature,
|
||||
attempt,
|
||||
maxAttempts: this.maxAttempts,
|
||||
});
|
||||
|
||||
const response: Response = await fetch(this.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": this.options.apiKey,
|
||||
"anthropic-version": this.version,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody: string = await response.text();
|
||||
AgentLogger.error("Anthropic request failed", {
|
||||
status: response.status,
|
||||
bodyPreview: errorBody.slice(0, 500),
|
||||
});
|
||||
throw new Error(
|
||||
`Anthropic request failed (${response.status}): ${errorBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body: AnthropicResponseBody =
|
||||
(await response.json()) as AnthropicResponseBody;
|
||||
return this.mapResponseToChatMessage(body);
|
||||
} catch (error) {
|
||||
AgentLogger.error("Anthropic request error", error as Error);
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
AgentLogger.debug("Anthropic request finalized", {
|
||||
attempt,
|
||||
maxAttempts: this.maxAttempts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private mapResponseToChatMessage(body: AnthropicResponseBody): ChatMessage {
|
||||
const textParts: Array<string> = [];
|
||||
const toolCalls: Array<OpenAIToolCall> = [];
|
||||
|
||||
for (const block of body.content ?? []) {
|
||||
if (block.type === "text" && typeof block.text === "string") {
|
||||
textParts.push(block.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
block.type === "tool_use" &&
|
||||
typeof block.id === "string" &&
|
||||
typeof block.name === "string"
|
||||
) {
|
||||
const args: string = JSON.stringify(block.input ?? {});
|
||||
toolCalls.push({
|
||||
id: block.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
AgentLogger.debug("Unhandled Anthropic content block", { block });
|
||||
}
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: textParts.length ? textParts.join("\n") : null,
|
||||
};
|
||||
|
||||
if (toolCalls.length) {
|
||||
assistantMessage.tool_calls = toolCalls;
|
||||
}
|
||||
|
||||
return assistantMessage;
|
||||
}
|
||||
|
||||
private mapMessages(messages: Array<ChatMessage>): {
|
||||
systemPrompt?: string;
|
||||
messages: Array<AnthropicMessage>;
|
||||
} {
|
||||
const systemParts: Array<string> = [];
|
||||
const anthropicMessages: Array<AnthropicMessage> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "system") {
|
||||
if (message.content) {
|
||||
systemParts.push(message.content);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "tool") {
|
||||
anthropicMessages.push(this.toToolResultMessage(message));
|
||||
continue;
|
||||
}
|
||||
|
||||
anthropicMessages.push(this.toStandardMessage(message));
|
||||
}
|
||||
|
||||
const result: { systemPrompt?: string; messages: Array<AnthropicMessage> } =
|
||||
{
|
||||
messages: anthropicMessages,
|
||||
};
|
||||
|
||||
if (systemParts.length) {
|
||||
result.systemPrompt = systemParts.join("\n\n");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private toStandardMessage(message: ChatMessage): AnthropicMessage {
|
||||
const role: AnthropicRole =
|
||||
message.role === "assistant" ? "assistant" : "user";
|
||||
const contentBlocks: Array<AnthropicContentBlock> = [];
|
||||
|
||||
if (message.content) {
|
||||
contentBlocks.push({ type: "text", text: message.content });
|
||||
}
|
||||
|
||||
for (const block of this.toToolUseBlocks(message.tool_calls)) {
|
||||
contentBlocks.push(block);
|
||||
}
|
||||
|
||||
if (!contentBlocks.length) {
|
||||
contentBlocks.push({ type: "text", text: "" });
|
||||
}
|
||||
|
||||
return { role, content: contentBlocks };
|
||||
}
|
||||
|
||||
private toToolResultMessage(message: ChatMessage): AnthropicMessage {
|
||||
const toolCallId: string = message.tool_call_id ?? "tool_result";
|
||||
const text: string = message.content ?? "";
|
||||
return {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: toolCallId,
|
||||
content: [{ type: "text", text }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private toToolUseBlocks(
|
||||
calls: Array<OpenAIToolCall> | undefined,
|
||||
): Array<AnthropicContentBlock> {
|
||||
if (!calls?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return calls.map((call: OpenAIToolCall) => {
|
||||
return {
|
||||
type: "tool_use",
|
||||
id: call.id,
|
||||
name: call.function.name,
|
||||
input: this.safeParseArguments(call.function.arguments),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private safeParseArguments(raw: string): JSONObject {
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as JSONObject;
|
||||
} catch (error) {
|
||||
AgentLogger.warn("Failed to parse tool arguments; defaulting to {}", {
|
||||
raw,
|
||||
error,
|
||||
});
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private isAbortError(error: unknown): boolean {
|
||||
if (!error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof DOMException !== "undefined" && error instanceof DOMException) {
|
||||
return error.name === "AbortError";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async delay(durationMs: number): Promise<void> {
|
||||
if (durationMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve: () => void) => {
|
||||
setTimeout(resolve, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
private createTimeoutError(originalError: Error): Error {
|
||||
const message: string = `Anthropic request timed out after ${this.options.timeoutMs} ms while calling ${this.endpoint}.`;
|
||||
return new Error(`${message} Original error: ${originalError.message}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { ChatMessage, ToolDefinition } from "../Types";
|
||||
|
||||
/** Interface implemented by all chat completion clients. */
|
||||
export interface LLMClient {
|
||||
createChatCompletion(data: {
|
||||
messages: Array<ChatMessage>;
|
||||
tools?: Array<ToolDefinition>;
|
||||
}): Promise<ChatMessage>;
|
||||
}
|
||||
|
|
@ -1,320 +0,0 @@
|
|||
import { fetch, Response } from "undici";
|
||||
import { ChatMessage, ToolDefinition } from "../Types";
|
||||
import { LLMClient } from "./LLMClient";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
|
||||
const DEFAULT_MAX_ATTEMPTS: number = 3;
|
||||
const DEFAULT_RETRY_DELAY_MS: number = 2000;
|
||||
|
||||
/**
|
||||
* Chat message payload in the minimal form accepted by the LM Studio API.
|
||||
*/
|
||||
type SerializableMessage = Omit<ChatMessage, "tool_calls"> & {
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wire format expected by LM Studio's OpenAI-compatible chat completions API.
|
||||
*/
|
||||
interface ChatCompletionRequestPayload {
|
||||
model: string;
|
||||
messages: Array<SerializableMessage>;
|
||||
temperature: number;
|
||||
tool_choice: "auto";
|
||||
tools?: Array<ToolDefinition>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of the OpenAI chat completions response returned by LM Studio.
|
||||
*/
|
||||
interface OpenAIChatCompletionResponse {
|
||||
choices: Array<{
|
||||
index: number;
|
||||
finish_reason: string;
|
||||
message: {
|
||||
role: "assistant";
|
||||
content: unknown;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options controlling how the local LM Studio endpoint is contacted.
|
||||
*/
|
||||
export interface LMStudioClientOptions {
|
||||
endpoint: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
timeoutMs: number;
|
||||
apiKey?: string | undefined;
|
||||
maxAttempts?: number;
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin wrapper around fetch that speaks LM Studio's OpenAI-compatible API.
|
||||
*/
|
||||
export class LMStudioClient implements LLMClient {
|
||||
private readonly maxAttempts: number;
|
||||
private readonly retryDelayMs: number;
|
||||
/**
|
||||
* Persists the endpoint configuration for future chat completion requests.
|
||||
*/
|
||||
public constructor(private readonly options: LMStudioClientOptions) {
|
||||
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
||||
this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the provided chat history plus tool metadata and returns the
|
||||
* assistant's reply (with optional tool calls).
|
||||
*/
|
||||
public async createChatCompletion(data: {
|
||||
messages: Array<ChatMessage>;
|
||||
tools?: Array<ToolDefinition>;
|
||||
}): Promise<ChatMessage> {
|
||||
let attempt: number = 0;
|
||||
let lastError: unknown;
|
||||
|
||||
while (attempt < this.maxAttempts) {
|
||||
attempt += 1;
|
||||
try {
|
||||
return await this.executeChatCompletionAttempt(data, attempt);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!this.isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (attempt >= this.maxAttempts) {
|
||||
throw this.createTimeoutError(error as Error);
|
||||
}
|
||||
|
||||
const delayMs: number = this.retryDelayMs * attempt;
|
||||
AgentLogger.warn("LLM request timed out; retrying", {
|
||||
attempt,
|
||||
maxAttempts: this.maxAttempts,
|
||||
retryDelayMs: delayMs,
|
||||
});
|
||||
await this.delay(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error("LLM request failed without a specific error.");
|
||||
}
|
||||
|
||||
private async executeChatCompletionAttempt(
|
||||
data: {
|
||||
messages: Array<ChatMessage>;
|
||||
tools?: Array<ToolDefinition>;
|
||||
},
|
||||
attempt: number,
|
||||
): Promise<ChatMessage> {
|
||||
const controller: AbortController = new AbortController();
|
||||
const timeout: NodeJS.Timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, this.options.timeoutMs);
|
||||
|
||||
try {
|
||||
AgentLogger.debug("Dispatching LLM request", {
|
||||
endpoint: this.options.endpoint,
|
||||
model: this.options.model,
|
||||
messageCount: data.messages.length,
|
||||
toolCount: data.tools?.length ?? 0,
|
||||
temperature: this.options.temperature,
|
||||
attempt,
|
||||
maxAttempts: this.maxAttempts,
|
||||
});
|
||||
const payload: ChatCompletionRequestPayload = {
|
||||
model: this.options.model,
|
||||
messages: data.messages.map((message: ChatMessage) => {
|
||||
const serialized: SerializableMessage = {
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
};
|
||||
|
||||
if (message.name !== undefined) {
|
||||
serialized.name = message.name;
|
||||
}
|
||||
|
||||
if (message.tool_call_id !== undefined) {
|
||||
serialized.tool_call_id = message.tool_call_id;
|
||||
}
|
||||
|
||||
if (message.tool_calls !== undefined) {
|
||||
serialized.tool_calls = message.tool_calls;
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}),
|
||||
temperature: this.options.temperature,
|
||||
tool_choice: "auto",
|
||||
...(data.tools !== undefined ? { tools: data.tools } : {}),
|
||||
};
|
||||
AgentLogger.debug("LLM payload prepared", {
|
||||
messageRoles: data.messages.map((message: ChatMessage) => {
|
||||
return message.role;
|
||||
}),
|
||||
toolNames: data.tools?.map((tool: ToolDefinition) => {
|
||||
return tool.function.name;
|
||||
}),
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (this.options.apiKey) {
|
||||
headers["Authorization"] = `Bearer ${this.options.apiKey}`;
|
||||
}
|
||||
|
||||
const response: Response = await fetch(this.options.endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody: string = await response.text();
|
||||
AgentLogger.error("LLM request failed", {
|
||||
status: response.status,
|
||||
bodyPreview: errorBody.slice(0, 500),
|
||||
});
|
||||
throw new Error(
|
||||
`LLM request failed (${response.status}): ${errorBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body: OpenAIChatCompletionResponse =
|
||||
(await response.json()) as OpenAIChatCompletionResponse;
|
||||
AgentLogger.debug("LLM request succeeded", {
|
||||
tokenUsage: body.usage,
|
||||
choiceCount: body.choices?.length ?? 0,
|
||||
});
|
||||
|
||||
if (!body.choices?.length) {
|
||||
throw new Error("LLM returned no choices");
|
||||
}
|
||||
|
||||
const assistantMessage:
|
||||
| OpenAIChatCompletionResponse["choices"][number]["message"]
|
||||
| undefined = body.choices[0]?.message;
|
||||
if (!assistantMessage) {
|
||||
throw new Error("LLM response missing assistant message");
|
||||
}
|
||||
|
||||
const assistantResponse: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: this.normalizeContent(assistantMessage.content),
|
||||
};
|
||||
|
||||
if (assistantMessage.tool_calls !== undefined) {
|
||||
assistantResponse.tool_calls = assistantMessage.tool_calls;
|
||||
}
|
||||
|
||||
return assistantResponse;
|
||||
} catch (error) {
|
||||
AgentLogger.error("LLM request error", error as Error);
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
AgentLogger.debug("LLM request finalized", {
|
||||
attempt,
|
||||
maxAttempts: this.maxAttempts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the flexible OpenAI `content` format into a simple string the
|
||||
* rest of the agent expects.
|
||||
*/
|
||||
private normalizeContent(content: unknown): string | null {
|
||||
if (typeof content === "string" || content === null) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((item: unknown) => {
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
"text" in item &&
|
||||
typeof (item as { text?: unknown }).text === "string"
|
||||
) {
|
||||
return (item as { text: string }).text;
|
||||
}
|
||||
return JSON.stringify(item);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
if (typeof content === "object") {
|
||||
return JSON.stringify(content);
|
||||
}
|
||||
|
||||
return String(content);
|
||||
}
|
||||
|
||||
private isAbortError(error: unknown): boolean {
|
||||
if (!error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof DOMException !== "undefined" && error instanceof DOMException) {
|
||||
return error.name === "AbortError";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async delay(durationMs: number): Promise<void> {
|
||||
if (durationMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve: () => void) => {
|
||||
setTimeout(resolve, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
private createTimeoutError(originalError: Error): Error {
|
||||
const message: string = `LLM request timed out after ${this.options.timeoutMs} ms while calling ${this.options.endpoint}. Increase the --timeout flag or ensure the endpoint is reachable.`;
|
||||
return new Error(`${message} Original error: ${originalError.message}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { LMStudioClient, LMStudioClientOptions } from "./LMStudioClient";
|
||||
|
||||
export interface OllamaClientOptions
|
||||
extends Omit<LMStudioClientOptions, "endpoint"> {
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that targets the Ollama OpenAI-compatible chat completions endpoint.
|
||||
*/
|
||||
export class OllamaClient extends LMStudioClient {
|
||||
public constructor(options: OllamaClientOptions) {
|
||||
super({
|
||||
...options,
|
||||
endpoint:
|
||||
options.endpoint ?? "http://localhost:11434/v1/chat/completions",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,509 +0,0 @@
|
|||
import { fetch, Response } from "undici";
|
||||
import { LMStudioClient, LMStudioClientOptions } from "./LMStudioClient";
|
||||
import { ChatMessage, OpenAIToolCall, ToolDefinition } from "../Types";
|
||||
import { LLMClient } from "./LLMClient";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
import { requiresOpenAIResponsesEndpoint } from "../Utils/OpenAIModel";
|
||||
|
||||
export interface OpenAIClientOptions
|
||||
extends Omit<LMStudioClientOptions, "endpoint"> {
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
const CHAT_ENDPOINT: string = "https://api.openai.com/v1/chat/completions";
|
||||
const RESPONSES_ENDPOINT: string = "https://api.openai.com/v1/responses";
|
||||
const DEFAULT_MAX_ATTEMPTS: number = 3;
|
||||
const DEFAULT_RETRY_DELAY_MS: number = 2000;
|
||||
|
||||
/**
|
||||
* Unified OpenAI client that routes to either the Chat Completions API or the
|
||||
* Responses API depending on the selected model.
|
||||
*/
|
||||
export class OpenAIClient implements LLMClient {
|
||||
private readonly chatClient: LMStudioClient;
|
||||
private readonly responsesEndpoint: string;
|
||||
private readonly maxAttempts: number;
|
||||
private readonly retryDelayMs: number;
|
||||
|
||||
public constructor(private readonly options: OpenAIClientOptions) {
|
||||
if (!options.apiKey) {
|
||||
throw new Error("OpenAI API key is required for the OpenAI provider.");
|
||||
}
|
||||
|
||||
const chatEndpoint: string = options.endpoint ?? CHAT_ENDPOINT;
|
||||
this.responsesEndpoint = options.endpoint ?? RESPONSES_ENDPOINT;
|
||||
this.chatClient = new LMStudioClient({
|
||||
...options,
|
||||
endpoint: chatEndpoint,
|
||||
});
|
||||
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
||||
this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
||||
}
|
||||
|
||||
public async createChatCompletion(data: {
|
||||
messages: Array<ChatMessage>;
|
||||
tools?: Array<ToolDefinition>;
|
||||
}): Promise<ChatMessage> {
|
||||
if (requiresOpenAIResponsesEndpoint(this.options.model)) {
|
||||
return await this.createResponsesCompletion(data);
|
||||
}
|
||||
|
||||
return await this.chatClient.createChatCompletion(data);
|
||||
}
|
||||
|
||||
private async createResponsesCompletion(data: {
|
||||
messages: Array<ChatMessage>;
|
||||
tools?: Array<ToolDefinition>;
|
||||
}): Promise<ChatMessage> {
|
||||
const payload: ResponsesRequestPayload = this.buildResponsesPayload(
|
||||
data.messages,
|
||||
data.tools,
|
||||
);
|
||||
return await this.executeWithRetries(payload);
|
||||
}
|
||||
|
||||
private mapMessagesToInput(
|
||||
messages: Array<ChatMessage>,
|
||||
): Array<ResponsesMessage> {
|
||||
return messages.map((message: ChatMessage) => {
|
||||
return {
|
||||
role: this.mapRoleToResponsesRole(message.role),
|
||||
content: this.createContentBlocksForMessage(message),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async executeWithRetries(
|
||||
payload: ResponsesRequestPayload,
|
||||
): Promise<ChatMessage> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt: number = 1; attempt <= this.maxAttempts; attempt += 1) {
|
||||
try {
|
||||
return await this.executeResponsesRequest(payload, attempt);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!this.isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (attempt === this.maxAttempts) {
|
||||
throw this.createTimeoutError(error as Error);
|
||||
}
|
||||
|
||||
await this.performRetryDelay(attempt);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error("OpenAI Responses request failed without a specific error.");
|
||||
}
|
||||
|
||||
private async executeResponsesRequest(
|
||||
payload: ResponsesRequestPayload,
|
||||
attempt: number,
|
||||
): Promise<ChatMessage> {
|
||||
const controller: AbortController = new AbortController();
|
||||
const timeout: NodeJS.Timeout | null = this.createAbortTimeout(controller);
|
||||
|
||||
try {
|
||||
AgentLogger.debug("Dispatching OpenAI Responses request", {
|
||||
endpoint: this.responsesEndpoint,
|
||||
model: this.options.model,
|
||||
messageCount: payload.input.length,
|
||||
toolCount: payload.tools?.length ?? 0,
|
||||
attempt,
|
||||
maxAttempts: this.maxAttempts,
|
||||
});
|
||||
|
||||
const response: Response = await fetch(this.responsesEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.options.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody: string = await response.text();
|
||||
AgentLogger.error("OpenAI Responses request failed", {
|
||||
status: response.status,
|
||||
bodyPreview: errorBody.slice(0, 500),
|
||||
});
|
||||
throw new Error(
|
||||
`OpenAI Responses request failed (${response.status}): ${errorBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body: OpenAIResponsesAPIResponse =
|
||||
(await response.json()) as OpenAIResponsesAPIResponse;
|
||||
return this.mapResponsesToChatMessage(body);
|
||||
} catch (error) {
|
||||
AgentLogger.error("OpenAI Responses request error", error as Error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
AgentLogger.debug("OpenAI Responses request finalized", {
|
||||
attempt,
|
||||
maxAttempts: this.maxAttempts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private mapResponsesToChatMessage(
|
||||
body: OpenAIResponsesAPIResponse,
|
||||
): ChatMessage {
|
||||
const outputItems: Array<ResponsesOutputItem> = Array.isArray(body.output)
|
||||
? (body.output as Array<ResponsesOutputItem>)
|
||||
: [];
|
||||
const textParts: Array<string> = [];
|
||||
const toolCalls: Array<OpenAIToolCall> = [];
|
||||
|
||||
for (const item of outputItems) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === "message" || this.isMessageOutput(item)) {
|
||||
const messageItem: ResponsesOutputMessage =
|
||||
item as ResponsesOutputMessage;
|
||||
const { textParts: messageTextParts, toolCalls: messageToolCalls } =
|
||||
this.extractOutputContent(messageItem);
|
||||
if (messageTextParts.length) {
|
||||
textParts.push(...messageTextParts);
|
||||
}
|
||||
if (messageToolCalls.length) {
|
||||
toolCalls.push(...messageToolCalls);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isFunctionCallOutput(item)) {
|
||||
const mappedCall: OpenAIToolCall | null =
|
||||
this.mapFunctionCallOutput(item);
|
||||
if (mappedCall) {
|
||||
toolCalls.push(mappedCall);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!textParts.length) {
|
||||
const fallbackText: Array<string> = this.extractFallbackOutputText(body);
|
||||
if (fallbackText.length) {
|
||||
textParts.push(...fallbackText);
|
||||
}
|
||||
}
|
||||
|
||||
const message: ChatMessage = {
|
||||
role: "assistant",
|
||||
content: textParts.length ? textParts.join("\n") : null,
|
||||
};
|
||||
|
||||
if (toolCalls.length) {
|
||||
message.tool_calls = toolCalls;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private buildResponsesPayload(
|
||||
messages: Array<ChatMessage>,
|
||||
tools?: Array<ToolDefinition>,
|
||||
): ResponsesRequestPayload {
|
||||
const hasTools: boolean = Boolean(tools?.length);
|
||||
const payload: ResponsesRequestPayload = {
|
||||
model: this.options.model,
|
||||
input: this.mapMessagesToInput(messages),
|
||||
...(hasTools
|
||||
? {
|
||||
tool_choice: "auto",
|
||||
tools: this.mapToolsToResponsesDefinitions(tools ?? []),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private mapToolsToResponsesDefinitions(
|
||||
tools: Array<ToolDefinition>,
|
||||
): Array<ResponsesToolDefinition> {
|
||||
return tools.map((tool: ToolDefinition) => {
|
||||
return {
|
||||
type: tool.type,
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private mapRoleToResponsesRole(
|
||||
role: ChatMessage["role"],
|
||||
): ResponsesMessage["role"] {
|
||||
return (role === "tool" ? "user" : role) as ResponsesMessage["role"];
|
||||
}
|
||||
|
||||
private createContentBlocksForMessage(
|
||||
message: ChatMessage,
|
||||
): Array<ResponsesContentBlock> {
|
||||
if (message.role === "tool") {
|
||||
return [
|
||||
{
|
||||
type: "input_text",
|
||||
text: this.formatToolResult(message),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (message.content !== null) {
|
||||
return [
|
||||
{
|
||||
type: message.role === "assistant" ? "output_text" : "input_text",
|
||||
text: message.content,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: message.role === "assistant" ? "output_text" : "input_text",
|
||||
text: "",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private formatToolResult(message: ChatMessage): string {
|
||||
const prefix: string = message.tool_call_id
|
||||
? `Tool ${message.tool_call_id} result:`
|
||||
: "Tool result:";
|
||||
return `${prefix}\n${message.content ?? ""}`.trimEnd();
|
||||
}
|
||||
|
||||
private async performRetryDelay(attempt: number): Promise<void> {
|
||||
const delayMs: number = this.retryDelayMs * attempt;
|
||||
AgentLogger.warn("OpenAI Responses request timed out; retrying", {
|
||||
attempt,
|
||||
maxAttempts: this.maxAttempts,
|
||||
retryDelayMs: delayMs,
|
||||
});
|
||||
await this.delay(delayMs);
|
||||
}
|
||||
|
||||
private createAbortTimeout(
|
||||
controller: AbortController,
|
||||
): NodeJS.Timeout | null {
|
||||
if (!this.options.timeoutMs || this.options.timeoutMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return setTimeout(() => {
|
||||
controller.abort();
|
||||
}, this.options.timeoutMs);
|
||||
}
|
||||
|
||||
private extractFallbackOutputText(
|
||||
body: OpenAIResponsesAPIResponse,
|
||||
): Array<string> {
|
||||
if (!Array.isArray(body.output_text)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return body.output_text.filter((chunk: unknown) => {
|
||||
return typeof chunk === "string" && chunk.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
private mapFunctionCallOutput(
|
||||
item: ResponsesFunctionCallOutput,
|
||||
): OpenAIToolCall | null {
|
||||
if (!item.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const args: string =
|
||||
typeof item.arguments === "string"
|
||||
? item.arguments
|
||||
: JSON.stringify(item.arguments ?? {});
|
||||
const identifier: string =
|
||||
item.call_id ??
|
||||
item.id ??
|
||||
`function_call_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
return {
|
||||
id: identifier,
|
||||
type: "function",
|
||||
function: {
|
||||
name: item.name,
|
||||
arguments: args,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private isMessageOutput(
|
||||
item: ResponsesOutputItem,
|
||||
): item is ResponsesOutputMessage {
|
||||
if (!item || typeof item !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Array.isArray((item as ResponsesOutputMessage).content);
|
||||
}
|
||||
|
||||
private isFunctionCallOutput(
|
||||
item: ResponsesOutputItem,
|
||||
): item is ResponsesFunctionCallOutput {
|
||||
if (!item || typeof item !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (item as ResponsesFunctionCallOutput).type === "function_call";
|
||||
}
|
||||
|
||||
private extractOutputContent(outputMessage: ResponsesOutputMessage): {
|
||||
textParts: Array<string>;
|
||||
toolCalls: Array<OpenAIToolCall>;
|
||||
} {
|
||||
const textParts: Array<string> = [];
|
||||
const toolCalls: Array<OpenAIToolCall> = [];
|
||||
const contentBlocks: Array<ResponsesContentBlock> = Array.isArray(
|
||||
outputMessage.content,
|
||||
)
|
||||
? outputMessage.content
|
||||
: [];
|
||||
|
||||
for (const block of contentBlocks) {
|
||||
if (block.type === "output_text") {
|
||||
textParts.push(block.text ?? "");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.type === "tool_use") {
|
||||
toolCalls.push(this.mapToolUseBlock(block));
|
||||
}
|
||||
}
|
||||
|
||||
if (outputMessage.tool_calls?.length) {
|
||||
toolCalls.push(...outputMessage.tool_calls);
|
||||
}
|
||||
|
||||
return { textParts, toolCalls };
|
||||
}
|
||||
|
||||
private mapToolUseBlock(block: ResponsesToolUseBlock): OpenAIToolCall {
|
||||
return {
|
||||
id: block.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private isAbortError(error: unknown): boolean {
|
||||
if (!error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof DOMException !== "undefined" && error instanceof DOMException) {
|
||||
return error.name === "AbortError";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async delay(durationMs: number): Promise<void> {
|
||||
if (durationMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve: () => void) => {
|
||||
setTimeout(resolve, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
private createTimeoutError(originalError: Error): Error {
|
||||
const message: string = `OpenAI Responses request timed out after ${this.options.timeoutMs} ms while calling ${this.responsesEndpoint}.`;
|
||||
return new Error(`${message} Original error: ${originalError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface ResponsesContentBlockBase {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface ResponsesTextBlock extends ResponsesContentBlockBase {
|
||||
type: "input_text" | "output_text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ResponsesToolUseBlock extends ResponsesContentBlockBase {
|
||||
type: "tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
interface ResponsesMessage {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: Array<ResponsesContentBlock>;
|
||||
}
|
||||
|
||||
type ResponsesContentBlock = ResponsesTextBlock | ResponsesToolUseBlock;
|
||||
|
||||
interface ResponsesRequestPayload {
|
||||
model: string;
|
||||
input: Array<ResponsesMessage>;
|
||||
tool_choice?: "auto" | undefined;
|
||||
tools?: Array<ResponsesToolDefinition> | undefined;
|
||||
}
|
||||
|
||||
interface ResponsesOutputMessage {
|
||||
type?: "message";
|
||||
role: "system" | "user" | "assistant";
|
||||
content: Array<ResponsesContentBlock>;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ResponsesFunctionCallOutput {
|
||||
type: "function_call";
|
||||
id?: string;
|
||||
call_id?: string;
|
||||
name: string;
|
||||
arguments?: unknown;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
type ResponsesOutputItem =
|
||||
| ResponsesOutputMessage
|
||||
| ResponsesFunctionCallOutput
|
||||
| { type?: string; [key: string]: unknown };
|
||||
|
||||
interface OpenAIResponsesAPIResponse {
|
||||
output?: Array<ResponsesOutputItem>;
|
||||
output_text?: Array<string>;
|
||||
}
|
||||
|
||||
interface ResponsesToolDefinition {
|
||||
type: "function";
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: ToolDefinition["function"]["parameters"];
|
||||
}
|
||||
|
|
@ -1,425 +0,0 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import Execute from "Common/Server/Utils/Execute";
|
||||
import LocalFile from "Common/Server/Utils/LocalFile";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import { StructuredTool, ToolResponse, ToolRuntime } from "./Tool";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
|
||||
/** Arguments accepted by the apply_patch tool. */
|
||||
interface ApplyPatchArgs {
|
||||
patch: string;
|
||||
note?: string | undefined;
|
||||
}
|
||||
|
||||
type PatchAction = "Update" | "Add" | "Delete";
|
||||
|
||||
interface PatchBlock {
|
||||
action: PatchAction;
|
||||
absolutePath: string;
|
||||
relativePath: string;
|
||||
diffBody: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies unified diffs (or the custom *** Begin Patch format) via git apply so
|
||||
* the agent can make precise edits.
|
||||
*/
|
||||
export class ApplyPatchTool extends StructuredTool<ApplyPatchArgs> {
|
||||
public readonly name: string = "apply_patch";
|
||||
public readonly description: string =
|
||||
"Applies a unified diff (or the *** Begin Patch format) to modify existing files precisely.";
|
||||
public readonly parameters: JSONObject = {
|
||||
type: "object",
|
||||
required: ["patch"],
|
||||
properties: {
|
||||
patch: {
|
||||
type: "string",
|
||||
description:
|
||||
"Unified diff or *** Begin Patch instructions describing the edits to apply.",
|
||||
},
|
||||
note: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional short description of why this patch is being applied.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
protected schema = z
|
||||
.object({
|
||||
patch: z.string().min(10),
|
||||
note: z.string().max(2000).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Writes the diff to a temp file and feeds it to git apply. */
|
||||
public async execute(
|
||||
args: ApplyPatchArgs,
|
||||
runtime: ToolRuntime,
|
||||
): Promise<ToolResponse> {
|
||||
AgentLogger.debug("ApplyPatchTool invoked", {
|
||||
note: args.note,
|
||||
});
|
||||
const payload: string = args.patch;
|
||||
if (!payload.trim()) {
|
||||
return {
|
||||
content: "Patch payload was empty. Nothing was applied.",
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.includes("*** Begin Patch")) {
|
||||
return await this.applyStructuredPatch(payload, runtime, args.note);
|
||||
}
|
||||
|
||||
return await this.applyGitPatch(payload, runtime, args.note);
|
||||
}
|
||||
|
||||
private async applyGitPatch(
|
||||
patch: string,
|
||||
runtime: ToolRuntime,
|
||||
note?: string,
|
||||
): Promise<ToolResponse> {
|
||||
AgentLogger.debug("Applying git-compatible patch", {
|
||||
length: patch.length,
|
||||
});
|
||||
const tempDir: string = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "oneuptime-patch-"),
|
||||
);
|
||||
const patchFile: string = path.join(tempDir, "patch.diff");
|
||||
await fs.writeFile(patchFile, patch, { encoding: "utf8" });
|
||||
|
||||
try {
|
||||
await Execute.executeCommandFile({
|
||||
command: "git",
|
||||
args: [
|
||||
"apply",
|
||||
"--whitespace=nowarn",
|
||||
"--reject",
|
||||
"--unidiff-zero",
|
||||
patchFile,
|
||||
],
|
||||
cwd: runtime.workspaceRoot,
|
||||
});
|
||||
|
||||
return {
|
||||
content: `Patch applied successfully${note ? `: ${note}` : "."}`,
|
||||
};
|
||||
} catch (error) {
|
||||
AgentLogger.error("Patch application failed", error as Error);
|
||||
const filePreview: string = await LocalFile.read(patchFile);
|
||||
return {
|
||||
content: `Failed to apply patch. Please review the diff and adjust.\n${filePreview}`,
|
||||
isError: true,
|
||||
};
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async applyStructuredPatch(
|
||||
input: string,
|
||||
runtime: ToolRuntime,
|
||||
note?: string,
|
||||
): Promise<ToolResponse> {
|
||||
const blocks: Array<PatchBlock> = this.extractStructuredBlocks(
|
||||
input,
|
||||
runtime,
|
||||
);
|
||||
if (!blocks.length) {
|
||||
return {
|
||||
content:
|
||||
"Failed to parse the *** Begin Patch instructions. Please verify the format and try again.",
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
for (const block of blocks) {
|
||||
await this.applyPatchBlock(block);
|
||||
}
|
||||
|
||||
return {
|
||||
content: `Patch applied successfully${note ? `: ${note}` : "."}`,
|
||||
};
|
||||
}
|
||||
|
||||
private extractStructuredBlocks(
|
||||
input: string,
|
||||
runtime: ToolRuntime,
|
||||
): Array<PatchBlock> {
|
||||
const blocks: Array<PatchBlock> = [];
|
||||
const sections: RegExpMatchArray[] = Array.from(
|
||||
input.matchAll(/\*\*\* Begin Patch([\s\S]*?)\*\*\* End Patch/g),
|
||||
);
|
||||
|
||||
for (const section of sections) {
|
||||
const body: string | undefined = section[1];
|
||||
if (!body) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileBlocks: RegExpMatchArray[] = Array.from(
|
||||
body.matchAll(
|
||||
/\*\*\* (Update|Add|Delete) File: (.+)\n([\s\S]*?)(?=\*\*\* (Update|Add|Delete) File: |$)/g,
|
||||
),
|
||||
);
|
||||
|
||||
for (const block of fileBlocks) {
|
||||
const actionRaw: string | undefined = block[1];
|
||||
const filePathRaw: string | undefined = block[2];
|
||||
const diffBodyRaw: string | undefined = block[3];
|
||||
if (!actionRaw || !filePathRaw || diffBodyRaw === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const diffBody: string = diffBodyRaw.trim();
|
||||
if (!diffBody) {
|
||||
AgentLogger.debug("Skipping empty structured patch block", {
|
||||
action: actionRaw,
|
||||
file: filePathRaw,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const absolute: string = runtime.workspacePaths.resolve(
|
||||
filePathRaw.trim(),
|
||||
);
|
||||
const relative: string = runtime.workspacePaths.relative(absolute);
|
||||
|
||||
blocks.push({
|
||||
action: actionRaw as PatchAction,
|
||||
absolutePath: absolute,
|
||||
relativePath: relative,
|
||||
diffBody,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private async applyPatchBlock(block: PatchBlock): Promise<void> {
|
||||
AgentLogger.debug("Applying structured patch block", {
|
||||
action: block.action,
|
||||
file: block.relativePath,
|
||||
});
|
||||
|
||||
if (block.action === "Delete") {
|
||||
await fs.rm(block.absolutePath, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const { content: existingContent, exists } = await this.readFileWithMeta(
|
||||
block.absolutePath,
|
||||
);
|
||||
|
||||
if (block.action === "Update" && !exists) {
|
||||
throw new Error(
|
||||
`Cannot update missing file ${block.relativePath}. Did you mean to use an Add block?`,
|
||||
);
|
||||
}
|
||||
|
||||
const patchedContent: string = this.applyHunksToContent(
|
||||
block.action === "Add" ? "" : existingContent,
|
||||
block.diffBody,
|
||||
block.relativePath,
|
||||
);
|
||||
|
||||
await fs.mkdir(path.dirname(block.absolutePath), { recursive: true });
|
||||
await fs.writeFile(block.absolutePath, patchedContent, {
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
private async readFileWithMeta(filePath: string): Promise<{
|
||||
content: string;
|
||||
exists: boolean;
|
||||
}> {
|
||||
try {
|
||||
const content: string = await fs.readFile(filePath, { encoding: "utf8" });
|
||||
return { content, exists: true };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return { content: "", exists: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private applyHunksToContent(
|
||||
originalContent: string,
|
||||
diffBody: string,
|
||||
relativePath: string,
|
||||
): string {
|
||||
const normalizedOriginal: string = originalContent.replace(/\r\n/g, "\n");
|
||||
const originalHadTrailingNewline: boolean =
|
||||
normalizedOriginal.endsWith("\n");
|
||||
const originalLines: Array<string> = normalizedOriginal.length
|
||||
? normalizedOriginal
|
||||
.slice(0, originalHadTrailingNewline ? -1 : undefined)
|
||||
.split("\n")
|
||||
: [];
|
||||
|
||||
let cursor: number = 0;
|
||||
let resultShouldEndWithNewline: boolean = originalHadTrailingNewline;
|
||||
const outputLines: Array<string> = [];
|
||||
const hunks: Array<Array<string>> = this.parseHunks(diffBody);
|
||||
|
||||
for (const hunk of hunks) {
|
||||
const matchSequence: Array<string> = hunk
|
||||
.filter((line: string) => {
|
||||
return line.startsWith(" ") || line.startsWith("-");
|
||||
})
|
||||
.map((line: string) => {
|
||||
return line.slice(1);
|
||||
});
|
||||
const startIndex: number = this.findMatchSequence(
|
||||
originalLines,
|
||||
cursor,
|
||||
matchSequence,
|
||||
relativePath,
|
||||
);
|
||||
|
||||
outputLines.push(...originalLines.slice(cursor, startIndex));
|
||||
|
||||
let localIndex: number = startIndex;
|
||||
let lastOp: string | null = null;
|
||||
for (const line of hunk) {
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("\\ No newline at end of file")) {
|
||||
if (lastOp === "+") {
|
||||
resultShouldEndWithNewline = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const op: string = line[0] ?? "";
|
||||
const value: string = line.length > 1 ? line.slice(1) : "";
|
||||
|
||||
if (op === " ") {
|
||||
this.assertLineMatches(
|
||||
originalLines,
|
||||
localIndex,
|
||||
value,
|
||||
relativePath,
|
||||
);
|
||||
outputLines.push(value);
|
||||
localIndex += 1;
|
||||
} else if (op === "-") {
|
||||
this.assertLineMatches(
|
||||
originalLines,
|
||||
localIndex,
|
||||
value,
|
||||
relativePath,
|
||||
);
|
||||
localIndex += 1;
|
||||
} else if (op === "+") {
|
||||
outputLines.push(value);
|
||||
resultShouldEndWithNewline = true;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unsupported diff operation '${op}' in ${relativePath}.`,
|
||||
);
|
||||
}
|
||||
|
||||
lastOp = op;
|
||||
}
|
||||
|
||||
cursor = localIndex;
|
||||
}
|
||||
|
||||
outputLines.push(...originalLines.slice(cursor));
|
||||
let result: string = outputLines.join("\n");
|
||||
if (result && resultShouldEndWithNewline) {
|
||||
result += "\n";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private parseHunks(diffBody: string): Array<Array<string>> {
|
||||
const normalizedBody: string = diffBody.replace(/\r\n/g, "\n");
|
||||
const lines: Array<string> = normalizedBody.split("\n");
|
||||
const hunks: Array<Array<string>> = [];
|
||||
let current: Array<string> | null = null;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line: string = rawLine;
|
||||
if (line.startsWith("---") || line.startsWith("+++")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("@@")) {
|
||||
if (current && current.length) {
|
||||
hunks.push(current);
|
||||
}
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
current = [];
|
||||
}
|
||||
current.push(line);
|
||||
}
|
||||
|
||||
if (current && current.length) {
|
||||
hunks.push(current);
|
||||
}
|
||||
|
||||
return hunks;
|
||||
}
|
||||
|
||||
private findMatchSequence(
|
||||
originalLines: Array<string>,
|
||||
startIndex: number,
|
||||
sequence: Array<string>,
|
||||
relativePath: string,
|
||||
): number {
|
||||
if (!sequence.length) {
|
||||
return startIndex;
|
||||
}
|
||||
|
||||
for (
|
||||
let index: number = startIndex;
|
||||
index <= originalLines.length - sequence.length;
|
||||
index += 1
|
||||
) {
|
||||
let matched: boolean = true;
|
||||
for (let offset: number = 0; offset < sequence.length; offset += 1) {
|
||||
if (originalLines[index + offset] !== sequence[offset]) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to locate patch context in ${relativePath}. Please ensure the file contents match the expected state before applying the patch.`,
|
||||
);
|
||||
}
|
||||
|
||||
private assertLineMatches(
|
||||
originalLines: Array<string>,
|
||||
index: number,
|
||||
expected: string,
|
||||
relativePath: string,
|
||||
): void {
|
||||
const actual: string | undefined = originalLines[index];
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`Patch mismatch in ${relativePath}: expected '${expected}' but found '${actual ?? "<eof>"}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import LocalFile from "Common/Server/Utils/LocalFile";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import { StructuredTool, ToolResponse, ToolRuntime } from "./Tool";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
|
||||
/**
|
||||
* Arguments controlling how deep and how broad a directory listing should be.
|
||||
*/
|
||||
interface ListDirectoryArgs {
|
||||
path?: string | undefined;
|
||||
depth?: number | undefined;
|
||||
includeFiles?: boolean | undefined;
|
||||
limit?: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively lists workspace directories so the agent can orient itself.
|
||||
*/
|
||||
export class ListDirectoryTool extends StructuredTool<ListDirectoryArgs> {
|
||||
public readonly name: string = "list_directory";
|
||||
public readonly description: string =
|
||||
"Lists files and folders inside the workspace to help you locate relevant code.";
|
||||
public readonly parameters: JSONObject = {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Directory to inspect. Defaults to the workspace root.",
|
||||
},
|
||||
depth: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
description: "How deep to recurse into subdirectories (default 2).",
|
||||
},
|
||||
includeFiles: {
|
||||
type: "boolean",
|
||||
description: "Include file entries as well as directories.",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 400,
|
||||
description: "Maximum number of entries to return (default 80).",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
protected schema = z
|
||||
.object({
|
||||
path: z.string().trim().optional(),
|
||||
depth: z.number().int().min(1).max(5).optional().default(2),
|
||||
includeFiles: z.boolean().optional().default(true),
|
||||
limit: z.number().int().min(1).max(400).optional().default(80),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Generates a formatted directory listing or reports if the target folder is
|
||||
* missing.
|
||||
*/
|
||||
public async execute(
|
||||
args: ListDirectoryArgs,
|
||||
runtime: ToolRuntime,
|
||||
): Promise<ToolResponse> {
|
||||
AgentLogger.debug("ListDirectoryTool executing", {
|
||||
path: args.path,
|
||||
depth: args.depth,
|
||||
includeFiles: args.includeFiles,
|
||||
limit: args.limit,
|
||||
});
|
||||
const targetPath: string = runtime.workspacePaths.resolve(args.path ?? ".");
|
||||
|
||||
if (!(await LocalFile.doesDirectoryExist(targetPath))) {
|
||||
AgentLogger.warn("ListDirectoryTool target missing", {
|
||||
targetPath,
|
||||
});
|
||||
return {
|
||||
content: `Directory ${args.path ?? "."} does not exist in the workspace`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const rows: Array<string> = [];
|
||||
await this.walkDirectory({
|
||||
current: targetPath,
|
||||
currentDepth: 0,
|
||||
maxDepth: args.depth ?? 2,
|
||||
includeFiles: args.includeFiles ?? true,
|
||||
limit: args.limit ?? 80,
|
||||
output: rows,
|
||||
runtime,
|
||||
});
|
||||
|
||||
const relativeRoot: string = runtime.workspacePaths.relative(targetPath);
|
||||
const header: string = `Listing ${rows.length} item(s) under ${relativeRoot || "."}`;
|
||||
|
||||
AgentLogger.debug("ListDirectoryTool completed", {
|
||||
relativeRoot,
|
||||
rowCount: rows.length,
|
||||
});
|
||||
return {
|
||||
content: [header, ...rows].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a depth-limited traversal while honoring entry skip rules and the
|
||||
* caller's result limit.
|
||||
*/
|
||||
private async walkDirectory(data: {
|
||||
current: string;
|
||||
currentDepth: number;
|
||||
maxDepth: number;
|
||||
includeFiles: boolean;
|
||||
limit: number;
|
||||
output: Array<string>;
|
||||
runtime: ToolRuntime;
|
||||
}): Promise<void> {
|
||||
if (data.output.length >= data.limit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries: Array<Dirent> = await fs.readdir(data.current, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
entries.sort((a: Dirent, b: Dirent) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
AgentLogger.debug("Listing directory entries", {
|
||||
current: data.current,
|
||||
depth: data.currentDepth,
|
||||
entryCount: entries.length,
|
||||
});
|
||||
|
||||
for (let index: number = 0; index < entries.length; index += 1) {
|
||||
const entry: Dirent | undefined = entries[index];
|
||||
if (entry === undefined) {
|
||||
AgentLogger.warn("Missing directory entry during traversal", {
|
||||
directory: data.current,
|
||||
requestedIndex: index,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (data.output.length >= data.limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.shouldSkip(entry.name)) {
|
||||
AgentLogger.debug("Skipping directory entry", {
|
||||
entry: entry.name,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const absoluteEntry: string = path.join(data.current, entry.name);
|
||||
const prefix: string = " ".repeat(data.currentDepth);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
data.output.push(`${prefix}${entry.name}/`);
|
||||
if (data.currentDepth + 1 < data.maxDepth) {
|
||||
await this.walkDirectory({
|
||||
...data,
|
||||
current: absoluteEntry,
|
||||
currentDepth: data.currentDepth + 1,
|
||||
});
|
||||
}
|
||||
} else if (data.includeFiles) {
|
||||
data.output.push(`${prefix}${entry.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true when an entry should be excluded from listings. */
|
||||
private shouldSkip(entryName: string): boolean {
|
||||
const blocked: Array<string> = [
|
||||
".git",
|
||||
"node_modules",
|
||||
".turbo",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
];
|
||||
|
||||
return blocked.includes(entryName);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
import { z } from "zod";
|
||||
import LocalFile from "Common/Server/Utils/LocalFile";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import { StructuredTool, ToolResponse, ToolRuntime } from "./Tool";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
|
||||
/** Describes how much of which file should be returned to the agent. */
|
||||
interface ReadFileArgs {
|
||||
path: string;
|
||||
startLine?: number | undefined;
|
||||
endLine?: number | undefined;
|
||||
limit?: number | undefined;
|
||||
}
|
||||
|
||||
/** Reads files from the workspace with optional line slicing and truncation. */
|
||||
export class ReadFileTool extends StructuredTool<ReadFileArgs> {
|
||||
public readonly name: string = "read_file";
|
||||
public readonly description: string =
|
||||
"Reads a file from the workspace so you can inspect existing code before editing.";
|
||||
public readonly parameters: JSONObject = {
|
||||
type: "object",
|
||||
required: ["path"],
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "File path relative to the workspace root.",
|
||||
},
|
||||
startLine: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
description: "Optional starting line (1-indexed).",
|
||||
},
|
||||
endLine: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
description: "Optional ending line (inclusive).",
|
||||
},
|
||||
limit: {
|
||||
type: "integer",
|
||||
minimum: 100,
|
||||
maximum: 20000,
|
||||
description: "Maximum number of characters to return (default 6000).",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
protected schema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
startLine: z.number().int().min(1).optional(),
|
||||
endLine: z.number().int().min(1).optional(),
|
||||
limit: z.number().int().min(100).max(20000).optional().default(6000),
|
||||
})
|
||||
.strict()
|
||||
.refine((data: ReadFileArgs) => {
|
||||
if (data.startLine && data.endLine) {
|
||||
return data.endLine >= data.startLine;
|
||||
}
|
||||
return true;
|
||||
}, "endLine must be greater than startLine");
|
||||
|
||||
public override parse(input: unknown): ReadFileArgs {
|
||||
const normalized: unknown = this.normalizeAliases(input);
|
||||
return super.parse(normalized);
|
||||
}
|
||||
|
||||
/** Provides the requested file slice or reports an error if missing. */
|
||||
public async execute(
|
||||
args: ReadFileArgs,
|
||||
runtime: ToolRuntime,
|
||||
): Promise<ToolResponse> {
|
||||
AgentLogger.debug("ReadFileTool executing", {
|
||||
path: args.path,
|
||||
startLine: args.startLine,
|
||||
endLine: args.endLine,
|
||||
limit: args.limit,
|
||||
});
|
||||
const absolutePath: string = runtime.workspacePaths.resolve(args.path);
|
||||
|
||||
if (!(await LocalFile.doesFileExist(absolutePath))) {
|
||||
AgentLogger.warn("ReadFileTool missing file", {
|
||||
absolutePath,
|
||||
});
|
||||
return {
|
||||
content: `File ${args.path} does not exist in the workspace`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const rawContent: string = await LocalFile.read(absolutePath);
|
||||
const lines: Array<string> = rawContent.split(/\r?\n/);
|
||||
const start: number = (args.startLine ?? 1) - 1;
|
||||
const end: number = args.endLine ? args.endLine : lines.length;
|
||||
const slice: Array<string> = lines.slice(start, end);
|
||||
let text: string = slice.join("\n");
|
||||
|
||||
let truncated: boolean = false;
|
||||
const limit: number = args.limit ?? 6000;
|
||||
if (text.length > limit) {
|
||||
text = text.substring(0, limit);
|
||||
truncated = true;
|
||||
AgentLogger.debug("ReadFileTool output truncated", {
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
const relative: string = runtime.workspacePaths.relative(absolutePath);
|
||||
const header: string = `Contents of ${relative} (lines ${start + 1}-${Math.min(end, lines.length)})`;
|
||||
|
||||
AgentLogger.debug("ReadFileTool completed", {
|
||||
relative,
|
||||
truncated,
|
||||
returnedChars: text.length,
|
||||
});
|
||||
return {
|
||||
content: truncated
|
||||
? `${header}\n${text}\n... [truncated]`
|
||||
: `${header}\n${text}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts historical argument names (lineStart/line_start/etc.) so older
|
||||
* prompts do not immediately fail validation.
|
||||
*/
|
||||
private normalizeAliases(input: unknown): unknown {
|
||||
if (!input || typeof input !== "object") {
|
||||
return input ?? {};
|
||||
}
|
||||
|
||||
const original: Record<string, unknown> = input as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = { ...original };
|
||||
|
||||
this.applyAlias(
|
||||
normalized,
|
||||
["lineStart", "line_start", "start_line"],
|
||||
"startLine",
|
||||
);
|
||||
this.applyAlias(normalized, ["lineEnd", "line_end", "end_line"], "endLine");
|
||||
this.applyAlias(
|
||||
normalized,
|
||||
["charLimit", "char_limit", "maxChars"],
|
||||
"limit",
|
||||
);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private applyAlias(
|
||||
target: Record<string, unknown>,
|
||||
aliases: Array<string>,
|
||||
canonical: string,
|
||||
): void {
|
||||
for (const alias of aliases) {
|
||||
if (target[alias] !== undefined && target[canonical] === undefined) {
|
||||
target[canonical] = target[alias];
|
||||
}
|
||||
delete target[alias];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import { ExecOptions } from "node:child_process";
|
||||
import { z } from "zod";
|
||||
import Execute from "Common/Server/Utils/Execute";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import { StructuredTool, ToolResponse, ToolRuntime } from "./Tool";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
|
||||
/** Describes how to execute a shell command from the workspace. */
|
||||
interface RunCommandArgs {
|
||||
command: string;
|
||||
path?: string | undefined;
|
||||
timeoutMs?: number | undefined;
|
||||
}
|
||||
|
||||
/** Runs shell commands so the agent can execute tests, linters, etc. */
|
||||
export class RunCommandTool extends StructuredTool<RunCommandArgs> {
|
||||
public readonly name: string = "run_command";
|
||||
public readonly description: string =
|
||||
"Runs a shell command inside the workspace (for unit tests, linters, or project-specific scripts).";
|
||||
public readonly parameters: JSONObject = {
|
||||
type: "object",
|
||||
required: ["command"],
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
description:
|
||||
"Shell command to execute. Prefer running package scripts instead of raw binaries when possible.",
|
||||
},
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Optional subdirectory to run the command from.",
|
||||
},
|
||||
timeoutMs: {
|
||||
type: "integer",
|
||||
minimum: 1000,
|
||||
maximum: 1800000,
|
||||
description: "Timeout in milliseconds (default 10 minutes).",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
protected schema = z
|
||||
.object({
|
||||
command: z.string().min(1),
|
||||
path: z.string().trim().optional(),
|
||||
timeoutMs: z.number().int().min(1000).max(1800000).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Executes the command with safe defaults and streams the result back. */
|
||||
public async execute(
|
||||
args: RunCommandArgs,
|
||||
runtime: ToolRuntime,
|
||||
): Promise<ToolResponse> {
|
||||
const cwd: string = args.path
|
||||
? runtime.workspacePaths.resolve(args.path)
|
||||
: runtime.workspaceRoot;
|
||||
AgentLogger.debug("RunCommandTool executing", {
|
||||
command: args.command,
|
||||
cwd,
|
||||
timeoutMs: args.timeoutMs,
|
||||
});
|
||||
|
||||
const options: ExecOptions = {
|
||||
cwd,
|
||||
timeout: args.timeoutMs ?? 10 * 60 * 1000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
};
|
||||
AgentLogger.debug("RunCommandTool options prepared", {
|
||||
cwd,
|
||||
timeout: options.timeout,
|
||||
maxBuffer: options.maxBuffer,
|
||||
});
|
||||
|
||||
try {
|
||||
const output: string = await Execute.executeCommand(
|
||||
args.command,
|
||||
options,
|
||||
);
|
||||
AgentLogger.debug("RunCommandTool succeeded", {
|
||||
command: args.command,
|
||||
cwd,
|
||||
outputPreview: output.slice(0, 500),
|
||||
});
|
||||
return {
|
||||
content: `Command executed in ${runtime.workspacePaths.relative(cwd) || "."}\n$ ${args.command}\n${output.trim()}`,
|
||||
};
|
||||
} catch (error) {
|
||||
AgentLogger.error("RunCommandTool failed", error as Error);
|
||||
return {
|
||||
content: `Command failed: ${args.command}\n${(error as Error).message}`,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
import { z } from "zod";
|
||||
import Execute from "Common/Server/Utils/Execute";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import { StructuredTool, ToolResponse, ToolRuntime } from "./Tool";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
|
||||
/** Parameters describing how to search the workspace. */
|
||||
interface SearchArgs {
|
||||
query: string;
|
||||
path?: string | undefined;
|
||||
useRegex?: boolean | undefined;
|
||||
maxResults?: number | undefined;
|
||||
}
|
||||
|
||||
/** Wraps ripgrep/grep so the agent can quickly locate text across files. */
|
||||
export class SearchWorkspaceTool extends StructuredTool<SearchArgs> {
|
||||
public readonly name: string = "search_workspace";
|
||||
public readonly description: string =
|
||||
"Searches the workspace for a literal string or regular expression to quickly find relevant files.";
|
||||
public readonly parameters: JSONObject = {
|
||||
type: "object",
|
||||
required: ["query"],
|
||||
properties: {
|
||||
query: {
|
||||
type: "string",
|
||||
description: "String or regex to search for.",
|
||||
},
|
||||
path: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional folder to scope the search to. Defaults to the workspace root.",
|
||||
},
|
||||
useRegex: {
|
||||
type: "boolean",
|
||||
description: "Set true to treat query as a regular expression.",
|
||||
},
|
||||
maxResults: {
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
maximum: 200,
|
||||
description: "Maximum number of matches to return (default 40).",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
protected schema = z
|
||||
.object({
|
||||
query: z.string().min(2),
|
||||
path: z.string().trim().optional(),
|
||||
useRegex: z.boolean().optional().default(false),
|
||||
maxResults: z.number().int().min(1).max(200).optional().default(40),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Runs the configured search and decorates the raw CLI output. */
|
||||
public async execute(
|
||||
args: SearchArgs,
|
||||
runtime: ToolRuntime,
|
||||
): Promise<ToolResponse> {
|
||||
const cwd: string = args.path
|
||||
? runtime.workspacePaths.resolve(args.path)
|
||||
: runtime.workspaceRoot;
|
||||
|
||||
const relativeScope: string = runtime.workspacePaths.relative(cwd);
|
||||
AgentLogger.debug("SearchWorkspaceTool executing", {
|
||||
query: args.query,
|
||||
path: args.path,
|
||||
useRegex: args.useRegex,
|
||||
maxResults: args.maxResults,
|
||||
});
|
||||
|
||||
try {
|
||||
const rgOutput: string = await this.runRipgrep(args, cwd);
|
||||
AgentLogger.debug("SearchWorkspaceTool ripgrep success", {
|
||||
scope: relativeScope,
|
||||
});
|
||||
return {
|
||||
content: this.decorateSearchResult({
|
||||
engine: "ripgrep",
|
||||
scope: relativeScope,
|
||||
body: rgOutput,
|
||||
}),
|
||||
};
|
||||
} catch (rgError) {
|
||||
AgentLogger.debug(
|
||||
"SearchWorkspaceTool ripgrep failed, falling back to grep",
|
||||
{
|
||||
error: (rgError as Error).message,
|
||||
},
|
||||
);
|
||||
const fallbackOutput: string = await this.runGrep(args, cwd);
|
||||
AgentLogger.debug("SearchWorkspaceTool grep success", {
|
||||
scope: relativeScope,
|
||||
});
|
||||
return {
|
||||
content: this.decorateSearchResult({
|
||||
engine: "grep",
|
||||
scope: relativeScope,
|
||||
body: fallbackOutput,
|
||||
}),
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Executes ripgrep with safe defaults and returns its stdout. */
|
||||
private async runRipgrep(args: SearchArgs, cwd: string): Promise<string> {
|
||||
const cliArgs: Array<string> = [
|
||||
"--line-number",
|
||||
"--color",
|
||||
"never",
|
||||
"--no-heading",
|
||||
"--context",
|
||||
"2",
|
||||
"--max-filesize",
|
||||
"200K",
|
||||
"--max-columns",
|
||||
"200",
|
||||
"--max-count",
|
||||
String(args.maxResults ?? 40),
|
||||
"--glob",
|
||||
"!*node_modules/*",
|
||||
"--glob",
|
||||
"!*.lock",
|
||||
"--glob",
|
||||
"!.git/*",
|
||||
];
|
||||
|
||||
if (!args.useRegex) {
|
||||
cliArgs.push("--fixed-strings");
|
||||
}
|
||||
|
||||
cliArgs.push(args.query);
|
||||
cliArgs.push(".");
|
||||
|
||||
return Execute.executeCommandFile({
|
||||
command: "rg",
|
||||
args: cliArgs,
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
|
||||
/** Fallback search implementation using standard grep. */
|
||||
private async runGrep(args: SearchArgs, cwd: string): Promise<string> {
|
||||
const finalArgs: Array<string> = [
|
||||
"-R",
|
||||
"-n",
|
||||
"-C",
|
||||
"2",
|
||||
"--exclude-dir=.git",
|
||||
"--exclude-dir=node_modules",
|
||||
"--exclude=*.lock",
|
||||
args.useRegex ? "-E" : "-F",
|
||||
args.query,
|
||||
".",
|
||||
];
|
||||
|
||||
try {
|
||||
return await Execute.executeCommandFile({
|
||||
command: "grep",
|
||||
args: finalArgs,
|
||||
cwd,
|
||||
});
|
||||
} catch (error) {
|
||||
// grep exits with code 1 when no matches are found - treat this as empty result
|
||||
const errorMessage: string = (error as Error).message || "";
|
||||
if (
|
||||
errorMessage.includes("exit code 1") ||
|
||||
errorMessage.includes("Command failed")
|
||||
) {
|
||||
return "No matches found";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats CLI output with context about scope and engine. */
|
||||
private decorateSearchResult(data: {
|
||||
engine: string;
|
||||
scope: string;
|
||||
body: string;
|
||||
}): string {
|
||||
const scope: string = data.scope || ".";
|
||||
const trimmedBody: string = data.body.trim() || "No matches found";
|
||||
return `Search (${data.engine}) under ${scope}\n${trimmedBody}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { z } from "zod";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import { ToolDefinition } from "../Types";
|
||||
import { WorkspacePaths } from "../Utils/WorkspacePaths";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
|
||||
/**
|
||||
* Shared context passed to every tool execution so it can resolve workspace
|
||||
* paths safely and consistently.
|
||||
*/
|
||||
export interface ToolRuntime {
|
||||
workspacePaths: WorkspacePaths;
|
||||
workspaceRoot: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical wrapper returned by tools with their textual output and optional
|
||||
* error flag so the agent can prefix failures.
|
||||
*/
|
||||
export interface ToolResponse {
|
||||
content: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract implemented by every tool so it can be surfaced as an OpenAI
|
||||
* function and executed with validated arguments.
|
||||
*/
|
||||
export interface AgentTool<TArgs> {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly parameters: JSONObject;
|
||||
getDefinition(): ToolDefinition;
|
||||
parse(input: unknown): TArgs;
|
||||
execute(args: TArgs, runtime: ToolRuntime): Promise<ToolResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class that combines a zod schema with helper logic to expose a tool as
|
||||
* an OpenAI function.
|
||||
*/
|
||||
export abstract class StructuredTool<TArgs> implements AgentTool<TArgs> {
|
||||
public abstract readonly name: string;
|
||||
public abstract readonly description: string;
|
||||
public abstract readonly parameters: JSONObject;
|
||||
protected abstract schema: z.ZodType<TArgs>;
|
||||
|
||||
/** Describes the tool in the OpenAI function-calling format. */
|
||||
public getDefinition(): ToolDefinition {
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name: this.name,
|
||||
description: this.description,
|
||||
parameters: this.parameters,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates raw JSON arguments against the schema before execution to guard
|
||||
* against malformed tool calls.
|
||||
*/
|
||||
public parse(input: unknown): TArgs {
|
||||
AgentLogger.debug("Parsing tool arguments", {
|
||||
tool: this.name,
|
||||
inputType: typeof input,
|
||||
});
|
||||
const parsed: TArgs = this.schema.parse(input ?? {});
|
||||
AgentLogger.debug("Parsed tool arguments", {
|
||||
tool: this.name,
|
||||
});
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the tool's core behavior with validated args and shared runtime.
|
||||
*/
|
||||
public abstract execute(
|
||||
args: TArgs,
|
||||
runtime: ToolRuntime,
|
||||
): Promise<ToolResponse>;
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import { OpenAIToolCall, ToolDefinition, ToolExecutionResult } from "../Types";
|
||||
import { WorkspacePaths } from "../Utils/WorkspacePaths";
|
||||
import { ApplyPatchTool } from "./ApplyPatchTool";
|
||||
import { ListDirectoryTool } from "./ListDirectoryTool";
|
||||
import { ReadFileTool } from "./ReadFileTool";
|
||||
import { RunCommandTool } from "./RunCommandTool";
|
||||
import { SearchWorkspaceTool } from "./SearchWorkspaceTool";
|
||||
import { AgentTool, ToolResponse, ToolRuntime } from "./Tool";
|
||||
import { WriteFileTool } from "./WriteFileTool";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
import { redactSecrets } from "../Utils/SecretSanitizer";
|
||||
|
||||
/**
|
||||
* Holds every tool available to the agent and brokers invocation/argument
|
||||
* parsing for tool calls originating from the LLM.
|
||||
*/
|
||||
export class ToolRegistry {
|
||||
private readonly tools: Map<string, AgentTool<unknown>>;
|
||||
private readonly runtime: ToolRuntime;
|
||||
|
||||
/**
|
||||
* Builds the registry with all supported tools scoped to the provided
|
||||
* workspace.
|
||||
*/
|
||||
public constructor(workspaceRoot: string) {
|
||||
const workspacePaths: WorkspacePaths = new WorkspacePaths(workspaceRoot);
|
||||
this.runtime = {
|
||||
workspacePaths,
|
||||
workspaceRoot: workspacePaths.getRoot(),
|
||||
};
|
||||
AgentLogger.debug("Tool registry initialized", {
|
||||
workspaceRoot: workspacePaths.getRoot(),
|
||||
});
|
||||
|
||||
const toolInstances: Array<AgentTool<unknown>> = [
|
||||
new ListDirectoryTool(),
|
||||
new ReadFileTool(),
|
||||
new SearchWorkspaceTool(),
|
||||
new ApplyPatchTool(),
|
||||
new WriteFileTool(),
|
||||
new RunCommandTool(),
|
||||
];
|
||||
|
||||
this.tools = new Map(
|
||||
toolInstances.map((tool: AgentTool<unknown>) => {
|
||||
return [tool.name, tool];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the OpenAI function metadata for every registered tool. */
|
||||
public getToolDefinitions(): Array<ToolDefinition> {
|
||||
const definitions: Array<ToolDefinition> = Array.from(
|
||||
this.tools.values(),
|
||||
).map((tool: AgentTool<unknown>) => {
|
||||
return tool.getDefinition();
|
||||
});
|
||||
AgentLogger.debug("Tool definitions requested", {
|
||||
count: definitions.length,
|
||||
toolNames: definitions.map((definition: ToolDefinition) => {
|
||||
return definition.function.name;
|
||||
}),
|
||||
});
|
||||
return definitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the tool requested by the LLM, handling JSON parsing, validation,
|
||||
* and error reporting so the agent loop stays simple.
|
||||
*/
|
||||
public async execute(call: OpenAIToolCall): Promise<ToolExecutionResult> {
|
||||
const tool: AgentTool<unknown> | undefined = this.tools.get(
|
||||
call.function.name,
|
||||
);
|
||||
|
||||
if (!tool) {
|
||||
const message: string = `Tool ${call.function.name} is not available.`;
|
||||
AgentLogger.error(message);
|
||||
return {
|
||||
toolCallId: call.id,
|
||||
output: this.sanitizeOutput(message),
|
||||
};
|
||||
}
|
||||
|
||||
let parsedArgs: unknown;
|
||||
try {
|
||||
parsedArgs = call.function.arguments
|
||||
? JSON.parse(call.function.arguments)
|
||||
: {};
|
||||
AgentLogger.debug("Tool arguments parsed", {
|
||||
toolName: call.function.name,
|
||||
argumentKeys:
|
||||
typeof parsedArgs === "object" && parsedArgs !== null
|
||||
? Object.keys(parsedArgs as Record<string, unknown>)
|
||||
: [],
|
||||
});
|
||||
} catch (error) {
|
||||
const message: string = `Unable to parse tool arguments for ${call.function.name}: ${(error as Error).message}`;
|
||||
AgentLogger.error(message);
|
||||
return {
|
||||
toolCallId: call.id,
|
||||
output: this.sanitizeOutput(message),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
AgentLogger.debug("Executing tool via registry", {
|
||||
toolName: call.function.name,
|
||||
});
|
||||
const typedArgs: unknown = tool.parse(parsedArgs);
|
||||
AgentLogger.debug("Tool arguments validated", {
|
||||
toolName: call.function.name,
|
||||
});
|
||||
const response: ToolResponse = await tool.execute(
|
||||
typedArgs,
|
||||
this.runtime,
|
||||
);
|
||||
const prefix: string = response.isError ? "ERROR: " : "";
|
||||
AgentLogger.debug("Tool execution result", {
|
||||
toolName: call.function.name,
|
||||
isError: response.isError ?? false,
|
||||
});
|
||||
const content: string = `${prefix}${response.content}`;
|
||||
return {
|
||||
toolCallId: call.id,
|
||||
output: this.sanitizeOutput(content),
|
||||
};
|
||||
} catch (error) {
|
||||
const message: string = `Tool ${call.function.name} failed: ${(error as Error).message}`;
|
||||
AgentLogger.error(message, error as Error);
|
||||
return {
|
||||
toolCallId: call.id,
|
||||
output: this.sanitizeOutput(message),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeOutput(text: string): string {
|
||||
return redactSecrets(text);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import fs from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
import LocalFile from "Common/Server/Utils/LocalFile";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import { StructuredTool, ToolResponse, ToolRuntime } from "./Tool";
|
||||
import AgentLogger from "../Utils/AgentLogger";
|
||||
|
||||
/** Arguments describing what to write and where. */
|
||||
interface WriteFileArgs {
|
||||
path: string;
|
||||
content: string;
|
||||
mode?: "overwrite" | "append" | undefined;
|
||||
}
|
||||
|
||||
/** Creates or appends to workspace files with validated inputs. */
|
||||
export class WriteFileTool extends StructuredTool<WriteFileArgs> {
|
||||
public readonly name: string = "write_file";
|
||||
public readonly description: string =
|
||||
"Creates a new file or replaces an existing file with the provided content. Use this for docs, configs, or single-file outputs.";
|
||||
public readonly parameters: JSONObject = {
|
||||
type: "object",
|
||||
required: ["path", "content"],
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "File path relative to the workspace root.",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "Entire file content to be written.",
|
||||
},
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["overwrite", "append"],
|
||||
description:
|
||||
"Overwrite replaces the file (default). Append adds content to the end of the file.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
protected schema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
mode: z.enum(["overwrite", "append"]).optional().default("overwrite"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Persists the provided content at the resolved workspace path. */
|
||||
public async execute(
|
||||
args: WriteFileArgs,
|
||||
runtime: ToolRuntime,
|
||||
): Promise<ToolResponse> {
|
||||
const absolutePath: string = runtime.workspacePaths.resolve(args.path);
|
||||
AgentLogger.debug("WriteFileTool executing", {
|
||||
path: args.path,
|
||||
mode: args.mode,
|
||||
contentLength: args.content.length,
|
||||
});
|
||||
await runtime.workspacePaths.ensureParentDirectory(absolutePath);
|
||||
|
||||
if (
|
||||
args.mode === "append" &&
|
||||
(await LocalFile.doesFileExist(absolutePath))
|
||||
) {
|
||||
await fs.appendFile(absolutePath, args.content);
|
||||
} else {
|
||||
await LocalFile.write(absolutePath, args.content);
|
||||
}
|
||||
|
||||
const relative: string = runtime.workspacePaths.relative(absolutePath);
|
||||
AgentLogger.debug("WriteFileTool completed", {
|
||||
relative,
|
||||
mode: args.mode ?? "overwrite",
|
||||
});
|
||||
return {
|
||||
content: `${args.mode === "append" ? "Appended to" : "Wrote"} ${relative} (${args.content.length} characters).`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { JSONObject } from "Common/Types/JSON";
|
||||
|
||||
/** Allowed OpenAI chat roles encountered by the agent. */
|
||||
export type ChatRole = "system" | "user" | "assistant" | "tool";
|
||||
|
||||
/** Serialized chat message exchanged between the agent and the LLM. */
|
||||
export interface ChatMessage {
|
||||
role: ChatRole;
|
||||
content: string | null;
|
||||
name?: string | undefined;
|
||||
tool_call_id?: string | undefined;
|
||||
tool_calls?: Array<OpenAIToolCall> | undefined;
|
||||
}
|
||||
|
||||
/** Raw tool call instructions returned by the LLM. */
|
||||
export interface OpenAIToolCall {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Description of a tool exposed to the LLM via function calling. */
|
||||
export interface ToolDefinition {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: JSONObject;
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrapper used when reporting tool execution results back to the agent loop. */
|
||||
export interface ToolExecutionResult {
|
||||
toolCallId: string;
|
||||
output: string;
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import logger, { LogBody } from "Common/Server/Utils/Logger";
|
||||
|
||||
/** Supported log levels for the agent-maintained file logs. */
|
||||
export type AgentLogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
|
||||
|
||||
/**
|
||||
* Extends the shared logger by optionally mirroring output to a persistent file
|
||||
* for auditing agent activity.
|
||||
*/
|
||||
export class AgentLogger {
|
||||
private static logStream: fs.WriteStream | null = null;
|
||||
private static logFilePath: string | null = null;
|
||||
private static exitHandlersRegistered: boolean = false;
|
||||
private static fileWriteFailed: boolean = false;
|
||||
|
||||
/** Enables/disables file logging depending on whether a path is provided. */
|
||||
public static async configure(options: {
|
||||
logFilePath?: string | undefined;
|
||||
}): Promise<void> {
|
||||
const targetPath: string | undefined = options.logFilePath?.trim()
|
||||
? path.resolve(options.logFilePath)
|
||||
: undefined;
|
||||
|
||||
if (!targetPath) {
|
||||
await this.closeStream();
|
||||
this.logFilePath = null;
|
||||
logger.debug("File logging disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.logFilePath === targetPath && this.logStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.closeStream();
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
// Remove existing debug file to start fresh for each command run
|
||||
try {
|
||||
await fs.promises.unlink(targetPath);
|
||||
} catch {
|
||||
// File doesn't exist, ignore
|
||||
}
|
||||
|
||||
this.logStream = fs.createWriteStream(targetPath, { flags: "w" });
|
||||
this.logFilePath = targetPath;
|
||||
this.fileWriteFailed = false;
|
||||
this.registerExitHandlers();
|
||||
this.info(`File logging enabled at ${targetPath}`);
|
||||
}
|
||||
|
||||
/** Writes a debug entry to the console logger and file stream. */
|
||||
public static debug(message: LogBody, meta?: unknown): void {
|
||||
logger.debug(message);
|
||||
this.writeToFile("DEBUG", message, meta);
|
||||
}
|
||||
|
||||
/** Writes an informational entry to the console logger and file stream. */
|
||||
public static info(message: LogBody, meta?: unknown): void {
|
||||
logger.info(message);
|
||||
this.writeToFile("INFO", message, meta);
|
||||
}
|
||||
|
||||
/** Writes a warning entry to the console logger and file stream. */
|
||||
public static warn(message: LogBody, meta?: unknown): void {
|
||||
logger.warn(message);
|
||||
this.writeToFile("WARN", message, meta);
|
||||
}
|
||||
|
||||
/** Writes an error entry to the console logger and file stream. */
|
||||
public static error(message: LogBody, meta?: unknown): void {
|
||||
logger.error(message);
|
||||
this.writeToFile("ERROR", message, meta);
|
||||
}
|
||||
|
||||
/** Closes the file stream if one is currently open. */
|
||||
private static async closeStream(): Promise<void> {
|
||||
if (!this.logStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve: () => void) => {
|
||||
this.logStream?.end(resolve);
|
||||
});
|
||||
|
||||
this.logStream = null;
|
||||
logger.debug("File logging stream closed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a log entry and safely writes it to the currently configured
|
||||
* file stream.
|
||||
*/
|
||||
private static writeToFile(
|
||||
level: AgentLogLevel,
|
||||
message: LogBody,
|
||||
meta?: unknown,
|
||||
): void {
|
||||
if (!this.logStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp: string = new Date().toISOString();
|
||||
const serializedMessage: string = logger.serializeLogBody(message);
|
||||
const serializedMeta: string | null = this.serializeMeta(meta);
|
||||
const line: string = serializedMeta
|
||||
? `${timestamp} [${level}] ${serializedMessage} ${serializedMeta}`
|
||||
: `${timestamp} [${level}] ${serializedMessage}`;
|
||||
|
||||
try {
|
||||
this.logStream.write(line + "\n");
|
||||
} catch (error) {
|
||||
if (!this.fileWriteFailed) {
|
||||
this.fileWriteFailed = true;
|
||||
logger.error(
|
||||
`Failed to write logs to ${this.logFilePath ?? "<unknown>"}: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts metadata into a string representation for log lines. */
|
||||
private static serializeMeta(meta?: unknown): string | null {
|
||||
if (meta === undefined || meta === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof meta === "string") {
|
||||
return meta;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(meta);
|
||||
} catch (error) {
|
||||
return `"<unserializable meta: ${(error as Error).message}>"`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs once-only handlers to flush file logs during process exit. */
|
||||
private static registerExitHandlers(): void {
|
||||
if (this.exitHandlersRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup: () => void = () => {
|
||||
void this.closeStream();
|
||||
};
|
||||
|
||||
process.once("exit", cleanup);
|
||||
process.once("SIGINT", cleanup);
|
||||
process.once("SIGTERM", cleanup);
|
||||
this.exitHandlersRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default AgentLogger;
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
/** Determines when OpenAI's Responses API must be used. */
|
||||
export function requiresOpenAIResponsesEndpoint(
|
||||
modelName: string | undefined,
|
||||
): boolean {
|
||||
if (!modelName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized: string = modelName.toLowerCase();
|
||||
return (
|
||||
normalized.includes("gpt-5") ||
|
||||
normalized.includes("gpt-4.1") ||
|
||||
normalized.includes("codex")
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
const SECRET_PATTERNS: Array<{ regex: RegExp; replacement: string }> = [
|
||||
{
|
||||
regex: /sk-[a-zA-Z0-9_-]{16,}/g,
|
||||
replacement: "[REDACTED-OPENAI-KEY]",
|
||||
},
|
||||
];
|
||||
|
||||
/** Masks known secret patterns so we never expose raw credentials to the LLM. */
|
||||
export function redactSecrets(text: string): string {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return SECRET_PATTERNS.reduce(
|
||||
(sanitized: string, pattern: { regex: RegExp; replacement: string }) => {
|
||||
return sanitized.replace(pattern.regex, pattern.replacement);
|
||||
},
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import LocalFile from "Common/Server/Utils/LocalFile";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import AgentLogger from "./AgentLogger";
|
||||
|
||||
/** Utility helpers for resolving and validating paths inside the workspace. */
|
||||
export class WorkspacePaths {
|
||||
private readonly root: string;
|
||||
|
||||
/** Stores the canonical workspace root for all future resolutions. */
|
||||
public constructor(workspaceRoot: string) {
|
||||
this.root = path.resolve(workspaceRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a candidate path relative to the workspace root while ensuring the
|
||||
* result never escapes the allowed directory tree.
|
||||
*/
|
||||
public resolve(candidate: string): string {
|
||||
const sanitizedCandidate: string = candidate.trim() || ".";
|
||||
const absolutePath: string = path.resolve(this.root, sanitizedCandidate);
|
||||
AgentLogger.debug("Resolving workspace path", {
|
||||
candidate,
|
||||
sanitizedCandidate,
|
||||
absolutePath,
|
||||
});
|
||||
|
||||
if (!this.isInsideWorkspace(absolutePath)) {
|
||||
AgentLogger.error("Path outside workspace", {
|
||||
candidate,
|
||||
absolutePath,
|
||||
workspaceRoot: this.root,
|
||||
});
|
||||
throw new BadDataException(
|
||||
`Path ${candidate} is outside the workspace root ${this.root}`,
|
||||
);
|
||||
}
|
||||
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
/** Returns the workspace-relative path for a given absolute target. */
|
||||
public relative(target: string): string {
|
||||
const absolute: string = path.resolve(target);
|
||||
const relativePath: string = path.relative(this.root, absolute) || ".";
|
||||
AgentLogger.debug("Computed relative path", {
|
||||
target,
|
||||
absolute,
|
||||
relativePath,
|
||||
});
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
/** Exposes the normalized workspace root path. */
|
||||
public getRoot(): string {
|
||||
return this.root;
|
||||
}
|
||||
|
||||
/** Creates parent directories as needed for a file inside the workspace. */
|
||||
public async ensureParentDirectory(targetFile: string): Promise<void> {
|
||||
const parentDir: string = path.dirname(targetFile);
|
||||
if (!(await LocalFile.doesDirectoryExist(parentDir))) {
|
||||
AgentLogger.debug("Creating parent directory", {
|
||||
parentDir,
|
||||
});
|
||||
await fs.mkdir(parentDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensures the provided absolute path is within the workspace root. */
|
||||
private isInsideWorkspace(target: string): boolean {
|
||||
const normalizedTarget: string = path.resolve(target);
|
||||
return (
|
||||
normalizedTarget === this.root ||
|
||||
normalizedTarget.startsWith(this.root + path.sep)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
{
|
||||
"ts-node": {
|
||||
// these options are overrides used only by ts-node
|
||||
// same as the --compilerOptions flag and the TS_NODE_COMPILER_OPTIONS environment variable
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"resolveJsonModule": true,
|
||||
}
|
||||
},
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2017" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
"jsx": "react" /* Specify what JSX code is generated. */,
|
||||
"experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
"emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
|
||||
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
|
||||
/* Modules */
|
||||
// "module": "es2022" /* Specify what module code is generated. */,
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
"baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
"paths": {
|
||||
"Common/*": ["../Common/*"]
|
||||
}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
"typeRoots": [
|
||||
"./node_modules/@types"
|
||||
], /* Specify multiple folders that act like `./node_modules/@types`. */
|
||||
"types": ["node"], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files */
|
||||
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
|
||||
"outDir": "./build/dist", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
|
||||
"strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
|
||||
"strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
"strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
|
||||
"strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
"noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
|
||||
"useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
|
||||
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
"noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
|
||||
"noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
|
||||
"exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
"noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
"noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
"noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
|
||||
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
"noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
|
|
@ -288,17 +288,6 @@ VAPID_PUBLIC_KEY=
|
|||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:support@oneuptime.com
|
||||
|
||||
# Copilot Environment Variables
|
||||
COPILOT_ONEUPTIME_URL=http://localhost
|
||||
COPILOT_ONEUPTIME_REPOSITORY_SECRET_KEY=
|
||||
COPILOT_CODE_REPOSITORY_PASSWORD=
|
||||
COPILOT_CODE_REPOSITORY_USERNAME=
|
||||
COPILOT_ONEUPTIME_LLM_SERVER_URL=
|
||||
# Set this to false if you want to enable copilot.
|
||||
DISABLE_COPILOT=true
|
||||
COPILOT_OPENAI_API_KEY=
|
||||
|
||||
|
||||
# LLM Environment Variables
|
||||
|
||||
# Hugging Face Token for LLM Server to downlod models from Hugging Face
|
||||
|
|
|
|||
|
|
@ -500,25 +500,6 @@ services:
|
|||
driver: "local"
|
||||
options:
|
||||
max-size: "1000m"
|
||||
|
||||
copilot:
|
||||
networks:
|
||||
- oneuptime
|
||||
restart: always
|
||||
environment:
|
||||
ONEUPTIME_URL: ${COPILOT_ONEUPTIME_URL}
|
||||
ONEUPTIME_REPOSITORY_SECRET_KEY: ${COPILOT_ONEUPTIME_REPOSITORY_SECRET_KEY}
|
||||
CODE_REPOSITORY_PASSWORD: ${COPILOT_CODE_REPOSITORY_PASSWORD}
|
||||
CODE_REPOSITORY_USERNAME: ${COPILOT_CODE_REPOSITORY_USERNAME}
|
||||
ONEUPTIME_LLM_SERVER_URL: ${COPILOT_ONEUPTIME_LLM_SERVER_URL}
|
||||
DISABLE_COPILOT: ${DISABLE_COPILOT}
|
||||
OPENAI_API_KEY: ${COPILOT_OPENAI_API_KEY}
|
||||
LOG_LEVEL: ${LOG_LEVEL}
|
||||
IS_ENTERPRISE_EDITION: ${IS_ENTERPRISE_EDITION}
|
||||
logging:
|
||||
driver: "local"
|
||||
options:
|
||||
max-size: "1000m"
|
||||
|
||||
e2e:
|
||||
restart: "no"
|
||||
|
|
|
|||
|
|
@ -453,24 +453,6 @@ services:
|
|||
context: .
|
||||
dockerfile: ./E2E/Dockerfile
|
||||
|
||||
copilot:
|
||||
volumes:
|
||||
- ./Copilot:/usr/src/app:cached
|
||||
# Use node modules of the container and not host system.
|
||||
# https://stackoverflow.com/questions/29181032/add-a-volume-to-docker-but-exclude-a-sub-folder
|
||||
- /usr/src/app/node_modules/
|
||||
- ./Common:/usr/src/Common:cached
|
||||
- /usr/src/Common/node_modules/
|
||||
ports:
|
||||
- '9985:9229' # Debugging port.
|
||||
extends:
|
||||
file: ./docker-compose.base.yml
|
||||
service: copilot
|
||||
build:
|
||||
network: host
|
||||
context: .
|
||||
dockerfile: ./Copilot/Dockerfile
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
clickhouse:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue