mirror of
https://github.com/mudler/LocalAGI.git
synced 2026-08-04 07:56:16 -04:00
Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 419a4363c3 | |||
| 292d0c9c19 | |||
| 1147e02844 | |||
| c8e83dc4b9 | |||
| 8b5188c35b | |||
| 55b631bc51 | |||
| c1e2eeb8af | |||
| 50f168f322 | |||
| b5dacb0e4f | |||
| 0762d6fabb | |||
| 6dc515bae5 | |||
| 9110e9cfdb | |||
| 9965e3df36 | |||
| da9e8d7c20 | |||
| 7f2e52c0c8 | |||
| 6a33484fce | |||
| bdf12e4c3d | |||
| 5743d6f757 | |||
| 0845e9dc81 | |||
| ef2a6e2296 | |||
| 272c54f851 | |||
| 775b1a9ab8 | |||
| 673909918a | |||
| 807b7e756b | |||
| c75da2235d | |||
| 80a12f6662 | |||
| 1128615140 | |||
| 8847bfe0fc | |||
| 25ce5475de | |||
| 2a4bc0eb0a | |||
| d0bfa01494 | |||
| 57023d6386 | |||
| 1c3e9b36e7 | |||
| 4a3ee02081 | |||
| d804ef66e5 | |||
| cea3b7b111 | |||
| 1d574069f2 | |||
| b09da13111 | |||
| ba2eafe4b2 | |||
| cbe4cd62ca | |||
| 4f4ad5069d | |||
| 0950791be6 | |||
| f47d5d0e01 | |||
| e3d4ac3d0b | |||
| c98178d41d | |||
| 4e89c8aac0 | |||
| c3bd2bee42 | |||
| 9f358abb54 | |||
| 456c32c284 | |||
| b527cbc332 | |||
| 7cc0c3e85b | |||
| 73ce451c13 | |||
| 72a7807a81 | |||
| 16775f356a | |||
| a363682b39 | |||
| 2288e7f08d | |||
| c703495e8c | |||
| 73c1158aa2 | |||
| a1036a166b | |||
| 9a669e2552 | |||
| cfdefcd075 | |||
| ec3f302cdf | |||
| 345dd672d2 | |||
| 7e50afde73 | |||
| e33046db03 | |||
| 9a33537ae0 | |||
| c7040585d3 | |||
| 85823961f3 | |||
| ef0e6d2025 | |||
| 4687efb502 | |||
| 9829930451 | |||
| eaf81438fa | |||
| 8141defbaf | |||
| a1667b2227 | |||
| 05f21c6c80 | |||
| f6ab437796 | |||
| 9405fe3825 | |||
| bef63357be | |||
| 0fec0d39b7 | |||
| 8709422e05 | |||
| 229aab41f8 | |||
| 853a168773 | |||
| fa963660e0 | |||
| fbb098e996 | |||
| b124244b0c | |||
| c7d1b58340 | |||
| 9971819350 | |||
| e3769d3e1f | |||
| f777ce2992 | |||
| 02eda4efb3 | |||
| 1b3420c857 | |||
| 2f537e72bb | |||
| 4afdb6f813 | |||
| c610c2c6ca | |||
| 047a324e0e | |||
| e37283cb11 | |||
| d6f10b28c1 | |||
| c082b42395 | |||
| 252ee2aff6 | |||
| fffc1f8647 | |||
| dc1653294d | |||
| 3862c3c0eb | |||
| 756c46b526 | |||
| 73a6be8264 | |||
| 84f21c6ab4 | |||
| a0ab067bf7 | |||
| c719cbd544 | |||
| 6fa73262fc | |||
| 558fa396a8 | |||
| 33ffe06510 | |||
| a5a602c5b0 | |||
| 500862b768 | |||
| b06522d1ec | |||
| 0c312c9caa | |||
| d456315f19 | |||
| 983f5599d5 | |||
| 471c937d57 | |||
| ad61551db5 | |||
| 2bad9c399f | |||
| 580ad7d46f | |||
| 47f013f3e1 | |||
| d5219a9379 | |||
| 00460b1538 | |||
| 98e62edf12 | |||
| 592969f976 | |||
| 6be849e409 | |||
| d42c2a5749 | |||
| 8d2eafbc35 | |||
| 848f688ae9 | |||
| b813842f85 | |||
| 684ce43eb8 | |||
| 39d13d155f | |||
| 2d5523a129 | |||
| 7fe3ed2169 | |||
| 6db3b5f31f | |||
| f6ac820a3f | |||
| d955284278 | |||
| 3fd6134bcd | |||
| 4d875e9e2a | |||
| 79b72ec311 | |||
| 8487459d90 | |||
| be336adf02 | |||
| be1acb06fd | |||
| 26ec479596 | |||
| efee3c462d | |||
| 4cf52ec698 | |||
| ba8ea0117e | |||
| 36770b70ae | |||
| 5784be2853 | |||
| 5b93e5c21c | |||
| b0a43b0cb7 | |||
| 51bb368073 | |||
| 153fcf6cf0 | |||
| b52571dbea | |||
| c0a4f1026f | |||
| aab73ead5c | |||
| f05af02864 | |||
| 46adfb5258 | |||
| d6784f11a7 | |||
| 4bd86bd842 | |||
| e630ead9c0 | |||
| 5bd97908b5 | |||
| f98c306b48 | |||
| cf60dd88c4 | |||
| dc7badd034 | |||
| 20511283b5 | |||
| 5c233c8aa5 | |||
| 9500ec7af0 | |||
| 1fb7f8bc75 | |||
| 62723267a9 | |||
| e4da4b307c | |||
| 578de58e44 | |||
| aff460c7c2 | |||
| ff6890c9c1 | |||
| 9160ca598e | |||
| 7a8b3d93cf | |||
| 86659e704a | |||
| 6e999927ec | |||
| 863c6f3dcb | |||
| 2a6378764b | |||
| da7cb91f83 | |||
| 13e0f12c1b | |||
| a0a9299cda | |||
| 15237d3137 | |||
| e97cd43cdb | |||
| 1313f805ae | |||
| aafbd9159a | |||
| cff90bc709 | |||
| e2de768e09 | |||
| 80871db0de | |||
| 50cad776aa | |||
| 56b6f7240c |
@@ -16,11 +16,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.24
|
||||
- name: Run GoReleaser
|
||||
|
||||
@@ -11,10 +11,11 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
containerImages:
|
||||
runs-on: ubuntu-latest
|
||||
#runs-on: ubuntu-latest
|
||||
runs-on: arc-runner-localagent
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Prepare
|
||||
id: prep
|
||||
@@ -57,7 +58,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
|
||||
with:
|
||||
images: quay.io/mudler/localagi
|
||||
tags: |
|
||||
@@ -83,84 +84,12 @@ jobs:
|
||||
#tags: ${{ steps.prep.outputs.tags }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
mcpbox-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
id: prep
|
||||
run: |
|
||||
DOCKER_IMAGE=quay.io/mudler/localagi-mcpbox
|
||||
# Use branch name as default
|
||||
VERSION=${GITHUB_REF#refs/heads/}
|
||||
BINARY_VERSION=$(git describe --always --tags --dirty)
|
||||
SHORTREF=${GITHUB_SHA::8}
|
||||
# If this is git tag, use the tag name as a docker tag
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
fi
|
||||
TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${SHORTREF}"
|
||||
# If the VERSION looks like a version number, assume that
|
||||
# this is the most recent version of the image and also
|
||||
# tag it 'latest'.
|
||||
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
TAGS="$TAGS,${DOCKER_IMAGE}:latest"
|
||||
fi
|
||||
# Set output parameters.
|
||||
echo ::set-output name=binary_version::${BINARY_VERSION}
|
||||
echo ::set-output name=tags::${TAGS}
|
||||
echo ::set-output name=docker_image::${DOCKER_IMAGE}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@master
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@master
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
|
||||
with:
|
||||
images: quay.io/mudler/localagi-mcpbox
|
||||
tags: |
|
||||
type=ref,event=branch,suffix=-{{date 'YYYYMMDDHHmmss'}}
|
||||
type=semver,pattern={{raw}}
|
||||
type=sha,suffix=-{{date 'YYYYMMDDHHmmss'}}
|
||||
type=ref,event=branch
|
||||
flavor: |
|
||||
latest=auto
|
||||
prefix=
|
||||
suffix=
|
||||
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.prep.outputs.binary_version }}
|
||||
context: ./
|
||||
file: ./Dockerfile.mcpbox
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
#tags: ${{ steps.prep.outputs.tags }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
sshbox-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Prepare
|
||||
id: prep
|
||||
@@ -203,7 +132,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
|
||||
with:
|
||||
images: quay.io/mudler/localagi-sshbox
|
||||
tags: |
|
||||
|
||||
+13
-37
@@ -1,4 +1,4 @@
|
||||
name: Run Go Tests
|
||||
name: Run Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -15,44 +15,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- run: |
|
||||
# Add Docker's official GPG key:
|
||||
sudo apt-get update
|
||||
sudo apt-get install ca-certificates curl
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
|
||||
# Add the repository to Apt sources:
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin make
|
||||
docker version
|
||||
|
||||
docker run --rm hello-world
|
||||
- uses: actions/setup-go@v5
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '>=1.17.0'
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo apt-get clean
|
||||
docker system prune -af || true
|
||||
df -h
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
make tests
|
||||
#sudo mv coverage/coverage.txt coverage.txt
|
||||
#sudo chmod 777 coverage.txt
|
||||
|
||||
# - name: Upload coverage to Codecov
|
||||
# uses: codecov/codecov-action@v4
|
||||
# with:
|
||||
# token: ${{ secrets.CODECOV_TOKEN }}
|
||||
test-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '>=1.17.0'
|
||||
- run: |
|
||||
make tests-e2e
|
||||
@@ -8,3 +8,5 @@ LocalAGI
|
||||
**/.env
|
||||
.vscode
|
||||
volumes/
|
||||
example/scheduler/scheduler
|
||||
example/scheduler/example_tasks.json
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Build stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o mcpbox ./cmd/mcpbox
|
||||
|
||||
# Final stage
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y ca-certificates tzdata docker.io bash wget curl
|
||||
|
||||
# Create non-root user
|
||||
#RUN adduser -D -g '' appuser
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/mcpbox .
|
||||
|
||||
# Use non-root user
|
||||
#USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["/app/mcpbox"]
|
||||
|
||||
# Default command
|
||||
CMD ["-addr", ":8080"]
|
||||
@@ -1,5 +1,5 @@
|
||||
# python
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.14-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y python3-dev portaudio19-dev ffmpeg build-essential
|
||||
|
||||
+12
-3
@@ -44,12 +44,21 @@ COPY --from=ui-builder /app/dist /work/webui/react-ui/dist
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o localagi ./
|
||||
|
||||
FROM scratch
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
docker.io \
|
||||
bash \
|
||||
wget \
|
||||
curl
|
||||
|
||||
# Copy the webui binary from the builder stage to the final image
|
||||
COPY --from=builder /work/localagi /localagi
|
||||
COPY --from=builder /etc/ssl/ /etc/ssl/
|
||||
COPY --from=builder /tmp /tmp
|
||||
|
||||
# Define the command that will be run when the container is started
|
||||
ENTRYPOINT ["/localagi"]
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
GOCMD?=go
|
||||
IMAGE_NAME?=webui
|
||||
MCPBOX_IMAGE_NAME?=mcpbox
|
||||
ROOT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
prepare-tests: build-mcpbox
|
||||
prepare-tests:
|
||||
docker compose up -d --build
|
||||
docker run -d -v /var/run/docker.sock:/var/run/docker.sock --privileged -p 9090:8080 --rm -ti $(MCPBOX_IMAGE_NAME)
|
||||
|
||||
cleanup-tests:
|
||||
docker compose down
|
||||
|
||||
tests: prepare-tests
|
||||
LOCALAGI_MCPBOX_URL="http://localhost:9090" LOCALAGI_MODEL="gemma-3-12b-it-qat" LOCALAI_API_URL="http://localhost:8081" LOCALAGI_API_URL="http://localhost:8080" $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --fail-fast -v -r ./...
|
||||
LOCALAGI_MODEL="gemma-3-4b-it-qat" LOCALAI_API_URL="http://localhost:8081" LOCALAGI_API_URL="http://localhost:8080" $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter="!E2E" --flake-attempts=5 --fail-fast -v -r ./...
|
||||
|
||||
run-nokb:
|
||||
$(MAKE) run KBDISABLEINDEX=true
|
||||
|
||||
webui/react-ui/dist:
|
||||
docker run --entrypoint /bin/bash -v $(ROOT_DIR):/app oven/bun:1 -c "cd /app/webui/react-ui && bun install && bun run build"
|
||||
docker run --entrypoint /bin/bash -v $(ROOT_DIR):/app:z oven/bun:1 -c "cd /app/webui/react-ui && bun install && bun run build"
|
||||
|
||||
.PHONY: build
|
||||
build: webui/react-ui/dist
|
||||
@@ -25,7 +23,7 @@ build: webui/react-ui/dist
|
||||
|
||||
.PHONY: run
|
||||
run: webui/react-ui/dist
|
||||
LOCALAGI_MCPBOX_URL="http://localhost:9090" $(GOCMD) run ./
|
||||
$(GOCMD) run ./
|
||||
|
||||
build-image:
|
||||
docker build -t $(IMAGE_NAME) -f Dockerfile.webui .
|
||||
@@ -33,8 +31,5 @@ build-image:
|
||||
image-push:
|
||||
docker push $(IMAGE_NAME)
|
||||
|
||||
build-mcpbox:
|
||||
docker build -t $(MCPBOX_IMAGE_NAME) -f Dockerfile.mcpbox .
|
||||
|
||||
run-mcpbox:
|
||||
docker run -v /var/run/docker.sock:/var/run/docker.sock --privileged -p 9090:8080 -ti mcpbox
|
||||
tests-e2e: prepare-tests
|
||||
LOCALAGI_MODEL="gemma-3-4b-it-qat" LOCALAI_API_URL="http://localhost:8081" LOCALAGI_API_URL="http://localhost:8080" $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter="E2E" --flake-attempts=5 --fail-fast -v -r ./tests/e2e/...
|
||||
|
||||
@@ -18,7 +18,7 @@ Try on [ and a web browser.
|
||||
|
||||
**LocalAGI** is a powerful, self-hostable AI Agent platform that allows you to design AI automations without writing code. A complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU).
|
||||
**LocalAGI** is a powerful, self-hostable AI Agent platform that allows you to design AI automations without writing code. Create Agents with a couple of clicks, connect via MCP and give it skills with [skillserver](https://github.com/mudler/skillserver). Every agent exposes a complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU).
|
||||
|
||||
## 🛡️ Take Back Your Privacy
|
||||
|
||||
@@ -31,7 +31,7 @@ LocalAGI ensures your data stays exactly where you want it—on your hardware. N
|
||||
- 🎛 **No-Code Agents**: Easy-to-configure multiple agents via Web UI.
|
||||
- 🖥 **Web-Based Interface**: Simple and intuitive agent management.
|
||||
- 🤖 **Advanced Agent Teaming**: Instantly create cooperative agent teams from a single prompt.
|
||||
- 📡 **Connectors Galore**: Built-in integrations with Discord, Slack, Telegram, GitHub Issues, and IRC.
|
||||
- 📡 **Connectors**: Built-in integrations with Discord, Slack, Telegram, GitHub Issues, and IRC.
|
||||
- 🛠 **Comprehensive REST API**: Seamless integration into your workflows. Every agent created will support OpenAI Responses API out of the box.
|
||||
- 📚 **Short & Long-Term Memory**: Powered by [LocalRecall](https://github.com/mudler/LocalRecall).
|
||||
- 🧠 **Planning & Reasoning**: Agents intelligently plan, reason, and adapt.
|
||||
@@ -58,12 +58,15 @@ docker compose -f docker-compose.nvidia.yaml up
|
||||
# Intel GPU setup (for Intel Arc and integrated GPUs)
|
||||
docker compose -f docker-compose.intel.yaml up
|
||||
|
||||
# AMD GPU setup
|
||||
docker compose -f docker-compose.amd.yaml up
|
||||
|
||||
# Start with a specific model (see available models in models.localai.io, or localai.io to use any model in huggingface)
|
||||
MODEL_NAME=gemma-3-12b-it docker compose up
|
||||
|
||||
# NVIDIA GPU setup with custom multimodal and image models
|
||||
MODEL_NAME=gemma-3-12b-it \
|
||||
MULTIMODAL_MODEL=minicpm-v-2_6 \
|
||||
MULTIMODAL_MODEL=moondream2-20250414 \
|
||||
IMAGE_MODEL=flux.1-dev-ggml \
|
||||
docker compose -f docker-compose.nvidia.yaml up
|
||||
```
|
||||
@@ -126,8 +129,8 @@ LocalAGI supports multiple hardware configurations through Docker Compose profil
|
||||
- Supports text, multimodal, and image generation models
|
||||
- Run with: `docker compose -f docker-compose.nvidia.yaml up`
|
||||
- Default models:
|
||||
- Text: `gemma-3-12b-it-qat`
|
||||
- Multimodal: `minicpm-v-2_6`
|
||||
- Text: `gemma-3-4b-it-qat`
|
||||
- Multimodal: `moondream2-20250414`
|
||||
- Image: `sd-1.5-ggml`
|
||||
- Environment variables:
|
||||
- `MODEL_NAME`: Text model to use
|
||||
@@ -142,8 +145,8 @@ LocalAGI supports multiple hardware configurations through Docker Compose profil
|
||||
- Supports text, multimodal, and image generation models
|
||||
- Run with: `docker compose -f docker-compose.intel.yaml up`
|
||||
- Default models:
|
||||
- Text: `gemma-3-12b-it-qat`
|
||||
- Multimodal: `minicpm-v-2_6`
|
||||
- Text: `gemma-3-4b-it-qat`
|
||||
- Multimodal: `moondream2-20250414`
|
||||
- Image: `sd-1.5-ggml`
|
||||
- Environment variables:
|
||||
- `MODEL_NAME`: Text model to use
|
||||
@@ -161,20 +164,23 @@ MODEL_NAME=gemma-3-12b-it docker compose up
|
||||
|
||||
# NVIDIA GPU with custom models
|
||||
MODEL_NAME=gemma-3-12b-it \
|
||||
MULTIMODAL_MODEL=minicpm-v-2_6 \
|
||||
MULTIMODAL_MODEL=moondream2-20250414 \
|
||||
IMAGE_MODEL=flux.1-dev-ggml \
|
||||
docker compose -f docker-compose.nvidia.yaml up
|
||||
|
||||
# Intel GPU with custom models
|
||||
MODEL_NAME=gemma-3-12b-it \
|
||||
MULTIMODAL_MODEL=minicpm-v-2_6 \
|
||||
MULTIMODAL_MODEL=moondream2-20250414 \
|
||||
IMAGE_MODEL=sd-1.5-ggml \
|
||||
docker compose -f docker-compose.intel.yaml up
|
||||
|
||||
# With custom actions directory
|
||||
LOCALAGI_CUSTOM_ACTIONS_DIR=/app/custom-actions docker compose up
|
||||
```
|
||||
|
||||
If no models are specified, it will use the defaults:
|
||||
- Text model: `gemma-3-12b-it-qat`
|
||||
- Multimodal model: `minicpm-v-2_6`
|
||||
- Text model: `gemma-3-4b-it-qat`
|
||||
- Multimodal model: `moondream2-20250414`
|
||||
- Image model: `sd-1.5-ggml`
|
||||
|
||||
Good (relatively small) models that have been tested are:
|
||||
@@ -234,6 +240,7 @@ LocalAGI supports environment configurations. Note that these environment variab
|
||||
| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection |
|
||||
| `LOCALAGI_ENABLE_CONVERSATIONS_LOGGING` | Toggle conversation logs |
|
||||
| `LOCALAGI_API_KEYS` | A comma separated list of api keys used for authentication |
|
||||
| `LOCALAGI_CUSTOM_ACTIONS_DIR` | Directory containing custom Go action files to be automatically loaded |
|
||||
|
||||
## Installation Options
|
||||
|
||||
@@ -336,7 +343,6 @@ pool, err := state.NewAgentPool(
|
||||
"http://localhost:8080", // API URL
|
||||
"your-api-key", // API key
|
||||
"./state", // state directory
|
||||
"", // MCP box URL (optional)
|
||||
"http://localhost:8081", // LocalRAG API URL
|
||||
func(config *AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action {
|
||||
// Define available actions for agents
|
||||
@@ -414,6 +420,279 @@ For more details about available configuration options and features, refer to th
|
||||
|
||||
</details>
|
||||
|
||||
## 🔧 Extending LocalAGI
|
||||
|
||||
LocalAGI provides two powerful ways to extend its functionality with custom actions:
|
||||
|
||||
### 1. Custom Actions (Go Code)
|
||||
|
||||
LocalAGI supports custom actions written in Go that can be defined inline when creating an agent. These actions are interpreted at runtime, so no compilation is required.
|
||||
|
||||
#### Automatic Custom Actions Loading
|
||||
|
||||
You can also place custom Go action files in a directory and have LocalAGI automatically load them. Set the `LOCALAGI_CUSTOM_ACTIONS_DIR` environment variable to point to a directory containing your custom action files. Each `.go` file in this directory will be automatically loaded and made available to all agents.
|
||||
|
||||
**Example setup:**
|
||||
```bash
|
||||
# Set the environment variable
|
||||
export LOCALAGI_CUSTOM_ACTIONS_DIR="/path/to/custom/actions"
|
||||
|
||||
# Or in docker-compose.yaml
|
||||
environment:
|
||||
- LOCALAGI_CUSTOM_ACTIONS_DIR=/app/custom-actions
|
||||
```
|
||||
|
||||
**Directory structure:**
|
||||
```
|
||||
custom-actions/
|
||||
├── weather_action.go
|
||||
├── file_processor.go
|
||||
└── database_query.go
|
||||
```
|
||||
|
||||
Each file should contain the three required functions (`Run`, `Definition`, `RequiredFields`) as described below.
|
||||
|
||||
#### How Custom Actions Work
|
||||
|
||||
When creating a new Agent, in the action sections select the "custom" action, you can add the Golang code directly there.
|
||||
|
||||
Custom actions in LocalAGI require three main functions:
|
||||
|
||||
1. **`Run(config map[string]interface{}) (string, map[string]interface{}, error)`** - The main execution function
|
||||
2. **`Definition() map[string][]string`** - Defines the action's parameters and their types
|
||||
3. **`RequiredFields() []string`** - Specifies which parameters are required
|
||||
|
||||
Note: You can't use additional modules, but just use libraries that are included in Go.
|
||||
|
||||
#### Example: Weather Information Action
|
||||
|
||||
Here's a practical example of a custom action that fetches weather information:
|
||||
|
||||
```go
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"io"
|
||||
)
|
||||
|
||||
type WeatherParams struct {
|
||||
City string `json:"city"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
type WeatherResponse struct {
|
||||
Main struct {
|
||||
Temp float64 `json:"temp"`
|
||||
Humidity int `json:"humidity"`
|
||||
} `json:"main"`
|
||||
Weather []struct {
|
||||
Description string `json:"description"`
|
||||
} `json:"weather"`
|
||||
}
|
||||
|
||||
func Run(config map[string]interface{}) (string, map[string]interface{}, error) {
|
||||
// Parse parameters
|
||||
p := WeatherParams{}
|
||||
b, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return "", map[string]interface{}{}, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
return "", map[string]interface{}{}, err
|
||||
}
|
||||
|
||||
// Make API call to weather service
|
||||
url := fmt.Sprintf("http://api.openweathermap.org/data/2.5/weather?q=%s,%s&appid=YOUR_API_KEY&units=metric", p.City, p.Country)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", map[string]interface{}{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", map[string]interface{}{}, err
|
||||
}
|
||||
|
||||
var weather WeatherResponse
|
||||
if err := json.Unmarshal(body, &weather); err != nil {
|
||||
return "", map[string]interface{}{}, err
|
||||
}
|
||||
|
||||
// Format response
|
||||
result := fmt.Sprintf("Weather in %s, %s: %.1f°C, %s, Humidity: %d%%",
|
||||
p.City, p.Country, weather.Main.Temp, weather.Weather[0].Description, weather.Main.Humidity)
|
||||
|
||||
return result, map[string]interface{}{}, nil
|
||||
}
|
||||
|
||||
func Definition() map[string][]string {
|
||||
return map[string][]string{
|
||||
"city": []string{
|
||||
"string",
|
||||
"The city name to get weather for",
|
||||
},
|
||||
"country": []string{
|
||||
"string",
|
||||
"The country code (e.g., US, UK, DE)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func RequiredFields() []string {
|
||||
return []string{"city", "country"}
|
||||
}
|
||||
```
|
||||
|
||||
#### Example: File System Action
|
||||
|
||||
Here's another example that demonstrates file system operations:
|
||||
|
||||
```go
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type FileParams struct {
|
||||
Path string `json:"path"`
|
||||
Action string `json:"action"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
func Run(config map[string]interface{}) (string, map[string]interface{}, error) {
|
||||
p := FileParams{}
|
||||
b, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return "", map[string]interface{}{}, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
return "", map[string]interface{}{}, err
|
||||
}
|
||||
|
||||
switch p.Action {
|
||||
case "read":
|
||||
content, err := os.ReadFile(p.Path)
|
||||
if err != nil {
|
||||
return "", map[string]interface{}{}, err
|
||||
}
|
||||
return string(content), map[string]interface{}{}, nil
|
||||
|
||||
case "write":
|
||||
err := os.WriteFile(p.Path, []byte(p.Content), 0644)
|
||||
if err != nil {
|
||||
return "", map[string]interface{}{}, err
|
||||
}
|
||||
return fmt.Sprintf("Successfully wrote to %s", p.Path), map[string]interface{}{}, nil
|
||||
|
||||
case "list":
|
||||
files, err := os.ReadDir(p.Path)
|
||||
if err != nil {
|
||||
return "", map[string]interface{}{}, err
|
||||
}
|
||||
|
||||
var fileList []string
|
||||
for _, file := range files {
|
||||
fileList = append(fileList, file.Name())
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(fileList)
|
||||
return string(result), map[string]interface{}{}, nil
|
||||
|
||||
default:
|
||||
return "", map[string]interface{}{}, fmt.Errorf("unknown action: %s", p.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func Definition() map[string][]string {
|
||||
return map[string][]string{
|
||||
"path": []string{
|
||||
"string",
|
||||
"The file or directory path",
|
||||
},
|
||||
"action": []string{
|
||||
"string",
|
||||
"The action to perform: read, write, or list",
|
||||
},
|
||||
"content": []string{
|
||||
"string",
|
||||
"Content to write (required for write action)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func RequiredFields() []string {
|
||||
return []string{"path", "action"}
|
||||
}
|
||||
```
|
||||
|
||||
#### Using Custom Actions in Agents
|
||||
|
||||
To use custom actions, add them to your agent configuration:
|
||||
|
||||
1. **Via Web UI**: In the agent creation form, add a "Custom" action and paste your Go code
|
||||
2. **Via API**: Include the custom action in your agent configuration JSON
|
||||
3. **Via Library**: Add the custom action to your agent's actions list
|
||||
|
||||
### 2. MCP (Model Context Protocol) Servers
|
||||
|
||||
LocalAGI supports both local and remote MCP servers, allowing you to extend functionality with external tools and services.
|
||||
|
||||
#### What is MCP?
|
||||
|
||||
The Model Context Protocol (MCP) is a standard for connecting AI applications to external data sources and tools. LocalAGI can connect to any MCP-compliant server to access additional capabilities.
|
||||
|
||||
#### Local MCP Servers
|
||||
|
||||
Local MCP servers run as processes that LocalAGI can spawn and communicate with via STDIO.
|
||||
|
||||
##### Example: GitHub MCP Server
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"ghcr.io/github/github-mcp-server"
|
||||
],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "<YOUR_TOKEN>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Remote MCP Servers
|
||||
|
||||
Remote MCP servers are HTTP-based and can be accessed over the network.
|
||||
|
||||
#### Creating Your Own MCP Server
|
||||
|
||||
You can create MCP servers in any language that supports the MCP protocol and add the URLs of the servers to LocalAGI.
|
||||
|
||||
#### Configuring MCP Servers in LocalAGI
|
||||
|
||||
1. **Via Web UI**: In the MCP Settings section of agent creation, add MCP servers
|
||||
2. **Via API**: Include MCP server configuration in your agent config
|
||||
|
||||
#### Best Practices
|
||||
|
||||
- **Security**: Always validate inputs and use proper authentication for remote MCP servers
|
||||
- **Error Handling**: Implement robust error handling in your MCP servers
|
||||
- **Documentation**: Provide clear descriptions for all tools exposed by your MCP server
|
||||
- **Testing**: Test your MCP servers independently before integrating with LocalAGI
|
||||
- **Resource Management**: Ensure your MCP servers properly clean up resources
|
||||
|
||||
### Development
|
||||
|
||||
The development workflow is similar to the source build, but with additional steps for hot reloading of the frontend:
|
||||
@@ -423,15 +702,39 @@ The development workflow is similar to the source build, but with additional ste
|
||||
git clone https://github.com/mudler/LocalAGI.git
|
||||
cd LocalAGI
|
||||
|
||||
# Install dependencies and start frontend development server
|
||||
cd webui/react-ui && bun i && bun run dev
|
||||
cd webui/react-ui
|
||||
|
||||
# Install dependencies
|
||||
bun i
|
||||
|
||||
# Compile frontend (the build directory needs to exist for the backend to start)
|
||||
bun run build
|
||||
|
||||
# Start frontend development server
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Then in separate terminal:
|
||||
|
||||
```bash
|
||||
cd LocalAGI
|
||||
|
||||
# Create a "pool" directory for agent state
|
||||
mkdir pool
|
||||
|
||||
# Set required environment variables
|
||||
export LOCALAGI_MODEL=gemma-3-4b-it-qat
|
||||
export LOCALAGI_MULTIMODAL_MODEL=moondream2-20250414
|
||||
export LOCALAGI_IMAGE_MODEL=sd-1.5-ggml
|
||||
export LOCALAGI_LLM_API_URL=http://localai:8080
|
||||
export LOCALAGI_LOCALRAG_URL=http://localrecall:8080
|
||||
export LOCALAGI_STATE_DIR=./pool
|
||||
export LOCALAGI_TIMEOUT=5m
|
||||
export LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
|
||||
export LOCALAGI_SSHBOX_URL=root:root@sshbox:22
|
||||
|
||||
# Start development server
|
||||
cd ../.. && go run main.go
|
||||
go run main.go
|
||||
```
|
||||
|
||||
> Note: see webui/react-ui/.vite.config.js for env vars that can be used to configure the backend URL
|
||||
@@ -730,9 +1033,9 @@ LocalAGI supports environment configurations. Note that these environment variab
|
||||
| `LOCALAGI_STATE_DIR` | Where state gets stored |
|
||||
| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection |
|
||||
| `LOCALAGI_SSHBOX_URL` | LocalAGI SSHBox URL, e.g. user:pass@ip:port |
|
||||
| `LOCALAGI_MCPBOX_URL` | LocalAGI MCPBox URL, e.g. http://mcpbox:8080 |
|
||||
| `LOCALAGI_ENABLE_CONVERSATIONS_LOGGING` | Toggle conversation logs |
|
||||
| `LOCALAGI_API_KEYS` | A comma separated list of api keys used for authentication |
|
||||
| `LOCALAGI_CUSTOM_ACTIONS_DIR` | Directory containing custom Go action files to be automatically loaded |
|
||||
</details>
|
||||
|
||||
## LICENSE
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/stdio"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Parse command line flags
|
||||
addr := flag.String("addr", ":8080", "HTTP server address")
|
||||
flag.Parse()
|
||||
|
||||
// Create and start the server
|
||||
server := stdio.NewServer()
|
||||
|
||||
// Handle graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
log.Printf("Starting server on %s", *addr)
|
||||
if err := server.Start(*addr); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
<-sigChan
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// TODO: Implement graceful shutdown if needed
|
||||
os.Exit(0)
|
||||
}
|
||||
+43
-6
@@ -3,11 +3,13 @@ package action
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
"github.com/traefik/yaegi/interp"
|
||||
"github.com/traefik/yaegi/stdlib"
|
||||
@@ -24,7 +26,7 @@ func NewCustom(config map[string]string, goPkgPath string) (*CustomAction, error
|
||||
}
|
||||
|
||||
if err := a.callInit(); err != nil {
|
||||
xlog.Error("Error calling custom action init", "error", err)
|
||||
xlog.Warn("No init function found for custom action", "error", err, "action", a.config["name"])
|
||||
}
|
||||
|
||||
return a, nil
|
||||
@@ -43,18 +45,23 @@ func (a *CustomAction) callInit() error {
|
||||
|
||||
v, err := a.i.Eval(fmt.Sprintf("%s.Init", a.config["name"]))
|
||||
if err != nil {
|
||||
return err
|
||||
xlog.Warn("No init function found for custom action", "error", err, "action", a.config["name"])
|
||||
return nil
|
||||
}
|
||||
|
||||
run := v.Interface().(func() error)
|
||||
run, ok := v.Interface().(func(string) error)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return run()
|
||||
return run(a.config["configuration"])
|
||||
}
|
||||
|
||||
func (a *CustomAction) initializeInterpreter() error {
|
||||
if _, exists := a.config["code"]; exists && a.i == nil {
|
||||
unsafe := strings.ToLower(a.config["unsafe"]) == "true"
|
||||
i := interp.New(interp.Options{
|
||||
Env: os.Environ(),
|
||||
GoPath: a.goPkgPath,
|
||||
Unrestricted: unsafe,
|
||||
})
|
||||
@@ -66,6 +73,15 @@ func (a *CustomAction) initializeInterpreter() error {
|
||||
a.config["name"] = "custom"
|
||||
}
|
||||
|
||||
// let's find first if there is already a package declarated in the code
|
||||
// the user might want to specify it to not break syntax with IDEs
|
||||
re := regexp.MustCompile("package (\\w+)")
|
||||
packageName := re.FindStringSubmatch(a.config["code"])
|
||||
if len(packageName) > 1 {
|
||||
// remove it from the code, normalize to `name`
|
||||
a.config["code"] = re.ReplaceAllString(a.config["code"], "")
|
||||
}
|
||||
|
||||
_, err := i.Eval(fmt.Sprintf("package %s\n%s", a.config["name"], a.config["code"]))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -106,6 +122,21 @@ func (a *CustomAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{}
|
||||
}
|
||||
|
||||
description := ""
|
||||
desc, err := a.i.Eval(fmt.Sprintf("%s.Description", a.config["name"]))
|
||||
if err != nil {
|
||||
xlog.Warn("No description found for custom action", "error", err, "action", a.config["name"])
|
||||
} else {
|
||||
d, ok := desc.Interface().(func() string)
|
||||
if ok {
|
||||
description = d()
|
||||
}
|
||||
}
|
||||
|
||||
if a.config["description"] != "" {
|
||||
description = a.config["description"]
|
||||
}
|
||||
|
||||
properties := v.Interface().(func() map[string][]string)
|
||||
|
||||
v, err = a.i.Eval(fmt.Sprintf("%s.RequiredFields", a.config["name"]))
|
||||
@@ -130,7 +161,7 @@ func (a *CustomAction) Definition() types.ActionDefinition {
|
||||
}
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(a.config["name"]),
|
||||
Description: a.config["description"],
|
||||
Description: description,
|
||||
Properties: prop,
|
||||
Required: requiredFields(),
|
||||
}
|
||||
@@ -164,5 +195,11 @@ func CustomConfigMeta() []config.Field {
|
||||
Type: config.FieldTypeCheckbox,
|
||||
HelpText: "Allow unsafe code execution",
|
||||
},
|
||||
{
|
||||
Name: "configuration",
|
||||
Label: "Configuration",
|
||||
Type: config.FieldTypeTextarea,
|
||||
HelpText: "Configuration for the custom action",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// NewGoal creates a new intention action
|
||||
// The inention action is special as it tries to identify
|
||||
// a tool to use and a reasoning over to use it
|
||||
func NewGoal() *GoalAction {
|
||||
return &GoalAction{}
|
||||
}
|
||||
|
||||
type GoalAction struct {
|
||||
}
|
||||
type GoalResponse struct {
|
||||
Goal string `json:"goal"`
|
||||
Achieved bool `json:"achieved"`
|
||||
}
|
||||
|
||||
func (a *GoalAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
return types.ActionResult{}, nil
|
||||
}
|
||||
|
||||
func (a *GoalAction) Plannable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *GoalAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: "goal",
|
||||
Description: "Check if the goal is achieved",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"goal": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The goal to check if it is achieved.",
|
||||
},
|
||||
"achieved": {
|
||||
Type: jsonschema.Boolean,
|
||||
Description: "Whether the goal is achieved",
|
||||
},
|
||||
},
|
||||
Required: []string{"goal", "achieved"},
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// NewIntention creates a new intention action
|
||||
// The inention action is special as it tries to identify
|
||||
// a tool to use and a reasoning over to use it
|
||||
func NewIntention(s ...string) *IntentAction {
|
||||
return &IntentAction{tools: s}
|
||||
}
|
||||
|
||||
type IntentAction struct {
|
||||
tools []string
|
||||
}
|
||||
type IntentResponse struct {
|
||||
Tool string `json:"tool"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
}
|
||||
|
||||
func (a *IntentAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
return types.ActionResult{}, nil
|
||||
}
|
||||
|
||||
func (a *IntentAction) Plannable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *IntentAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: "pick_tool",
|
||||
Description: "Pick a tool",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"reasoning": {
|
||||
Type: jsonschema.String,
|
||||
Description: "A detailed reasoning on why you want to call this tool.",
|
||||
},
|
||||
"tool": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The tool you want to use",
|
||||
Enum: a.tools,
|
||||
},
|
||||
},
|
||||
Required: []string{"tool", "reasoning"},
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const ConversationActionName = "new_conversation"
|
||||
const ConversationActionName = "send_message"
|
||||
|
||||
func NewConversation() *ConversationAction {
|
||||
return &ConversationAction{}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// PlanActionName is the name of the plan action
|
||||
// used by the LLM to schedule more actions
|
||||
const PlanActionName = "plan"
|
||||
|
||||
func NewPlan(plannableActions []string) *PlanAction {
|
||||
return &PlanAction{
|
||||
plannables: plannableActions,
|
||||
}
|
||||
}
|
||||
|
||||
type PlanAction struct {
|
||||
plannables []string
|
||||
}
|
||||
|
||||
type PlanResult struct {
|
||||
Subtasks []PlanSubtask `json:"subtasks"`
|
||||
Goal string `json:"goal"`
|
||||
}
|
||||
type PlanSubtask struct {
|
||||
Action string `json:"action"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
}
|
||||
|
||||
func (a *PlanAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
return types.ActionResult{}, nil
|
||||
}
|
||||
|
||||
func (a *PlanAction) Plannable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *PlanAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: PlanActionName,
|
||||
Description: "Use it for situations that involves doing more actions in sequence.",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"subtasks": {
|
||||
Type: jsonschema.Array,
|
||||
Description: "The subtasks to be executed",
|
||||
Items: &jsonschema.Definition{
|
||||
Type: jsonschema.Object,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"action": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The action to call",
|
||||
Enum: a.plannables,
|
||||
},
|
||||
"reasoning": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The reasoning for calling this action",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"goal": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The goal of this plan",
|
||||
},
|
||||
},
|
||||
Required: []string{"subtasks", "goal"},
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// NewReasoning creates a new reasoning action
|
||||
// The reasoning action is special as it tries to force the LLM
|
||||
// to think about what to do next
|
||||
func NewReasoning() *ReasoningAction {
|
||||
return &ReasoningAction{}
|
||||
}
|
||||
|
||||
type ReasoningAction struct{}
|
||||
|
||||
type ReasoningResponse struct {
|
||||
Reasoning string `json:"reasoning"`
|
||||
}
|
||||
|
||||
func (a *ReasoningAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
return types.ActionResult{}, nil
|
||||
}
|
||||
|
||||
func (a *ReasoningAction) Plannable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *ReasoningAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: "pick_action",
|
||||
Description: "try to understand what's the best thing to do and pick an action with a reasoning",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"reasoning": {
|
||||
Type: jsonschema.String,
|
||||
Description: "A detailed reasoning on what would you do in this situation.",
|
||||
},
|
||||
},
|
||||
Required: []string{"reasoning"},
|
||||
}
|
||||
}
|
||||
+127
-52
@@ -6,19 +6,24 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/scheduler"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const (
|
||||
ReminderActionName = "set_reminder"
|
||||
ListRemindersName = "list_reminders"
|
||||
RemoveReminderName = "remove_reminder"
|
||||
RecurringReminderActionName = "set_recurring_task"
|
||||
OneTimeReminderActionName = "set_onetime_task"
|
||||
ListRemindersName = "list_tasks"
|
||||
RemoveReminderName = "remove_task"
|
||||
)
|
||||
|
||||
func NewReminder() *ReminderAction {
|
||||
return &ReminderAction{}
|
||||
func NewRecurringReminder() *RecurringReminderAction {
|
||||
return &RecurringReminderAction{}
|
||||
}
|
||||
|
||||
func NewOneTimeReminder() *OneTimeReminderAction {
|
||||
return &OneTimeReminderAction{}
|
||||
}
|
||||
|
||||
func NewListReminders() *ListRemindersAction {
|
||||
@@ -29,7 +34,8 @@ func NewRemoveReminder() *RemoveReminderAction {
|
||||
return &RemoveReminderAction{}
|
||||
}
|
||||
|
||||
type ReminderAction struct{}
|
||||
type RecurringReminderAction struct{}
|
||||
type OneTimeReminderAction struct{}
|
||||
type ListRemindersAction struct{}
|
||||
type RemoveReminderAction struct{}
|
||||
|
||||
@@ -37,46 +43,87 @@ type RemoveReminderParams struct {
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
func (a *ReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := types.ReminderActionResponse{}
|
||||
func (a *RecurringReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := types.RecurringReminderParams{}
|
||||
err := params.Unmarshal(&result)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
// Validate the cron expression
|
||||
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
_, err = parser.Parse(result.CronExpr)
|
||||
task, err := scheduler.NewTask(
|
||||
sharedState.AgentName,
|
||||
result.Message,
|
||||
scheduler.ScheduleTypeCron,
|
||||
result.CronExpr,
|
||||
)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
// Calculate next run time
|
||||
now := time.Now()
|
||||
schedule, _ := parser.Parse(result.CronExpr) // We can ignore the error since we validated above
|
||||
nextRun := schedule.Next(now)
|
||||
task.Metadata["reminder_type"] = "user_created"
|
||||
|
||||
// Set the reminder details
|
||||
result.LastRun = now
|
||||
result.NextRun = nextRun
|
||||
// IsRecurring is set by the user through the action parameters
|
||||
|
||||
// Store the reminder in the shared state
|
||||
if sharedState.Reminders == nil {
|
||||
sharedState.Reminders = make([]types.ReminderActionResponse, 0)
|
||||
err = sharedState.Scheduler.CreateTask(task)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
sharedState.Reminders = append(sharedState.Reminders, result)
|
||||
|
||||
return types.ActionResult{
|
||||
Result: "Reminder set successfully",
|
||||
Result: fmt.Sprintf("Recurring reminder set successfully (ID: %s). Next run: %s", task.ID, task.NextRun.Format(time.RFC3339)),
|
||||
Metadata: map[string]interface{}{
|
||||
"reminder": result,
|
||||
"task_id": task.ID,
|
||||
"message": result.Message,
|
||||
"next_run": task.NextRun,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *OneTimeReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := types.OneTimeReminderParams{}
|
||||
err := params.Unmarshal(&result)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
// Validate the delay parses correctly before creating the task
|
||||
_, err = scheduler.ParseDuration(result.Delay)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid delay format, expected a duration like '30m', '2h', '1d', '1d12h': %w", err)
|
||||
}
|
||||
|
||||
task, err := scheduler.NewTask(
|
||||
sharedState.AgentName,
|
||||
result.Message,
|
||||
scheduler.ScheduleTypeOnce,
|
||||
result.Delay,
|
||||
)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
task.Metadata["reminder_type"] = "user_created"
|
||||
|
||||
err = sharedState.Scheduler.CreateTask(task)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("One-time reminder set in %s (at %s, ID: %s)", result.Delay, task.NextRun.Format(time.RFC3339), task.ID),
|
||||
Metadata: map[string]interface{}{
|
||||
"task_id": task.ID,
|
||||
"message": result.Message,
|
||||
"next_run": task.NextRun,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ListRemindersAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if sharedState.Reminders == nil || len(sharedState.Reminders) == 0 {
|
||||
tasks, err := sharedState.Scheduler.GetAllTasks()
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return types.ActionResult{
|
||||
Result: "No reminders set",
|
||||
}, nil
|
||||
@@ -84,22 +131,25 @@ func (a *ListRemindersAction) Run(ctx context.Context, sharedState *types.AgentS
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString("Current reminders:\n")
|
||||
for i, reminder := range sharedState.Reminders {
|
||||
|
||||
for i, task := range tasks {
|
||||
status := "one-time"
|
||||
if reminder.IsRecurring {
|
||||
if task.ScheduleType == scheduler.ScheduleTypeCron || task.ScheduleType == scheduler.ScheduleTypeInterval {
|
||||
status = "recurring"
|
||||
}
|
||||
result.WriteString(fmt.Sprintf("%d. %s (Next run: %s, Status: %s)\n",
|
||||
|
||||
result.WriteString(fmt.Sprintf("%d. %s (Next run: %s, Status: %s, ID: %s)\n",
|
||||
i+1,
|
||||
reminder.Message,
|
||||
reminder.NextRun.Format(time.RFC3339),
|
||||
status))
|
||||
task.Prompt,
|
||||
task.NextRun.Format(time.RFC3339),
|
||||
status,
|
||||
task.ID))
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: result.String(),
|
||||
Metadata: map[string]interface{}{
|
||||
"reminders": sharedState.Reminders,
|
||||
"tasks": tasks,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -111,7 +161,12 @@ func (a *RemoveReminderAction) Run(ctx context.Context, sharedState *types.Agent
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
if sharedState.Reminders == nil || len(sharedState.Reminders) == 0 {
|
||||
tasks, err := sharedState.Scheduler.GetAllTasks()
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return types.ActionResult{
|
||||
Result: "No reminders to remove",
|
||||
}, nil
|
||||
@@ -119,23 +174,29 @@ func (a *RemoveReminderAction) Run(ctx context.Context, sharedState *types.Agent
|
||||
|
||||
// Convert from 1-based index to 0-based
|
||||
index := removeParams.Index - 1
|
||||
if index < 0 || index >= len(sharedState.Reminders) {
|
||||
if index < 0 || index >= len(tasks) {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid reminder index: %d", removeParams.Index)
|
||||
}
|
||||
|
||||
// Remove the reminder
|
||||
removed := sharedState.Reminders[index]
|
||||
sharedState.Reminders = append(sharedState.Reminders[:index], sharedState.Reminders[index+1:]...)
|
||||
task := tasks[index]
|
||||
err = sharedState.Scheduler.DeleteTask(task.ID)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Removed reminder: %s", removed.Message),
|
||||
Result: fmt.Sprintf("Removed reminder: %s", task.Prompt),
|
||||
Metadata: map[string]interface{}{
|
||||
"removed_reminder": removed,
|
||||
"removed_task_id": task.ID,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ReminderAction) Plannable() bool {
|
||||
func (a *RecurringReminderAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *OneTimeReminderAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -147,10 +208,10 @@ func (a *RemoveReminderAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *ReminderAction) Definition() types.ActionDefinition {
|
||||
func (a *RecurringReminderAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: ReminderActionName,
|
||||
Description: "Set a reminder for the agent to wake up and perform a task based on a cron schedule. Examples: '0 0 * * *' (daily at midnight), '0 */2 * * *' (every 2 hours), '0 0 * * 1' (every Monday at midnight)",
|
||||
Name: RecurringReminderActionName,
|
||||
Description: "Set a recurring reminder for the agent to wake up and perform a task on a cron schedule. The reminder will keep repeating. Examples: '0 0 * * *' (daily at midnight), '0 */2 * * *' (every 2 hours), '0 0 * * 1' (every Monday at midnight)",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"message": {
|
||||
Type: jsonschema.String,
|
||||
@@ -158,14 +219,28 @@ func (a *ReminderAction) Definition() types.ActionDefinition {
|
||||
},
|
||||
"cron_expr": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Cron expression for scheduling (e.g. '0 0 * * *' for daily at midnight). Format: 'second minute hour day month weekday'",
|
||||
},
|
||||
"is_recurring": {
|
||||
Type: jsonschema.Boolean,
|
||||
Description: "Whether this reminder should repeat according to the cron schedule (true) or trigger only once (false)",
|
||||
Description: "Cron expression for scheduling (e.g. '0 0 * * *' for daily at midnight). Format: 'minute hour day month weekday'",
|
||||
},
|
||||
},
|
||||
Required: []string{"message", "cron_expr", "is_recurring"},
|
||||
Required: []string{"message", "cron_expr"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *OneTimeReminderAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: OneTimeReminderActionName,
|
||||
Description: "Set a one-time reminder for the agent to wake up and perform a task after a delay. The reminder triggers only once and is then automatically removed. Use this when asked to do something 'in X minutes/hours/days'. Examples: '30m' (30 minutes), '2h' (2 hours), '1d' (1 day), '1d12h' (1.5 days), '2h30m' (2.5 hours)",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"message": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The message or task to be reminded about",
|
||||
},
|
||||
"delay": {
|
||||
Type: jsonschema.String,
|
||||
Description: "How long to wait before triggering. Use Go duration format: '30m' (30 minutes), '2h' (2 hours), '1d' (1 day), '1d12h' (1.5 days), '2h30m' (2.5 hours)",
|
||||
},
|
||||
},
|
||||
Required: []string{"message", "delay"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// ReplyActionName is the name of the reply action
|
||||
// used by the LLM to reply to the user without
|
||||
// any additional processing
|
||||
const ReplyActionName = "reply"
|
||||
|
||||
func NewReply() *ReplyAction {
|
||||
return &ReplyAction{}
|
||||
}
|
||||
|
||||
type ReplyAction struct{}
|
||||
|
||||
type ReplyResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (a *ReplyAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (string, error) {
|
||||
return "no-op", nil
|
||||
}
|
||||
|
||||
func (a *ReplyAction) Plannable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *ReplyAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: ReplyActionName,
|
||||
Description: "Use this tool to reply to the user once we have all the informations we need.",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"message": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The message to reply with",
|
||||
},
|
||||
},
|
||||
Required: []string{"message"},
|
||||
}
|
||||
}
|
||||
+22
-446
@@ -1,142 +1,18 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/action"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
type decisionResult struct {
|
||||
actionParams types.ActionParams
|
||||
message string
|
||||
actioName string
|
||||
}
|
||||
|
||||
// decision forces the agent to take one of the available actions
|
||||
func (a *Agent) decision(
|
||||
job *types.Job,
|
||||
conversation []openai.ChatCompletionMessage,
|
||||
tools []openai.Tool, toolchoice string, maxRetries int) (*decisionResult, error) {
|
||||
|
||||
var choice *openai.ToolChoice
|
||||
|
||||
if toolchoice != "" {
|
||||
choice = &openai.ToolChoice{
|
||||
Type: openai.ToolTypeFunction,
|
||||
Function: openai.ToolFunction{Name: toolchoice},
|
||||
}
|
||||
}
|
||||
|
||||
decision := openai.ChatCompletionRequest{
|
||||
Model: a.options.LLMAPI.Model,
|
||||
Messages: conversation,
|
||||
Tools: tools,
|
||||
}
|
||||
|
||||
if choice != nil {
|
||||
decision.ToolChoice = *choice
|
||||
}
|
||||
|
||||
var obs *types.Observable
|
||||
if job.Obs != nil {
|
||||
obs = a.observer.NewObservable()
|
||||
obs.Name = "decision"
|
||||
obs.ParentID = job.Obs.ID
|
||||
obs.Icon = "brain"
|
||||
obs.Creation = &types.Creation{
|
||||
ChatCompletionRequest: &decision,
|
||||
}
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempts := 0; attempts < maxRetries; attempts++ {
|
||||
resp, err := a.client.CreateChatCompletion(job.GetContext(), decision)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
xlog.Warn("Attempt to make a decision failed", "attempt", attempts+1, "error", err)
|
||||
|
||||
if obs != nil {
|
||||
obs.Progress = append(obs.Progress, types.Progress{
|
||||
Error: err.Error(),
|
||||
})
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
jsonResp, _ := json.Marshal(resp)
|
||||
xlog.Debug("Decision response", "response", string(jsonResp))
|
||||
|
||||
if obs != nil {
|
||||
obs.AddProgress(types.Progress{
|
||||
ChatCompletionResponse: &resp,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.Choices) != 1 {
|
||||
lastErr = fmt.Errorf("no choices: %d", len(resp.Choices))
|
||||
xlog.Warn("Attempt to make a decision failed", "attempt", attempts+1, "error", lastErr)
|
||||
|
||||
if obs != nil {
|
||||
obs.Progress[len(obs.Progress)-1].Error = lastErr.Error()
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
msg := resp.Choices[0].Message
|
||||
if len(msg.ToolCalls) != 1 {
|
||||
if err := a.saveConversation(append(conversation, msg), "decision"); err != nil {
|
||||
xlog.Error("Error saving conversation", "error", err)
|
||||
}
|
||||
|
||||
if obs != nil {
|
||||
obs.MakeLastProgressCompletion()
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
return &decisionResult{message: msg.Content}, nil
|
||||
}
|
||||
|
||||
params := types.ActionParams{}
|
||||
if err := params.Read(msg.ToolCalls[0].Function.Arguments); err != nil {
|
||||
lastErr = err
|
||||
xlog.Warn("Attempt to parse action parameters failed", "attempt", attempts+1, "error", err)
|
||||
|
||||
if obs != nil {
|
||||
obs.Progress[len(obs.Progress)-1].Error = lastErr.Error()
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := a.saveConversation(append(conversation, msg), "decision"); err != nil {
|
||||
xlog.Error("Error saving conversation", "error", err)
|
||||
}
|
||||
|
||||
if obs != nil {
|
||||
obs.MakeLastProgressCompletion()
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
return &decisionResult{actionParams: params, actioName: msg.ToolCalls[0].Function.Name, message: msg.Content}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to make a decision after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
type Messages []openai.ChatCompletionMessage
|
||||
|
||||
func (m Messages) ToOpenAI() []openai.ChatCompletionMessage {
|
||||
@@ -204,6 +80,7 @@ func (m Messages) Save(path string) error {
|
||||
}
|
||||
|
||||
func (m Messages) GetLatestUserMessage() *openai.ChatCompletionMessage {
|
||||
xlog.Debug("Getting latest user message", "messages", m)
|
||||
for i := len(m) - 1; i >= 0; i-- {
|
||||
msg := m[i]
|
||||
if msg.Role == UserRole {
|
||||
@@ -214,187 +91,27 @@ func (m Messages) GetLatestUserMessage() *openai.ChatCompletionMessage {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Messages) IsLastMessageFromRole(role string) bool {
|
||||
if len(m) == 0 {
|
||||
return false
|
||||
// getAvailableActionsForJob returns available actions including user-defined ones for a specific job
|
||||
func (a *Agent) getAvailableActionsForJob(job *types.Job) types.Actions {
|
||||
// Start with regular available actions
|
||||
baseActions := a.availableActions(job)
|
||||
|
||||
// Add user-defined actions from the job
|
||||
userTools := job.GetUserTools()
|
||||
if len(userTools) > 0 {
|
||||
userDefinedActions := types.CreateUserDefinedActions(userTools)
|
||||
baseActions = append(baseActions, userDefinedActions...)
|
||||
xlog.Debug("Added user-defined actions", "definitions", userTools)
|
||||
}
|
||||
|
||||
return m[len(m)-1].Role == role
|
||||
return baseActions
|
||||
}
|
||||
|
||||
func (a *Agent) generateParameters(job *types.Job, pickTemplate string, act types.Action, c []openai.ChatCompletionMessage, reasoning string, maxAttempts int) (*decisionResult, error) {
|
||||
|
||||
if len(act.Definition().Properties) > 0 {
|
||||
xlog.Debug("Action has properties", "action", act.Definition().Name, "properties", act.Definition().Properties)
|
||||
} else {
|
||||
xlog.Debug("Action has no properties", "action", act.Definition().Name)
|
||||
return &decisionResult{actionParams: types.ActionParams{}}, nil
|
||||
}
|
||||
|
||||
stateHUD, err := renderTemplate(pickTemplate, a.prepareHUD(), a.availableActions(), reasoning)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conversation := c
|
||||
if !Messages(c).Exist(stateHUD) && a.options.enableHUD {
|
||||
conversation = append([]openai.ChatCompletionMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: stateHUD,
|
||||
},
|
||||
}, conversation...)
|
||||
}
|
||||
|
||||
cc := conversation
|
||||
if a.options.forceReasoning {
|
||||
cc = append(conversation, openai.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("The agent decided to use the tool %s with the following reasoning: %s", act.Definition().Name, reasoning),
|
||||
})
|
||||
}
|
||||
|
||||
var result *decisionResult
|
||||
var attemptErr error
|
||||
|
||||
for attempts := 0; attempts < maxAttempts; attempts++ {
|
||||
result, attemptErr = a.decision(job,
|
||||
cc,
|
||||
a.availableActions().ToTools(),
|
||||
act.Definition().Name.String(),
|
||||
maxAttempts,
|
||||
)
|
||||
if attemptErr == nil && result.actionParams != nil {
|
||||
return result, nil
|
||||
}
|
||||
xlog.Warn("Attempt to generate parameters failed", "attempt", attempts+1, "error", attemptErr)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to generate parameters after %d attempts: %w", maxAttempts, attemptErr)
|
||||
}
|
||||
|
||||
func (a *Agent) handlePlanning(ctx context.Context, job *types.Job, chosenAction types.Action, actionParams types.ActionParams, reasoning string, pickTemplate string, conv Messages) (Messages, error) {
|
||||
// Planning: run all the actions in sequence
|
||||
if !chosenAction.Definition().Name.Is(action.PlanActionName) {
|
||||
xlog.Debug("no plan action")
|
||||
return conv, nil
|
||||
}
|
||||
|
||||
xlog.Debug("[planning]...")
|
||||
planResult := action.PlanResult{}
|
||||
if err := actionParams.Unmarshal(&planResult); err != nil {
|
||||
return conv, fmt.Errorf("error unmarshalling plan result: %w", err)
|
||||
}
|
||||
|
||||
stateResult := types.ActionState{
|
||||
ActionCurrentState: types.ActionCurrentState{
|
||||
Job: job,
|
||||
Action: chosenAction,
|
||||
Params: actionParams,
|
||||
Reasoning: reasoning,
|
||||
},
|
||||
ActionResult: types.ActionResult{
|
||||
Result: fmt.Sprintf("planning %s, subtasks: %+v", planResult.Goal, planResult.Subtasks),
|
||||
},
|
||||
}
|
||||
job.Result.SetResult(stateResult)
|
||||
job.CallbackWithResult(stateResult)
|
||||
|
||||
xlog.Info("[Planning] starts", "agent", a.Character.Name, "goal", planResult.Goal)
|
||||
for _, s := range planResult.Subtasks {
|
||||
xlog.Info("[Planning] subtask", "agent", a.Character.Name, "action", s.Action, "reasoning", s.Reasoning)
|
||||
}
|
||||
|
||||
if len(planResult.Subtasks) == 0 {
|
||||
return conv, fmt.Errorf("no subtasks")
|
||||
}
|
||||
|
||||
// Execute all subtasks in sequence
|
||||
for _, subtask := range planResult.Subtasks {
|
||||
xlog.Info("[subtask] Generating parameters",
|
||||
"agent", a.Character.Name,
|
||||
"action", subtask.Action,
|
||||
"reasoning", reasoning,
|
||||
)
|
||||
|
||||
subTaskAction := a.availableActions().Find(subtask.Action)
|
||||
subTaskReasoning := fmt.Sprintf("%s Overall goal is: %s", subtask.Reasoning, planResult.Goal)
|
||||
|
||||
params, err := a.generateParameters(job, pickTemplate, subTaskAction, conv, subTaskReasoning, maxRetries)
|
||||
if err != nil {
|
||||
xlog.Error("error generating action's parameters", "error", err)
|
||||
return conv, fmt.Errorf("error generating action's parameters: %w", err)
|
||||
|
||||
}
|
||||
actionParams = params.actionParams
|
||||
|
||||
if !job.Callback(types.ActionCurrentState{
|
||||
Job: job,
|
||||
Action: subTaskAction,
|
||||
Params: actionParams,
|
||||
Reasoning: subTaskReasoning,
|
||||
}) {
|
||||
job.Result.SetResult(types.ActionState{
|
||||
ActionCurrentState: types.ActionCurrentState{
|
||||
Job: job,
|
||||
Action: chosenAction,
|
||||
Params: actionParams,
|
||||
Reasoning: subTaskReasoning,
|
||||
},
|
||||
ActionResult: types.ActionResult{
|
||||
Result: "stopped by callback",
|
||||
},
|
||||
})
|
||||
job.Result.Conversation = conv
|
||||
job.Result.Finish(nil)
|
||||
break
|
||||
}
|
||||
|
||||
result, err := a.runAction(job, subTaskAction, actionParams)
|
||||
if err != nil {
|
||||
xlog.Error("error running action", "error", err)
|
||||
return conv, fmt.Errorf("error running action: %w", err)
|
||||
}
|
||||
|
||||
stateResult := types.ActionState{
|
||||
ActionCurrentState: types.ActionCurrentState{
|
||||
Job: job,
|
||||
Action: subTaskAction,
|
||||
Params: actionParams,
|
||||
Reasoning: subTaskReasoning,
|
||||
},
|
||||
ActionResult: result,
|
||||
}
|
||||
job.Result.SetResult(stateResult)
|
||||
job.CallbackWithResult(stateResult)
|
||||
xlog.Debug("[subtask] Action executed", "agent", a.Character.Name, "action", subTaskAction.Definition().Name, "result", result)
|
||||
conv = a.addFunctionResultToConversation(subTaskAction, actionParams, result, conv)
|
||||
}
|
||||
|
||||
return conv, nil
|
||||
}
|
||||
|
||||
func (a *Agent) availableActions() types.Actions {
|
||||
func (a *Agent) availableActions(j *types.Job) types.Actions {
|
||||
// defaultActions := append(a.options.userActions, action.NewReply())
|
||||
|
||||
addPlanAction := func(actions types.Actions) types.Actions {
|
||||
if !a.options.canPlan {
|
||||
return actions
|
||||
}
|
||||
plannablesActions := []string{}
|
||||
for _, a := range actions {
|
||||
if a.Plannable() {
|
||||
plannablesActions = append(plannablesActions, a.Definition().Name.String())
|
||||
}
|
||||
}
|
||||
planAction := action.NewPlan(plannablesActions)
|
||||
actions = append(actions, planAction)
|
||||
return actions
|
||||
}
|
||||
|
||||
defaultActions := append(a.mcpActions, a.options.userActions...)
|
||||
|
||||
if a.options.initiateConversations && a.selfEvaluationInProgress { // && self-evaluation..
|
||||
defaultActions := slices.Clone(a.options.userActions)
|
||||
if j.Metadata["type"] == "scheduled" || (a.options.initiateConversations && a.selfEvaluationInProgress) { // && self-evaluation..
|
||||
acts := append(defaultActions, action.NewConversation())
|
||||
if a.options.enableHUD {
|
||||
acts = append(acts, action.NewState())
|
||||
@@ -403,7 +120,7 @@ func (a *Agent) availableActions() types.Actions {
|
||||
// acts = append(acts, action.NewStop())
|
||||
// }
|
||||
|
||||
return addPlanAction(acts)
|
||||
return acts
|
||||
}
|
||||
|
||||
if a.options.canStopItself {
|
||||
@@ -411,14 +128,14 @@ func (a *Agent) availableActions() types.Actions {
|
||||
if a.options.enableHUD {
|
||||
acts = append(acts, action.NewState())
|
||||
}
|
||||
return addPlanAction(acts)
|
||||
return acts
|
||||
}
|
||||
|
||||
if a.options.enableHUD {
|
||||
return addPlanAction(append(defaultActions, action.NewState()))
|
||||
return append(defaultActions, action.NewState())
|
||||
}
|
||||
|
||||
return addPlanAction(defaultActions)
|
||||
return defaultActions
|
||||
}
|
||||
|
||||
func (a *Agent) prepareHUD() (promptHUD *PromptHUD) {
|
||||
@@ -433,144 +150,3 @@ func (a *Agent) prepareHUD() (promptHUD *PromptHUD) {
|
||||
ShowCharacter: a.options.showCharacter,
|
||||
}
|
||||
}
|
||||
|
||||
// pickAction picks an action based on the conversation
|
||||
func (a *Agent) pickAction(job *types.Job, templ string, messages []openai.ChatCompletionMessage, maxRetries int) (types.Action, types.ActionParams, string, error) {
|
||||
c := messages
|
||||
|
||||
xlog.Debug("[pickAction] picking action starts", "messages", messages)
|
||||
|
||||
// Identify the goal of this conversation
|
||||
|
||||
if !a.options.forceReasoning {
|
||||
xlog.Debug("not forcing reasoning")
|
||||
// We also could avoid to use functions here and get just a reply from the LLM
|
||||
// and then use the reply to get the action
|
||||
thought, err := a.decision(job,
|
||||
messages,
|
||||
a.availableActions().ToTools(),
|
||||
"",
|
||||
maxRetries)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
xlog.Debug(fmt.Sprintf("thought action Name: %v", thought.actioName))
|
||||
xlog.Debug(fmt.Sprintf("thought message: %v", thought.message))
|
||||
|
||||
// Find the action
|
||||
chosenAction := a.availableActions().Find(thought.actioName)
|
||||
if chosenAction == nil || thought.actioName == "" {
|
||||
xlog.Debug("no answer")
|
||||
|
||||
// LLM replied with an answer?
|
||||
//fmt.Errorf("no action found for intent:" + thought.actioName)
|
||||
return nil, nil, thought.message, nil
|
||||
}
|
||||
xlog.Debug(fmt.Sprintf("chosenAction: %v", chosenAction.Definition().Name))
|
||||
return chosenAction, thought.actionParams, thought.message, nil
|
||||
}
|
||||
|
||||
xlog.Debug("[pickAction] forcing reasoning")
|
||||
|
||||
prompt, err := renderTemplate(templ, a.prepareHUD(), a.availableActions(), "")
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
// Get the LLM to think on what to do
|
||||
// and have a thought
|
||||
if !Messages(c).Exist(prompt) {
|
||||
c = append([]openai.ChatCompletionMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: prompt,
|
||||
},
|
||||
}, c...)
|
||||
}
|
||||
|
||||
reasoningAction := action.NewReasoning()
|
||||
thought, err := a.decision(job,
|
||||
c,
|
||||
types.Actions{reasoningAction}.ToTools(),
|
||||
reasoningAction.Definition().Name.String(), maxRetries)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if thought.actioName != "" && thought.actioName != reasoningAction.Definition().Name.String() {
|
||||
return nil, nil, "", fmt.Errorf("Expected reasoning action not: %s", thought.actioName)
|
||||
}
|
||||
|
||||
originalReasoning := ""
|
||||
response := &action.ReasoningResponse{}
|
||||
if thought.actionParams != nil {
|
||||
if err := thought.actionParams.Unmarshal(response); err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
originalReasoning = response.Reasoning
|
||||
}
|
||||
if thought.message != "" {
|
||||
originalReasoning = thought.message
|
||||
}
|
||||
|
||||
xlog.Debug("[pickAction] picking action", "messages", c)
|
||||
// thought, err := a.askLLM(ctx,
|
||||
// c,
|
||||
|
||||
actionsID := []string{"reply"}
|
||||
for _, m := range a.availableActions() {
|
||||
actionsID = append(actionsID, m.Definition().Name.String())
|
||||
}
|
||||
|
||||
xlog.Debug("[pickAction] actionsID", "actionsID", actionsID)
|
||||
|
||||
intentionsTools := action.NewIntention(actionsID...)
|
||||
// TODO: FORCE to select ana ction here
|
||||
// NOTE: we do not give the full conversation here to pick the action
|
||||
// to avoid hallucinations
|
||||
|
||||
// Extract an action
|
||||
params, err := a.decision(job,
|
||||
append(c, openai.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: "Pick the relevant action given the following reasoning: " + originalReasoning,
|
||||
}),
|
||||
types.Actions{intentionsTools}.ToTools(),
|
||||
intentionsTools.Definition().Name.String(), maxRetries)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to get the action tool parameters: %v", err)
|
||||
}
|
||||
|
||||
if params.actionParams == nil {
|
||||
xlog.Debug("[pickAction] no action params found")
|
||||
return nil, nil, params.message, nil
|
||||
}
|
||||
|
||||
actionChoice := action.IntentResponse{}
|
||||
err = params.actionParams.Unmarshal(&actionChoice)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
if actionChoice.Tool == "" || actionChoice.Tool == "reply" {
|
||||
xlog.Debug("[pickAction] no action found, replying")
|
||||
return nil, nil, "", nil
|
||||
}
|
||||
|
||||
chosenAction := a.availableActions().Find(actionChoice.Tool)
|
||||
|
||||
xlog.Debug("[pickAction] chosenAction", "chosenAction", chosenAction, "actionName", actionChoice.Tool)
|
||||
|
||||
// // Let's double check if the action is correct by asking the LLM to judge it
|
||||
|
||||
// if chosenAction!= nil {
|
||||
// promptString:= "Given the following goal and thoughts, is the action correct? \n\n"
|
||||
// promptString+= fmt.Sprintf("Goal: %s\n", goalResponse.Goal)
|
||||
// promptString+= fmt.Sprintf("Thoughts: %s\n", originalReasoning)
|
||||
// promptString+= fmt.Sprintf("Action: %s\n", chosenAction.Definition().Name.String())
|
||||
// promptString+= fmt.Sprintf("Action description: %s\n", chosenAction.Definition().Description)
|
||||
// promptString+= fmt.Sprintf("Action parameters: %s\n", params.actionParams)
|
||||
|
||||
// }
|
||||
|
||||
return chosenAction, nil, originalReasoning, nil
|
||||
}
|
||||
|
||||
+739
-605
File diff suppressed because it is too large
Load Diff
+47
-18
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
. "github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
@@ -37,7 +37,8 @@ var debugOptions = []types.JobOption{
|
||||
}
|
||||
|
||||
type TestAction struct {
|
||||
response map[string]string
|
||||
response map[string]string
|
||||
definition *types.ActionDefinition
|
||||
}
|
||||
|
||||
func (a *TestAction) Plannable() bool {
|
||||
@@ -55,7 +56,7 @@ func (a *TestAction) Run(c context.Context, sharedState *types.AgentSharedState,
|
||||
}
|
||||
|
||||
func (a *TestAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
def := types.ActionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "get current weather",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
@@ -71,6 +72,11 @@ func (a *TestAction) Definition() types.ActionDefinition {
|
||||
|
||||
Required: []string{"location"},
|
||||
}
|
||||
|
||||
if a.definition != nil {
|
||||
def = *a.definition
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
type FakeStoreResultAction struct {
|
||||
@@ -128,7 +134,6 @@ var _ = Describe("Agent test", func() {
|
||||
WithModel(testModel),
|
||||
EnableForceReasoning,
|
||||
WithTimeout("10m"),
|
||||
WithLoopDetectionSteps(3),
|
||||
// WithRandomIdentity(),
|
||||
WithActions(&TestAction{response: map[string]string{
|
||||
"boston": testActionResult,
|
||||
@@ -153,6 +158,9 @@ var _ = Describe("Agent test", func() {
|
||||
}
|
||||
Expect(reasons).To(ContainElement(testActionResult), fmt.Sprint(res))
|
||||
Expect(reasons).To(ContainElement(testActionResult2), fmt.Sprint(res))
|
||||
|
||||
Expect(len(res.Conversation)).To(BeNumerically(">", 1), fmt.Sprint(res.Conversation))
|
||||
|
||||
reasons = []string{}
|
||||
|
||||
res = agent.Ask(
|
||||
@@ -225,11 +233,33 @@ var _ = Describe("Agent test", func() {
|
||||
WithModel(testModel),
|
||||
WithLLMAPIKey(apiKeyURL),
|
||||
WithTimeout("10m"),
|
||||
WithMaxEvaluationLoops(1),
|
||||
WithActions(
|
||||
&TestAction{response: map[string]string{
|
||||
"boston": testActionResult,
|
||||
"milan": testActionResult2,
|
||||
}},
|
||||
&TestAction{
|
||||
response: map[string]string{
|
||||
"boston": testActionResult,
|
||||
"milan": testActionResult2,
|
||||
},
|
||||
},
|
||||
&TestAction{
|
||||
response: map[string]string{
|
||||
"flight": "Flight options from Boston to Milan (April 22-26, 2025):\n• Outbound: Boston Logan (BOS) → Milan Malpensa (MXP), April 22, 2025\n - Economy: $450-650 (Alitalia, Delta, Lufthansa)\n - Business: $1,200-1,800\n - Flight time: 8h 15m (1 stop) or 9h 45m (direct)\n• Return: Milan Malpensa (MXP) → Boston Logan (BOS), April 26, 2025\n - Economy: $420-580\n - Business: $1,100-1,600\n• Total estimated cost: $870-1,230 per person\n• Best booking window: 2-3 months in advance for optimal prices",
|
||||
"hotel": "Hotel recommendations in Milan for April 22-26, 2025:\n• Luxury (4-5 stars): $200-400/night\n - Hotel Principe di Savoia: $380/night (central location)\n - Mandarin Oriental: $420/night (luxury amenities)\n• Mid-range (3-4 stars): $120-200/night\n - Hotel Spadari al Duomo: $160/night (near cathedral)\n - Hotel Milano Scala: $140/night (theater district)\n• Budget (2-3 stars): $80-120/night\n - Hotel Bernina: $95/night (near train station)\n• Total 4-night stay: $320-1,680 depending on category\n• Booking tip: Reserve early for spring season discounts",
|
||||
"car": "Car rental options in Milan for April 22-26, 2025:\n• Economy cars: $35-50/day (Fiat 500, VW Polo)\n• Compact cars: $45-65/day (Ford Focus, Opel Astra)\n• Mid-size cars: $60-85/day (BMW 3 Series, Audi A4)\n• SUV/Luxury: $90-150/day (BMW X3, Mercedes E-Class)\n• Total 4-day rental: $140-600\n• Pickup locations: Milan Malpensa Airport, Milan Central Station, city center\n• Insurance: $15-25/day additional\n• Fuel: ~$60-80 for 4 days of city driving\n• Parking: $20-40/day in city center hotels",
|
||||
"food": "Dining budget and recommendations for Milan (April 22-26, 2025):\n• Fine dining: $80-150/person (Michelin-starred restaurants)\n - Cracco: $120/person (2 Michelin stars)\n - Trussardi alla Scala: $100/person\n• Mid-range restaurants: $40-80/person\n - Luini: $15/person (famous panzerotti)\n - Piz: $25/person (authentic pizza)\n• Casual dining: $20-40/person\n - Aperitivo bars: $15-25/person\n - Street food: $8-15/person\n• Daily food budget: $60-120/person\n• Total 4-day food cost: $240-480/person\n• Must-try: Risotto alla Milanese, Osso Buco, Panettone",
|
||||
},
|
||||
definition: &types.ActionDefinition{
|
||||
Name: "search_internet",
|
||||
Description: "search the internet for information",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"query": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The query to search for",
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
},
|
||||
},
|
||||
),
|
||||
EnablePlanning,
|
||||
EnableForceReasoning,
|
||||
@@ -241,21 +271,20 @@ var _ = Describe("Agent test", func() {
|
||||
defer agent.Stop()
|
||||
|
||||
result := agent.Ask(
|
||||
types.WithText("Use the plan tool to do two actions in sequence: search for the weather in boston and search for the weather in milan"),
|
||||
types.WithText("Create a plan for my 4-day trip from Boston to milan in April of this year (2025). I'm not sure about the dates yet, I want you to find out the best dates also according to what you find."),
|
||||
)
|
||||
Expect(len(result.State)).To(BeNumerically(">", 1))
|
||||
|
||||
Expect(len(result.Conversation)).To(BeNumerically(">", 1), fmt.Sprint(result.Conversation))
|
||||
|
||||
Expect(len(result.Plans)).To(BeNumerically(">=", 1), fmt.Sprintf("%+v", result))
|
||||
Expect(len(result.State)).To(BeNumerically(">=", 1))
|
||||
|
||||
actionsExecuted := []string{}
|
||||
actionResults := []string{}
|
||||
for _, r := range result.State {
|
||||
xlog.Info(r.Result)
|
||||
actionsExecuted = append(actionsExecuted, r.Action.Definition().Name.String())
|
||||
actionResults = append(actionResults, r.ActionResult.Result)
|
||||
}
|
||||
Expect(actionsExecuted).To(ContainElement("get_weather"), fmt.Sprint(result))
|
||||
Expect(actionsExecuted).To(ContainElement("plan"), fmt.Sprint(result))
|
||||
Expect(actionResults).To(ContainElement(testActionResult), fmt.Sprint(result))
|
||||
Expect(actionResults).To(ContainElement(testActionResult2), fmt.Sprint(result))
|
||||
Expect(actionsExecuted).To(Or(ContainElement("search_internet"), ContainElement("get_weather")), fmt.Sprint(result))
|
||||
})
|
||||
|
||||
It("Can initiate conversations", func() {
|
||||
@@ -267,9 +296,9 @@ var _ = Describe("Agent test", func() {
|
||||
WithModel(testModel),
|
||||
WithLLMAPIKey(apiKeyURL),
|
||||
WithTimeout("10m"),
|
||||
WithNewConversationSubscriber(func(m openai.ChatCompletionMessage) {
|
||||
WithNewConversationSubscriber(func(m *types.ConversationMessage) {
|
||||
mu.Lock()
|
||||
message = m
|
||||
message = m.Message
|
||||
mu.Unlock()
|
||||
}),
|
||||
WithActions(
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/llm"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
type EvaluationResult struct {
|
||||
Satisfied bool `json:"satisfied"`
|
||||
Gaps []string `json:"gaps"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
}
|
||||
|
||||
type GoalExtraction struct {
|
||||
Goal string `json:"goal"`
|
||||
Constraints []string `json:"constraints"`
|
||||
Context string `json:"context"`
|
||||
}
|
||||
|
||||
func (a *Agent) extractGoal(job *types.Job, conv []openai.ChatCompletionMessage) (*GoalExtraction, error) {
|
||||
// Create the goal extraction schema
|
||||
schema := jsonschema.Definition{
|
||||
Type: jsonschema.Object,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"goal": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The main goal or request from the user",
|
||||
},
|
||||
"constraints": {
|
||||
Type: jsonschema.Array,
|
||||
Items: &jsonschema.Definition{
|
||||
Type: jsonschema.String,
|
||||
},
|
||||
Description: "Any constraints or requirements specified by the user",
|
||||
},
|
||||
"context": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Additional context that might be relevant for understanding the goal",
|
||||
},
|
||||
},
|
||||
Required: []string{"goal", "constraints", "context"},
|
||||
}
|
||||
|
||||
// Create the goal extraction prompt
|
||||
prompt := `Analyze the conversation and extract the user's main goal, any constraints, and relevant context.
|
||||
Consider the entire conversation history to understand the complete context and requirements.
|
||||
Focus on identifying the primary objective and any specific requirements or limitations mentioned.`
|
||||
|
||||
var result GoalExtraction
|
||||
err := llm.GenerateTypedJSONWithConversation(job.GetContext(), a.client,
|
||||
append(
|
||||
[]openai.ChatCompletionMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: prompt,
|
||||
},
|
||||
},
|
||||
conv...), a.options.LLMAPI.Model, schema, &result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error extracting goal: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (a *Agent) evaluateJob(job *types.Job, conv []openai.ChatCompletionMessage) (*EvaluationResult, error) {
|
||||
if !a.options.enableEvaluation {
|
||||
return &EvaluationResult{Satisfied: true}, nil
|
||||
}
|
||||
|
||||
// Extract the goal first
|
||||
goal, err := a.extractGoal(job, conv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error extracting goal: %w", err)
|
||||
}
|
||||
|
||||
// Create the evaluation schema
|
||||
schema := jsonschema.Definition{
|
||||
Type: jsonschema.Object,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"satisfied": {
|
||||
Type: jsonschema.Boolean,
|
||||
},
|
||||
"gaps": {
|
||||
Type: jsonschema.Array,
|
||||
Items: &jsonschema.Definition{
|
||||
Type: jsonschema.String,
|
||||
},
|
||||
},
|
||||
"reasoning": {
|
||||
Type: jsonschema.String,
|
||||
},
|
||||
},
|
||||
Required: []string{"satisfied", "gaps", "reasoning"},
|
||||
}
|
||||
|
||||
// Create the evaluation prompt
|
||||
prompt := fmt.Sprintf(`Evaluate if the assistant has satisfied the user's request. Consider:
|
||||
1. The identified goal: %s
|
||||
2. Constraints and requirements: %v
|
||||
3. Context: %s
|
||||
4. The conversation history
|
||||
5. Any gaps or missing information
|
||||
6. Whether the response fully addresses the user's needs
|
||||
|
||||
Provide a detailed evaluation with specific gaps if any are found.`,
|
||||
goal.Goal,
|
||||
goal.Constraints,
|
||||
goal.Context)
|
||||
|
||||
var result EvaluationResult
|
||||
err = llm.GenerateTypedJSONWithConversation(job.GetContext(), a.client,
|
||||
append(
|
||||
[]openai.ChatCompletionMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: prompt,
|
||||
},
|
||||
},
|
||||
conv...),
|
||||
a.options.LLMAPI.Model, schema, &result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error generating evaluation: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleEvaluation(job *types.Job, conv []openai.ChatCompletionMessage, currentLoop int) (bool, []openai.ChatCompletionMessage, error) {
|
||||
if !a.options.enableEvaluation || currentLoop >= a.options.maxEvaluationLoops {
|
||||
return true, conv, nil
|
||||
}
|
||||
|
||||
result, err := a.evaluateJob(job, conv)
|
||||
if err != nil {
|
||||
return false, conv, err
|
||||
}
|
||||
|
||||
if result.Satisfied {
|
||||
return true, conv, nil
|
||||
}
|
||||
|
||||
// If there are gaps, we need to address them
|
||||
if len(result.Gaps) > 0 {
|
||||
// Add the evaluation result to the conversation
|
||||
conv = append(conv, openai.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("Evaluation found gaps that need to be addressed:\n%s\nReasoning: %s",
|
||||
result.Gaps, result.Reasoning),
|
||||
})
|
||||
|
||||
xlog.Debug("Evaluation found gaps, incrementing loop count", "loop", currentLoop+1)
|
||||
return false, conv, nil
|
||||
}
|
||||
|
||||
return true, conv, nil
|
||||
}
|
||||
+169
-13
@@ -1,23 +1,35 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
func (a *Agent) knowledgeBaseLookup(job *types.Job, conv Messages) Messages {
|
||||
if (!a.options.enableKB && !a.options.enableLongTermMemory && !a.options.enableSummaryMemory) ||
|
||||
len(conv) <= 0 {
|
||||
// Only run KB recall/lookup when KB is explicitly enabled; long-term/summary memory
|
||||
// only affect saving in saveConversation, not this lookup.
|
||||
if !a.options.enableKB || len(conv) <= 0 {
|
||||
xlog.Debug("[Knowledge Base Lookup] Disabled, skipping", "agent", a.Character.Name)
|
||||
return conv
|
||||
}
|
||||
|
||||
if !a.options.kbAutoSearch {
|
||||
xlog.Debug("[Knowledge Base Lookup] Auto search disabled, skipping", "agent", a.Character.Name)
|
||||
return conv
|
||||
}
|
||||
if a.options.ragdb == nil {
|
||||
xlog.Debug("[Knowledge Base Lookup] No RAG DB configured, skipping", "agent", a.Character.Name)
|
||||
return conv
|
||||
}
|
||||
|
||||
var obs *types.Observable
|
||||
if job != nil && job.Obs != nil && a.observer != nil {
|
||||
obs = a.observer.NewObservable()
|
||||
@@ -90,7 +102,7 @@ func (a *Agent) knowledgeBaseLookup(job *types.Job, conv Messages) Messages {
|
||||
|
||||
if obs != nil {
|
||||
obs.Completion = &types.Completion{
|
||||
Conversation: []openai.ChatCompletionMessage{systemMessage},
|
||||
Conversation: []openai.ChatCompletionMessage{systemMessage},
|
||||
}
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
@@ -125,24 +137,168 @@ func (a *Agent) saveCurrentConversation(conv Messages) {
|
||||
xlog.Info("Saving conversation", "agent", a.Character.Name, "conversation size", len(conv))
|
||||
|
||||
if a.options.enableSummaryMemory && len(conv) > 0 {
|
||||
msg, err := a.askLLM(a.context.Context, []openai.ChatCompletionMessage{{
|
||||
Role: "user",
|
||||
Content: "Summarize the conversation below, keep the highlights as a bullet list:\n" + Messages(conv).String(),
|
||||
}}, maxRetries)
|
||||
fragment := cogito.NewEmptyFragment().AddStartMessage("user", "Summarize the conversation below, keep the highlights as a bullet list:\n"+Messages(conv).String())
|
||||
fragment, err := a.llm.Ask(a.context.Context, fragment)
|
||||
if err != nil {
|
||||
xlog.Error("Error summarizing conversation", "error", err)
|
||||
}
|
||||
msg := fragment.LastMessage()
|
||||
|
||||
if err := a.options.ragdb.Store(msg.Content); err != nil {
|
||||
xlog.Error("Error storing into memory", "error", err)
|
||||
}
|
||||
} else {
|
||||
for _, message := range conv {
|
||||
if message.Role == "user" {
|
||||
if err := a.options.ragdb.Store(message.Content); err != nil {
|
||||
xlog.Error("Error storing into memory", "error", err)
|
||||
// Use the conversation storage mode to determine what to store
|
||||
switch a.options.conversationStorageMode {
|
||||
case StoreWholeConversation:
|
||||
// Store the entire conversation as a single block
|
||||
if len(conv) > 0 {
|
||||
convStr := Messages(conv).String()
|
||||
if err := a.options.ragdb.Store(convStr); err != nil {
|
||||
xlog.Error("Error storing whole conversation into memory", "error", err)
|
||||
}
|
||||
}
|
||||
case StoreUserAndAssistant:
|
||||
// Store user and assistant messages separately
|
||||
for _, message := range conv {
|
||||
if message.Role == "user" || message.Role == "assistant" {
|
||||
if err := a.options.ragdb.Store(message.Content); err != nil {
|
||||
xlog.Error("Error storing message into memory", "error", err, "role", message.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
case StoreUserOnly:
|
||||
fallthrough
|
||||
default:
|
||||
// Store only user messages (default behavior)
|
||||
for _, message := range conv {
|
||||
if message.Role == "user" {
|
||||
if err := a.options.ragdb.Store(message.Content); err != nil {
|
||||
xlog.Error("Error storing into memory", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KBWrapperActions wraps RAGDB functionality as actions
|
||||
type KBWrapperActions struct {
|
||||
ragdb RAGDB
|
||||
kbResults int
|
||||
}
|
||||
|
||||
type SearchKnowledgeBaseAction struct {
|
||||
*KBWrapperActions
|
||||
}
|
||||
|
||||
type AddToKnowledgeBaseAction struct {
|
||||
*KBWrapperActions
|
||||
}
|
||||
|
||||
// NewKBWrapperActions creates factory functions for KB wrapper actions
|
||||
func NewKBWrapperActions(ragdb RAGDB, kbResults int) (*SearchKnowledgeBaseAction, *AddToKnowledgeBaseAction) {
|
||||
wrapper := &KBWrapperActions{
|
||||
ragdb: ragdb,
|
||||
kbResults: kbResults,
|
||||
}
|
||||
return &SearchKnowledgeBaseAction{wrapper}, &AddToKnowledgeBaseAction{wrapper}
|
||||
}
|
||||
|
||||
func (a *SearchKnowledgeBaseAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if a.ragdb == nil {
|
||||
return types.ActionResult{}, fmt.Errorf("knowledge base is not configured")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
if err := params.Unmarshal(&req); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
|
||||
if req.Query == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("query cannot be empty")
|
||||
}
|
||||
|
||||
results, err := a.ragdb.Search(req.Query, a.kbResults)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to search knowledge base: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("No results found for query: %q", req.Query),
|
||||
}, nil
|
||||
}
|
||||
|
||||
formatResults := ""
|
||||
for i, r := range results {
|
||||
formatResults += fmt.Sprintf("%d. %s\n", i+1, r)
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Found %d result(s) for query %q:\n%s", len(results), req.Query, formatResults),
|
||||
Metadata: map[string]interface{}{
|
||||
"query": req.Query,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *SearchKnowledgeBaseAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName("search_memory"),
|
||||
Description: "Search your memory for relevant information using a query string",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"query": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The search query to find relevant information in the knowledge base",
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AddToKnowledgeBaseAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if a.ragdb == nil {
|
||||
return types.ActionResult{}, fmt.Errorf("knowledge base is not configured")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := params.Unmarshal(&req); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
|
||||
if req.Content == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("content cannot be empty")
|
||||
}
|
||||
|
||||
if err := a.ragdb.Store(req.Content); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to store content in knowledge base: %w", err)
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: "Successfully added content to knowledge base",
|
||||
Metadata: map[string]interface{}{
|
||||
"content": req.Content,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AddToKnowledgeBaseAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName("add_memory"),
|
||||
Description: "Add new content to your memory for future retrieval",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"content": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The content to store in the knowledge base",
|
||||
},
|
||||
},
|
||||
Required: []string{"content"},
|
||||
}
|
||||
}
|
||||
|
||||
+118
-120
@@ -3,18 +3,21 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
mcp "github.com/metoro-io/mcp-golang"
|
||||
"github.com/metoro-io/mcp-golang/transport/http"
|
||||
stdioTransport "github.com/metoro-io/mcp-golang/transport/stdio"
|
||||
"net/http"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/stdio"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
var _ types.Action = &mcpAction{}
|
||||
var _ types.Action = &mcpWrapperAction{}
|
||||
|
||||
type MCPServer struct {
|
||||
URL string `json:"url"`
|
||||
@@ -22,49 +25,26 @@ type MCPServer struct {
|
||||
}
|
||||
|
||||
type MCPSTDIOServer struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
Cmd string `json:"cmd"`
|
||||
}
|
||||
|
||||
type mcpAction struct {
|
||||
mcpClient *mcp.Client
|
||||
type mcpWrapperAction struct {
|
||||
mcpClient *mcp.ClientSession
|
||||
inputSchema ToolInputSchema
|
||||
toolName string
|
||||
toolDescription string
|
||||
}
|
||||
|
||||
func (a *mcpAction) Plannable() bool {
|
||||
return true
|
||||
func (m *mcpWrapperAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
// We don't call the method here, it is used by cogito.
|
||||
// We will just use these to have a list of actions that MCP server provides for resolving internal states
|
||||
return types.ActionResult{Result: "MCP action called"}, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (m *mcpAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
resp, err := m.mcpClient.CallTool(ctx, m.toolName, params)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to call tool", "error", err.Error())
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
xlog.Debug("MCP response", "response", resp)
|
||||
|
||||
textResult := ""
|
||||
for _, c := range resp.Content {
|
||||
switch c.Type {
|
||||
case mcp.ContentTypeText:
|
||||
textResult += c.TextContent.Text + "\n"
|
||||
case mcp.ContentTypeImage:
|
||||
xlog.Error("Image content not supported yet")
|
||||
case mcp.ContentTypeEmbeddedResource:
|
||||
xlog.Error("Embedded resource content not supported yet")
|
||||
}
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: textResult,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mcpAction) Definition() types.ActionDefinition {
|
||||
func (m *mcpWrapperAction) Definition() types.ActionDefinition {
|
||||
props := map[string]jsonschema.Definition{}
|
||||
dat, err := json.Marshal(m.inputSchema.Properties)
|
||||
if err != nil {
|
||||
@@ -87,87 +67,110 @@ type ToolInputSchema struct {
|
||||
Required []string `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
func (a *Agent) addTools(client *mcp.Client) (types.Actions, error) {
|
||||
|
||||
func (a *Agent) addTools(client *mcp.ClientSession) (types.Actions, error) {
|
||||
var generatedActions types.Actions
|
||||
xlog.Debug("Initializing client")
|
||||
// Initialize the client
|
||||
response, e := client.Initialize(a.context)
|
||||
if e != nil {
|
||||
xlog.Error("Failed to initialize client", "error", e.Error())
|
||||
return nil, e
|
||||
|
||||
tools, err := client.ListTools(a.context, nil)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to list tools", "error", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
xlog.Debug("Client initialized: %v", response.Instructions)
|
||||
for _, t := range tools.Tools {
|
||||
desc := ""
|
||||
if t.Description != "" {
|
||||
desc = t.Description
|
||||
}
|
||||
|
||||
var cursor *string
|
||||
for {
|
||||
tools, err := client.ListTools(a.context, cursor)
|
||||
xlog.Debug("Tool", "name", t.Name, "description", desc)
|
||||
|
||||
dat, err := json.Marshal(t.InputSchema)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to list tools", "error", err.Error())
|
||||
return nil, err
|
||||
xlog.Error("Failed to marshal input schema", "error", err.Error())
|
||||
}
|
||||
|
||||
for _, t := range tools.Tools {
|
||||
desc := ""
|
||||
if t.Description != nil {
|
||||
desc = *t.Description
|
||||
}
|
||||
xlog.Debug("Input schema", "tool", t.Name, "schema", string(dat))
|
||||
|
||||
xlog.Debug("Tool", "name", t.Name, "description", desc)
|
||||
|
||||
dat, err := json.Marshal(t.InputSchema)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to marshal input schema", "error", err.Error())
|
||||
}
|
||||
|
||||
xlog.Debug("Input schema", "tool", t.Name, "schema", string(dat))
|
||||
|
||||
// XXX: This is a wild guess, to verify (data types might be incompatible)
|
||||
var inputSchema ToolInputSchema
|
||||
err = json.Unmarshal(dat, &inputSchema)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to unmarshal input schema", "error", err.Error())
|
||||
}
|
||||
|
||||
// Create a new action with Client + tool
|
||||
generatedActions = append(generatedActions, &mcpAction{
|
||||
mcpClient: client,
|
||||
toolName: t.Name,
|
||||
inputSchema: inputSchema,
|
||||
toolDescription: desc,
|
||||
})
|
||||
// XXX: This is a wild guess, to verify (data types might be incompatible)
|
||||
var inputSchema ToolInputSchema
|
||||
err = json.Unmarshal(dat, &inputSchema)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to unmarshal input schema", "error", err.Error())
|
||||
}
|
||||
|
||||
if tools.NextCursor == nil {
|
||||
break // No more pages
|
||||
}
|
||||
cursor = tools.NextCursor
|
||||
// Create a new action with Client + tool
|
||||
generatedActions = append(generatedActions, &mcpWrapperAction{
|
||||
mcpClient: client,
|
||||
toolName: t.Name,
|
||||
inputSchema: inputSchema,
|
||||
toolDescription: desc,
|
||||
})
|
||||
}
|
||||
|
||||
return generatedActions, nil
|
||||
}
|
||||
|
||||
// bearerTokenRoundTripper is a custom roundtripper that injects a bearer token
|
||||
// into HTTP requests
|
||||
type bearerTokenRoundTripper struct {
|
||||
token string
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
// RoundTrip implements the http.RoundTripper interface
|
||||
func (rt *bearerTokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if rt.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+rt.token)
|
||||
}
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
// newBearerTokenRoundTripper creates a new roundtripper that injects the given token
|
||||
func newBearerTokenRoundTripper(token string, base http.RoundTripper) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
return &bearerTokenRoundTripper{
|
||||
token: token,
|
||||
base: base,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) initMCPActions() error {
|
||||
a.closeMCPServers() // Make sure we stop all previous servers if any is active
|
||||
|
||||
a.mcpActions = nil
|
||||
a.mcpActionDefinitions = nil
|
||||
var err error
|
||||
|
||||
generatedActions := types.Actions{}
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "LocalAI", Version: "v1.0.0"}, nil)
|
||||
|
||||
// Connect to a server over stdin/stdout.
|
||||
|
||||
// MCP HTTP Servers
|
||||
for _, mcpServer := range a.options.mcpServers {
|
||||
transport := http.NewHTTPClientTransport("/mcp")
|
||||
transport.WithBaseURL(mcpServer.URL)
|
||||
if mcpServer.Token != "" {
|
||||
transport.WithHeader("Authorization", "Bearer "+mcpServer.Token)
|
||||
// Create HTTP client with custom roundtripper for bearer token injection
|
||||
httpclient := &http.Client{
|
||||
Timeout: 360 * time.Second,
|
||||
Transport: newBearerTokenRoundTripper(mcpServer.Token, http.DefaultTransport),
|
||||
}
|
||||
|
||||
// Create a new client
|
||||
client := mcp.NewClient(transport)
|
||||
streamableTransport := &mcp.StreamableClientTransport{HTTPClient: httpclient, Endpoint: mcpServer.URL}
|
||||
session, err := client.Connect(a.context, streamableTransport, nil)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to connect to MCP server via StreamableClientTransport", "server", mcpServer, "error", err.Error())
|
||||
|
||||
sseTransport := &mcp.SSEClientTransport{HTTPClient: httpclient, Endpoint: mcpServer.URL}
|
||||
session, err = client.Connect(a.context, sseTransport, nil)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to connect to MCP server via SSEClientTransport", "server", mcpServer, "error", err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
a.mcpSessions = append(a.mcpSessions, session)
|
||||
|
||||
xlog.Debug("Adding tools for MCP server", "server", mcpServer)
|
||||
actions, err := a.addTools(client)
|
||||
actions, err := a.addTools(session)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to add tools for MCP server", "server", mcpServer, "error", err.Error())
|
||||
}
|
||||
@@ -175,51 +178,46 @@ func (a *Agent) initMCPActions() error {
|
||||
}
|
||||
|
||||
// MCP STDIO Servers
|
||||
|
||||
a.closeMCPSTDIOServers() // Make sure we stop all previous servers if any is active
|
||||
|
||||
if a.options.mcpPrepareScript != "" {
|
||||
xlog.Debug("Preparing MCP box", "script", a.options.mcpPrepareScript)
|
||||
client := stdio.NewClient(a.options.mcpBoxURL)
|
||||
client.RunProcess(a.context, "/bin/bash", []string{"-c", a.options.mcpPrepareScript}, []string{})
|
||||
xlog.Debug("Preparing MCP", "script", a.options.mcpPrepareScript)
|
||||
|
||||
prepareCmd := exec.Command("/bin/bash", "-c", a.options.mcpPrepareScript)
|
||||
output, err := prepareCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
xlog.Error("Failed with error: '%s' - %s", err.Error(), output)
|
||||
}
|
||||
xlog.Debug("Prepared MCP: \n%s", output)
|
||||
}
|
||||
|
||||
for _, mcpStdioServer := range a.options.mcpStdioServers {
|
||||
client := stdio.NewClient(a.options.mcpBoxURL)
|
||||
p, err := client.CreateProcess(a.context,
|
||||
mcpStdioServer.Cmd,
|
||||
mcpStdioServer.Args,
|
||||
mcpStdioServer.Env,
|
||||
a.Character.Name)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to create process", "error", err.Error())
|
||||
continue
|
||||
}
|
||||
read, writer, err := client.GetProcessIO(p.ID)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to get process IO", "error", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
transport := stdioTransport.NewStdioServerTransportWithIO(read, writer)
|
||||
command := exec.Command(mcpStdioServer.Cmd, mcpStdioServer.Args...)
|
||||
command.Env = os.Environ()
|
||||
command.Env = append(command.Env, mcpStdioServer.Env...)
|
||||
|
||||
// Create a new client
|
||||
mcpClient := mcp.NewClient(transport)
|
||||
session, err := client.Connect(a.context, &mcp.CommandTransport{
|
||||
Command: command}, nil)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to connect to MCP server", "server", mcpStdioServer, "error", err.Error())
|
||||
continue
|
||||
}
|
||||
a.mcpSessions = append(a.mcpSessions, session)
|
||||
|
||||
xlog.Debug("Adding tools for MCP server (stdio)", "server", mcpStdioServer)
|
||||
actions, err := a.addTools(mcpClient)
|
||||
actions, err := a.addTools(session)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to add tools for MCP server", "server", mcpStdioServer, "error", err.Error())
|
||||
}
|
||||
generatedActions = append(generatedActions, actions...)
|
||||
}
|
||||
|
||||
a.mcpActions = generatedActions
|
||||
a.mcpActionDefinitions = generatedActions
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Agent) closeMCPSTDIOServers() {
|
||||
client := stdio.NewClient(a.options.mcpBoxURL)
|
||||
client.StopGroup(a.Character.Name)
|
||||
func (a *Agent) closeMCPServers() {
|
||||
for _, s := range a.mcpSessions {
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
|
||||
+14
-5
@@ -7,13 +7,14 @@ import (
|
||||
|
||||
"github.com/mudler/LocalAGI/core/sse"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
type Observer interface {
|
||||
NewObservable() *types.Observable
|
||||
Update(types.Observable)
|
||||
History() []types.Observable
|
||||
ClearHistory()
|
||||
}
|
||||
|
||||
type SSEObserver struct {
|
||||
@@ -21,8 +22,8 @@ type SSEObserver struct {
|
||||
maxID int32
|
||||
manager sse.Manager
|
||||
|
||||
mutex sync.Mutex
|
||||
history []types.Observable
|
||||
mutex sync.Mutex
|
||||
history []types.Observable
|
||||
historyLast int
|
||||
}
|
||||
|
||||
@@ -36,8 +37,8 @@ func NewSSEObserver(agent string, manager sse.Manager) *SSEObserver {
|
||||
}
|
||||
|
||||
func (s *SSEObserver) NewObservable() *types.Observable {
|
||||
id := atomic.AddInt32(&s.maxID, 1)
|
||||
|
||||
id := atomic.AddInt32(&s.maxID, 1)
|
||||
|
||||
return &types.Observable{
|
||||
ID: id - 1,
|
||||
Agent: s.agent,
|
||||
@@ -86,3 +87,11 @@ func (s *SSEObserver) History() []types.Observable {
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (s *SSEObserver) ClearHistory() {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
s.history = make([]types.Observable, 100)
|
||||
s.historyLast = 0
|
||||
}
|
||||
|
||||
+119
-31
@@ -6,16 +6,31 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
type Option func(*options) error
|
||||
|
||||
// ConversationStorageMode defines how conversations are stored in the knowledge base
|
||||
type ConversationStorageMode string
|
||||
|
||||
const (
|
||||
// StoreUserOnly stores only user messages (default)
|
||||
StoreUserOnly ConversationStorageMode = "user_only"
|
||||
// StoreUserAndAssistant stores both user and assistant messages separately
|
||||
StoreUserAndAssistant ConversationStorageMode = "user_and_assistant"
|
||||
// StoreWholeConversation stores the entire conversation as a single block
|
||||
StoreWholeConversation ConversationStorageMode = "whole_conversation"
|
||||
)
|
||||
|
||||
type llmOptions struct {
|
||||
APIURL string
|
||||
APIKey string
|
||||
Model string
|
||||
MultimodalModel string
|
||||
APIURL string
|
||||
APIKey string
|
||||
Model string
|
||||
MultimodalModel string
|
||||
ReviewerModel string
|
||||
TranscriptionModel string
|
||||
TranscriptionLanguage string
|
||||
TTSModel string
|
||||
}
|
||||
|
||||
type options struct {
|
||||
@@ -27,18 +42,23 @@ type options struct {
|
||||
jobFilters types.JobFilters
|
||||
enableHUD, standaloneJob, showCharacter, enableKB, enableSummaryMemory, enableLongTermMemory bool
|
||||
stripThinkingTags bool
|
||||
kbAutoSearch bool
|
||||
conversationStorageMode ConversationStorageMode
|
||||
|
||||
canStopItself bool
|
||||
initiateConversations bool
|
||||
loopDetectionSteps int
|
||||
forceReasoning bool
|
||||
enableGuidedTools bool
|
||||
canPlan bool
|
||||
disableSinkState bool
|
||||
characterfile string
|
||||
statefile string
|
||||
schedulerStorePath string // Path to scheduler JSON storage file
|
||||
context context.Context
|
||||
permanentGoal string
|
||||
timeout string
|
||||
periodicRuns time.Duration
|
||||
schedulerPollInterval time.Duration
|
||||
kbResults int
|
||||
ragdb RAGDB
|
||||
|
||||
@@ -58,9 +78,8 @@ type options struct {
|
||||
|
||||
mcpServers []MCPServer
|
||||
mcpStdioServers []MCPSTDIOServer
|
||||
mcpBoxURL string
|
||||
mcpPrepareScript string
|
||||
newConversationsSubscribers []func(openai.ChatCompletionMessage)
|
||||
newConversationsSubscribers []func(*types.ConversationMessage)
|
||||
|
||||
observer Observer
|
||||
parallelJobs int
|
||||
@@ -74,14 +93,19 @@ func (o *options) SeparatedMultimodalModel() bool {
|
||||
|
||||
func defaultOptions() *options {
|
||||
return &options{
|
||||
parallelJobs: 1,
|
||||
periodicRuns: 15 * time.Minute,
|
||||
loopDetectionSteps: 10,
|
||||
maxEvaluationLoops: 2,
|
||||
enableEvaluation: false,
|
||||
parallelJobs: 1,
|
||||
periodicRuns: 15 * time.Minute,
|
||||
schedulerPollInterval: 30 * time.Second,
|
||||
maxEvaluationLoops: 2,
|
||||
enableEvaluation: false,
|
||||
kbAutoSearch: true, // Default to true to maintain backward compatibility
|
||||
conversationStorageMode: StoreUserOnly, // Default to user-only for backward compatibility
|
||||
LLMAPI: llmOptions{
|
||||
APIURL: "http://localhost:8080",
|
||||
Model: "gpt-4",
|
||||
APIURL: "http://localhost:8080",
|
||||
Model: "gpt-4",
|
||||
TranscriptionModel: "whisper-1",
|
||||
TranscriptionLanguage: "",
|
||||
TTSModel: "tts-1",
|
||||
},
|
||||
character: Character{
|
||||
Name: "",
|
||||
@@ -113,6 +137,11 @@ var EnableForceReasoning = func(o *options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var EnableGuidedTools = func(o *options) error {
|
||||
o.enableGuidedTools = true
|
||||
return nil
|
||||
}
|
||||
|
||||
var EnableKnowledgeBase = func(o *options) error {
|
||||
o.enableKB = true
|
||||
o.kbResults = 5
|
||||
@@ -131,13 +160,6 @@ func WithTimeout(timeout string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithLoopDetectionSteps(steps int) Option {
|
||||
return func(o *options) error {
|
||||
o.loopDetectionSteps = steps
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithConversationsPath(path string) Option {
|
||||
return func(o *options) error {
|
||||
o.conversationsPath = path
|
||||
@@ -171,7 +193,7 @@ func WithParallelJobs(jobs int) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithNewConversationSubscriber(sub func(openai.ChatCompletionMessage)) Option {
|
||||
func WithNewConversationSubscriber(sub func(*types.ConversationMessage)) Option {
|
||||
return func(o *options) error {
|
||||
o.newConversationsSubscribers = append(o.newConversationsSubscribers, sub)
|
||||
return nil
|
||||
@@ -188,6 +210,18 @@ var EnablePlanning = func(o *options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var DisableSinkState = func(o *options) error {
|
||||
o.disableSinkState = true
|
||||
return nil
|
||||
}
|
||||
|
||||
var WithPlanReviewerLLM = func(model string) Option {
|
||||
return func(o *options) error {
|
||||
o.LLMAPI.ReviewerModel = model
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// EnableStandaloneJob is an option to enable the agent
|
||||
// to run jobs in the background automatically
|
||||
var EnableStandaloneJob = func(o *options) error {
|
||||
@@ -217,6 +251,19 @@ func WithRAGDB(db RAGDB) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithConversationStorageMode sets how conversations are stored in the knowledge base
|
||||
func WithConversationStorageMode(mode ConversationStorageMode) Option {
|
||||
return func(o *options) error {
|
||||
switch mode {
|
||||
case StoreUserOnly, StoreUserAndAssistant, StoreWholeConversation:
|
||||
o.conversationStorageMode = mode
|
||||
default:
|
||||
o.conversationStorageMode = StoreUserOnly
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithSystemPrompt(prompt string) Option {
|
||||
return func(o *options) error {
|
||||
o.systemPrompt = prompt
|
||||
@@ -238,13 +285,6 @@ func WithMCPSTDIOServers(servers ...MCPSTDIOServer) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithMCPBoxURL(url string) Option {
|
||||
return func(o *options) error {
|
||||
o.mcpBoxURL = url
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithMCPPrepareScript(script string) Option {
|
||||
return func(o *options) error {
|
||||
o.mcpPrepareScript = script
|
||||
@@ -331,6 +371,18 @@ func WithPeriodicRuns(duration string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithSchedulerPollInterval(duration string) Option {
|
||||
return func(o *options) error {
|
||||
t, err := time.ParseDuration(duration)
|
||||
if err != nil {
|
||||
o.schedulerPollInterval = 30 * time.Second
|
||||
return nil
|
||||
}
|
||||
o.schedulerPollInterval = t
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithContext(ctx context.Context) Option {
|
||||
return func(o *options) error {
|
||||
o.context = ctx
|
||||
@@ -388,7 +440,7 @@ func WithRandomIdentity(guidance ...string) Option {
|
||||
|
||||
func WithActions(actions ...types.Action) Option {
|
||||
return func(o *options) error {
|
||||
o.userActions = actions
|
||||
o.userActions = append(o.userActions, actions...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -425,3 +477,39 @@ func EnableEvaluation() Option {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithTranscriptionModel(model string) Option {
|
||||
return func(o *options) error {
|
||||
o.LLMAPI.TranscriptionModel = model
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithTranscriptionLanguage(language string) Option {
|
||||
return func(o *options) error {
|
||||
o.LLMAPI.TranscriptionLanguage = language
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithTTSModel(model string) Option {
|
||||
return func(o *options) error {
|
||||
o.LLMAPI.TTSModel = model
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithKBAutoSearch(enabled bool) Option {
|
||||
return func(o *options) error {
|
||||
o.kbAutoSearch = enabled
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithSchedulerStorePath sets the path for the scheduler's JSON storage file
|
||||
func WithSchedulerStorePath(path string) Option {
|
||||
return func(o *options) error {
|
||||
o.schedulerStorePath = path
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package agent
|
||||
|
||||
import "github.com/mudler/LocalAGI/core/types"
|
||||
|
||||
type DynamicPrompt interface {
|
||||
Render(a *Agent) (string, error)
|
||||
Render(a *Agent) (types.PromptResult, error)
|
||||
Role() string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/scheduler"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
)
|
||||
|
||||
// agentSchedulerExecutor implements scheduler.AgentExecutor for executing scheduled tasks through the agent
|
||||
type agentSchedulerExecutor struct {
|
||||
agent *Agent
|
||||
}
|
||||
|
||||
// Execute processes a scheduled task by creating a job for the agent
|
||||
func (e *agentSchedulerExecutor) Execute(ctx context.Context, agentName string, prompt string) (*scheduler.JobResult, error) {
|
||||
// Create a job for the reminder
|
||||
reminderJob := types.NewJob(
|
||||
types.WithText(fmt.Sprintf("You need to execute the following task, by using the tools available to you. When the task is completed, you need to send a message to the user with send_message tool to inform them that the task is completed: %s", prompt)),
|
||||
types.WithReasoningCallback(e.agent.options.reasoningCallback),
|
||||
types.WithResultCallback(e.agent.options.resultCallback),
|
||||
types.WithContext(ctx),
|
||||
types.WithMetadata(map[string]any{
|
||||
"message": prompt,
|
||||
"is_reminder": true,
|
||||
"type": "scheduled",
|
||||
}),
|
||||
)
|
||||
|
||||
// Attach observable so UI can show reminder processing state
|
||||
if e.agent.observer != nil {
|
||||
obs := e.agent.observer.NewObservable()
|
||||
obs.Name = "reminder"
|
||||
obs.Icon = "bell"
|
||||
e.agent.observer.Update(*obs)
|
||||
reminderJob.Obs = obs
|
||||
}
|
||||
|
||||
// Send the job to be processed
|
||||
e.agent.jobQueue <- reminderJob
|
||||
|
||||
// Wait for the job to complete or context to be cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
result, err := reminderJob.Result.WaitResult(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Error != nil {
|
||||
return &scheduler.JobResult{
|
||||
Response: "",
|
||||
Error: result.Error,
|
||||
}, result.Error
|
||||
}
|
||||
return &scheduler.JobResult{
|
||||
Response: result.Response,
|
||||
Error: nil,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
+19
-44
@@ -2,18 +2,32 @@ package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
func templateBase(templateName, templatetext string) (*template.Template, error) {
|
||||
return template.New(templateName).Funcs(sprig.FuncMap()).Parse(templatetext)
|
||||
}
|
||||
|
||||
func templateExecute(template *template.Template, data interface{}) (string, error) {
|
||||
prompt := bytes.NewBuffer([]byte{})
|
||||
err := template.Execute(prompt, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prompt.String(), nil
|
||||
}
|
||||
|
||||
func renderTemplate(templ string, hud *PromptHUD, actions types.Actions, reasoning string) (string, error) {
|
||||
// prepare the prompt
|
||||
prompt := bytes.NewBuffer([]byte{})
|
||||
|
||||
promptTemplate, err := template.New("pickAction").Parse(templ)
|
||||
promptTemplate, err := templateBase("pickAction", templ)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -34,7 +48,7 @@ func renderTemplate(templ string, hud *PromptHUD, actions types.Actions, reasoni
|
||||
Actions: definitions,
|
||||
HUD: hud,
|
||||
Reasoning: reasoning,
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
Time: time.Now().UTC().Format(time.RFC1123),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -80,7 +94,8 @@ Current State:
|
||||
- Current Goal: {{if .CurrentState.Goal}}{{.CurrentState.Goal}}{{else}}None{{end}}
|
||||
- Action History: {{range .CurrentState.DoneHistory}}{{.}} {{end}}
|
||||
- Short-term Memory: {{range .CurrentState.Memories}}{{.}} {{end}}{{end}}
|
||||
Current Time: {{.Time}}`
|
||||
|
||||
Current Time and Date: {{.Time}}`
|
||||
|
||||
const pickSelfTemplate = `
|
||||
You are an autonomous AI agent with a defined character and state (as shown above).
|
||||
@@ -93,7 +108,6 @@ Guidelines:
|
||||
4. Update your state appropriately
|
||||
|
||||
When making decisions:
|
||||
- Use the "reply" tool to provide final responses
|
||||
- Update your state using appropriate tools
|
||||
- Plan complex tasks using the planning tool
|
||||
- Consider both immediate and long-term goals
|
||||
@@ -104,43 +118,4 @@ Remember:
|
||||
- Keep track of your progress and state
|
||||
- Be proactive in addressing potential issues
|
||||
|
||||
Available Tools:
|
||||
{{range .Actions -}}
|
||||
- {{.Name}}: {{.Description }}
|
||||
{{ end }}
|
||||
|
||||
{{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}}
|
||||
` + hudTemplate
|
||||
|
||||
const reSelfEvalTemplate = pickSelfTemplate
|
||||
|
||||
const pickActionTemplate = hudTemplate + `
|
||||
Your only task is to analyze the conversation and determine a goal and the best tool to use, or just a final response if we have fullfilled the goal.
|
||||
|
||||
Guidelines:
|
||||
1. Review the current state, what was done already and context
|
||||
2. Consider available tools and their purposes
|
||||
3. Plan your approach carefully
|
||||
4. Explain your reasoning clearly
|
||||
|
||||
When choosing actions:
|
||||
- Use "reply" or "answer" tools for direct responses
|
||||
- Select appropriate tools for specific tasks
|
||||
- Consider the impact of each action
|
||||
- Plan for potential challenges
|
||||
|
||||
Decision Process:
|
||||
1. Analyze the situation
|
||||
2. Consider available options
|
||||
3. Choose the best course of action
|
||||
4. Explain your reasoning
|
||||
5. Execute the chosen action
|
||||
|
||||
Available Tools:
|
||||
{{range .Actions -}}
|
||||
- {{.Name}}: {{.Description }}
|
||||
{{ end }}
|
||||
|
||||
{{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}}`
|
||||
|
||||
const reEvalTemplate = pickActionTemplate
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// TaskStore defines the interface for task persistence
|
||||
type TaskStore interface {
|
||||
// Create adds a new task
|
||||
Create(task *Task) error
|
||||
|
||||
// Get retrieves a task by ID
|
||||
Get(id string) (*Task, error)
|
||||
|
||||
// GetAll retrieves all tasks
|
||||
GetAll() ([]*Task, error)
|
||||
|
||||
// GetDue retrieves tasks that are due for execution
|
||||
GetDue() ([]*Task, error)
|
||||
|
||||
// GetByAgent retrieves all tasks for a specific agent
|
||||
GetByAgent(agentName string) ([]*Task, error)
|
||||
|
||||
// Update updates an existing task
|
||||
Update(task *Task) error
|
||||
|
||||
// Delete removes a task
|
||||
Delete(id string) error
|
||||
|
||||
// LogRun records a task execution
|
||||
LogRun(run *TaskRun) error
|
||||
|
||||
// GetRuns retrieves execution history for a task
|
||||
GetRuns(taskID string, limit int) ([]*TaskRun, error)
|
||||
|
||||
// Close releases resources
|
||||
Close() error
|
||||
}
|
||||
|
||||
// AgentExecutor defines the interface for executing agent tasks
|
||||
type AgentExecutor interface {
|
||||
Execute(ctx context.Context, agentName string, prompt string) (*JobResult, error)
|
||||
}
|
||||
|
||||
// JobResult represents the result of an agent execution
|
||||
type JobResult struct {
|
||||
Response string
|
||||
Error error
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JSONStore implements TaskStore using JSON file storage
|
||||
type JSONStore struct {
|
||||
filePath string
|
||||
mu sync.RWMutex
|
||||
data *storeData
|
||||
}
|
||||
|
||||
type storeData struct {
|
||||
Tasks []*Task `json:"tasks"`
|
||||
TaskRuns []*TaskRun `json:"task_runs"`
|
||||
}
|
||||
|
||||
// NewJSONStore creates a new JSON-based task store
|
||||
func NewJSONStore(filePath string) (*JSONStore, error) {
|
||||
store := &JSONStore{
|
||||
filePath: filePath,
|
||||
data: &storeData{
|
||||
Tasks: make([]*Task, 0),
|
||||
TaskRuns: make([]*TaskRun, 0),
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.load(); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to load store: %w", err)
|
||||
}
|
||||
// File doesn't exist, create it
|
||||
if err := store.save(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create store file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// Create adds a new task
|
||||
func (s *JSONStore) Create(task *Task) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check for duplicate ID
|
||||
for _, t := range s.data.Tasks {
|
||||
if t.ID == task.ID {
|
||||
return fmt.Errorf("task with ID %s already exists", task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
s.data.Tasks = append(s.data.Tasks, task)
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// Get retrieves a task by ID
|
||||
func (s *JSONStore) Get(id string) (*Task, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, task := range s.data.Tasks {
|
||||
if task.ID == id {
|
||||
return task, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("task not found: %s", id)
|
||||
}
|
||||
|
||||
// GetAll retrieves all tasks
|
||||
func (s *JSONStore) GetAll() ([]*Task, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// Return a copy to prevent external modification
|
||||
tasks := make([]*Task, len(s.data.Tasks))
|
||||
copy(tasks, s.data.Tasks)
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// GetDue retrieves tasks that are due for execution
|
||||
func (s *JSONStore) GetDue() ([]*Task, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
dueTasks := make([]*Task, 0)
|
||||
|
||||
for _, task := range s.data.Tasks {
|
||||
if task.Status == TaskStatusActive && now.After(task.NextRun) {
|
||||
dueTasks = append(dueTasks, task)
|
||||
}
|
||||
}
|
||||
|
||||
return dueTasks, nil
|
||||
}
|
||||
|
||||
// GetByAgent retrieves all tasks for a specific agent
|
||||
func (s *JSONStore) GetByAgent(agentName string) ([]*Task, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
agentTasks := make([]*Task, 0)
|
||||
for _, task := range s.data.Tasks {
|
||||
if task.AgentName == agentName {
|
||||
agentTasks = append(agentTasks, task)
|
||||
}
|
||||
}
|
||||
|
||||
return agentTasks, nil
|
||||
}
|
||||
|
||||
// Update updates an existing task
|
||||
func (s *JSONStore) Update(task *Task) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, t := range s.data.Tasks {
|
||||
if t.ID == task.ID {
|
||||
task.UpdatedAt = time.Now()
|
||||
s.data.Tasks[i] = task
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("task not found: %s", task.ID)
|
||||
}
|
||||
|
||||
// Delete removes a task
|
||||
func (s *JSONStore) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, task := range s.data.Tasks {
|
||||
if task.ID == id {
|
||||
// Remove task from slice
|
||||
s.data.Tasks = append(s.data.Tasks[:i], s.data.Tasks[i+1:]...)
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("task not found: %s", id)
|
||||
}
|
||||
|
||||
// LogRun records a task execution
|
||||
func (s *JSONStore) LogRun(run *TaskRun) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.data.TaskRuns = append(s.data.TaskRuns, run)
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// GetRuns retrieves execution history for a task
|
||||
func (s *JSONStore) GetRuns(taskID string, limit int) ([]*TaskRun, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
runs := make([]*TaskRun, 0)
|
||||
for i := len(s.data.TaskRuns) - 1; i >= 0 && len(runs) < limit; i-- {
|
||||
if s.data.TaskRuns[i].TaskID == taskID {
|
||||
runs = append(runs, s.data.TaskRuns[i])
|
||||
}
|
||||
}
|
||||
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
// Close releases resources (no-op for JSON store)
|
||||
func (s *JSONStore) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// load reads data from the JSON file
|
||||
func (s *JSONStore) load() error {
|
||||
file, err := os.ReadFile(s.filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle empty file
|
||||
if len(file) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(file, s.data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure slices are not nil after unmarshaling
|
||||
if s.data.Tasks == nil {
|
||||
s.data.Tasks = make([]*Task, 0)
|
||||
}
|
||||
if s.data.TaskRuns == nil {
|
||||
s.data.TaskRuns = make([]*TaskRun, 0)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// save writes data to the JSON file
|
||||
func (s *JSONStore) save() error {
|
||||
data, err := json.MarshalIndent(s.data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal data: %w", err)
|
||||
}
|
||||
|
||||
basePath := filepath.Dir(s.filePath)
|
||||
os.MkdirAll(basePath, 0755)
|
||||
|
||||
return os.WriteFile(s.filePath, data, 0644)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// Scheduler manages scheduled tasks
|
||||
type Scheduler struct {
|
||||
store TaskStore
|
||||
executor AgentExecutor
|
||||
pollInterval time.Duration
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
mu sync.RWMutex
|
||||
runningTasks map[string]context.CancelFunc
|
||||
}
|
||||
|
||||
// NewScheduler creates a new scheduler with the given store and executor
|
||||
func NewScheduler(store TaskStore, executor AgentExecutor, pollInterval time.Duration) *Scheduler {
|
||||
|
||||
return &Scheduler{
|
||||
store: store,
|
||||
executor: executor,
|
||||
pollInterval: pollInterval,
|
||||
runningTasks: make(map[string]context.CancelFunc),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the scheduler's polling loop
|
||||
func (s *Scheduler) Start() {
|
||||
if s.ctx != nil {
|
||||
xlog.Warn("Scheduler already started")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.ctx = ctx
|
||||
s.cancel = cancel
|
||||
s.wg.Add(1)
|
||||
go s.run()
|
||||
xlog.Info("Task scheduler started", "poll_interval", s.pollInterval)
|
||||
}
|
||||
|
||||
// Stop gracefully stops the scheduler
|
||||
func (s *Scheduler) Stop() {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
s.wg.Wait()
|
||||
s.store.Close()
|
||||
xlog.Info("Task scheduler stopped")
|
||||
s.cancel = nil
|
||||
s.ctx = nil
|
||||
}
|
||||
|
||||
// run is the main polling loop
|
||||
func (s *Scheduler) run() {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(s.pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.processDueTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processDueTasks checks for and executes due tasks
|
||||
func (s *Scheduler) processDueTasks() {
|
||||
tasks, err := s.store.GetDue()
|
||||
if err != nil {
|
||||
xlog.Error("Failed to get due tasks", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(tasks) > 0 {
|
||||
xlog.Debug("Processing due tasks", "count", len(tasks))
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
// Check if task is already running
|
||||
s.mu.RLock()
|
||||
_, running := s.runningTasks[task.ID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if running {
|
||||
xlog.Warn("Task already running, skipping", "task_id", task.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Execute task in goroutine
|
||||
s.wg.Add(1)
|
||||
go s.executeTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
// executeTask runs a single task
|
||||
func (s *Scheduler) executeTask(task *Task) {
|
||||
defer s.wg.Done()
|
||||
|
||||
taskCtx, cancel := context.WithCancel(s.ctx)
|
||||
defer cancel()
|
||||
|
||||
// Register running task
|
||||
s.mu.Lock()
|
||||
s.runningTasks[task.ID] = cancel
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.runningTasks, task.ID)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
xlog.Info("Executing task", "task_id", task.ID, "agent", task.AgentName, "prompt", task.Prompt)
|
||||
|
||||
startTime := time.Now()
|
||||
run := NewTaskRun(task.ID)
|
||||
|
||||
// Execute the task
|
||||
result, err := s.executor.Execute(taskCtx, task.AgentName, task.Prompt)
|
||||
|
||||
run.DurationMs = time.Since(startTime).Milliseconds()
|
||||
|
||||
if err != nil {
|
||||
run.Status = "error"
|
||||
run.Error = err.Error()
|
||||
xlog.Error("Task execution failed", "task_id", task.ID, "error", err)
|
||||
} else {
|
||||
run.Status = "success"
|
||||
if result != nil {
|
||||
run.Result = result.Response
|
||||
}
|
||||
xlog.Info("Task executed successfully", "task_id", task.ID, "duration_ms", run.DurationMs)
|
||||
}
|
||||
|
||||
// Log the run
|
||||
if err := s.store.LogRun(run); err != nil {
|
||||
xlog.Error("Failed to log task run", "task_id", task.ID, "error", err)
|
||||
}
|
||||
|
||||
// Update task for next run
|
||||
now := time.Now()
|
||||
task.LastRun = &now
|
||||
|
||||
// For one-time tasks, mark as deleted
|
||||
if task.ScheduleType == ScheduleTypeOnce {
|
||||
if err := s.store.Delete(task.ID); err != nil {
|
||||
xlog.Error("Failed to delete task", "task_id", task.ID, "error", err)
|
||||
}
|
||||
} else {
|
||||
// Calculate next run
|
||||
if err := task.CalculateNextRun(); err != nil {
|
||||
xlog.Error("Failed to calculate next run", "task_id", task.ID, "error", err)
|
||||
task.Status = TaskStatusPaused
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.Update(task); err != nil {
|
||||
xlog.Error("Failed to update task", "task_id", task.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD operations
|
||||
|
||||
// CreateTask adds a new task
|
||||
func (s *Scheduler) CreateTask(task *Task) error {
|
||||
return s.store.Create(task)
|
||||
}
|
||||
|
||||
// GetTask retrieves a task by ID
|
||||
func (s *Scheduler) GetTask(id string) (*Task, error) {
|
||||
return s.store.Get(id)
|
||||
}
|
||||
|
||||
// GetAllTasks retrieves all tasks
|
||||
func (s *Scheduler) GetAllTasks() ([]*Task, error) {
|
||||
return s.store.GetAll()
|
||||
}
|
||||
|
||||
// GetTasksByAgent retrieves all tasks for a specific agent
|
||||
func (s *Scheduler) GetTasksByAgent(agentName string) ([]*Task, error) {
|
||||
return s.store.GetByAgent(agentName)
|
||||
}
|
||||
|
||||
// UpdateTask updates an existing task
|
||||
func (s *Scheduler) UpdateTask(task *Task) error {
|
||||
return s.store.Update(task)
|
||||
}
|
||||
|
||||
// DeleteTask removes a task
|
||||
func (s *Scheduler) DeleteTask(id string) error {
|
||||
return s.store.Delete(id)
|
||||
}
|
||||
|
||||
// GetTaskRuns retrieves execution history for a task
|
||||
func (s *Scheduler) GetTaskRuns(taskID string, limit int) ([]*TaskRun, error) {
|
||||
return s.store.GetRuns(taskID, limit)
|
||||
}
|
||||
|
||||
// PauseTask pauses a task
|
||||
func (s *Scheduler) PauseTask(id string) error {
|
||||
task, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task.Status = TaskStatusPaused
|
||||
return s.store.Update(task)
|
||||
}
|
||||
|
||||
// ResumeTask resumes a paused task
|
||||
func (s *Scheduler) ResumeTask(id string) error {
|
||||
task, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task.Status = TaskStatusActive
|
||||
if err := task.CalculateNextRun(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.store.Update(task)
|
||||
}
|
||||
|
||||
// CancelRunningTask cancels a currently running task
|
||||
func (s *Scheduler) CancelRunningTask(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cancel, exists := s.runningTasks[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("task not running: %s", id)
|
||||
}
|
||||
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestScheduler(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Scheduler Suite")
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/scheduler"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// MockExecutor for testing
|
||||
type MockExecutor struct {
|
||||
executedTasks []string
|
||||
shouldError bool
|
||||
}
|
||||
|
||||
func (m *MockExecutor) Execute(ctx context.Context, agentName string, prompt string) (*scheduler.JobResult, error) {
|
||||
m.executedTasks = append(m.executedTasks, agentName+":"+prompt)
|
||||
if m.shouldError {
|
||||
return nil, errors.New("mock execution error")
|
||||
}
|
||||
return &scheduler.JobResult{Response: "test response"}, nil
|
||||
}
|
||||
|
||||
var _ = Describe("Scheduler", func() {
|
||||
var (
|
||||
tempFile string
|
||||
store scheduler.TaskStore
|
||||
executor *MockExecutor
|
||||
sched *scheduler.Scheduler
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// Create temporary file for JSON store
|
||||
f, err := os.CreateTemp("", "scheduler_test_*.json")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
tempFile = f.Name()
|
||||
f.Close()
|
||||
|
||||
store, err = scheduler.NewJSONStore(tempFile)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
executor = &MockExecutor{}
|
||||
sched = scheduler.NewScheduler(store, executor, 100*time.Millisecond)
|
||||
sched.Start()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if sched != nil {
|
||||
sched.Stop()
|
||||
}
|
||||
os.Remove(tempFile)
|
||||
})
|
||||
|
||||
Describe("Task Creation", func() {
|
||||
It("should create a valid task with cron schedule", func() {
|
||||
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(task.ID).NotTo(BeEmpty())
|
||||
Expect(task.AgentName).To(Equal("test-agent"))
|
||||
Expect(task.Prompt).To(Equal("test prompt"))
|
||||
Expect(task.ScheduleType).To(Equal(scheduler.ScheduleTypeCron))
|
||||
Expect(task.Status).To(Equal(scheduler.TaskStatusActive))
|
||||
Expect(task.NextRun).NotTo(BeZero())
|
||||
})
|
||||
|
||||
It("should return error for invalid cron expression", func() {
|
||||
_, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "invalid cron")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should create a valid task with interval schedule", func() {
|
||||
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeInterval, "3600000")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(time.Hour), 5*time.Second))
|
||||
})
|
||||
|
||||
It("should create a valid task with once schedule using duration", func() {
|
||||
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "24h")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(24*time.Hour), 5*time.Second))
|
||||
})
|
||||
|
||||
It("should create a valid task with once schedule using day syntax", func() {
|
||||
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "1d")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(24*time.Hour), 5*time.Second))
|
||||
})
|
||||
|
||||
It("should create a valid task with once schedule using combined day+time", func() {
|
||||
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "2d12h30m")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(2*24*time.Hour+12*time.Hour+30*time.Minute), 5*time.Second))
|
||||
})
|
||||
|
||||
It("should return error for invalid once duration", func() {
|
||||
_, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "invalid")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Task IsDue", func() {
|
||||
It("should return true for active task past due time", func() {
|
||||
task := &scheduler.Task{
|
||||
Status: scheduler.TaskStatusActive,
|
||||
NextRun: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
Expect(task.IsDue()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should return false for active task not yet due", func() {
|
||||
task := &scheduler.Task{
|
||||
Status: scheduler.TaskStatusActive,
|
||||
NextRun: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
Expect(task.IsDue()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should return false for paused task even if past due", func() {
|
||||
task := &scheduler.Task{
|
||||
Status: scheduler.TaskStatusPaused,
|
||||
NextRun: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
Expect(task.IsDue()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("JSON Store", func() {
|
||||
Context("CRUD operations", func() {
|
||||
It("should create and retrieve a task", func() {
|
||||
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
err = store.Create(task)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
retrieved, err := store.Get(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(retrieved.ID).To(Equal(task.ID))
|
||||
Expect(retrieved.AgentName).To(Equal(task.AgentName))
|
||||
Expect(retrieved.Prompt).To(Equal(task.Prompt))
|
||||
})
|
||||
|
||||
It("should update a task", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
store.Create(task)
|
||||
|
||||
task.Prompt = "updated prompt"
|
||||
err := store.Update(task)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
updated, err := store.Get(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updated.Prompt).To(Equal("updated prompt"))
|
||||
})
|
||||
|
||||
It("should delete a task", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
store.Create(task)
|
||||
|
||||
err := store.Delete(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = store.Get(task.ID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error when getting non-existent task", func() {
|
||||
_, err := store.Get("non-existent-id")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Context("Querying tasks", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test tasks
|
||||
// task1: once schedule with 0s delay => immediately due
|
||||
task1, err := scheduler.NewTask("agent1", "prompt1", scheduler.ScheduleTypeOnce, "0s")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
task1.NextRun = time.Now().Add(-1 * time.Hour) // force into the past
|
||||
// task2: cron schedule => next run in the future, not due
|
||||
task2, err := scheduler.NewTask("agent2", "prompt2", scheduler.ScheduleTypeCron, "0 0 1 1 *")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
// task3: once schedule but paused => not due
|
||||
task3, err := scheduler.NewTask("agent1", "prompt3", scheduler.ScheduleTypeOnce, "0s")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
task3.Status = scheduler.TaskStatusPaused
|
||||
|
||||
Expect(store.Create(task1)).To(Succeed())
|
||||
Expect(store.Create(task2)).To(Succeed())
|
||||
Expect(store.Create(task3)).To(Succeed())
|
||||
})
|
||||
|
||||
It("should get all tasks", func() {
|
||||
tasks, err := store.GetAll()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(tasks).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("should get only due tasks", func() {
|
||||
dueTasks, err := store.GetDue()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(dueTasks).To(HaveLen(1))
|
||||
Expect(dueTasks[0].AgentName).To(Equal("agent1"))
|
||||
Expect(dueTasks[0].Prompt).To(Equal("prompt1"))
|
||||
})
|
||||
|
||||
It("should get tasks by agent", func() {
|
||||
agentTasks, err := store.GetByAgent("agent1")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(agentTasks).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Task runs", func() {
|
||||
It("should log and retrieve task runs", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
store.Create(task)
|
||||
|
||||
run := scheduler.NewTaskRun(task.ID)
|
||||
run.Status = "success"
|
||||
run.Result = "test result"
|
||||
run.DurationMs = 1000
|
||||
|
||||
err := store.LogRun(run)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
runs, err := store.GetRuns(task.ID, 10)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(runs).To(HaveLen(1))
|
||||
Expect(runs[0].Status).To(Equal("success"))
|
||||
Expect(runs[0].Result).To(Equal("test result"))
|
||||
})
|
||||
|
||||
It("should limit returned runs", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
store.Create(task)
|
||||
|
||||
// Create 5 runs
|
||||
for i := 0; i < 5; i++ {
|
||||
run := scheduler.NewTaskRun(task.ID)
|
||||
store.LogRun(run)
|
||||
}
|
||||
|
||||
runs, err := store.GetRuns(task.ID, 3)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(runs).To(HaveLen(3))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Persistence", func() {
|
||||
It("should persist data across store instances", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
store.Create(task)
|
||||
store.Close()
|
||||
|
||||
// Create new store instance with same file
|
||||
newStore, err := scheduler.NewJSONStore(tempFile)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
defer newStore.Close()
|
||||
|
||||
retrieved, err := newStore.Get(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(retrieved.ID).To(Equal(task.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scheduler Execution", func() {
|
||||
It("should execute a due task", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "0s")
|
||||
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
|
||||
err := sched.CreateTask(task)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Scheduler is already started in BeforeEach
|
||||
|
||||
Eventually(func() int {
|
||||
return len(executor.executedTasks)
|
||||
}, "2s", "100ms").Should(Equal(1))
|
||||
|
||||
Expect(executor.executedTasks[0]).To(Equal("test-agent:test prompt"))
|
||||
|
||||
// Verify task run was logged
|
||||
runs, err := sched.GetTaskRuns(task.ID, 10)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(runs).To(HaveLen(1))
|
||||
Expect(runs[0].Status).To(Equal("success"))
|
||||
|
||||
// Verify one-time task was deleted
|
||||
_, err = sched.GetTask(task.ID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should execute recurring tasks multiple times", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "recurring", scheduler.ScheduleTypeInterval, "500")
|
||||
task.NextRun = time.Now().Add(-1 * time.Second)
|
||||
err := sched.CreateTask(task)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Scheduler is already started in BeforeEach
|
||||
|
||||
Eventually(func() int {
|
||||
return len(executor.executedTasks)
|
||||
}, "3s", "100ms").Should(BeNumerically(">=", 2))
|
||||
|
||||
// Verify task is still active
|
||||
updatedTask, err := sched.GetTask(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedTask.Status).To(Equal(scheduler.TaskStatusActive))
|
||||
})
|
||||
|
||||
It("should handle task execution errors", func() {
|
||||
executor.shouldError = true
|
||||
task, _ := scheduler.NewTask("test-agent", "error task", scheduler.ScheduleTypeOnce, "0s")
|
||||
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
|
||||
sched.CreateTask(task)
|
||||
|
||||
// Scheduler is already started in BeforeEach
|
||||
|
||||
Eventually(func() int {
|
||||
runs, _ := sched.GetTaskRuns(task.ID, 10)
|
||||
return len(runs)
|
||||
}, "2s", "100ms").Should(Equal(1))
|
||||
|
||||
runs, _ := sched.GetTaskRuns(task.ID, 10)
|
||||
Expect(runs[0].Status).To(Equal("error"))
|
||||
Expect(runs[0].Error).NotTo(BeEmpty())
|
||||
})
|
||||
|
||||
It("should not execute paused tasks", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "paused", scheduler.ScheduleTypeOnce, "0s")
|
||||
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
|
||||
task.Status = scheduler.TaskStatusPaused
|
||||
sched.CreateTask(task)
|
||||
|
||||
// Scheduler is already started in BeforeEach
|
||||
|
||||
Consistently(func() int {
|
||||
return len(executor.executedTasks)
|
||||
}, "1s", "100ms").Should(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Task Management", func() {
|
||||
It("should pause and resume a task", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
sched.CreateTask(task)
|
||||
|
||||
err := sched.PauseTask(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
paused, _ := sched.GetTask(task.ID)
|
||||
Expect(paused.Status).To(Equal(scheduler.TaskStatusPaused))
|
||||
|
||||
err = sched.ResumeTask(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
resumed, _ := sched.GetTask(task.ID)
|
||||
Expect(resumed.Status).To(Equal(scheduler.TaskStatusActive))
|
||||
Expect(resumed.NextRun).NotTo(BeZero())
|
||||
})
|
||||
|
||||
It("should get tasks by agent", func() {
|
||||
task1, err := scheduler.NewTask("agent1", "prompt1", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
task2, err := scheduler.NewTask("agent2", "prompt2", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
task3, err := scheduler.NewTask("agent1", "prompt3", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(sched.CreateTask(task1)).To(Succeed())
|
||||
Expect(sched.CreateTask(task2)).To(Succeed())
|
||||
Expect(sched.CreateTask(task3)).To(Succeed())
|
||||
|
||||
agent1Tasks, err := sched.GetTasksByAgent("agent1")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(agent1Tasks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("should delete a task", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
sched.CreateTask(task)
|
||||
|
||||
err := sched.DeleteTask(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = sched.GetTask(task.ID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
var dayPattern = regexp.MustCompile(`^(\d+)d(.*)$`)
|
||||
|
||||
// ParseDuration extends time.ParseDuration with support for days ("d").
|
||||
// Examples: "1d" = 24h, "2d12h" = 60h, "30m", "2h30m".
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
if m := dayPattern.FindStringSubmatch(s); m != nil {
|
||||
days, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration: %s", s)
|
||||
}
|
||||
d := time.Duration(days) * 24 * time.Hour
|
||||
if m[2] != "" {
|
||||
rest, err := time.ParseDuration(m[2])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration: %w", err)
|
||||
}
|
||||
d += rest
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
return time.ParseDuration(s)
|
||||
}
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusActive TaskStatus = "active"
|
||||
TaskStatusPaused TaskStatus = "paused"
|
||||
)
|
||||
|
||||
type ScheduleType string
|
||||
|
||||
const (
|
||||
ScheduleTypeCron ScheduleType = "cron"
|
||||
ScheduleTypeInterval ScheduleType = "interval"
|
||||
ScheduleTypeOnce ScheduleType = "once"
|
||||
)
|
||||
|
||||
// Task represents a scheduled task
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
Prompt string `json:"prompt"`
|
||||
ScheduleType ScheduleType `json:"schedule_type"`
|
||||
ScheduleValue string `json:"schedule_value"`
|
||||
Status TaskStatus `json:"status"`
|
||||
NextRun time.Time `json:"next_run"`
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ContextMode string `json:"context_mode"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// TaskRun represents a single execution of a task
|
||||
type TaskRun struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id"`
|
||||
RunAt time.Time `json:"run_at"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Status string `json:"status"` // "success", "error", "timeout"
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewTask creates a new task with the given parameters
|
||||
func NewTask(agentName, prompt string, scheduleType ScheduleType, scheduleValue string) (*Task, error) {
|
||||
task := &Task{
|
||||
ID: uuid.New().String(),
|
||||
AgentName: agentName,
|
||||
Prompt: prompt,
|
||||
ScheduleType: scheduleType,
|
||||
ScheduleValue: scheduleValue,
|
||||
Status: TaskStatusActive,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
ContextMode: "agent",
|
||||
Metadata: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
if err := task.CalculateNextRun(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// CalculateNextRun calculates the next run time based on schedule type
|
||||
func (t *Task) CalculateNextRun() error {
|
||||
now := time.Now()
|
||||
|
||||
switch t.ScheduleType {
|
||||
case ScheduleTypeCron:
|
||||
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
schedule, err := parser.Parse(t.ScheduleValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid cron expression: %w", err)
|
||||
}
|
||||
t.NextRun = schedule.Next(now)
|
||||
|
||||
case ScheduleTypeInterval:
|
||||
intervalMs, err := strconv.ParseInt(t.ScheduleValue, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval: %w", err)
|
||||
}
|
||||
if intervalMs <= 0 {
|
||||
return fmt.Errorf("invalid interval: %d", intervalMs)
|
||||
}
|
||||
if t.LastRun != nil {
|
||||
t.NextRun = t.LastRun.Add(time.Duration(intervalMs) * time.Millisecond)
|
||||
} else {
|
||||
t.NextRun = now.Add(time.Duration(intervalMs) * time.Millisecond)
|
||||
}
|
||||
|
||||
case ScheduleTypeOnce:
|
||||
duration, err := ParseDuration(t.ScheduleValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration: %w", err)
|
||||
}
|
||||
if duration < 0 {
|
||||
return fmt.Errorf("duration must be positive: %s", t.ScheduleValue)
|
||||
}
|
||||
t.NextRun = now.Add(duration)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown schedule type: %s", t.ScheduleType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDue checks if the task should be executed now
|
||||
func (t *Task) IsDue() bool {
|
||||
return t.Status == TaskStatusActive && time.Now().After(t.NextRun)
|
||||
}
|
||||
|
||||
// NewTaskRun creates a new task run record
|
||||
func NewTaskRun(taskID string) *TaskRun {
|
||||
return &TaskRun{
|
||||
ID: uuid.New().String(),
|
||||
TaskID: taskID,
|
||||
RunAt: time.Now(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/llm"
|
||||
"github.com/mudler/LocalAGI/pkg/localrag"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
// datePrefixRegex matches YYYY-MM-DD at the start of a filename (e.g. 2006-01-02-15-04-05-hash.txt).
|
||||
var datePrefixRegex = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})`)
|
||||
|
||||
// summaryPrefix is the filename prefix for compaction summary entries; skip re-compacting these.
|
||||
const summaryPrefix = "summary-"
|
||||
|
||||
// bucketKey returns the period bucket key for a date string (YYYY-MM-DD).
|
||||
func bucketKey(dateStr, period string) (string, error) {
|
||||
t, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch period {
|
||||
case "daily":
|
||||
return dateStr, nil
|
||||
case "weekly":
|
||||
year, week := t.ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", year, week), nil
|
||||
case "monthly":
|
||||
return t.Format("2006-01"), nil
|
||||
default:
|
||||
return dateStr, nil
|
||||
}
|
||||
}
|
||||
|
||||
// dateFromFilename extracts YYYY-MM-DD from the start of a filename if present.
|
||||
func dateFromFilename(filename string) (string, bool) {
|
||||
base := filepath.Base(filename)
|
||||
matches := datePrefixRegex.FindStringSubmatch(base)
|
||||
if len(matches) < 2 {
|
||||
return "", false
|
||||
}
|
||||
return matches[1], true
|
||||
}
|
||||
|
||||
// groupEntriesByPeriod groups entry names by period bucket (daily/weekly/monthly). Skips summary-* and entries without a parseable date.
|
||||
func groupEntriesByPeriod(entries []string, period string) map[string][]string {
|
||||
groups := make(map[string][]string)
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(filepath.Base(entry), summaryPrefix) {
|
||||
continue
|
||||
}
|
||||
dateStr, ok := dateFromFilename(entry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, err := bucketKey(dateStr, period)
|
||||
if err != nil {
|
||||
xlog.Debug("compaction: skip entry, invalid date", "entry", entry, "error", err)
|
||||
continue
|
||||
}
|
||||
groups[key] = append(groups[key], entry)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// summarizer summarizes text via the LLM.
|
||||
type summarizer interface {
|
||||
Summarize(ctx context.Context, content string) (string, error)
|
||||
}
|
||||
|
||||
type openAISummarizer struct {
|
||||
client *openai.Client
|
||||
model string
|
||||
}
|
||||
|
||||
func (s *openAISummarizer) Summarize(ctx context.Context, content string) (string, error) {
|
||||
if content == "" {
|
||||
return "", nil
|
||||
}
|
||||
resp, err := s.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
|
||||
Model: s.model,
|
||||
Messages: []openai.ChatCompletionMessage{
|
||||
{Role: openai.ChatMessageRoleSystem, Content: "Summarize the following knowledge base entries into a concise summary. Preserve important facts and key points."},
|
||||
{Role: openai.ChatMessageRoleUser, Content: content},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
return "", fmt.Errorf("no completion choices")
|
||||
}
|
||||
return strings.TrimSpace(resp.Choices[0].Message.Content), nil
|
||||
}
|
||||
|
||||
// RunCompaction runs one compaction pass: list entries, group by period, for each group fetch content, optionally summarize, store result, delete originals.
|
||||
func RunCompaction(ctx context.Context, client *localrag.WrappedClient, period string, summarize bool, apiURL, apiKey, model string) error {
|
||||
collection := client.Collection()
|
||||
entries, err := client.Client.ListEntries(collection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list entries: %w", err)
|
||||
}
|
||||
groups := groupEntriesByPeriod(entries, period)
|
||||
if len(groups) == 0 {
|
||||
xlog.Debug("compaction: no groups to compact", "collection", collection, "period", period)
|
||||
return nil
|
||||
}
|
||||
|
||||
var sum summarizer
|
||||
if summarize && apiURL != "" && model != "" {
|
||||
openAIClient := llm.NewClient(apiKey, apiURL, "120s")
|
||||
sum = &openAISummarizer{client: openAIClient, model: model}
|
||||
}
|
||||
|
||||
for key, groupEntries := range groups {
|
||||
if len(groupEntries) == 0 {
|
||||
continue
|
||||
}
|
||||
var combined strings.Builder
|
||||
for _, entry := range groupEntries {
|
||||
entryContent, _, err := client.GetEntryContent(entry)
|
||||
if err != nil {
|
||||
xlog.Warn("compaction: get entry content failed", "entry", entry, "error", err)
|
||||
continue
|
||||
}
|
||||
if entryContent != "" {
|
||||
combined.WriteString(entryContent)
|
||||
combined.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
content := strings.TrimSpace(combined.String())
|
||||
if content == "" {
|
||||
xlog.Debug("compaction: empty content for group", "key", key)
|
||||
continue
|
||||
}
|
||||
|
||||
if sum != nil {
|
||||
summary, err := sum.Summarize(ctx, content)
|
||||
if err != nil {
|
||||
xlog.Warn("compaction: summarize failed", "key", key, "error", err)
|
||||
continue
|
||||
}
|
||||
content = summary
|
||||
}
|
||||
|
||||
// Store result as summary-<key>.txt
|
||||
resultFilename := fmt.Sprintf("%s%s.txt", summaryPrefix, key)
|
||||
tmpDir, err := os.MkdirTemp("", "localagi-compact")
|
||||
if err != nil {
|
||||
xlog.Warn("compaction: mkdir temp failed", "error", err)
|
||||
continue
|
||||
}
|
||||
tmpPath := filepath.Join(tmpDir, resultFilename)
|
||||
if err := os.WriteFile(tmpPath, []byte(content), 0644); err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
xlog.Warn("compaction: write temp file failed", "error", err)
|
||||
continue
|
||||
}
|
||||
if err := client.Client.Store(collection, tmpPath); err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
xlog.Warn("compaction: store failed", "key", key, "error", err)
|
||||
continue
|
||||
}
|
||||
os.RemoveAll(tmpDir)
|
||||
|
||||
for _, entry := range groupEntries {
|
||||
if _, err := client.Client.DeleteEntry(collection, entry); err != nil {
|
||||
xlog.Warn("compaction: delete entry failed", "entry", entry, "error", err)
|
||||
}
|
||||
}
|
||||
xlog.Info("compaction: compacted group", "collection", collection, "period", period, "key", key, "entries", len(groupEntries))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCompactionTicker runs compaction on a schedule (daily/weekly/monthly). It stops when ctx is done.
|
||||
func runCompactionTicker(ctx context.Context, client *localrag.WrappedClient, config *AgentConfig, apiURL, apiKey, model string) {
|
||||
// Run first compaction immediately on startup
|
||||
if err := RunCompaction(ctx, client, config.KBCompactionInterval, config.KBCompactionSummarize, apiURL, apiKey, model); err != nil {
|
||||
xlog.Warn("compaction ticker initial run failed", "collection", client.Collection(), "error", err)
|
||||
}
|
||||
|
||||
interval := 24 * time.Hour
|
||||
switch config.KBCompactionInterval {
|
||||
case "weekly":
|
||||
interval = 7 * 24 * time.Hour
|
||||
case "monthly":
|
||||
interval = 30 * 24 * time.Hour
|
||||
default:
|
||||
interval = 24 * time.Hour
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
xlog.Debug("compaction ticker stopped", "collection", client.Collection())
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := RunCompaction(ctx, client, config.KBCompactionInterval, config.KBCompactionSummarize, apiURL, apiKey, model); err != nil {
|
||||
xlog.Warn("compaction ticker failed", "collection", client.Collection(), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
-23
@@ -3,6 +3,7 @@ package state
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
@@ -10,6 +11,21 @@ import (
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
)
|
||||
|
||||
// parseIntField parses an integer field that may be received as either a number or a string
|
||||
func parseIntField(value interface{}) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
if i, err := strconv.Atoi(v); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ConnectorConfig struct {
|
||||
Type string `json:"type"` // e.g. Slack
|
||||
Config string `json:"config"`
|
||||
@@ -43,18 +59,20 @@ type AgentConfig struct {
|
||||
MCPServers []agent.MCPServer `json:"mcp_servers" form:"mcp_servers"`
|
||||
MCPSTDIOServers []agent.MCPSTDIOServer `json:"mcp_stdio_servers" form:"mcp_stdio_servers"`
|
||||
MCPPrepareScript string `json:"mcp_prepare_script" form:"mcp_prepare_script"`
|
||||
MCPBoxURL string `json:"mcp_box_url" form:"mcp_box_url"`
|
||||
Filters []FiltersConfig `json:"filters" form:"filters"`
|
||||
|
||||
Description string `json:"description" form:"description"`
|
||||
|
||||
Model string `json:"model" form:"model"`
|
||||
MultimodalModel string `json:"multimodal_model" form:"multimodal_model"`
|
||||
APIURL string `json:"api_url" form:"api_url"`
|
||||
APIKey string `json:"api_key" form:"api_key"`
|
||||
LocalRAGURL string `json:"local_rag_url" form:"local_rag_url"`
|
||||
LocalRAGAPIKey string `json:"local_rag_api_key" form:"local_rag_api_key"`
|
||||
LastMessageDuration string `json:"last_message_duration" form:"last_message_duration"`
|
||||
Model string `json:"model" form:"model"`
|
||||
MultimodalModel string `json:"multimodal_model" form:"multimodal_model"`
|
||||
TranscriptionModel string `json:"transcription_model" form:"transcription_model"`
|
||||
TranscriptionLanguage string `json:"transcription_language" form:"transcription_language"`
|
||||
TTSModel string `json:"tts_model" form:"tts_model"`
|
||||
APIURL string `json:"api_url" form:"api_url"`
|
||||
APIKey string `json:"api_key" form:"api_key"`
|
||||
LocalRAGURL string `json:"local_rag_url" form:"local_rag_url"`
|
||||
LocalRAGAPIKey string `json:"local_rag_api_key" form:"local_rag_api_key"`
|
||||
LastMessageDuration string `json:"last_message_duration" form:"last_message_duration"`
|
||||
|
||||
Name string `json:"name" form:"name"`
|
||||
HUD bool `json:"hud" form:"hud"`
|
||||
@@ -62,18 +80,27 @@ type AgentConfig struct {
|
||||
RandomIdentity bool `json:"random_identity" form:"random_identity"`
|
||||
InitiateConversations bool `json:"initiate_conversations" form:"initiate_conversations"`
|
||||
CanPlan bool `json:"enable_planning" form:"enable_planning"`
|
||||
PlanReviewerModel string `json:"plan_reviewer_model" form:"plan_reviewer_model"`
|
||||
DisableSinkState bool `json:"disable_sink_state" form:"disable_sink_state"`
|
||||
IdentityGuidance string `json:"identity_guidance" form:"identity_guidance"`
|
||||
PeriodicRuns string `json:"periodic_runs" form:"periodic_runs"`
|
||||
SchedulerPollInterval string `json:"scheduler_poll_interval" form:"scheduler_poll_interval"`
|
||||
PermanentGoal string `json:"permanent_goal" form:"permanent_goal"`
|
||||
EnableKnowledgeBase bool `json:"enable_kb" form:"enable_kb"`
|
||||
EnableKBCompaction bool `json:"enable_kb_compaction" form:"enable_kb_compaction"`
|
||||
KBCompactionInterval string `json:"kb_compaction_interval" form:"kb_compaction_interval"`
|
||||
KBCompactionSummarize bool `json:"kb_compaction_summarize" form:"kb_compaction_summarize"`
|
||||
KBAutoSearch bool `json:"kb_auto_search" form:"kb_auto_search"`
|
||||
KBAsTools bool `json:"kb_as_tools" form:"kb_as_tools"`
|
||||
EnableReasoning bool `json:"enable_reasoning" form:"enable_reasoning"`
|
||||
EnableGuidedTools bool `json:"enable_guided_tools" form:"enable_guided_tools"`
|
||||
KnowledgeBaseResults int `json:"kb_results" form:"kb_results"`
|
||||
LoopDetectionSteps int `json:"loop_detection_steps" form:"loop_detection_steps"`
|
||||
CanStopItself bool `json:"can_stop_itself" form:"can_stop_itself"`
|
||||
SystemPrompt string `json:"system_prompt" form:"system_prompt"`
|
||||
LongTermMemory bool `json:"long_term_memory" form:"long_term_memory"`
|
||||
SummaryLongTermMemory bool `json:"summary_long_term_memory" form:"summary_long_term_memory"`
|
||||
ParallelJobs int `json:"parallel_jobs" form:"parallel_jobs"`
|
||||
LongTermMemory bool `json:"long_term_memory" form:"long_term_memory"`
|
||||
SummaryLongTermMemory bool `json:"summary_long_term_memory" form:"summary_long_term_memory"`
|
||||
ConversationStorageMode string `json:"conversation_storage_mode" form:"conversation_storage_mode"`
|
||||
ParallelJobs int `json:"parallel_jobs" form:"parallel_jobs"`
|
||||
StripThinkingTags bool `json:"strip_thinking_tags" form:"strip_thinking_tags"`
|
||||
EnableEvaluation bool `json:"enable_evaluation" form:"enable_evaluation"`
|
||||
MaxEvaluationLoops int `json:"max_evaluation_loops" form:"max_evaluation_loops"`
|
||||
@@ -146,6 +173,34 @@ func NewAgentConfigMeta(
|
||||
DefaultValue: "",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "transcription_model",
|
||||
Label: "Transcription Model",
|
||||
Type: "text",
|
||||
DefaultValue: "",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "transcription_language",
|
||||
Label: "Transcription Language",
|
||||
Type: "text",
|
||||
DefaultValue: "",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "tts_model",
|
||||
Label: "TTS Model",
|
||||
Type: "text",
|
||||
DefaultValue: "",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "plan_reviewer_model",
|
||||
Label: "Plan Reviewer Model",
|
||||
Type: "text",
|
||||
DefaultValue: "",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "api_url",
|
||||
Label: "API URL",
|
||||
@@ -190,6 +245,31 @@ func NewAgentConfigMeta(
|
||||
Step: 1,
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "enable_kb_compaction",
|
||||
Label: "Enable KB Compaction",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Periodically group collection entries by date (daily/weekly/monthly), optionally summarize or concatenate, then store and remove originals",
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "kb_compaction_interval",
|
||||
Label: "KB Compaction Interval",
|
||||
Type: "text",
|
||||
DefaultValue: "daily",
|
||||
Placeholder: "daily, weekly, monthly",
|
||||
HelpText: "Compaction window: daily, weekly, or monthly",
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "kb_compaction_summarize",
|
||||
Label: "KB Compaction Summarize",
|
||||
Type: "checkbox",
|
||||
DefaultValue: true,
|
||||
HelpText: "When enabled, summarize grouped content via LLM; when disabled, store concatenated content only (no LLM call)",
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "long_term_memory",
|
||||
Label: "Long Term Memory",
|
||||
@@ -204,6 +284,35 @@ func NewAgentConfigMeta(
|
||||
DefaultValue: false,
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "kb_auto_search",
|
||||
Label: "KB Auto Search",
|
||||
Type: "checkbox",
|
||||
DefaultValue: true,
|
||||
HelpText: "Automatically search knowledge base when a user message is received",
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "kb_as_tools",
|
||||
Label: "KB As Tools",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Inject knowledge base search and add actions as tools, allowing the agent to access its memory without manual configuration",
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "conversation_storage_mode",
|
||||
Label: "Conversation Storage Mode",
|
||||
Type: "select",
|
||||
DefaultValue: "user_only",
|
||||
Options: []config.FieldOption{
|
||||
{Value: "user_only", Label: "User Messages Only"},
|
||||
{Value: "user_and_assistant", Label: "User and Assistant Messages"},
|
||||
{Value: "whole_conversation", Label: "Whole Conversation as Block"},
|
||||
},
|
||||
HelpText: "Controls what gets stored in the knowledge base: only user messages, user and assistant messages separately, or the entire conversation as a single block",
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "system_prompt",
|
||||
Label: "System Prompt",
|
||||
@@ -261,6 +370,15 @@ func NewAgentConfigMeta(
|
||||
HelpText: "Duration for scheduling periodic agent runs",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "scheduler_poll_interval",
|
||||
Label: "Scheduler Poll Interval",
|
||||
Type: "text",
|
||||
DefaultValue: "30s",
|
||||
Placeholder: "30s",
|
||||
HelpText: "Duration for polling the scheduler for planned tasks",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "enable_reasoning",
|
||||
Label: "Enable Reasoning",
|
||||
@@ -270,12 +388,11 @@ func NewAgentConfigMeta(
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "loop_detection_steps",
|
||||
Label: "Max Loop Detection Steps",
|
||||
Type: "number",
|
||||
DefaultValue: 5,
|
||||
Min: 1,
|
||||
Step: 1,
|
||||
Name: "enable_guided_tools",
|
||||
Label: "Enable Guided Tools",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Filter tools through guidance using their descriptions; creates virtual guidelines when none exist",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
@@ -288,21 +405,29 @@ func NewAgentConfigMeta(
|
||||
HelpText: "Number of concurrent tasks that can run in parallel",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "disable_sink_state",
|
||||
Label: "Disable Sink State",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Disable the sink state of the agent",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "mcp_stdio_servers",
|
||||
Label: "MCP STDIO Servers",
|
||||
Type: "textarea",
|
||||
DefaultValue: "",
|
||||
HelpText: "JSON configuration for MCP STDIO servers",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
Tags: config.Tags{Section: "MCP"},
|
||||
},
|
||||
{
|
||||
Name: "mcp_prepare_script",
|
||||
Label: "MCP Prepare Script",
|
||||
Type: "textarea",
|
||||
DefaultValue: "",
|
||||
HelpText: "Script to prepare the MCP box",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
HelpText: "Script to prepare for running MCP servers",
|
||||
Tags: config.Tags{Section: "MCP"},
|
||||
},
|
||||
{
|
||||
Name: "strip_thinking_tags",
|
||||
@@ -373,6 +498,9 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
aux := &struct {
|
||||
*Alias
|
||||
MCPSTDIOServersConfig interface{} `json:"mcp_stdio_servers"`
|
||||
MaxEvaluationLoops interface{} `json:"max_evaluation_loops"`
|
||||
ParallelJobs interface{} `json:"parallel_jobs"`
|
||||
KnowledgeBaseResults interface{} `json:"kb_results"`
|
||||
}{
|
||||
Alias: (*Alias)(a),
|
||||
}
|
||||
@@ -381,6 +509,11 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse integer fields that may come as strings
|
||||
a.MaxEvaluationLoops = parseIntField(aux.MaxEvaluationLoops)
|
||||
a.ParallelJobs = parseIntField(aux.ParallelJobs)
|
||||
a.KnowledgeBaseResults = parseIntField(aux.KnowledgeBaseResults)
|
||||
|
||||
// Handle MCP STDIO servers configuration
|
||||
if aux.MCPSTDIOServersConfig != nil {
|
||||
switch v := aux.MCPSTDIOServersConfig.(type) {
|
||||
@@ -399,7 +532,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
a.MCPSTDIOServers = make([]agent.MCPSTDIOServer, 0, len(mcpConfig.MCPServers))
|
||||
for _, server := range mcpConfig.MCPServers {
|
||||
for name, server := range mcpConfig.MCPServers {
|
||||
// Convert env map to slice of "KEY=VALUE" strings
|
||||
envSlice := make([]string, 0, len(server.Env))
|
||||
for k, v := range server.Env {
|
||||
@@ -407,6 +540,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
a.MCPSTDIOServers = append(a.MCPSTDIOServers, agent.MCPSTDIOServer{
|
||||
Name: name,
|
||||
Cmd: server.Command,
|
||||
Args: server.Args,
|
||||
Env: envSlice,
|
||||
@@ -421,6 +555,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
return fmt.Errorf("invalid server configuration format")
|
||||
}
|
||||
|
||||
name, _ := serverMap["name"].(string)
|
||||
cmd, _ := serverMap["cmd"].(string)
|
||||
args := make([]string, 0)
|
||||
if argsInterface, ok := serverMap["args"].([]interface{}); ok {
|
||||
@@ -441,6 +576,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
a.MCPSTDIOServers = append(a.MCPSTDIOServers, agent.MCPSTDIOServer{
|
||||
Name: name,
|
||||
Cmd: cmd,
|
||||
Args: args,
|
||||
Env: env,
|
||||
@@ -489,7 +625,11 @@ func (a *AgentConfig) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
mcpConfig.MCPServers[fmt.Sprintf("server%d", i)] = struct {
|
||||
key := server.Name
|
||||
if key == "" {
|
||||
key = fmt.Sprintf("server%d", i)
|
||||
}
|
||||
mcpConfig.MCPServers[key] = struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
|
||||
+132
-169
@@ -2,7 +2,6 @@ package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -15,32 +14,29 @@ import (
|
||||
. "github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/sse"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/llm"
|
||||
"github.com/mudler/LocalAGI/pkg/localrag"
|
||||
"github.com/mudler/LocalAGI/pkg/utils"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
type AgentPool struct {
|
||||
sync.Mutex
|
||||
file string
|
||||
pooldir string
|
||||
pool AgentPoolData
|
||||
agents map[string]*Agent
|
||||
managers map[string]sse.Manager
|
||||
agentStatus map[string]*Status
|
||||
apiURL, defaultModel, defaultMultimodalModel string
|
||||
mcpBoxURL string
|
||||
imageModel, localRAGAPI, localRAGKey, apiKey string
|
||||
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action
|
||||
connectors func(*AgentConfig) []Connector
|
||||
dynamicPrompt func(*AgentConfig) []DynamicPrompt
|
||||
filters func(*AgentConfig) types.JobFilters
|
||||
timeout string
|
||||
conversationLogs string
|
||||
file string
|
||||
pooldir string
|
||||
pool AgentPoolData
|
||||
agents map[string]*Agent
|
||||
managers map[string]sse.Manager
|
||||
agentStatus map[string]*Status
|
||||
apiURL, defaultModel, defaultMultimodalModel, defaultTTSModel string
|
||||
defaultTranscriptionModel, defaultTranscriptionLanguage string
|
||||
localRAGAPI, localRAGKey, apiKey string
|
||||
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action
|
||||
connectors func(*AgentConfig) []Connector
|
||||
dynamicPrompt func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []DynamicPrompt
|
||||
filters func(*AgentConfig) types.JobFilters
|
||||
timeout string
|
||||
conversationLogs string
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
@@ -74,11 +70,11 @@ func loadPoolFromFile(path string) (*AgentPoolData, error) {
|
||||
}
|
||||
|
||||
func NewAgentPool(
|
||||
defaultModel, defaultMultimodalModel, imageModel, apiURL, apiKey, directory, mcpBoxURL string,
|
||||
defaultModel, defaultMultimodalModel, defaultTranscriptionModel, defaultTranscriptionLanguage, defaultTTSModel, apiURL, apiKey, directory string,
|
||||
LocalRAGAPI string,
|
||||
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action,
|
||||
connectors func(*AgentConfig) []Connector,
|
||||
promptBlocks func(*AgentConfig) []DynamicPrompt,
|
||||
promptBlocks func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []DynamicPrompt,
|
||||
filters func(*AgentConfig) types.JobFilters,
|
||||
timeout string,
|
||||
withLogs bool,
|
||||
@@ -92,29 +88,29 @@ func NewAgentPool(
|
||||
if withLogs {
|
||||
conversationPath = filepath.Join(directory, "conversations")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(poolfile); err != nil {
|
||||
// file does not exist, create a new pool
|
||||
return &AgentPool{
|
||||
file: poolfile,
|
||||
pooldir: directory,
|
||||
apiURL: apiURL,
|
||||
defaultModel: defaultModel,
|
||||
defaultMultimodalModel: defaultMultimodalModel,
|
||||
mcpBoxURL: mcpBoxURL,
|
||||
imageModel: imageModel,
|
||||
localRAGAPI: LocalRAGAPI,
|
||||
apiKey: apiKey,
|
||||
agents: make(map[string]*Agent),
|
||||
pool: make(map[string]AgentConfig),
|
||||
agentStatus: make(map[string]*Status),
|
||||
managers: make(map[string]sse.Manager),
|
||||
connectors: connectors,
|
||||
availableActions: availableActions,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
file: poolfile,
|
||||
pooldir: directory,
|
||||
apiURL: apiURL,
|
||||
defaultModel: defaultModel,
|
||||
defaultMultimodalModel: defaultMultimodalModel,
|
||||
defaultTranscriptionModel: defaultTranscriptionModel,
|
||||
defaultTranscriptionLanguage: defaultTranscriptionLanguage,
|
||||
defaultTTSModel: defaultTTSModel,
|
||||
localRAGAPI: LocalRAGAPI,
|
||||
apiKey: apiKey,
|
||||
agents: make(map[string]*Agent),
|
||||
pool: make(map[string]AgentConfig),
|
||||
agentStatus: make(map[string]*Status),
|
||||
managers: make(map[string]sse.Manager),
|
||||
connectors: connectors,
|
||||
availableActions: availableActions,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -123,25 +119,26 @@ func NewAgentPool(
|
||||
return nil, err
|
||||
}
|
||||
return &AgentPool{
|
||||
file: poolfile,
|
||||
apiURL: apiURL,
|
||||
pooldir: directory,
|
||||
defaultModel: defaultModel,
|
||||
defaultMultimodalModel: defaultMultimodalModel,
|
||||
mcpBoxURL: mcpBoxURL,
|
||||
imageModel: imageModel,
|
||||
apiKey: apiKey,
|
||||
agents: make(map[string]*Agent),
|
||||
managers: make(map[string]sse.Manager),
|
||||
agentStatus: map[string]*Status{},
|
||||
pool: *poolData,
|
||||
connectors: connectors,
|
||||
localRAGAPI: LocalRAGAPI,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
availableActions: availableActions,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
file: poolfile,
|
||||
apiURL: apiURL,
|
||||
pooldir: directory,
|
||||
defaultModel: defaultModel,
|
||||
defaultMultimodalModel: defaultMultimodalModel,
|
||||
defaultTranscriptionModel: defaultTranscriptionModel,
|
||||
defaultTranscriptionLanguage: defaultTranscriptionLanguage,
|
||||
defaultTTSModel: defaultTTSModel,
|
||||
apiKey: apiKey,
|
||||
agents: make(map[string]*Agent),
|
||||
managers: make(map[string]sse.Manager),
|
||||
agentStatus: map[string]*Status{},
|
||||
pool: *poolData,
|
||||
connectors: connectors,
|
||||
localRAGAPI: LocalRAGAPI,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
availableActions: availableActions,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -166,14 +163,7 @@ func (a *AgentPool) CreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
return err
|
||||
}
|
||||
|
||||
go func(ac AgentConfig) {
|
||||
// Create the agent avatar
|
||||
if err := createAgentAvatar(a.apiURL, a.apiKey, a.defaultModel, a.imageModel, a.pooldir, ac); err != nil {
|
||||
xlog.Error("Failed to create agent avatar", "error", err)
|
||||
}
|
||||
}(a.pool[name])
|
||||
|
||||
return a.startAgentWithConfig(name, agentConfig, nil)
|
||||
return a.startAgentWithConfig(name, a.pooldir, agentConfig, nil)
|
||||
}
|
||||
|
||||
func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
@@ -209,7 +199,7 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := a.startAgentWithConfig(name, agentConfig, obs); err != nil {
|
||||
if err := a.startAgentWithConfig(name, a.pooldir, agentConfig, obs); err != nil {
|
||||
if obs != nil {
|
||||
o.Completion = &types.Completion{Error: err.Error()}
|
||||
obs.Update(*o)
|
||||
@@ -225,84 +215,6 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createAgentAvatar(APIURL, APIKey, model, imageModel, avatarDir string, agent AgentConfig) error {
|
||||
client := llm.NewClient(APIKey, APIURL+"/v1", "10m")
|
||||
|
||||
if imageModel == "" {
|
||||
return fmt.Errorf("image model not set")
|
||||
}
|
||||
|
||||
if model == "" {
|
||||
return fmt.Errorf("default model not set")
|
||||
}
|
||||
|
||||
imagePath := filepath.Join(avatarDir, "avatars", fmt.Sprintf("%s.png", agent.Name))
|
||||
if _, err := os.Stat(imagePath); err == nil {
|
||||
// Image already exists
|
||||
xlog.Debug("Avatar already exists", "path", imagePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
var results struct {
|
||||
ImagePrompt string `json:"image_prompt"`
|
||||
}
|
||||
|
||||
err := llm.GenerateTypedJSONWithGuidance(
|
||||
context.Background(),
|
||||
llm.NewClient(APIKey, APIURL, "10m"),
|
||||
"Generate a prompt that I can use to create a random avatar for the bot '"+agent.Name+"', the description of the bot is: "+agent.Description,
|
||||
model,
|
||||
jsonschema.Definition{
|
||||
Type: jsonschema.Object,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"image_prompt": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The prompt to generate the image",
|
||||
},
|
||||
},
|
||||
Required: []string{"image_prompt"},
|
||||
}, &results)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate image prompt: %w", err)
|
||||
}
|
||||
|
||||
if results.ImagePrompt == "" {
|
||||
xlog.Error("Failed to generate image prompt")
|
||||
return fmt.Errorf("failed to generate image prompt")
|
||||
}
|
||||
|
||||
req := openai.ImageRequest{
|
||||
Prompt: results.ImagePrompt,
|
||||
Model: imageModel,
|
||||
Size: openai.CreateImageSize256x256,
|
||||
ResponseFormat: openai.CreateImageResponseFormatB64JSON,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.CreateImage(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate image: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Data) == 0 {
|
||||
return fmt.Errorf("failed to generate image")
|
||||
}
|
||||
|
||||
imageJson := resp.Data[0].B64JSON
|
||||
|
||||
os.MkdirAll(filepath.Join(avatarDir, "avatars"), 0755)
|
||||
|
||||
// Save the image to the agent directory
|
||||
imageData, err := base64.StdEncoding.DecodeString(imageJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(imagePath, imageData, 0644)
|
||||
}
|
||||
|
||||
func (a *AgentPool) List() []string {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
@@ -324,7 +236,7 @@ func (a *AgentPool) GetStatusHistory(name string) *Status {
|
||||
return a.agentStatus[name]
|
||||
}
|
||||
|
||||
func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs Observer) error {
|
||||
func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConfig, obs Observer) error {
|
||||
var manager sse.Manager
|
||||
if m, ok := a.managers[name]; ok {
|
||||
manager = m
|
||||
@@ -334,25 +246,39 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
ctx := context.Background()
|
||||
model := a.defaultModel
|
||||
multimodalModel := a.defaultMultimodalModel
|
||||
transcriptionModel := a.defaultTranscriptionModel
|
||||
transcriptionLanguage := a.defaultTranscriptionLanguage
|
||||
ttsModel := a.defaultTTSModel
|
||||
|
||||
if config.MultimodalModel != "" {
|
||||
multimodalModel = config.MultimodalModel
|
||||
}
|
||||
|
||||
if config.TranscriptionModel != "" {
|
||||
transcriptionModel = config.TranscriptionModel
|
||||
}
|
||||
|
||||
if config.TranscriptionLanguage != "" {
|
||||
transcriptionLanguage = config.TranscriptionLanguage
|
||||
}
|
||||
if config.TTSModel != "" {
|
||||
ttsModel = config.TTSModel
|
||||
}
|
||||
|
||||
if config.Model != "" {
|
||||
model = config.Model
|
||||
} else {
|
||||
config.Model = model
|
||||
}
|
||||
|
||||
if config.MCPBoxURL != "" {
|
||||
a.mcpBoxURL = config.MCPBoxURL
|
||||
}
|
||||
|
||||
if config.PeriodicRuns == "" {
|
||||
config.PeriodicRuns = "10m"
|
||||
}
|
||||
|
||||
if config.SchedulerPollInterval == "" {
|
||||
config.SchedulerPollInterval = "30s"
|
||||
}
|
||||
|
||||
// XXX: Why do we update the pool config from an Agent's config?
|
||||
if config.APIURL != "" {
|
||||
a.apiURL = config.APIURL
|
||||
@@ -375,7 +301,7 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
}
|
||||
|
||||
connectors := a.connectors(config)
|
||||
promptBlocks := a.dynamicPrompt(config)
|
||||
promptBlocks := a.dynamicPrompt(config)(ctx, a)
|
||||
actions := a.availableActions(config)(ctx, a)
|
||||
filters := a.filters(config)
|
||||
stateFile, characterFile := a.stateFiles(name)
|
||||
@@ -415,14 +341,18 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
}
|
||||
|
||||
opts := []Option{
|
||||
WithSchedulerStorePath(filepath.Join(pooldir, fmt.Sprintf("scheduler-%s.json", name))),
|
||||
WithModel(model),
|
||||
WithLLMAPIURL(a.apiURL),
|
||||
WithContext(ctx),
|
||||
WithMCPServers(config.MCPServers...),
|
||||
WithTranscriptionModel(transcriptionModel),
|
||||
WithTranscriptionLanguage(transcriptionLanguage),
|
||||
WithTTSModel(ttsModel),
|
||||
WithPeriodicRuns(config.PeriodicRuns),
|
||||
WithSchedulerPollInterval(config.SchedulerPollInterval),
|
||||
WithPermanentGoal(config.PermanentGoal),
|
||||
WithMCPSTDIOServers(config.MCPSTDIOServers...),
|
||||
WithMCPBoxURL(a.mcpBoxURL),
|
||||
WithPrompts(promptBlocks...),
|
||||
WithJobFilters(filters...),
|
||||
WithMCPPrepareScript(config.MCPPrepareScript),
|
||||
@@ -437,7 +367,6 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
WithCharacterFile(characterFile),
|
||||
WithLLMAPIKey(a.apiKey),
|
||||
WithTimeout(a.timeout),
|
||||
WithRAGDB(localrag.NewWrappedClient(a.localRAGAPI, a.localRAGKey, name)),
|
||||
WithAgentReasoningCallback(func(state types.ActionCurrentState) bool {
|
||||
xlog.Info(
|
||||
"Agent is thinking",
|
||||
@@ -518,6 +447,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
opts = append(opts, EnableSummaryMemory)
|
||||
}
|
||||
|
||||
if config.ConversationStorageMode != "" {
|
||||
opts = append(opts, WithConversationStorageMode(ConversationStorageMode(config.ConversationStorageMode)))
|
||||
}
|
||||
|
||||
if config.CanStopItself {
|
||||
opts = append(opts, CanStopItself)
|
||||
}
|
||||
@@ -526,6 +459,14 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
opts = append(opts, EnablePlanning)
|
||||
}
|
||||
|
||||
if config.PlanReviewerModel != "" {
|
||||
opts = append(opts, WithPlanReviewerLLM(config.PlanReviewerModel))
|
||||
}
|
||||
|
||||
if config.DisableSinkState {
|
||||
opts = append(opts, DisableSinkState)
|
||||
}
|
||||
|
||||
if config.InitiateConversations {
|
||||
opts = append(opts, EnableInitiateConversations)
|
||||
}
|
||||
@@ -538,14 +479,38 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
}
|
||||
}
|
||||
|
||||
var ragClient *localrag.WrappedClient
|
||||
if config.EnableKnowledgeBase {
|
||||
opts = append(opts, EnableKnowledgeBase)
|
||||
ragClient = localrag.NewWrappedClient(a.localRAGAPI, a.localRAGKey, name)
|
||||
opts = append(opts, WithRAGDB(ragClient), EnableKnowledgeBase)
|
||||
// Set KB auto search option (defaults to true for backward compatibility)
|
||||
// For backward compatibility: if both new KB fields are false (zero values),
|
||||
// assume this is an old config and default KBAutoSearch to true
|
||||
kbAutoSearch := config.KBAutoSearch
|
||||
if !config.KBAutoSearch && !config.KBAsTools {
|
||||
// Both new fields are false, likely an old config - default to true for backward compatibility
|
||||
kbAutoSearch = true
|
||||
}
|
||||
opts = append(opts, WithKBAutoSearch(kbAutoSearch))
|
||||
// Inject KB wrapper actions if enabled
|
||||
if config.KBAsTools && ragClient != nil {
|
||||
kbResults := config.KnowledgeBaseResults
|
||||
if kbResults <= 0 {
|
||||
kbResults = 5 // Default
|
||||
}
|
||||
searchAction, addAction := NewKBWrapperActions(ragClient, kbResults)
|
||||
opts = append(opts, WithActions(searchAction, addAction))
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableReasoning {
|
||||
opts = append(opts, EnableForceReasoning)
|
||||
}
|
||||
|
||||
if config.EnableGuidedTools {
|
||||
opts = append(opts, EnableGuidedTools)
|
||||
}
|
||||
|
||||
if config.StripThinkingTags {
|
||||
opts = append(opts, EnableStripThinkingTags)
|
||||
}
|
||||
@@ -554,19 +519,16 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
opts = append(opts, EnableKnowledgeBaseWithResults(config.KnowledgeBaseResults))
|
||||
}
|
||||
|
||||
if config.LoopDetectionSteps > 0 {
|
||||
opts = append(opts, WithLoopDetectionSteps(config.LoopDetectionSteps))
|
||||
}
|
||||
|
||||
if config.ParallelJobs > 0 {
|
||||
opts = append(opts, WithParallelJobs(config.ParallelJobs))
|
||||
}
|
||||
|
||||
if config.EnableEvaluation {
|
||||
opts = append(opts, EnableEvaluation())
|
||||
if config.MaxEvaluationLoops > 0 {
|
||||
opts = append(opts, WithMaxEvaluationLoops(config.MaxEvaluationLoops))
|
||||
}
|
||||
}
|
||||
|
||||
if config.MaxEvaluationLoops > 0 {
|
||||
opts = append(opts, WithMaxEvaluationLoops(config.MaxEvaluationLoops))
|
||||
}
|
||||
|
||||
xlog.Info("Starting agent", "name", name, "config", config)
|
||||
@@ -585,6 +547,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
}
|
||||
}()
|
||||
|
||||
if config.EnableKnowledgeBase && config.EnableKBCompaction && ragClient != nil {
|
||||
go runCompactionTicker(ctx, ragClient, config, a.apiURL, a.apiKey, model)
|
||||
}
|
||||
|
||||
xlog.Info("Starting connectors", "name", name, "config", config)
|
||||
|
||||
for _, c := range connectors {
|
||||
@@ -613,7 +579,7 @@ func (a *AgentPool) StartAll() error {
|
||||
if a.agents[name] != nil { // Agent already started
|
||||
continue
|
||||
}
|
||||
if err := a.startAgentWithConfig(name, &config, nil); err != nil {
|
||||
if err := a.startAgentWithConfig(name, a.pooldir, &config, nil); err != nil {
|
||||
xlog.Error("Failed to start agent", "name", name, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -651,7 +617,7 @@ func (a *AgentPool) Start(name string) error {
|
||||
return nil
|
||||
}
|
||||
if config, ok := a.pool[name]; ok {
|
||||
return a.startAgentWithConfig(name, &config, nil)
|
||||
return a.startAgentWithConfig(name, a.pooldir, &config, nil)
|
||||
}
|
||||
|
||||
return fmt.Errorf("agent %s not found", name)
|
||||
@@ -677,9 +643,6 @@ func (a *AgentPool) Remove(name string) error {
|
||||
delete(a.agents, name)
|
||||
delete(a.pool, name)
|
||||
|
||||
// remove avatar
|
||||
os.Remove(filepath.Join(a.pooldir, "avatars", fmt.Sprintf("%s.png", name)))
|
||||
|
||||
if err := a.save(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+94
-4
@@ -3,7 +3,9 @@ package types
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
@@ -29,9 +31,10 @@ func NewActionContext(ctx context.Context, cancel context.CancelFunc) *ActionCon
|
||||
type ActionParams map[string]interface{}
|
||||
|
||||
type ActionResult struct {
|
||||
Job *Job
|
||||
Result string
|
||||
Metadata map[string]interface{}
|
||||
Job *Job
|
||||
Result string
|
||||
ImageBase64Result string
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
func (ap ActionParams) Read(s string) error {
|
||||
@@ -86,11 +89,90 @@ func (a ActionDefinition) ToFunctionDefinition() *openai.FunctionDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
type cogitoWrapper struct {
|
||||
action Action
|
||||
ctx context.Context
|
||||
sharedState *AgentSharedState
|
||||
}
|
||||
|
||||
func (c *cogitoWrapper) Tool() openai.Tool {
|
||||
return openai.Tool{
|
||||
Type: openai.ToolTypeFunction,
|
||||
Function: c.action.Definition().ToFunctionDefinition(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cogitoWrapper) Execute(args map[string]any) (string, any, error) {
|
||||
ctx := c.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
result, err := c.action.Run(ctx, c.sharedState, ActionParams(args))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return result.Result, result, nil
|
||||
}
|
||||
|
||||
// Actions is something the agent can do
|
||||
type Action interface {
|
||||
Run(ctx context.Context, sharedState *AgentSharedState, action ActionParams) (ActionResult, error)
|
||||
Definition() ActionDefinition
|
||||
Plannable() bool
|
||||
}
|
||||
|
||||
// UserDefinedChecker interface to identify user-defined actions
|
||||
type UserDefinedChecker interface {
|
||||
IsUserDefined() bool
|
||||
}
|
||||
|
||||
// BaseAction provides default implementation for Action interface
|
||||
// Embed this in action implementations to get the default IsUserDefined behavior
|
||||
type BaseAction struct{}
|
||||
|
||||
func (b *BaseAction) IsUserDefined() bool {
|
||||
return false // Regular actions are not user-defined
|
||||
}
|
||||
|
||||
// IsActionUserDefined checks if an action is user-defined
|
||||
func IsActionUserDefined(action Action) bool {
|
||||
if checker, ok := action.(UserDefinedChecker); ok {
|
||||
return checker.IsUserDefined()
|
||||
}
|
||||
return false // Actions without UserDefinedChecker are not user-defined
|
||||
}
|
||||
|
||||
// UserDefinedAction represents a user-defined function tool
|
||||
type UserDefinedAction struct {
|
||||
ActionDef *ActionDefinition
|
||||
}
|
||||
|
||||
func (u *UserDefinedAction) Run(ctx context.Context, sharedState *AgentSharedState, action ActionParams) (ActionResult, error) {
|
||||
// User-defined actions should not be executed directly
|
||||
return ActionResult{}, fmt.Errorf("user-defined action '%s' cannot be executed by agent", u.ActionDef.Name)
|
||||
}
|
||||
|
||||
func (u *UserDefinedAction) Definition() ActionDefinition {
|
||||
return *u.ActionDef
|
||||
}
|
||||
|
||||
func (u *UserDefinedAction) Plannable() bool {
|
||||
return true // User-defined actions are plannable
|
||||
}
|
||||
|
||||
func (u *UserDefinedAction) IsUserDefined() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateUserDefinedActions converts user tools to UserDefinedAction instances
|
||||
func CreateUserDefinedActions(userTools []ActionDefinition) []Action {
|
||||
var actions []Action
|
||||
for _, tool := range userTools {
|
||||
actions = append(actions, &UserDefinedAction{
|
||||
ActionDef: &tool,
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
type Actions []Action
|
||||
@@ -106,6 +188,14 @@ func (a Actions) ToTools() []openai.Tool {
|
||||
return tools
|
||||
}
|
||||
|
||||
func (a Actions) ToCogitoTools(ctx context.Context, sharedState *AgentSharedState) []cogito.ToolDefinitionInterface {
|
||||
tools := []cogito.ToolDefinitionInterface{}
|
||||
for _, action := range a {
|
||||
tools = append(tools, &cogitoWrapper{action: action, ctx: ctx, sharedState: sharedState})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func (a Actions) Find(name string) Action {
|
||||
for _, action := range a {
|
||||
if action.Definition().Name.Is(name) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
// ConversationMessage represents a message with associated metadata
|
||||
// Used when the agent initiates new conversations to preserve context
|
||||
// such as generated images, files, or URLs
|
||||
type ConversationMessage struct {
|
||||
Message openai.ChatCompletionMessage
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
// NewConversationMessage creates a new ConversationMessage with the given message
|
||||
func NewConversationMessage(msg openai.ChatCompletionMessage) *ConversationMessage {
|
||||
return &ConversationMessage{
|
||||
Message: msg,
|
||||
Metadata: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// WithMetadata adds metadata to the conversation message
|
||||
func (c *ConversationMessage) WithMetadata(metadata map[string]interface{}) *ConversationMessage {
|
||||
c.Metadata = metadata
|
||||
return c
|
||||
}
|
||||
+45
-38
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -21,13 +22,14 @@ type Job struct {
|
||||
Metadata map[string]interface{}
|
||||
DoneFilter bool
|
||||
|
||||
pastActions []*ActionRequest
|
||||
nextAction *Action
|
||||
nextActionParams *ActionParams
|
||||
nextActionReasoning string
|
||||
// Tools available for this job
|
||||
BuiltinTools []ActionDefinition // Built-in tools like web search
|
||||
UserTools []ActionDefinition // User-defined function tools
|
||||
ToolChoice string
|
||||
|
||||
context context.Context
|
||||
cancel context.CancelFunc
|
||||
context context.Context
|
||||
fragment *cogito.Fragment
|
||||
cancel context.CancelFunc
|
||||
|
||||
Obs *Observable
|
||||
}
|
||||
@@ -45,6 +47,24 @@ func WithConversationHistory(history []openai.ChatCompletionMessage) JobOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithBuiltinTools(tools []ActionDefinition) JobOption {
|
||||
return func(j *Job) {
|
||||
j.BuiltinTools = tools
|
||||
}
|
||||
}
|
||||
|
||||
func WithUserTools(tools []ActionDefinition) JobOption {
|
||||
return func(j *Job) {
|
||||
j.UserTools = tools
|
||||
}
|
||||
}
|
||||
|
||||
func WithToolChoice(choice string) JobOption {
|
||||
return func(j *Job) {
|
||||
j.ToolChoice = choice
|
||||
}
|
||||
}
|
||||
|
||||
func WithReasoningCallback(f func(ActionCurrentState) bool) JobOption {
|
||||
return func(r *Job) {
|
||||
r.ReasoningCallback = f
|
||||
@@ -57,7 +77,7 @@ func WithResultCallback(f func(ActionState)) JobOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithMetadata(metadata map[string]interface{}) JobOption {
|
||||
func WithMetadata(metadata map[string]any) JobOption {
|
||||
return func(j *Job) {
|
||||
j.Metadata = metadata
|
||||
}
|
||||
@@ -85,37 +105,6 @@ func (j *Job) CallbackWithResult(stateResult ActionState) {
|
||||
j.ResultCallback(stateResult)
|
||||
}
|
||||
|
||||
func (j *Job) SetNextAction(action *Action, params *ActionParams, reasoning string) {
|
||||
j.nextAction = action
|
||||
j.nextActionParams = params
|
||||
j.nextActionReasoning = reasoning
|
||||
}
|
||||
|
||||
func (j *Job) AddPastAction(action Action, params *ActionParams) {
|
||||
j.pastActions = append(j.pastActions, &ActionRequest{
|
||||
Action: action,
|
||||
Params: params,
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Job) GetPastActions() []*ActionRequest {
|
||||
return j.pastActions
|
||||
}
|
||||
|
||||
func (j *Job) GetNextAction() (*Action, *ActionParams, string) {
|
||||
return j.nextAction, j.nextActionParams, j.nextActionReasoning
|
||||
}
|
||||
|
||||
func (j *Job) HasNextAction() bool {
|
||||
return j.nextAction != nil
|
||||
}
|
||||
|
||||
func (j *Job) ResetNextAction() {
|
||||
j.nextAction = nil
|
||||
j.nextActionParams = nil
|
||||
j.nextActionReasoning = ""
|
||||
}
|
||||
|
||||
func WithTextImage(text, image string) JobOption {
|
||||
return func(j *Job) {
|
||||
j.ConversationHistory = append(j.ConversationHistory, openai.ChatCompletionMessage{
|
||||
@@ -227,3 +216,21 @@ func (j *Job) IncrementEvaluationLoop() {
|
||||
currentLoop := j.GetEvaluationLoop()
|
||||
j.Metadata["evaluation_loop"] = currentLoop + 1
|
||||
}
|
||||
|
||||
// GetBuiltinTools returns the builtin tools for this job
|
||||
func (j *Job) GetBuiltinTools() []ActionDefinition {
|
||||
return j.BuiltinTools
|
||||
}
|
||||
|
||||
// GetUserTools returns the user tools for this job
|
||||
func (j *Job) GetUserTools() []ActionDefinition {
|
||||
return j.UserTools
|
||||
}
|
||||
|
||||
// GetAllTools returns all tools (builtin + user) for this job
|
||||
func (j *Job) GetAllTools() []ActionDefinition {
|
||||
allTools := make([]ActionDefinition, 0, len(j.BuiltinTools)+len(j.UserTools))
|
||||
allTools = append(allTools, j.BuiltinTools...)
|
||||
allTools = append(allTools, j.UserTools...)
|
||||
return allTools
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ type Completion struct {
|
||||
Conversation []openai.ChatCompletionMessage `json:"conversation,omitempty"`
|
||||
ActionResult string `json:"action_result,omitempty"`
|
||||
AgentState *AgentInternalState `json:"agent_state,omitempty"`
|
||||
FilterResult *FilterResult `json:"filter_result,omitempty"`
|
||||
FilterResult *FilterResult `json:"filter_result,omitempty"`
|
||||
}
|
||||
|
||||
type Observable struct {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package types
|
||||
|
||||
type PromptResult struct {
|
||||
Content string
|
||||
ImageBase64 string
|
||||
}
|
||||
+10
-3
@@ -1,8 +1,10 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -11,6 +13,7 @@ type JobResult struct {
|
||||
sync.Mutex
|
||||
// The result of a job
|
||||
State []ActionState
|
||||
Plans []cogito.PlanStatus
|
||||
Conversation []openai.ChatCompletionMessage
|
||||
|
||||
Finalizers []func([]openai.ChatCompletionMessage)
|
||||
@@ -59,9 +62,13 @@ func (j *JobResult) SetResponse(response string) {
|
||||
}
|
||||
|
||||
// WaitResult waits for the result of a job
|
||||
func (j *JobResult) WaitResult() *JobResult {
|
||||
<-j.ready
|
||||
func (j *JobResult) WaitResult(ctx context.Context) (*JobResult, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-j.ready:
|
||||
}
|
||||
j.Lock()
|
||||
defer j.Unlock()
|
||||
return j
|
||||
return j, nil
|
||||
}
|
||||
|
||||
+27
-8
@@ -5,8 +5,19 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/conversations"
|
||||
"github.com/mudler/LocalAGI/core/scheduler"
|
||||
)
|
||||
|
||||
// Forward declaration to avoid circular import
|
||||
type TaskScheduler interface {
|
||||
CreateTask(task *scheduler.Task) error
|
||||
GetAllTasks() ([]*scheduler.Task, error)
|
||||
GetTask(id string) (*scheduler.Task, error)
|
||||
DeleteTask(id string) error
|
||||
PauseTask(id string) error
|
||||
ResumeTask(id string) error
|
||||
}
|
||||
|
||||
// State is the structure
|
||||
// that is used to keep track of the current state
|
||||
// and the Agent's short memory that it can update
|
||||
@@ -29,17 +40,26 @@ const (
|
||||
DefaultLastMessageDuration = 5 * time.Minute
|
||||
)
|
||||
|
||||
type ReminderActionResponse struct {
|
||||
Message string `json:"message"`
|
||||
CronExpr string `json:"cron_expr"` // Cron expression for scheduling
|
||||
LastRun time.Time `json:"last_run"` // Last time this reminder was triggered
|
||||
NextRun time.Time `json:"next_run"` // Next scheduled run time
|
||||
IsRecurring bool `json:"is_recurring"` // Whether this is a recurring reminder
|
||||
// RecurringReminderParams are the parameters the LLM provides for set_recurring_reminder.
|
||||
type RecurringReminderParams struct {
|
||||
Message string `json:"message"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
}
|
||||
|
||||
// OneTimeReminderParams are the parameters the LLM provides for set_onetime_reminder.
|
||||
type OneTimeReminderParams struct {
|
||||
Message string `json:"message"`
|
||||
Delay string `json:"delay"` // Go duration format with day support: "30m", "2h", "1d", "1d12h"
|
||||
}
|
||||
|
||||
// ReminderActionResponse is kept for backward compatibility.
|
||||
// Deprecated: use RecurringReminderParams or OneTimeReminderParams.
|
||||
type ReminderActionResponse = RecurringReminderParams
|
||||
|
||||
type AgentSharedState struct {
|
||||
ConversationTracker *conversations.ConversationTracker[string] `json:"conversation_tracker"`
|
||||
Reminders []ReminderActionResponse `json:"reminders"`
|
||||
Scheduler TaskScheduler `json:"-"` // Not serialized, set at runtime
|
||||
AgentName string `json:"agent_name"`
|
||||
}
|
||||
|
||||
func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
|
||||
@@ -48,7 +68,6 @@ func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
|
||||
}
|
||||
return &AgentSharedState{
|
||||
ConversationTracker: conversations.NewConversationTracker[string](lastMessageDuration),
|
||||
Reminders: make([]ReminderActionResponse, 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
localai:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localai
|
||||
environment:
|
||||
- LOCALAI_SINGLE_ACTIVE_BACKEND=true
|
||||
- DEBUG=true
|
||||
image: localai/localai:master-gpu-hipblas
|
||||
devices:
|
||||
- /dev/dri
|
||||
- /dev/kfd
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: dind
|
||||
|
||||
localrecall-postgres:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall-postgres
|
||||
|
||||
localrecall:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall
|
||||
|
||||
localrecall-healthcheck:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall-healthcheck
|
||||
|
||||
localagi:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localagi
|
||||
@@ -6,22 +6,22 @@ services:
|
||||
environment:
|
||||
- LOCALAI_SINGLE_ACTIVE_BACKEND=true
|
||||
- DEBUG=true
|
||||
image: localai/localai:master-sycl-f32
|
||||
image: localai/localai:master-gpu-intel
|
||||
devices:
|
||||
# On a system with integrated GPU and an Arc 770, this is the Arc 770
|
||||
- /dev/dri/card1
|
||||
- /dev/dri/renderD129
|
||||
|
||||
mcpbox:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: mcpbox
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: dind
|
||||
|
||||
localrecall-postgres:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall-postgres
|
||||
|
||||
localrecall:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
environment:
|
||||
- LOCALAI_SINGLE_ACTIVE_BACKEND=true
|
||||
- DEBUG=true
|
||||
image: localai/localai:master-cublas-cuda12
|
||||
image: localai/localai:master-gpu-nvidia-cuda-12
|
||||
# For images with python backends, use:
|
||||
# image: localai/localai:master-cublas-cuda12-ffmpeg
|
||||
deploy:
|
||||
@@ -17,16 +17,16 @@ services:
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
mcpbox:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: mcpbox
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: dind
|
||||
|
||||
localrecall-postgres:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall-postgres
|
||||
|
||||
localrecall:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
+48
-34
@@ -7,9 +7,8 @@ services:
|
||||
# Image list (dockerhub): https://hub.docker.com/r/localai/localai
|
||||
image: localai/localai:master
|
||||
command:
|
||||
- ${MODEL_NAME:-gemma-3-12b-it-qat}
|
||||
- ${MULTIMODAL_MODEL:-minicpm-v-2_6}
|
||||
- ${IMAGE_MODEL:-sd-1.5-ggml}
|
||||
- ${MODEL_NAME:-gemma-3-4b-it-qat}
|
||||
- ${MULTIMODAL_MODEL:-gemma-3-4b-it-qat}
|
||||
- granite-embedding-107m-multilingual
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/readyz"]
|
||||
@@ -22,22 +21,46 @@ services:
|
||||
- DEBUG=true
|
||||
#- LOCALAI_API_KEY=sk-1234567890
|
||||
volumes:
|
||||
- ./volumes/models:/build/models:cached
|
||||
- ./volumes/images:/tmp/generated/images
|
||||
- models:/models
|
||||
- backends:/backends
|
||||
- images:/tmp/generated/images
|
||||
|
||||
localrecall-postgres:
|
||||
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.2}-postgresql
|
||||
environment:
|
||||
- POSTGRES_DB=localrecall
|
||||
- POSTGRES_USER=localrecall
|
||||
- POSTGRES_PASSWORD=localrecall
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U localrecall"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
localrecall:
|
||||
image: quay.io/mudler/localrecall:main
|
||||
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.4}
|
||||
depends_on:
|
||||
localrecall-postgres:
|
||||
condition: service_healthy
|
||||
localai:
|
||||
condition: service_started
|
||||
ports:
|
||||
- 8080
|
||||
environment:
|
||||
- COLLECTION_DB_PATH=/db
|
||||
- DATABASE_URL=postgresql://localrecall:localrecall@localrecall-postgres:5432/localrecall?sslmode=disable
|
||||
- VECTOR_ENGINE=postgres
|
||||
- EMBEDDING_MODEL=granite-embedding-107m-multilingual
|
||||
- FILE_ASSETS=/assets
|
||||
- OPENAI_API_KEY=sk-1234567890
|
||||
- OPENAI_BASE_URL=http://localai:8080
|
||||
- HYBRID_SEARCH_BM25_WEIGHT=0.5
|
||||
- HYBRID_SEARCH_VECTOR_WEIGHT=0.5
|
||||
volumes:
|
||||
- ./volumes/localrag/db:/db
|
||||
- ./volumes/localrag/assets/:/assets
|
||||
- localrag_assets:/assets
|
||||
|
||||
localrecall-healthcheck:
|
||||
depends_on:
|
||||
@@ -60,30 +83,14 @@ services:
|
||||
dind:
|
||||
condition: service_healthy
|
||||
|
||||
mcpbox:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.mcpbox
|
||||
ports:
|
||||
- "8080"
|
||||
volumes:
|
||||
- ./volumes/mcpbox:/app/data
|
||||
environment:
|
||||
- DOCKER_HOST=tcp://dind:2375
|
||||
depends_on:
|
||||
dind:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/processes"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
dind:
|
||||
image: docker:dind
|
||||
privileged: true
|
||||
command: ["dockerd", "-H", "tcp://0.0.0.0:2375", "-H", "unix:///var/run/docker.sock"]
|
||||
environment:
|
||||
- DOCKER_TLS_CERTDIR=""
|
||||
expose:
|
||||
- 2375
|
||||
healthcheck:
|
||||
test: ["CMD", "docker", "info"]
|
||||
interval: 10s
|
||||
@@ -96,7 +103,7 @@ services:
|
||||
condition: service_healthy
|
||||
localrecall-healthcheck:
|
||||
condition: service_completed_successfully
|
||||
mcpbox:
|
||||
dind:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: .
|
||||
@@ -105,18 +112,25 @@ services:
|
||||
- 8080:3000
|
||||
#image: quay.io/mudler/localagi:master
|
||||
environment:
|
||||
- LOCALAGI_MODEL=${MODEL_NAME:-gemma-3-12b-it-qat}
|
||||
- LOCALAGI_MULTIMODAL_MODEL=${MULTIMODAL_MODEL:-minicpm-v-2_6}
|
||||
- LOCALAGI_IMAGE_MODEL=${IMAGE_MODEL:-sd-1.5-ggml}
|
||||
- LOCALAGI_MODEL=${MODEL_NAME:-gemma-3-4b-it-qat}
|
||||
- LOCALAGI_MULTIMODAL_MODEL=${MULTIMODAL_MODEL:-moondream2-20250414}
|
||||
- LOCALAGI_LLM_API_URL=http://localai:8080
|
||||
#- LOCALAGI_LLM_API_KEY=sk-1234567890
|
||||
- LOCALAGI_LOCALRAG_URL=http://localrecall:8080
|
||||
- LOCALAGI_STATE_DIR=/pool
|
||||
- LOCALAGI_TIMEOUT=5m
|
||||
- LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
|
||||
- LOCALAGI_MCPBOX_URL=http://mcpbox:8080
|
||||
- LOCALAGI_SSHBOX_URL=root:root@sshbox:22
|
||||
- DOCKER_HOST=tcp://dind:2375
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./volumes/localagi/:/pool
|
||||
- localagi_pool:/pool
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
models:
|
||||
backends:
|
||||
images:
|
||||
localrag_assets:
|
||||
localagi_pool:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package custom_actions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type Params struct {
|
||||
Message string `json:"message"` // field name
|
||||
}
|
||||
|
||||
func Run(config map[string]interface{}) (string, map[string]interface{}, error) {
|
||||
p := Params{}
|
||||
b, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return "", map[string]interface{}{}, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
return "", map[string]interface{}{}, err
|
||||
}
|
||||
|
||||
return "Hello, " + p.Message + "!", map[string]interface{}{}, nil
|
||||
}
|
||||
|
||||
func Description() string {
|
||||
return "Send a message to the user"
|
||||
}
|
||||
|
||||
func Definition() map[string][]string {
|
||||
return map[string][]string{
|
||||
"message": { // field name
|
||||
"string", // type
|
||||
"The message to send", // description
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func RequiredFields() []string {
|
||||
return []string{"message"} // field name
|
||||
}
|
||||
|
||||
var config string
|
||||
|
||||
func Init(configuration string) error {
|
||||
// Do something with the configuration that was passed-by
|
||||
config = configuration
|
||||
return nil
|
||||
}
|
||||
|
||||
// DynamicPrompt
|
||||
func Render() (string, string, error) {
|
||||
return "Hello, " + config + "!", "", nil
|
||||
}
|
||||
|
||||
func Role() string {
|
||||
return "system" // Role for the dynamic prompt
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from RealtimeSTT import AudioToTextRecorder
|
||||
|
||||
def process_text(text):
|
||||
print(text)
|
||||
|
||||
if __name__ == '__main__':
|
||||
recorder = AudioToTextRecorder(wake_words="jarvis")
|
||||
|
||||
while True:
|
||||
recorder.text(process_text)
|
||||
@@ -1,92 +1,110 @@
|
||||
module github.com/mudler/LocalAGI
|
||||
|
||||
go 1.24
|
||||
|
||||
toolchain go1.24.2
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.28.1
|
||||
github.com/Masterminds/sprig/v3 v3.3.0
|
||||
github.com/blevesearch/bleve/v2 v2.5.7
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/chasefleming/elem-go v0.30.0
|
||||
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2
|
||||
github.com/donseba/go-htmx v1.12.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/eritikass/githubmarkdownconvertergo v0.1.10
|
||||
github.com/go-telegram/bot v1.15.0
|
||||
github.com/gofiber/fiber/v2 v2.52.8
|
||||
github.com/gofiber/template/html/v2 v2.1.3
|
||||
github.com/go-telegram/bot v1.17.0
|
||||
github.com/gofiber/fiber/v2 v2.52.9
|
||||
github.com/google/go-github/v69 v69.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/metoro-io/mcp-golang v0.13.0
|
||||
github.com/onsi/ginkgo/v2 v2.23.4
|
||||
github.com/onsi/gomega v1.37.0
|
||||
github.com/jung-kurt/gofpdf v1.16.2
|
||||
github.com/modelcontextprotocol/go-sdk v1.1.0
|
||||
github.com/mudler/cogito v0.9.1-0.20260216182842-e9820e6bf7b9
|
||||
github.com/mudler/xlog v0.0.1
|
||||
github.com/onsi/ginkgo/v2 v2.25.3
|
||||
github.com/onsi/gomega v1.38.2
|
||||
github.com/philippgille/chromem-go v0.7.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sashabaranov/go-openai v1.40.0
|
||||
github.com/slack-go/slack v0.16.0
|
||||
github.com/sashabaranov/go-openai v1.41.2
|
||||
github.com/slack-go/slack v0.17.3
|
||||
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64
|
||||
github.com/tmc/langchaingo v0.1.13
|
||||
github.com/tmc/langchaingo v0.1.14
|
||||
github.com/traefik/yaegi v0.16.1
|
||||
github.com/valyala/fasthttp v1.62.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
github.com/valyala/fasthttp v1.68.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056
|
||||
maunium.net/go/mautrix v0.17.0
|
||||
mvdan.cc/xurls/v2 v2.6.0
|
||||
)
|
||||
|
||||
require github.com/JohannesKaufmann/dom v0.2.0 // indirect
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/JohannesKaufmann/dom v0.2.0 // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.22.0 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.2.11 // indirect
|
||||
github.com/blevesearch/geo v0.2.4 // indirect
|
||||
github.com/blevesearch/go-faiss v1.0.26 // indirect
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
|
||||
github.com/blevesearch/gtreap v0.1.1 // indirect
|
||||
github.com/blevesearch/mmap-go v1.0.4 // indirect
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect
|
||||
github.com/blevesearch/segment v0.9.1 // indirect
|
||||
github.com/blevesearch/snowballstem v0.9.0 // indirect
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
|
||||
github.com/blevesearch/vellum v1.1.0 // indirect
|
||||
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v16 v16.2.8 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/jsonschema-go v0.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.etcd.io/bbolt v1.4.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.2
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0
|
||||
github.com/PuerkitoBio/goquery v1.10.3 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/antchfx/htmlquery v1.3.4 // indirect
|
||||
github.com/antchfx/xmlquery v1.4.4 // indirect
|
||||
github.com/antchfx/xpath v1.3.4 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.5
|
||||
github.com/emersion/go-message v0.18.2
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
github.com/emersion/go-smtp v0.22.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.10.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gocolly/colly v1.2.0 // indirect
|
||||
github.com/gofiber/template v1.8.3 // indirect
|
||||
github.com/gofiber/utils v1.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kennygrant/sanitize v1.2.4 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pkoukk/tiktoken-go v0.1.7 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/zerolog v1.31.0 // indirect
|
||||
@@ -97,21 +115,16 @@ require (
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
go.mau.fi/util v0.3.0 // indirect
|
||||
go.starlark.net v0.0.0-20250417143717-f57e51f710eb // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
golang.org/x/arch v0.16.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
|
||||
golang.org/x/net v0.40.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/tools v0.32.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
maunium.net/go/maulogger/v2 v2.4.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ=
|
||||
github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.2 h1:eeMLttqTjTgILD6no79Ge96V7Wv8pWDfMVn4jy+koIY=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.2/go.mod h1:HtsP+1Fchp4dVvaiIsLHAl/yqL3H1YLwqLC9kNwqQEg=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0 h1:C0/TerKdQX9Y9pbYi1EsLr5LDNANsqunyI/btpyfCg8=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0/go.mod h1:OLaKh+giepO8j7teevrNwiy/fwf8LXgoc9g7rwaE1jk=
|
||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
|
||||
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ=
|
||||
@@ -15,79 +29,119 @@ github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fus
|
||||
github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/xpath v1.3.4 h1:1ixrW1VnXd4HurCj7qnqnR0jo14g8JMe20Fshg1Vgz4=
|
||||
github.com/antchfx/xpath v1.3.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
|
||||
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
|
||||
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
|
||||
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
|
||||
github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
|
||||
github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
|
||||
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
|
||||
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
|
||||
github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
|
||||
github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
|
||||
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
|
||||
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
|
||||
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
|
||||
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
|
||||
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
|
||||
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
|
||||
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
|
||||
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
|
||||
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
|
||||
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
|
||||
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
|
||||
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
|
||||
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
|
||||
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
|
||||
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
|
||||
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
|
||||
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
|
||||
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
|
||||
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
|
||||
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
|
||||
github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI=
|
||||
github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
|
||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/chasefleming/elem-go v0.30.0 h1:BlhV1ekv1RbFiM8XZUQeln1Ikb4D+bu2eDO4agREvok=
|
||||
github.com/chasefleming/elem-go v0.30.0/go.mod h1:hz73qILBIKnTgOujnSMtEj20/epI+f6vg71RUilJAA4=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2 h1:flLYmnQFZNo04x2NPehMbf30m7Pli57xwZ0NFqR/hb0=
|
||||
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2/go.mod h1:NtWqRzAp/1tw+twkW8uuBenEVVYndEAZACWU3F3xdoQ=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/donseba/go-htmx v1.12.0 h1:7tESER0uxaqsuGMv3yP3pK1drfBUXM6apG4H7/3+IgE=
|
||||
github.com/donseba/go-htmx v1.12.0/go.mod h1:8PTAYvNKf8+QYis+DpAsggKz+sa2qljtMgvdAeNBh5s=
|
||||
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
|
||||
github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.5 h1:H3858DNmBuXyMK1++YrQIRdpKE1MwBc+ywBtg3n+0wA=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.5/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.22.0 h1:/d3HWxkZZ4riB+0kzfoODh9X+xyCrLEezMnAAa1LEMU=
|
||||
github.com/emersion/go-smtp v0.22.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/eritikass/githubmarkdownconvertergo v0.1.10 h1:mL93ADvYMOeT15DcGtK9AaFFc+RcWcy6kQBC6yS/5f4=
|
||||
github.com/eritikass/githubmarkdownconvertergo v0.1.10/go.mod h1:BdpHs6imOtzE5KorbUtKa6bZ0ZBh1yFcrTTAL8FwDKY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-telegram/bot v1.15.0 h1:/ba5pp084MUhjR5sQDymQ7JNZ001CQa7QjtxLWcuGpg=
|
||||
github.com/go-telegram/bot v1.15.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
|
||||
github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
|
||||
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/go-telegram/bot v1.17.0 h1:Hs0kGxSj97QFqOQP0zxduY/4tSx8QDzvNI9uVRS+zmY=
|
||||
github.com/go-telegram/bot v1.17.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI=
|
||||
github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
|
||||
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
|
||||
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
|
||||
github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o=
|
||||
github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE=
|
||||
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
|
||||
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
|
||||
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
@@ -95,11 +149,12 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
@@ -108,6 +163,8 @@ github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMM
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
|
||||
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 h1:gD0vax+4I+mAj+jEChEf25Ia07Jq7kYOFO5PPhAxFl4=
|
||||
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -115,26 +172,25 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
||||
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
|
||||
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@@ -145,29 +201,74 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/metoro-io/mcp-golang v0.13.0 h1:54TFBJIW76VRB55CJovQQje9x4GnXg0BQQwGRtXrbCE=
|
||||
github.com/metoro-io/mcp-golang v0.13.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
|
||||
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||
github.com/mudler/cogito v0.8.2-0.20260206153401-a5346975d42b h1:LXHovZzNgP0n/oYEoO4zDt4k4CRvG0Owhu8x/OVGhYc=
|
||||
github.com/mudler/cogito v0.8.2-0.20260206153401-a5346975d42b/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.8.2-0.20260214201734-da0d4ceb2b44 h1:joGszpItINnZdoL/0p2077Wz2xnxMGRSRgYN5mS7I4c=
|
||||
github.com/mudler/cogito v0.8.2-0.20260214201734-da0d4ceb2b44/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.8.2-0.20260215213413-af34921ff561 h1:qA7dGJhF5GjgGKHh0lOITZjl9q2jehjKqxxCnaUR1yg=
|
||||
github.com/mudler/cogito v0.8.2-0.20260215213413-af34921ff561/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.8.2-0.20260215230740-d1c0dc9bd9dc h1:tBAGwQq5kOSIh+vfLffVr5Th2ajFwrTj0usLgyGM2CQ=
|
||||
github.com/mudler/cogito v0.8.2-0.20260215230740-d1c0dc9bd9dc/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.9.1-0.20260216144443-c96e8ddc1157 h1:M2Yx84QtKQrhMWlB+K99RKJ+x5s8HoRumegObaLuQlI=
|
||||
github.com/mudler/cogito v0.9.1-0.20260216144443-c96e8ddc1157/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.9.1-0.20260216163119-07b624cc772f h1:LkCJD11Mlx8ZyHoNRbpxbrw1Znl9GiFKjZbcXYwAKwk=
|
||||
github.com/mudler/cogito v0.9.1-0.20260216163119-07b624cc772f/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.9.1-0.20260216182842-e9820e6bf7b9 h1:Ek+fAIUt9v5tqBgPZ89QTgGXwOSZcCiElD37NkbdmSk=
|
||||
github.com/mudler/cogito v0.9.1-0.20260216182842-e9820e6bf7b9/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/xlog v0.0.1 h1:yR3/wszd3ZM6u1n96YITJZ4yUcDgqHSwvQmzUJa+8vg=
|
||||
github.com/mudler/xlog v0.0.1/go.mod h1:39f5vcd05Qd6GWKM8IjyHNQ7AmOx3ZM0YfhfIGhC18U=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw=
|
||||
github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/philippgille/chromem-go v0.7.0 h1:4jfvfyKymjKNfGxBUhHUcj1kp7B17NL/I1P+vGh1RvY=
|
||||
github.com/philippgille/chromem-go v0.7.0/go.mod h1:hTd+wGEm/fFPQl7ilfCwQXkgEUxceYh86iIdoKMolPo=
|
||||
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
|
||||
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -180,31 +281,39 @@ github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
|
||||
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||
github.com/sashabaranov/go-openai v1.40.0 h1:Peg9Iag5mUJtPW00aYatlsn97YML0iNULiLNe74iPrU=
|
||||
github.com/sashabaranov/go-openai v1.40.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/sebdah/goldie/v2 v2.5.5 h1:rx1mwF95RxZ3/83sdS4Yp7t2C5TCokvWP4TBRbAyEWY=
|
||||
github.com/sebdah/goldie/v2 v2.5.5/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/slack-go/slack v0.16.0 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8=
|
||||
github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
|
||||
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
|
||||
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E=
|
||||
github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
|
||||
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
|
||||
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
|
||||
github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw=
|
||||
github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w=
|
||||
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64 h1:l/T7dYuJEQZOwVOpjIXr1180aM9PZL/d1MnMVIxefX4=
|
||||
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64/go.mod h1:Q1NAJOuRdQCqN/VIWdnaaEhV8LpeO2rtlBP7/iDJNII=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
@@ -217,33 +326,47 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tmc/langchaingo v0.1.13 h1:rcpMWBIi2y3B90XxfE4Ao8dhCQPVDMaNPnN5cGB1CaA=
|
||||
github.com/tmc/langchaingo v0.1.13/go.mod h1:vpQ5NOIhpzxDfTZK9B6tf2GM/MoaHewPWM5KXXGh7hg=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/tmc/langchaingo v0.1.14 h1:o1qWBPigAIuFvrG6cjTFo0cZPFEZ47ZqpOYMjM15yZc=
|
||||
github.com/tmc/langchaingo v0.1.14/go.mod h1:aKKYXYoqhIDEv7WKdpnnCLRaqXic69cX9MnDUk72378=
|
||||
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
|
||||
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0=
|
||||
github.com/valyala/fasthttp v1.62.0/go.mod h1:FCINgr4GKdKqV8Q0xv8b+UxPV+H/O5nNFo3D+r54Htg=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
|
||||
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo=
|
||||
github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
|
||||
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
|
||||
go.mau.fi/util v0.3.0 h1:Lt3lbRXP6ZBqTINK0EieRWor3zEwwwrDT14Z5N8RUCs=
|
||||
go.mau.fi/util v0.3.0/go.mod h1:9dGsBCCbZJstx16YgnVMVi3O2bOizELoKpugLD4FoGs=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.starlark.net v0.0.0-20250417143717-f57e51f710eb h1:zOg9DxxrorEmgGUr5UPdCEwKqiqG0MlZciuCuA3XiDE=
|
||||
go.starlark.net v0.0.0-20250417143717-f57e51f710eb/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
|
||||
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
@@ -251,10 +374,11 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
|
||||
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
|
||||
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -270,8 +394,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -279,6 +405,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -293,8 +421,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -304,8 +432,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -317,30 +445,31 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 h1:6YFJoB+0fUH6X3xU/G2tQqCYg+PkGtnZ5nMR5rpw72g=
|
||||
@@ -351,6 +480,5 @@ maunium.net/go/mautrix v0.17.0 h1:scc1qlUbzPn+wc+3eAPquyD+3gZwwy/hBANBm+iGKK8=
|
||||
maunium.net/go/mautrix v0.17.0/go.mod h1:j+puTEQCEydlVxhJ/dQP5chfa26TdvBO7X6F3Ataav8=
|
||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
|
||||
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
|
||||
var baseModel = os.Getenv("LOCALAGI_MODEL")
|
||||
var multimodalModel = os.Getenv("LOCALAGI_MULTIMODAL_MODEL")
|
||||
var transcriptionModel = os.Getenv("LOCALAGI_TRANSCRIPTION_MODEL")
|
||||
var transcriptionLanguage = os.Getenv("LOCALAGI_TRANSCRIPTION_LANGUAGE")
|
||||
var ttsModel = os.Getenv("LOCALAGI_TTS_MODEL")
|
||||
var apiURL = os.Getenv("LOCALAGI_LLM_API_URL")
|
||||
var apiKey = os.Getenv("LOCALAGI_LLM_API_KEY")
|
||||
var timeout = os.Getenv("LOCALAGI_TIMEOUT")
|
||||
@@ -20,10 +23,8 @@ var stateDir = os.Getenv("LOCALAGI_STATE_DIR")
|
||||
var localRAG = os.Getenv("LOCALAGI_LOCALRAG_URL")
|
||||
var withLogs = os.Getenv("LOCALAGI_ENABLE_CONVERSATIONS_LOGGING") == "true"
|
||||
var apiKeysEnv = os.Getenv("LOCALAGI_API_KEYS")
|
||||
var imageModel = os.Getenv("LOCALAGI_IMAGE_MODEL")
|
||||
var conversationDuration = os.Getenv("LOCALAGI_CONVERSATION_DURATION")
|
||||
var localOperatorBaseURL = os.Getenv("LOCALOPERATOR_BASE_URL")
|
||||
var mcpboxURL = os.Getenv("LOCALAGI_MCPBOX_URL")
|
||||
var customActionsDir = os.Getenv("LOCALAGI_CUSTOM_ACTIONS_DIR")
|
||||
var sshBoxURL = os.Getenv("LOCALAGI_SSHBOX_URL")
|
||||
|
||||
func init() {
|
||||
@@ -31,7 +32,7 @@ func init() {
|
||||
panic("LOCALAGI_MODEL not set")
|
||||
}
|
||||
if apiURL == "" {
|
||||
panic("LOCALAGI_API_URL not set")
|
||||
panic("LOCALAGI_LLM_API_URL not set")
|
||||
}
|
||||
if timeout == "" {
|
||||
timeout = "5m"
|
||||
@@ -59,19 +60,23 @@ func main() {
|
||||
pool, err := state.NewAgentPool(
|
||||
baseModel,
|
||||
multimodalModel,
|
||||
imageModel,
|
||||
transcriptionModel,
|
||||
transcriptionLanguage,
|
||||
ttsModel,
|
||||
apiURL,
|
||||
apiKey,
|
||||
stateDir,
|
||||
mcpboxURL,
|
||||
localRAG,
|
||||
services.Actions(map[string]string{
|
||||
services.ActionConfigBrowserAgentRunner: localOperatorBaseURL,
|
||||
services.ActionConfigDeepResearchRunner: localOperatorBaseURL,
|
||||
services.ActionConfigSSHBoxURL: sshBoxURL,
|
||||
services.ActionConfigSSHBoxURL: sshBoxURL,
|
||||
services.ConfigStateDir: stateDir,
|
||||
services.CustomActionsDir: customActionsDir,
|
||||
}),
|
||||
services.Connectors,
|
||||
services.DynamicPrompts,
|
||||
services.DynamicPrompts(map[string]string{
|
||||
services.ConfigStateDir: stateDir,
|
||||
services.CustomActionsDir: customActionsDir,
|
||||
}),
|
||||
services.Filters,
|
||||
timeout,
|
||||
withLogs,
|
||||
@@ -88,6 +93,7 @@ func main() {
|
||||
webui.WithLLMAPIUrl(apiURL),
|
||||
webui.WithLLMAPIKey(apiKey),
|
||||
webui.WithLLMModel(baseModel),
|
||||
webui.WithCustomActionsDir(customActionsDir),
|
||||
webui.WithStateDir(stateDir),
|
||||
)
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
|
||||
// AgentConfig represents the configuration for an agent
|
||||
type AgentConfig struct {
|
||||
Name string `json:"name"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
Connectors []string `json:"connectors,omitempty"`
|
||||
PromptBlocks []string `json:"prompt_blocks,omitempty"`
|
||||
InitialPrompt string `json:"initial_prompt,omitempty"`
|
||||
Parallel bool `json:"parallel,omitempty"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
Connectors []string `json:"connectors,omitempty"`
|
||||
PromptBlocks []string `json:"prompt_blocks,omitempty"`
|
||||
InitialPrompt string `json:"initial_prompt,omitempty"`
|
||||
Parallel bool `json:"parallel,omitempty"`
|
||||
EnableReasoning bool `json:"enable_reasoning,omitempty"`
|
||||
}
|
||||
|
||||
// AgentStatus represents the status of an agent
|
||||
|
||||
+94
-6
@@ -4,18 +4,58 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// UserLocation represents the user's location for web search
|
||||
type UserLocation struct {
|
||||
Type string `json:"type"`
|
||||
City *string `json:"city,omitempty"`
|
||||
Country *string `json:"country,omitempty"`
|
||||
Region *string `json:"region,omitempty"`
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
// Function tool fields (used when type == "function")
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Parameters *jsonschema.Definition `json:"parameters,omitempty"`
|
||||
|
||||
// Web search tool fields (used when type == "web_search_preview" etc.)
|
||||
SearchContextSize *string `json:"search_context_size,omitempty"`
|
||||
UserLocation *UserLocation `json:"user_location,omitempty"`
|
||||
}
|
||||
|
||||
type ToolChoice struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// RequestBody represents the message request to the AI model
|
||||
type RequestBody struct {
|
||||
Model string `json:"model"`
|
||||
Input any `json:"input"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
ToolChoice *ToolChoice `json:"tool_choice"`
|
||||
MaxTokens *int `json:"max_output_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type InputFunctionToolCallOutput struct {
|
||||
CallID string `json:"call_id"`
|
||||
Output string `json:"output"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// InputMessage represents a user input message
|
||||
type InputMessage struct {
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
}
|
||||
@@ -29,10 +69,49 @@ type ContentItem struct {
|
||||
|
||||
// ResponseBody represents the response from the AI model
|
||||
type ResponseBody struct {
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Status string `json:"status"`
|
||||
Error any `json:"error,omitempty"`
|
||||
Output []ResponseMessage `json:"output"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Status string `json:"status"`
|
||||
Error any `json:"error,omitempty"`
|
||||
Output []ResponseBase `json:"output"`
|
||||
Tools []Tool `json:"tools"`
|
||||
}
|
||||
|
||||
type ResponseType string
|
||||
|
||||
const (
|
||||
ResponseTypeFunctionToolCall ResponseType = "function_call"
|
||||
ResponseTypeMessage ResponseType = "message"
|
||||
)
|
||||
|
||||
type ResponseBase json.RawMessage
|
||||
|
||||
func (r *ResponseBase) UnmarshalJSON(data []byte) error {
|
||||
return (*json.RawMessage)(r).UnmarshalJSON(data)
|
||||
}
|
||||
|
||||
func (r *ResponseBase) ToMessage() (msg ResponseMessage, err error) {
|
||||
err = json.Unmarshal(*r, &msg)
|
||||
if msg.Type != string(ResponseTypeMessage) {
|
||||
return ResponseMessage{}, fmt.Errorf("Expected %s, not %s", ResponseTypeMessage, msg.Type)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *ResponseBase) ToFunctionToolCall() (msg ResponseFunctionToolCall, err error) {
|
||||
err = json.Unmarshal(*r, &msg)
|
||||
if msg.Type != string(ResponseTypeFunctionToolCall) {
|
||||
return ResponseFunctionToolCall{}, fmt.Errorf("Expected %s, not %s", ResponseTypeFunctionToolCall, msg.Type)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type ResponseFunctionToolCall struct {
|
||||
Arguments string `json:"arguments"`
|
||||
CallID string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ResponseMessage represents a message in the response
|
||||
@@ -85,7 +164,11 @@ func (c *Client) SimpleAIResponse(agentName, input string) (string, error) {
|
||||
}
|
||||
|
||||
// Extract the text response from the output
|
||||
for _, msg := range response.Output {
|
||||
for _, out := range response.Output {
|
||||
msg, err := out.ToMessage()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("out.ToMessage: %w", err)
|
||||
}
|
||||
if msg.Role == "assistant" {
|
||||
for _, content := range msg.Content {
|
||||
if content.Type == "output_text" {
|
||||
@@ -113,7 +196,12 @@ func (c *Client) ChatAIResponse(agentName string, messages []InputMessage) (stri
|
||||
}
|
||||
|
||||
// Extract the text response from the output
|
||||
for _, msg := range response.Output {
|
||||
for _, out := range response.Output {
|
||||
msg, err := out.ToMessage()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("out.ToMessage: %w", err)
|
||||
}
|
||||
|
||||
if msg.Role == "assistant" {
|
||||
for _, content := range msg.Content {
|
||||
if content.Type == "output_text" {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package localoperator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL string, timeout ...time.Duration) *Client {
|
||||
defaultTimeout := 30 * time.Second
|
||||
if len(timeout) > 0 {
|
||||
defaultTimeout = timeout[0]
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: defaultTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type AgentRequest struct {
|
||||
Goal string `json:"goal"`
|
||||
MaxAttempts int `json:"max_attempts,omitempty"`
|
||||
MaxNoActionAttempts int `json:"max_no_action_attempts,omitempty"`
|
||||
}
|
||||
|
||||
type DesktopAgentRequest struct {
|
||||
AgentRequest
|
||||
DesktopURL string `json:"desktop_url"`
|
||||
}
|
||||
|
||||
type DeepResearchRequest struct {
|
||||
Topic string `json:"topic"`
|
||||
MaxCycles int `json:"max_cycles,omitempty"`
|
||||
MaxNoActionAttempts int `json:"max_no_action_attempts,omitempty"`
|
||||
MaxResults int `json:"max_results,omitempty"`
|
||||
}
|
||||
|
||||
// Response types
|
||||
type StateDescription struct {
|
||||
CurrentURL string `json:"current_url"`
|
||||
PageTitle string `json:"page_title"`
|
||||
PageContentDescription string `json:"page_content_description"`
|
||||
Screenshot string `json:"screenshot"`
|
||||
ScreenshotMimeType string `json:"screenshot_mime_type"`
|
||||
}
|
||||
|
||||
type StateHistory struct {
|
||||
States []StateDescription `json:"states"`
|
||||
}
|
||||
|
||||
type DesktopStateDescription struct {
|
||||
ScreenContent string `json:"screen_content"`
|
||||
ScreenshotPath string `json:"screenshot_path"`
|
||||
}
|
||||
|
||||
type DesktopStateHistory struct {
|
||||
States []DesktopStateDescription `json:"states"`
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ResearchResult struct {
|
||||
Topic string `json:"topic"`
|
||||
Summary string `json:"summary"`
|
||||
Sources []SearchResult `json:"sources"`
|
||||
KnowledgeGaps []string `json:"knowledge_gaps"`
|
||||
SearchQueries []string `json:"search_queries"`
|
||||
ResearchCycles int `json:"research_cycles"`
|
||||
CompletionTime time.Duration `json:"completion_time"`
|
||||
}
|
||||
|
||||
func (c *Client) RunBrowserAgent(req AgentRequest) (*StateHistory, error) {
|
||||
return post[*StateHistory](c.httpClient, c.baseURL+"/api/browser/run", req)
|
||||
}
|
||||
|
||||
func (c *Client) RunDesktopAgent(req DesktopAgentRequest) (*DesktopStateHistory, error) {
|
||||
return post[*DesktopStateHistory](c.httpClient, c.baseURL+"/api/desktop/run", req)
|
||||
}
|
||||
|
||||
func (c *Client) RunDeepResearch(req DeepResearchRequest) (*ResearchResult, error) {
|
||||
return post[*ResearchResult](c.httpClient, c.baseURL+"/api/deep-research/run", req)
|
||||
}
|
||||
|
||||
func (c *Client) Readyz() (string, error) {
|
||||
return c.get("/readyz")
|
||||
}
|
||||
|
||||
func (c *Client) Healthz() (string, error) {
|
||||
return c.get("/healthz")
|
||||
}
|
||||
|
||||
func (c *Client) get(path string) (string, error) {
|
||||
resp, err := c.httpClient.Get(c.baseURL + path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return resp.Status, nil
|
||||
}
|
||||
|
||||
func post[T any](client *http.Client, url string, body interface{}) (T, error) {
|
||||
var result T
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Sending request", "url", url, "body", string(jsonBody))
|
||||
|
||||
resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
fmt.Println("Response", "status", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return result, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return result, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
+179
-50
@@ -11,12 +11,14 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
var _ agent.RAGDB = &WrappedClient{}
|
||||
@@ -26,7 +28,8 @@ type WrappedClient struct {
|
||||
collection string
|
||||
}
|
||||
|
||||
func NewWrappedClient(baseURL, apiKey, collection string) *WrappedClient {
|
||||
func NewWrappedClient(baseURL, apiKey, c string) *WrappedClient {
|
||||
collection := strings.TrimSpace(strings.ToLower(c))
|
||||
wc := &WrappedClient{
|
||||
Client: NewClient(baseURL, apiKey),
|
||||
collection: collection,
|
||||
@@ -37,6 +40,11 @@ func NewWrappedClient(baseURL, apiKey, collection string) *WrappedClient {
|
||||
return wc
|
||||
}
|
||||
|
||||
// Collection returns the collection name for this client.
|
||||
func (c *WrappedClient) Collection() string {
|
||||
return c.collection
|
||||
}
|
||||
|
||||
func (c *WrappedClient) Count() int {
|
||||
entries, err := c.ListEntries(c.collection)
|
||||
if err != nil {
|
||||
@@ -88,6 +96,37 @@ func (c *WrappedClient) Store(s string) error {
|
||||
return c.Client.Store(c.collection, f)
|
||||
}
|
||||
|
||||
// GetEntryContent returns the full file content (no chunk overlap) and the number of chunks for the entry.
|
||||
func (c *WrappedClient) GetEntryContent(entry string) (content string, chunkCount int, err error) {
|
||||
return c.Client.GetEntryContent(c.collection, entry)
|
||||
}
|
||||
|
||||
// apiResponse is the standardized LocalRecall API response wrapper (since 3f73ff3a).
|
||||
type apiResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Error *apiError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// parseAPIError reads the response body and returns an error from the API response or a generic message.
|
||||
func parseAPIError(resp *http.Response, body []byte, fallback string) error {
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err == nil && wrap.Error != nil {
|
||||
if wrap.Error.Details != "" {
|
||||
return fmt.Errorf("%s: %s", wrap.Error.Message, wrap.Error.Details)
|
||||
}
|
||||
return errors.New(wrap.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("%s: %s", fallback, string(body))
|
||||
}
|
||||
|
||||
// Result represents a single result from a query.
|
||||
type Result struct {
|
||||
ID string
|
||||
@@ -101,6 +140,13 @@ type Result struct {
|
||||
Similarity float32
|
||||
}
|
||||
|
||||
// EntryChunk represents a single chunk (legacy; GetEntryContent now returns full file content).
|
||||
type EntryChunk struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// Client is a client for the RAG API
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
@@ -151,7 +197,8 @@ func (c *Client) CreateCollection(name string) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return errors.New("failed to create collection")
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return parseAPIError(resp, body, "failed to create collection")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -174,17 +221,30 @@ func (c *Client) ListCollections() ([]string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("failed to list collections")
|
||||
}
|
||||
|
||||
var collections []string
|
||||
err = json.NewDecoder(resp.Body).Decode(&collections)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return collections, nil
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, parseAPIError(resp, body, "failed to list collections")
|
||||
}
|
||||
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
|
||||
if wrap.Error != nil {
|
||||
return nil, errors.New(wrap.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid response: %w", err)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Collections []string `json:"collections"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.Collections, nil
|
||||
}
|
||||
|
||||
// ListEntries lists all entries in a collection
|
||||
@@ -204,17 +264,75 @@ func (c *Client) ListEntries(collection string) ([]string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("failed to list entries")
|
||||
}
|
||||
|
||||
var entries []string
|
||||
err = json.NewDecoder(resp.Body).Decode(&entries)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, parseAPIError(resp, body, "failed to list entries")
|
||||
}
|
||||
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
|
||||
if wrap.Error != nil {
|
||||
return nil, errors.New(wrap.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid response: %w", err)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Entries []string `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.Entries, nil
|
||||
}
|
||||
|
||||
// GetEntryContent returns the full file content (no chunk overlap) and the number of chunks for the entry.
|
||||
func (c *Client) GetEntryContent(collection, entry string) (content string, chunkCount int, err error) {
|
||||
entryEscaped := url.PathEscape(entry)
|
||||
reqURL := fmt.Sprintf("%s/api/collections/%s/entries/%s", c.BaseURL, collection, entryEscaped)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
c.addAuthHeader(req)
|
||||
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", 0, parseAPIError(resp, body, "failed to get entry content")
|
||||
}
|
||||
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
|
||||
if wrap.Error != nil {
|
||||
return "", 0, errors.New(wrap.Error.Message)
|
||||
}
|
||||
return "", 0, fmt.Errorf("invalid response: %w", err)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Content string `json:"content"`
|
||||
ChunkCount int `json:"chunk_count"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return data.Content, data.ChunkCount, nil
|
||||
}
|
||||
|
||||
// DeleteEntry deletes an entry in a collection
|
||||
@@ -244,19 +362,30 @@ func (c *Client) DeleteEntry(collection, entry string) ([]string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyResult := new(bytes.Buffer)
|
||||
bodyResult.ReadFrom(resp.Body)
|
||||
return nil, errors.New("failed to delete entry: " + bodyResult.String())
|
||||
}
|
||||
|
||||
var results []string
|
||||
err = json.NewDecoder(resp.Body).Decode(&results)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, parseAPIError(resp, body, "failed to delete entry")
|
||||
}
|
||||
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
|
||||
if wrap.Error != nil {
|
||||
return nil, errors.New(wrap.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid response: %w", err)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
RemainingEntries []string `json:"remaining_entries"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.RemainingEntries, nil
|
||||
}
|
||||
|
||||
// Search searches a collection
|
||||
@@ -287,17 +416,30 @@ func (c *Client) Search(collection, query string, maxResults int) ([]Result, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("failed to search collection")
|
||||
}
|
||||
|
||||
var results []Result
|
||||
err = json.NewDecoder(resp.Body).Decode(&results)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, parseAPIError(resp, body, "failed to search collection")
|
||||
}
|
||||
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
|
||||
if wrap.Error != nil {
|
||||
return nil, errors.New(wrap.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid response: %w", err)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Results []Result `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.Results, nil
|
||||
}
|
||||
|
||||
// Reset resets a collection
|
||||
@@ -318,9 +460,8 @@ func (c *Client) Reset(collection string) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b := new(bytes.Buffer)
|
||||
b.ReadFrom(resp.Body)
|
||||
return errors.New("failed to reset collection: " + b.String())
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return parseAPIError(resp, body, "failed to reset collection")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -369,20 +510,8 @@ func (c *Client) Store(collection, filePath string) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b := new(bytes.Buffer)
|
||||
b.ReadFrom(resp.Body)
|
||||
|
||||
type response struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
var r response
|
||||
err = json.Unmarshal(b.Bytes(), &r)
|
||||
if err == nil {
|
||||
return errors.New("failed to upload file: " + r.Error)
|
||||
}
|
||||
|
||||
return errors.New("failed to upload file")
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return parseAPIError(resp, body, "failed to upload file")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Client implements the transport.Interface for stdio processes
|
||||
type Client struct {
|
||||
baseURL string
|
||||
processes map[string]*Process
|
||||
groups map[string][]string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewClient creates a new stdio transport client
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
processes: make(map[string]*Process),
|
||||
groups: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateProcess starts a new process in a group
|
||||
func (c *Client) CreateProcess(ctx context.Context, command string, args []string, env []string, groupID string) (*Process, error) {
|
||||
log.Printf("Creating process: command=%s, args=%v, groupID=%s", command, args, groupID)
|
||||
|
||||
req := struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
GroupID string `json:"group_id"`
|
||||
}{
|
||||
Command: command,
|
||||
Args: args,
|
||||
Env: env,
|
||||
GroupID: groupID,
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/processes", c.baseURL)
|
||||
log.Printf("Sending POST request to %s", url)
|
||||
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start process: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
log.Printf("Received response with status: %d", resp.StatusCode)
|
||||
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w. body: %s", err, string(body))
|
||||
}
|
||||
|
||||
log.Printf("Successfully created process with ID: %s", result.ID)
|
||||
|
||||
process := &Process{
|
||||
ID: result.ID,
|
||||
GroupID: groupID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.processes[process.ID] = process
|
||||
if groupID != "" {
|
||||
c.groups[groupID] = append(c.groups[groupID], process.ID)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
return process, nil
|
||||
}
|
||||
|
||||
// GetProcess returns a process by ID
|
||||
func (c *Client) GetProcess(id string) (*Process, error) {
|
||||
c.mu.RLock()
|
||||
process, exists := c.processes[id]
|
||||
c.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("process not found: %s", id)
|
||||
}
|
||||
|
||||
return process, nil
|
||||
}
|
||||
|
||||
// GetGroupProcesses returns all processes in a group
|
||||
func (c *Client) GetGroupProcesses(groupID string) ([]*Process, error) {
|
||||
c.mu.RLock()
|
||||
processIDs, exists := c.groups[groupID]
|
||||
if !exists {
|
||||
c.mu.RUnlock()
|
||||
return nil, fmt.Errorf("group not found: %s", groupID)
|
||||
}
|
||||
|
||||
processes := make([]*Process, 0, len(processIDs))
|
||||
for _, pid := range processIDs {
|
||||
if process, exists := c.processes[pid]; exists {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
return processes, nil
|
||||
}
|
||||
|
||||
// StopProcess stops a single process
|
||||
func (c *Client) StopProcess(id string) error {
|
||||
c.mu.Lock()
|
||||
process, exists := c.processes[id]
|
||||
if !exists {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("process not found: %s", id)
|
||||
}
|
||||
|
||||
// Remove from group if it exists
|
||||
if process.GroupID != "" {
|
||||
groupProcesses := c.groups[process.GroupID]
|
||||
for i, pid := range groupProcesses {
|
||||
if pid == id {
|
||||
c.groups[process.GroupID] = append(groupProcesses[:i], groupProcesses[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(c.groups[process.GroupID]) == 0 {
|
||||
delete(c.groups, process.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
delete(c.processes, id)
|
||||
c.mu.Unlock()
|
||||
|
||||
req, err := http.NewRequest(
|
||||
"DELETE",
|
||||
fmt.Sprintf("%s/processes/%s", c.baseURL, id),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop process: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopGroup stops all processes in a group
|
||||
func (c *Client) StopGroup(groupID string) error {
|
||||
c.mu.Lock()
|
||||
processIDs, exists := c.groups[groupID]
|
||||
if !exists {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("group not found: %s", groupID)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
for _, pid := range processIDs {
|
||||
if err := c.StopProcess(pid); err != nil {
|
||||
return fmt.Errorf("failed to stop process %s in group %s: %w", pid, groupID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListGroups returns all group IDs
|
||||
func (c *Client) ListGroups() []string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
groups := make([]string, 0, len(c.groups))
|
||||
for groupID := range c.groups {
|
||||
groups = append(groups, groupID)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// GetProcessIO returns io.Reader and io.Writer for a process
|
||||
func (c *Client) GetProcessIO(id string) (io.Reader, io.Writer, error) {
|
||||
log.Printf("Getting IO for process: %s", id)
|
||||
|
||||
process, err := c.GetProcess(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Parse the base URL to get the host
|
||||
baseURL, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse base URL: %w", err)
|
||||
}
|
||||
|
||||
// Connect to WebSocket
|
||||
u := url.URL{
|
||||
Scheme: "ws",
|
||||
Host: baseURL.Host,
|
||||
Path: fmt.Sprintf("/ws/%s", process.ID),
|
||||
}
|
||||
|
||||
log.Printf("Connecting to WebSocket at: %s", u.String())
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect to WebSocket: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Successfully connected to WebSocket for process: %s", id)
|
||||
|
||||
// Create reader and writer
|
||||
reader := &websocketReader{conn: conn}
|
||||
writer := &websocketWriter{conn: conn}
|
||||
|
||||
return reader, writer, nil
|
||||
}
|
||||
|
||||
// websocketReader implements io.Reader for WebSocket
|
||||
type websocketReader struct {
|
||||
conn *websocket.Conn
|
||||
}
|
||||
|
||||
func (r *websocketReader) Read(p []byte) (n int, err error) {
|
||||
_, message, err := r.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n = copy(p, message)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// websocketWriter implements io.Writer for WebSocket
|
||||
type websocketWriter struct {
|
||||
conn *websocket.Conn
|
||||
}
|
||||
|
||||
func (w *websocketWriter) Write(p []byte) (n int, err error) {
|
||||
// Use BinaryMessage type for better compatibility
|
||||
err = w.conn.WriteMessage(websocket.BinaryMessage, p)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write WebSocket message: %w", err)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Close closes all connections and stops all processes
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Stop all processes
|
||||
for id := range c.processes {
|
||||
if err := c.StopProcess(id); err != nil {
|
||||
return fmt.Errorf("failed to stop process %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunProcess executes a command and returns its output
|
||||
func (c *Client) RunProcess(ctx context.Context, command string, args []string, env []string) (string, error) {
|
||||
log.Printf("Running one-time process: command=%s, args=%v", command, args)
|
||||
|
||||
req := struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
}{
|
||||
Command: command,
|
||||
Args: args,
|
||||
Env: env,
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/run", c.baseURL)
|
||||
log.Printf("Sending POST request to %s", url)
|
||||
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute process: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
log.Printf("Received response with status: %d", resp.StatusCode)
|
||||
|
||||
var result struct {
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("failed to decode response: %w. body: %s", err, string(body))
|
||||
}
|
||||
|
||||
log.Printf("Successfully executed process with output length: %d", len(result.Output))
|
||||
return result.Output, nil
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestSTDIOTransport(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "STDIOTransport test suite")
|
||||
}
|
||||
|
||||
var baseURL string
|
||||
|
||||
func init() {
|
||||
baseURL = os.Getenv("LOCALAGI_MCPBOX_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080"
|
||||
}
|
||||
}
|
||||
|
||||
var _ = AfterSuite(func() {
|
||||
client := NewClient(baseURL)
|
||||
client.StopGroup("test-group")
|
||||
})
|
||||
@@ -1,235 +0,0 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
mcp "github.com/metoro-io/mcp-golang"
|
||||
"github.com/metoro-io/mcp-golang/transport/stdio"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Client", func() {
|
||||
var (
|
||||
client *Client
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
client = NewClient(baseURL)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if client != nil {
|
||||
Expect(client.Close()).To(Succeed())
|
||||
}
|
||||
})
|
||||
|
||||
Context("Process Management", func() {
|
||||
It("should create and stop a process", func() {
|
||||
ctx := context.Background()
|
||||
// Use a command that doesn't exit immediately
|
||||
process, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Hello, World!'; sleep 10"}, []string{}, "test-group")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(process).NotTo(BeNil())
|
||||
Expect(process.ID).NotTo(BeEmpty())
|
||||
|
||||
// Get process IO
|
||||
reader, writer, err := client.GetProcessIO(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(reader).NotTo(BeNil())
|
||||
Expect(writer).NotTo(BeNil())
|
||||
|
||||
// Write to process
|
||||
_, err = writer.Write([]byte("test input\n"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Read from process with timeout
|
||||
buf := make([]byte, 1024)
|
||||
readDone := make(chan struct{})
|
||||
var readErr error
|
||||
var readN int
|
||||
|
||||
go func() {
|
||||
readN, readErr = reader.Read(buf)
|
||||
close(readDone)
|
||||
}()
|
||||
|
||||
// Wait for read with timeout
|
||||
select {
|
||||
case <-readDone:
|
||||
Expect(readErr).NotTo(HaveOccurred())
|
||||
Expect(readN).To(BeNumerically(">", 0))
|
||||
Expect(string(buf[:readN])).To(ContainSubstring("Hello, World!"))
|
||||
case <-time.After(5 * time.Second):
|
||||
Fail("Timeout waiting for process output")
|
||||
}
|
||||
|
||||
// Stop the process
|
||||
err = client.StopProcess(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should manage process groups", func() {
|
||||
ctx := context.Background()
|
||||
groupID := "test-group"
|
||||
|
||||
// Create multiple processes in the same group
|
||||
process1, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Process 1'; sleep 1"}, []string{}, groupID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(process1).NotTo(BeNil())
|
||||
|
||||
process2, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Process 2'; sleep 1"}, []string{}, groupID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(process2).NotTo(BeNil())
|
||||
|
||||
// Get group processes
|
||||
processes, err := client.GetGroupProcesses(groupID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(processes).To(HaveLen(2))
|
||||
|
||||
// List groups
|
||||
groups := client.ListGroups()
|
||||
Expect(groups).To(ContainElement(groupID))
|
||||
|
||||
// Stop the group
|
||||
err = client.StopGroup(groupID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should run a one-time process", func() {
|
||||
ctx := context.Background()
|
||||
output, err := client.RunProcess(ctx, "echo", []string{"One-time process"}, []string{})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(output).To(ContainSubstring("One-time process"))
|
||||
})
|
||||
|
||||
It("should handle process with environment variables", func() {
|
||||
ctx := context.Background()
|
||||
env := []string{"TEST_VAR=test_value"}
|
||||
process, err := client.CreateProcess(ctx, "sh", []string{"-c", "env | grep TEST_VAR; sleep 1"}, env, "test-group")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(process).NotTo(BeNil())
|
||||
|
||||
// Get process IO
|
||||
reader, _, err := client.GetProcessIO(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Read environment variables with timeout
|
||||
buf := make([]byte, 1024)
|
||||
readDone := make(chan struct{})
|
||||
var readErr error
|
||||
var readN int
|
||||
|
||||
go func() {
|
||||
readN, readErr = reader.Read(buf)
|
||||
close(readDone)
|
||||
}()
|
||||
|
||||
// Wait for read with timeout
|
||||
select {
|
||||
case <-readDone:
|
||||
Expect(readErr).NotTo(HaveOccurred())
|
||||
Expect(readN).To(BeNumerically(">", 0))
|
||||
Expect(string(buf[:readN])).To(ContainSubstring("TEST_VAR=test_value"))
|
||||
case <-time.After(5 * time.Second):
|
||||
Fail("Timeout waiting for process output")
|
||||
}
|
||||
|
||||
// Stop the process
|
||||
err = client.StopProcess(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should handle long-running processes", func() {
|
||||
ctx := context.Background()
|
||||
process, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Starting long process'; sleep 5"}, []string{}, "test-group")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(process).NotTo(BeNil())
|
||||
|
||||
// Get process IO
|
||||
reader, _, err := client.GetProcessIO(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Read initial output
|
||||
buf := make([]byte, 1024)
|
||||
readDone := make(chan struct{})
|
||||
var readErr error
|
||||
var readN int
|
||||
|
||||
go func() {
|
||||
readN, readErr = reader.Read(buf)
|
||||
close(readDone)
|
||||
}()
|
||||
|
||||
// Wait for read with timeout
|
||||
select {
|
||||
case <-readDone:
|
||||
Expect(readErr).NotTo(HaveOccurred())
|
||||
Expect(readN).To(BeNumerically(">", 0))
|
||||
Expect(string(buf[:readN])).To(ContainSubstring("Starting long process"))
|
||||
case <-time.After(5 * time.Second):
|
||||
Fail("Timeout waiting for process output")
|
||||
}
|
||||
|
||||
// Wait a bit to ensure process is running
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// Stop the process
|
||||
err = client.StopProcess(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("MCP", func() {
|
||||
ctx := context.Background()
|
||||
process, err := client.CreateProcess(ctx,
|
||||
"docker", []string{"run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"},
|
||||
[]string{"GITHUB_PERSONAL_ACCESS_TOKEN=test"}, "test-group")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(process).NotTo(BeNil())
|
||||
Expect(process.ID).NotTo(BeEmpty())
|
||||
|
||||
defer client.StopProcess(process.ID)
|
||||
|
||||
// MCP client
|
||||
|
||||
read, writer, err := client.GetProcessIO(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(read).NotTo(BeNil())
|
||||
Expect(writer).NotTo(BeNil())
|
||||
|
||||
transport := stdio.NewStdioServerTransportWithIO(read, writer)
|
||||
|
||||
// Create a new client
|
||||
mcpClient := mcp.NewClient(transport)
|
||||
// Initialize the client
|
||||
response, e := mcpClient.Initialize(ctx)
|
||||
Expect(e).NotTo(HaveOccurred())
|
||||
Expect(response).NotTo(BeNil())
|
||||
|
||||
Expect(mcpClient.Ping(ctx)).To(Succeed())
|
||||
|
||||
xlog.Debug("Client initialized: %v", response.Instructions)
|
||||
|
||||
alltools := []mcp.ToolRetType{}
|
||||
var cursor *string
|
||||
for {
|
||||
tools, err := mcpClient.ListTools(ctx, cursor)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(tools).NotTo(BeNil())
|
||||
Expect(tools.Tools).NotTo(BeEmpty())
|
||||
alltools = append(alltools, tools.Tools...)
|
||||
|
||||
if tools.NextCursor == nil {
|
||||
break // No more pages
|
||||
}
|
||||
cursor = tools.NextCursor
|
||||
}
|
||||
|
||||
for _, tool := range alltools {
|
||||
xlog.Debug("Tool: %v", tool)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,473 +0,0 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
)
|
||||
|
||||
// Process represents a running process with its stdio streams
|
||||
type Process struct {
|
||||
ID string
|
||||
GroupID string
|
||||
Cmd *exec.Cmd
|
||||
Stdin io.WriteCloser
|
||||
Stdout io.ReadCloser
|
||||
Stderr io.ReadCloser
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Server handles process management and stdio streaming
|
||||
type Server struct {
|
||||
processes map[string]*Process
|
||||
groups map[string][]string // maps group ID to process IDs
|
||||
mu sync.RWMutex
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// NewServer creates a new stdio server
|
||||
func NewServer() *Server {
|
||||
return &Server{
|
||||
processes: make(map[string]*Process),
|
||||
groups: make(map[string][]string),
|
||||
upgrader: websocket.Upgrader{},
|
||||
}
|
||||
}
|
||||
|
||||
// StartProcess starts a new process and returns its ID
|
||||
func (s *Server) StartProcess(ctx context.Context, command string, args []string, env []string, groupID string) (string, error) {
|
||||
xlog.Debug("Starting process", "command", command, "args", args, "groupID", groupID)
|
||||
|
||||
cmd := exec.CommandContext(ctx, command, args...)
|
||||
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
xlog.Debug("Process environment", "env", cmd.Env)
|
||||
}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("failed to start process: %w", err)
|
||||
}
|
||||
|
||||
process := &Process{
|
||||
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
GroupID: groupID,
|
||||
Cmd: cmd,
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.processes[process.ID] = process
|
||||
if groupID != "" {
|
||||
s.groups[groupID] = append(s.groups[groupID], process.ID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
xlog.Debug("Successfully started process", "id", process.ID, "pid", cmd.Process.Pid)
|
||||
return process.ID, nil
|
||||
}
|
||||
|
||||
// StopProcess stops a running process
|
||||
func (s *Server) StopProcess(id string) error {
|
||||
s.mu.Lock()
|
||||
process, exists := s.processes[id]
|
||||
if !exists {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("process not found: %s", id)
|
||||
}
|
||||
|
||||
xlog.Debug("Stopping process", "processID", id, "pid", process.Cmd.Process.Pid)
|
||||
|
||||
// Remove from group if it exists
|
||||
if process.GroupID != "" {
|
||||
groupProcesses := s.groups[process.GroupID]
|
||||
for i, pid := range groupProcesses {
|
||||
if pid == id {
|
||||
s.groups[process.GroupID] = append(groupProcesses[:i], groupProcesses[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(s.groups[process.GroupID]) == 0 {
|
||||
delete(s.groups, process.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
delete(s.processes, id)
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := process.Cmd.Process.Kill(); err != nil {
|
||||
xlog.Debug("Failed to kill process", "processID", id, "pid", process.Cmd.Process.Pid, "error", err)
|
||||
return fmt.Errorf("failed to kill process: %w", err)
|
||||
}
|
||||
|
||||
xlog.Debug("Successfully killed process", "processID", id, "pid", process.Cmd.Process.Pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopGroup stops all processes in a group
|
||||
func (s *Server) StopGroup(groupID string) error {
|
||||
s.mu.Lock()
|
||||
processIDs, exists := s.groups[groupID]
|
||||
if !exists {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("group not found: %s", groupID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, pid := range processIDs {
|
||||
if err := s.StopProcess(pid); err != nil {
|
||||
return fmt.Errorf("failed to stop process %s in group %s: %w", pid, groupID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGroupProcesses returns all processes in a group
|
||||
func (s *Server) GetGroupProcesses(groupID string) ([]*Process, error) {
|
||||
s.mu.RLock()
|
||||
processIDs, exists := s.groups[groupID]
|
||||
if !exists {
|
||||
s.mu.RUnlock()
|
||||
return nil, fmt.Errorf("group not found: %s", groupID)
|
||||
}
|
||||
|
||||
processes := make([]*Process, 0, len(processIDs))
|
||||
for _, pid := range processIDs {
|
||||
if process, exists := s.processes[pid]; exists {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
return processes, nil
|
||||
}
|
||||
|
||||
// ListGroups returns all group IDs
|
||||
func (s *Server) ListGroups() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
groups := make([]string, 0, len(s.groups))
|
||||
for groupID := range s.groups {
|
||||
groups = append(groups, groupID)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// GetProcess returns a process by ID
|
||||
func (s *Server) GetProcess(id string) (*Process, error) {
|
||||
s.mu.RLock()
|
||||
process, exists := s.processes[id]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("process not found: %s", id)
|
||||
}
|
||||
|
||||
return process, nil
|
||||
}
|
||||
|
||||
// ListProcesses returns all running processes
|
||||
func (s *Server) ListProcesses() []*Process {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
processes := make([]*Process, 0, len(s.processes))
|
||||
for _, p := range s.processes {
|
||||
processes = append(processes, p)
|
||||
}
|
||||
|
||||
return processes
|
||||
}
|
||||
|
||||
// RunProcess executes a command and returns its output
|
||||
func (s *Server) RunProcess(ctx context.Context, command string, args []string, env []string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, command, args...)
|
||||
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(output), fmt.Errorf("process failed: %w", err)
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (s *Server) Start(addr string) error {
|
||||
http.HandleFunc("/processes", s.handleProcesses)
|
||||
http.HandleFunc("/processes/", s.handleProcess)
|
||||
http.HandleFunc("/ws/", s.handleWebSocket)
|
||||
http.HandleFunc("/groups", s.handleGroups)
|
||||
http.HandleFunc("/groups/", s.handleGroup)
|
||||
http.HandleFunc("/run", s.handleRun)
|
||||
|
||||
return http.ListenAndServe(addr, nil)
|
||||
}
|
||||
|
||||
func (s *Server) handleProcesses(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Handling /processes request: method=%s", r.Method)
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
processes := s.ListProcesses()
|
||||
json.NewEncoder(w).Encode(processes)
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
GroupID string `json:"group_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := s.StartProcess(context.Background(), req.Command, req.Args, req.Env, req.GroupID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": id})
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleProcess(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Path[len("/processes/"):]
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
process, err := s.GetProcess(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(process)
|
||||
case http.MethodDelete:
|
||||
if err := s.StopProcess(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Path[len("/ws/"):]
|
||||
xlog.Debug("Handling WebSocket connection", "processID", id)
|
||||
|
||||
process, err := s.GetProcess(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if process.Cmd.ProcessState != nil && process.Cmd.ProcessState.Exited() {
|
||||
xlog.Debug("Process already exited", "processID", id)
|
||||
http.Error(w, "Process already exited", http.StatusGone)
|
||||
return
|
||||
}
|
||||
|
||||
xlog.Debug("Process is running", "processID", id, "pid", process.Cmd.Process.Pid)
|
||||
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
xlog.Debug("WebSocket connection established", "processID", id)
|
||||
|
||||
// Create a done channel to signal process completion
|
||||
done := make(chan struct{})
|
||||
|
||||
// Handle stdin
|
||||
go func() {
|
||||
defer func() {
|
||||
select {
|
||||
case <-done:
|
||||
xlog.Debug("Process stdin handler done", "processID", id)
|
||||
default:
|
||||
xlog.Debug("WebSocket stdin connection closed", "processID", id)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
xlog.Debug("WebSocket stdin unexpected error", "processID", id, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
xlog.Debug("Received message", "processID", id, "message", string(message))
|
||||
if _, err := process.Stdin.Write(message); err != nil {
|
||||
if err != io.EOF {
|
||||
xlog.Debug("WebSocket stdin write error", "processID", id, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
xlog.Debug("Message sent to process", "processID", id, "message", string(message))
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle stdout and stderr
|
||||
go func() {
|
||||
defer func() {
|
||||
select {
|
||||
case <-done:
|
||||
xlog.Debug("Process output handler done", "processID", id)
|
||||
default:
|
||||
xlog.Debug("WebSocket output connection closed", "processID", id)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create a buffer for reading
|
||||
buf := make([]byte, 4096)
|
||||
reader := io.MultiReader(process.Stdout, process.Stderr)
|
||||
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
xlog.Debug("Read error", "processID", id, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
xlog.Debug("Sending message", "processID", id, "size", n)
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
xlog.Debug("WebSocket output write error", "processID", id, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
xlog.Debug("Message sent to client", "processID", id, "size", n)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for process to exit
|
||||
xlog.Debug("Waiting for process to exit", "processID", id)
|
||||
err = process.Cmd.Wait()
|
||||
close(done) // Signal that the process is done
|
||||
|
||||
if err != nil {
|
||||
xlog.Debug("Process exited with error",
|
||||
"processID", id,
|
||||
"pid", process.Cmd.Process.Pid,
|
||||
"error", err)
|
||||
} else {
|
||||
xlog.Debug("Process exited successfully",
|
||||
"processID", id,
|
||||
"pid", process.Cmd.Process.Pid)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new handlers for group management
|
||||
func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
groups := s.ListGroups()
|
||||
json.NewEncoder(w).Encode(groups)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGroup(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := r.URL.Path[len("/groups/"):]
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
processes, err := s.GetGroupProcesses(groupID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(processes)
|
||||
case http.MethodDelete:
|
||||
if err := s.StopGroup(groupID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Handling /run request")
|
||||
|
||||
var req struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Executing one-time process: command=%s, args=%v", req.Command, req.Args)
|
||||
|
||||
output, err := s.RunProcess(r.Context(), req.Command, req.Args, req.Env)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("One-time process completed with output length: %d", len(output))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"output": output,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package ptr
|
||||
|
||||
func To[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package xlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
var logger *slog.Logger
|
||||
|
||||
func init() {
|
||||
var level = slog.LevelDebug
|
||||
|
||||
switch os.Getenv("LOG_LEVEL") {
|
||||
case "info":
|
||||
level = slog.LevelInfo
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
|
||||
var opts = &slog.HandlerOptions{
|
||||
Level: level,
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
|
||||
if os.Getenv("LOG_FORMAT") == "json" {
|
||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||
} else {
|
||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||
}
|
||||
logger = slog.New(handler)
|
||||
}
|
||||
|
||||
func _log(level slog.Level, msg string, args ...any) {
|
||||
_, f, l, _ := runtime.Caller(2)
|
||||
group := slog.Group(
|
||||
"source",
|
||||
slog.Attr{
|
||||
Key: "file",
|
||||
Value: slog.AnyValue(f),
|
||||
},
|
||||
slog.Attr{
|
||||
Key: "L",
|
||||
Value: slog.AnyValue(l),
|
||||
},
|
||||
)
|
||||
args = append(args, group)
|
||||
logger.Log(context.Background(), level, msg, args...)
|
||||
}
|
||||
|
||||
func Info(msg string, args ...any) {
|
||||
_log(slog.LevelInfo, msg, args...)
|
||||
}
|
||||
|
||||
func Debug(msg string, args ...any) {
|
||||
_log(slog.LevelDebug, msg, args...)
|
||||
}
|
||||
|
||||
func Error(msg string, args ...any) {
|
||||
_log(slog.LevelError, msg, args...)
|
||||
}
|
||||
|
||||
func Warn(msg string, args ...any) {
|
||||
_log(slog.LevelWarn, msg, args...)
|
||||
}
|
||||
+345
-186
@@ -4,12 +4,16 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/action"
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
)
|
||||
@@ -18,8 +22,6 @@ const (
|
||||
// Actions
|
||||
ActionSearch = "search"
|
||||
ActionCustom = "custom"
|
||||
ActionBrowserAgentRunner = "browser-agent-runner"
|
||||
ActionDeepResearchRunner = "deep-research-runner"
|
||||
ActionGithubIssueLabeler = "github-issue-labeler"
|
||||
ActionGithubIssueOpener = "github-issue-opener"
|
||||
ActionGithubIssueEditor = "github-issue-editor"
|
||||
@@ -43,13 +45,29 @@ const (
|
||||
ActionTwitterPost = "twitter-post"
|
||||
ActionSendMail = "send-mail"
|
||||
ActionGenerateImage = "generate_image"
|
||||
ActionGenerateSong = "generate_song"
|
||||
ActionGeneratePDF = "generate_pdf"
|
||||
ActionCounter = "counter"
|
||||
ActionCallAgents = "call_agents"
|
||||
ActionShellcommand = "shell-command"
|
||||
ActionSendTelegramMessage = "send-telegram-message"
|
||||
ActionSetReminder = "set_reminder"
|
||||
ActionSetRecurringReminder = "set_recurring_reminder"
|
||||
ActionSetOneTimeReminder = "set_onetime_reminder"
|
||||
ActionListReminders = "list_reminders"
|
||||
ActionRemoveReminder = "remove_reminder"
|
||||
ActionAddToMemory = "add_to_memory"
|
||||
ActionListMemory = "list_memory"
|
||||
ActionRemoveFromMemory = "remove_from_memory"
|
||||
ActionSearchMemory = "search_memory"
|
||||
ActionPiKVMPowerControl = "pikvm_power_control"
|
||||
ActionWebhook = "webhook"
|
||||
)
|
||||
|
||||
const (
|
||||
nameField = "name"
|
||||
descriptionField = "description"
|
||||
configurationField = "configuration"
|
||||
)
|
||||
|
||||
var AvailableActions = []string{
|
||||
@@ -64,8 +82,6 @@ var AvailableActions = []string{
|
||||
ActionGithubGetAllContent,
|
||||
ActionGithubRepositorySearchFiles,
|
||||
ActionGithubRepositoryListFiles,
|
||||
ActionBrowserAgentRunner,
|
||||
ActionDeepResearchRunner,
|
||||
ActionGithubRepositoryCreateOrUpdate,
|
||||
ActionGithubIssueReader,
|
||||
ActionGithubIssueCommenter,
|
||||
@@ -79,6 +95,8 @@ var AvailableActions = []string{
|
||||
ActionWikipedia,
|
||||
ActionSendMail,
|
||||
ActionGenerateImage,
|
||||
ActionGenerateSong,
|
||||
ActionGeneratePDF,
|
||||
ActionTwitterPost,
|
||||
ActionCounter,
|
||||
ActionCallAgents,
|
||||
@@ -87,14 +105,270 @@ var AvailableActions = []string{
|
||||
ActionSetReminder,
|
||||
ActionListReminders,
|
||||
ActionRemoveReminder,
|
||||
ActionAddToMemory,
|
||||
ActionListMemory,
|
||||
ActionRemoveFromMemory,
|
||||
ActionSearchMemory,
|
||||
ActionPiKVMPowerControl,
|
||||
ActionWebhook,
|
||||
}
|
||||
|
||||
var DefaultActions = []config.FieldGroup{
|
||||
{
|
||||
Name: "search",
|
||||
Label: "Search",
|
||||
Fields: actions.SearchConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "generate_image",
|
||||
Label: "Generate Image",
|
||||
Fields: actions.GenImageConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "generate_song",
|
||||
Label: "Generate Song",
|
||||
Fields: actions.GenSongConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "generate_pdf",
|
||||
Label: "Generate PDF",
|
||||
Fields: actions.GenPDFConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "add_to_memory",
|
||||
Label: "Add to Memory",
|
||||
Fields: actions.AddToMemoryConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "list_memory",
|
||||
Label: "List Memory",
|
||||
Fields: actions.ListMemoryConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "remove_from_memory",
|
||||
Label: "Remove from Memory",
|
||||
Fields: actions.RemoveFromMemoryConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "search_memory",
|
||||
Label: "Search Memory",
|
||||
Fields: actions.SearchMemoryConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-labeler",
|
||||
Label: "GitHub Issue Labeler",
|
||||
Fields: actions.GithubIssueLabelerConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-opener",
|
||||
Label: "GitHub Issue Opener",
|
||||
Fields: actions.GithubIssueOpenerConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-editor",
|
||||
Label: "GitHub Issue Editor",
|
||||
Fields: actions.GithubIssueEditorConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-closer",
|
||||
Label: "GitHub Issue Closer",
|
||||
Fields: actions.GithubIssueCloserConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-commenter",
|
||||
Label: "GitHub Issue Commenter",
|
||||
Fields: actions.GithubIssueCommenterConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-reader",
|
||||
Label: "GitHub Issue Reader",
|
||||
Fields: actions.GithubIssueReaderConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-searcher",
|
||||
Label: "GitHub Issue Search",
|
||||
Fields: actions.GithubIssueSearchConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-repository-get-content",
|
||||
Label: "GitHub Repository Get Content",
|
||||
Fields: actions.GithubRepositoryGetContentConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-get-all-repository-content",
|
||||
Label: "GitHub Get All Repository Content",
|
||||
Fields: actions.GithubRepositoryGetAllContentConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-repository-search-files",
|
||||
Label: "GitHub Repository Search Files",
|
||||
Fields: actions.GithubRepositorySearchFilesConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-repository-list-files",
|
||||
Label: "GitHub Repository List Files",
|
||||
Fields: actions.GithubRepositoryListFilesConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-repository-create-or-update-content",
|
||||
Label: "GitHub Repository Create/Update Content",
|
||||
Fields: actions.GithubRepositoryCreateOrUpdateContentConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-readme",
|
||||
Label: "GitHub Repository README",
|
||||
Fields: actions.GithubRepositoryREADMEConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-pr-reader",
|
||||
Label: "GitHub PR Reader",
|
||||
Fields: actions.GithubPRReaderConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-pr-commenter",
|
||||
Label: "GitHub PR Commenter",
|
||||
Fields: actions.GithubPRCommenterConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-pr-reviewer",
|
||||
Label: "GitHub PR Reviewer",
|
||||
Fields: actions.GithubPRReviewerConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-pr-creator",
|
||||
Label: "GitHub PR Creator",
|
||||
Fields: actions.GithubPRCreatorConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "twitter-post",
|
||||
Label: "Twitter Post",
|
||||
Fields: actions.TwitterPostConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "send-mail",
|
||||
Label: "Send Mail",
|
||||
Fields: actions.SendMailConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "shell-command",
|
||||
Label: "Shell Command",
|
||||
Fields: actions.ShellConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "custom",
|
||||
Label: "Custom",
|
||||
Fields: action.CustomConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "scraper",
|
||||
Label: "Scraper",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "wikipedia",
|
||||
Label: "Wikipedia",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "browse",
|
||||
Label: "Browse",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "counter",
|
||||
Label: "Counter",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "call_agents",
|
||||
Label: "Call Agents",
|
||||
Fields: actions.CallAgentConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "send-telegram-message",
|
||||
Label: "Send Telegram Message",
|
||||
Fields: actions.SendTelegramMessageConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "set_recurring_reminder",
|
||||
Label: "Set Recurring Reminder",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "set_onetime_reminder",
|
||||
Label: "Set One-Time Reminder",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "list_reminders",
|
||||
Label: "List Reminders",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "remove_reminder",
|
||||
Label: "Remove Reminder",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "pikvm_power_control",
|
||||
Label: "PiKVM Power Control",
|
||||
Fields: actions.PiKVMConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "webhook",
|
||||
Label: "Webhook",
|
||||
Fields: actions.WebhookConfigMeta(),
|
||||
},
|
||||
}
|
||||
|
||||
const (
|
||||
ActionConfigBrowserAgentRunner = "browser-agent-runner-base-url"
|
||||
ActionConfigDeepResearchRunner = "deep-research-runner-base-url"
|
||||
ActionConfigSSHBoxURL = "sshbox-url"
|
||||
ActionConfigSSHBoxURL = "sshbox-url"
|
||||
ConfigStateDir = "state-dir"
|
||||
CustomActionsDir = "custom-actions-dir"
|
||||
)
|
||||
|
||||
func customActions(customActionsDir string, existingActionConfigs map[string]map[string]string) (allActions []types.Action) {
|
||||
files, err := os.ReadDir(customActionsDir)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading custom actions directory", "error", err)
|
||||
return allActions
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if filepath.Ext(file.Name()) != ".go" {
|
||||
continue
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(customActionsDir, file.Name()))
|
||||
if err != nil {
|
||||
xlog.Error("Error reading custom action file", "error", err, "file", file.Name())
|
||||
continue
|
||||
}
|
||||
actionName := strings.TrimSuffix(file.Name(), ".go")
|
||||
|
||||
actionConfig := map[string]string{
|
||||
"name": actionName,
|
||||
"description": "",
|
||||
"code": string(content),
|
||||
"unsafe": "false",
|
||||
}
|
||||
|
||||
if c, exists := existingActionConfigs[actionName]; exists {
|
||||
// We allow the user to customize name and description
|
||||
actionConfig[descriptionField] = c[descriptionField]
|
||||
actionConfig[nameField] = c[nameField]
|
||||
actionConfig[configurationField] = c[configurationField]
|
||||
}
|
||||
a, err := Action(ActionCustom, "", actionConfig, nil, map[string]string{})
|
||||
if err != nil {
|
||||
xlog.Error("Error creating custom action", "error", err, "file", file.Name())
|
||||
continue
|
||||
}
|
||||
allActions = append(allActions, a)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func Actions(actionsConfigs map[string]string) func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action {
|
||||
return func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action {
|
||||
return func(ctx context.Context, pool *state.AgentPool) []types.Action {
|
||||
@@ -102,6 +376,7 @@ func Actions(actionsConfigs map[string]string) func(a *state.AgentConfig) func(c
|
||||
|
||||
agentName := a.Name
|
||||
|
||||
existingActionConfigs := map[string]map[string]string{}
|
||||
for _, a := range a.Actions {
|
||||
var config map[string]string
|
||||
if err := json.Unmarshal([]byte(a.Config), &config); err != nil {
|
||||
@@ -109,6 +384,8 @@ func Actions(actionsConfigs map[string]string) func(a *state.AgentConfig) func(c
|
||||
continue
|
||||
}
|
||||
|
||||
existingActionConfigs[a.Name] = config
|
||||
|
||||
a, err := Action(a.Name, agentName, config, pool, actionsConfigs)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -116,6 +393,11 @@ func Actions(actionsConfigs map[string]string) func(a *state.AgentConfig) func(c
|
||||
allActions = append(allActions, a)
|
||||
}
|
||||
|
||||
// Now we will scan a directory for custom actions
|
||||
if actionsConfigs[CustomActionsDir] != "" {
|
||||
allActions = append(allActions, customActions(actionsConfigs[CustomActionsDir], existingActionConfigs)...)
|
||||
}
|
||||
|
||||
return allActions
|
||||
}
|
||||
}
|
||||
@@ -130,11 +412,17 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
|
||||
config = map[string]string{}
|
||||
}
|
||||
|
||||
memoryIdxPath := memoryIndexPath(agentName, actionsConfigs)
|
||||
|
||||
switch name {
|
||||
case ActionCustom:
|
||||
a, err = action.NewCustom(config, "")
|
||||
case ActionGenerateImage:
|
||||
a = actions.NewGenImage(config)
|
||||
case ActionGenerateSong:
|
||||
a = actions.NewGenSong(config)
|
||||
case ActionGeneratePDF:
|
||||
a = actions.NewGenPDF(config)
|
||||
case ActionSearch:
|
||||
a = actions.NewSearch(config)
|
||||
case ActionGithubIssueLabeler:
|
||||
@@ -147,10 +435,6 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
|
||||
a = actions.NewGithubIssueCloser(config)
|
||||
case ActionGithubIssueSearcher:
|
||||
a = actions.NewGithubIssueSearch(config)
|
||||
case ActionBrowserAgentRunner:
|
||||
a = actions.NewBrowserAgentRunner(config, actionsConfigs[ActionConfigBrowserAgentRunner])
|
||||
case ActionDeepResearchRunner:
|
||||
a = actions.NewDeepResearchRunner(config, actionsConfigs[ActionConfigDeepResearchRunner])
|
||||
case ActionGithubIssueReader:
|
||||
a = actions.NewGithubIssueReader(config)
|
||||
case ActionGithubPRReader:
|
||||
@@ -183,6 +467,8 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
|
||||
a = actions.NewBrowse(config)
|
||||
case ActionSendMail:
|
||||
a = actions.NewSendMail(config)
|
||||
case ActionWebhook:
|
||||
a = actions.NewWebhook(config)
|
||||
case ActionTwitterPost:
|
||||
a = actions.NewPostTweet(config)
|
||||
case ActionCounter:
|
||||
@@ -193,12 +479,24 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
|
||||
a = actions.NewShell(config, actionsConfigs[ActionConfigSSHBoxURL])
|
||||
case ActionSendTelegramMessage:
|
||||
a = actions.NewSendTelegramMessageRunner(config)
|
||||
case ActionSetReminder:
|
||||
a = action.NewReminder()
|
||||
case ActionSetRecurringReminder:
|
||||
a = action.NewRecurringReminder()
|
||||
case ActionSetOneTimeReminder:
|
||||
a = action.NewOneTimeReminder()
|
||||
case ActionListReminders:
|
||||
a = action.NewListReminders()
|
||||
case ActionRemoveReminder:
|
||||
a = action.NewRemoveReminder()
|
||||
case ActionAddToMemory:
|
||||
a, _, _, _ = actions.NewMemoryActions(memoryIdxPath, config)
|
||||
case ActionListMemory:
|
||||
_, a, _, _ = actions.NewMemoryActions(memoryIdxPath, config)
|
||||
case ActionRemoveFromMemory:
|
||||
_, _, a, _ = actions.NewMemoryActions(memoryIdxPath, config)
|
||||
case ActionSearchMemory:
|
||||
_, _, _, a = actions.NewMemoryActions(memoryIdxPath, config)
|
||||
case ActionPiKVMPowerControl:
|
||||
a = actions.NewPiKVMAction(config)
|
||||
default:
|
||||
xlog.Error("Action not found", "name", name)
|
||||
return nil, fmt.Errorf("Action not found")
|
||||
@@ -211,177 +509,38 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func ActionsConfigMeta() []config.FieldGroup {
|
||||
return []config.FieldGroup{
|
||||
{
|
||||
Name: "search",
|
||||
Label: "Search",
|
||||
Fields: actions.SearchConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "browser-agent-runner",
|
||||
Label: "Browser Agent Runner",
|
||||
Fields: actions.BrowserAgentRunnerConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "deep-research-runner",
|
||||
Label: "Deep Research Runner",
|
||||
Fields: actions.DeepResearchRunnerConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "generate_image",
|
||||
Label: "Generate Image",
|
||||
Fields: actions.GenImageConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-labeler",
|
||||
Label: "GitHub Issue Labeler",
|
||||
Fields: actions.GithubIssueLabelerConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-opener",
|
||||
Label: "GitHub Issue Opener",
|
||||
Fields: actions.GithubIssueOpenerConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-editor",
|
||||
Label: "GitHub Issue Editor",
|
||||
Fields: actions.GithubIssueEditorConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-closer",
|
||||
Label: "GitHub Issue Closer",
|
||||
Fields: actions.GithubIssueCloserConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-commenter",
|
||||
Label: "GitHub Issue Commenter",
|
||||
Fields: actions.GithubIssueCommenterConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-reader",
|
||||
Label: "GitHub Issue Reader",
|
||||
Fields: actions.GithubIssueReaderConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-searcher",
|
||||
Label: "GitHub Issue Search",
|
||||
Fields: actions.GithubIssueSearchConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-repository-get-content",
|
||||
Label: "GitHub Repository Get Content",
|
||||
Fields: actions.GithubRepositoryGetContentConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-get-all-repository-content",
|
||||
Label: "GitHub Get All Repository Content",
|
||||
Fields: actions.GithubRepositoryGetAllContentConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-repository-search-files",
|
||||
Label: "GitHub Repository Search Files",
|
||||
Fields: actions.GithubRepositorySearchFilesConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-repository-list-files",
|
||||
Label: "GitHub Repository List Files",
|
||||
Fields: actions.GithubRepositoryListFilesConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-repository-create-or-update-content",
|
||||
Label: "GitHub Repository Create/Update Content",
|
||||
Fields: actions.GithubRepositoryCreateOrUpdateContentConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-readme",
|
||||
Label: "GitHub Repository README",
|
||||
Fields: actions.GithubRepositoryREADMEConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-pr-reader",
|
||||
Label: "GitHub PR Reader",
|
||||
Fields: actions.GithubPRReaderConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-pr-commenter",
|
||||
Label: "GitHub PR Commenter",
|
||||
Fields: actions.GithubPRCommenterConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-pr-reviewer",
|
||||
Label: "GitHub PR Reviewer",
|
||||
Fields: actions.GithubPRReviewerConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-pr-creator",
|
||||
Label: "GitHub PR Creator",
|
||||
Fields: actions.GithubPRCreatorConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "twitter-post",
|
||||
Label: "Twitter Post",
|
||||
Fields: actions.TwitterPostConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "send-mail",
|
||||
Label: "Send Mail",
|
||||
Fields: actions.SendMailConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "shell-command",
|
||||
Label: "Shell Command",
|
||||
Fields: actions.ShellConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "custom",
|
||||
Label: "Custom",
|
||||
Fields: action.CustomConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "scraper",
|
||||
Label: "Scraper",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "wikipedia",
|
||||
Label: "Wikipedia",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "browse",
|
||||
Label: "Browse",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "counter",
|
||||
Label: "Counter",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "call_agents",
|
||||
Label: "Call Agents",
|
||||
Fields: actions.CallAgentConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "send-telegram-message",
|
||||
Label: "Send Telegram Message",
|
||||
Fields: actions.SendTelegramMessageConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "set_reminder",
|
||||
Label: "Set Reminder",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "list_reminders",
|
||||
Label: "List Reminders",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "remove_reminder",
|
||||
Label: "Remove Reminder",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
func ActionsConfigMeta(customActionDir string) []config.FieldGroup {
|
||||
all := slices.Clone(DefaultActions)
|
||||
|
||||
if customActionDir != "" {
|
||||
actions := customActions(customActionDir, map[string]map[string]string{})
|
||||
|
||||
for _, a := range actions {
|
||||
all = append(all, config.FieldGroup{
|
||||
Name: a.Definition().Name.String(),
|
||||
Label: a.Definition().Name.String(),
|
||||
Fields: []config.Field{
|
||||
{
|
||||
Name: nameField,
|
||||
Label: "Name",
|
||||
Type: config.FieldTypeText,
|
||||
HelpText: "Name of the custom action",
|
||||
},
|
||||
{
|
||||
Name: descriptionField,
|
||||
Label: "Description",
|
||||
Type: config.FieldTypeTextarea,
|
||||
HelpText: "Description of the custom action",
|
||||
},
|
||||
{
|
||||
Name: configurationField,
|
||||
Label: "Configuration",
|
||||
Type: config.FieldTypeTextarea,
|
||||
HelpText: "Configuration of the custom action",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
api "github.com/mudler/LocalAGI/pkg/localoperator"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataBrowserAgentHistory = "browser_agent_history"
|
||||
)
|
||||
|
||||
type BrowserAgentRunner struct {
|
||||
baseURL, customActionName string
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
func NewBrowserAgentRunner(config map[string]string, defaultURL string) *BrowserAgentRunner {
|
||||
if config["baseURL"] == "" {
|
||||
config["baseURL"] = defaultURL
|
||||
}
|
||||
|
||||
timeout := "15m"
|
||||
if config["timeout"] != "" {
|
||||
timeout = config["timeout"]
|
||||
}
|
||||
|
||||
duration, err := time.ParseDuration(timeout)
|
||||
if err != nil {
|
||||
// If parsing fails, use default 15 minutes
|
||||
duration = 15 * time.Minute
|
||||
}
|
||||
|
||||
client := api.NewClient(config["baseURL"], duration)
|
||||
|
||||
return &BrowserAgentRunner{
|
||||
client: client,
|
||||
baseURL: config["baseURL"],
|
||||
customActionName: config["customActionName"],
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BrowserAgentRunner) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := api.AgentRequest{}
|
||||
err := params.Unmarshal(&result)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to unmarshal params: %w", err)
|
||||
}
|
||||
|
||||
req := api.AgentRequest{
|
||||
Goal: result.Goal,
|
||||
MaxAttempts: result.MaxAttempts,
|
||||
MaxNoActionAttempts: result.MaxNoActionAttempts,
|
||||
}
|
||||
|
||||
stateHistory, err := b.client.RunBrowserAgent(req)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to run browser agent: %w", err)
|
||||
}
|
||||
|
||||
// Format the state history into a readable string
|
||||
var historyStr string
|
||||
// for i, state := range stateHistory.States {
|
||||
// historyStr += fmt.Sprintf("State %d:\n", i+1)
|
||||
// historyStr += fmt.Sprintf(" URL: %s\n", state.CurrentURL)
|
||||
// historyStr += fmt.Sprintf(" Title: %s\n", state.PageTitle)
|
||||
// historyStr += fmt.Sprintf(" Description: %s\n\n", state.PageContentDescription)
|
||||
// }
|
||||
|
||||
historyStr += fmt.Sprintf(" URL: %s\n", stateHistory.States[len(stateHistory.States)-1].CurrentURL)
|
||||
historyStr += fmt.Sprintf(" Title: %s\n", stateHistory.States[len(stateHistory.States)-1].PageTitle)
|
||||
historyStr += fmt.Sprintf(" Description: %s\n\n", stateHistory.States[len(stateHistory.States)-1].PageContentDescription)
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Browser agent completed successfully. History:\n%s", historyStr),
|
||||
Metadata: map[string]interface{}{MetadataBrowserAgentHistory: stateHistory},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *BrowserAgentRunner) Definition() types.ActionDefinition {
|
||||
actionName := "run_browser_agent"
|
||||
if b.customActionName != "" {
|
||||
actionName = b.customActionName
|
||||
}
|
||||
description := "Run a browser agent to achieve a specific goal, for example: 'Go to https://www.google.com and search for 'LocalAI', and tell me what's on the first page'"
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(actionName),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"goal": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The goal for the browser agent to achieve",
|
||||
},
|
||||
"max_attempts": {
|
||||
Type: jsonschema.Number,
|
||||
Description: "Maximum number of attempts the agent can make (optional)",
|
||||
},
|
||||
"max_no_action_attempts": {
|
||||
Type: jsonschema.Number,
|
||||
Description: "Maximum number of attempts without taking an action (optional)",
|
||||
},
|
||||
},
|
||||
Required: []string{"goal"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *BrowserAgentRunner) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// BrowserAgentRunnerConfigMeta returns the metadata for Browser Agent Runner action configuration fields
|
||||
func BrowserAgentRunnerConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "baseURL",
|
||||
Label: "Base URL",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Base URL of the LocalOperator API",
|
||||
},
|
||||
{
|
||||
Name: "customActionName",
|
||||
Label: "Custom Action Name",
|
||||
Type: config.FieldTypeText,
|
||||
HelpText: "Custom name for this action",
|
||||
},
|
||||
{
|
||||
Name: "timeout",
|
||||
Label: "Client Timeout",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Client timeout duration (e.g. '15m', '1h'). Defaults to '15m' if not specified.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
api "github.com/mudler/LocalAGI/pkg/localoperator"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataDeepResearchResult = "deep_research_result"
|
||||
)
|
||||
|
||||
type DeepResearchRunner struct {
|
||||
baseURL, customActionName string
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
func NewDeepResearchRunner(config map[string]string, defaultURL string) *DeepResearchRunner {
|
||||
if config["baseURL"] == "" {
|
||||
config["baseURL"] = defaultURL
|
||||
}
|
||||
|
||||
timeout := "15m"
|
||||
if config["timeout"] != "" {
|
||||
timeout = config["timeout"]
|
||||
}
|
||||
|
||||
duration, err := time.ParseDuration(timeout)
|
||||
if err != nil {
|
||||
// If parsing fails, use default 15 minutes
|
||||
duration = 15 * time.Minute
|
||||
}
|
||||
|
||||
client := api.NewClient(config["baseURL"], duration)
|
||||
|
||||
return &DeepResearchRunner{
|
||||
client: client,
|
||||
baseURL: config["baseURL"],
|
||||
customActionName: config["customActionName"],
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DeepResearchRunner) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := api.DeepResearchRequest{}
|
||||
err := params.Unmarshal(&result)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to unmarshal params: %w", err)
|
||||
}
|
||||
|
||||
req := api.DeepResearchRequest{
|
||||
Topic: result.Topic,
|
||||
MaxCycles: result.MaxCycles,
|
||||
MaxNoActionAttempts: result.MaxNoActionAttempts,
|
||||
MaxResults: result.MaxResults,
|
||||
}
|
||||
|
||||
researchResult, err := d.client.RunDeepResearch(req)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to run deep research: %w", err)
|
||||
}
|
||||
|
||||
// Format the research result into a readable string
|
||||
var resultStr string
|
||||
|
||||
resultStr += "Deep research result\n"
|
||||
resultStr += fmt.Sprintf("Topic: %s\n", researchResult.Topic)
|
||||
resultStr += fmt.Sprintf("Summary: %s\n", researchResult.Summary)
|
||||
resultStr += fmt.Sprintf("Research Cycles: %d\n", researchResult.ResearchCycles)
|
||||
resultStr += fmt.Sprintf("Completion Time: %s\n\n", researchResult.CompletionTime)
|
||||
|
||||
if len(researchResult.Sources) > 0 {
|
||||
resultStr += "Sources:\n"
|
||||
for _, source := range researchResult.Sources {
|
||||
resultStr += fmt.Sprintf("- %s (%s)\n %s\n", source.Title, source.URL, source.Description)
|
||||
}
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Deep research completed successfully.\n%s", resultStr),
|
||||
Metadata: map[string]interface{}{MetadataDeepResearchResult: researchResult},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DeepResearchRunner) Definition() types.ActionDefinition {
|
||||
actionName := "run_deep_research"
|
||||
if d.customActionName != "" {
|
||||
actionName = d.customActionName
|
||||
}
|
||||
description := "Run a deep research on a specific topic, gathering information from multiple sources and providing a comprehensive summary"
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(actionName),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"topic": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The topic to research",
|
||||
},
|
||||
"max_cycles": {
|
||||
Type: jsonschema.Number,
|
||||
Description: "Maximum number of research cycles to perform (optional)",
|
||||
},
|
||||
"max_no_action_attempts": {
|
||||
Type: jsonschema.Number,
|
||||
Description: "Maximum number of attempts without taking an action (optional)",
|
||||
},
|
||||
"max_results": {
|
||||
Type: jsonschema.Number,
|
||||
Description: "Maximum number of results to collect (optional)",
|
||||
},
|
||||
},
|
||||
Required: []string{"topic"},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DeepResearchRunner) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// DeepResearchRunnerConfigMeta returns the metadata for Deep Research Runner action configuration fields
|
||||
func DeepResearchRunnerConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "baseURL",
|
||||
Label: "Base URL",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Base URL of the LocalOperator API",
|
||||
},
|
||||
{
|
||||
Name: "customActionName",
|
||||
Label: "Custom Action Name",
|
||||
Type: config.FieldTypeText,
|
||||
HelpText: "Custom name for this action",
|
||||
},
|
||||
{
|
||||
Name: "timeout",
|
||||
Label: "Client Timeout",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Client timeout duration (e.g. '15m', '1h'). Defaults to '15m' if not specified.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jung-kurt/gofpdf"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataPDFs = "pdf_paths"
|
||||
)
|
||||
|
||||
// NewGenPDF creates a new PDF generation action
|
||||
func NewGenPDF(config map[string]string) *GenPDFAction {
|
||||
a := &GenPDFAction{
|
||||
outputDir: config["outputDir"],
|
||||
cleanOnStart: config["cleanOnStart"] == "true" || config["cleanOnStart"] == "1",
|
||||
}
|
||||
|
||||
if a.outputDir != "" {
|
||||
if err := os.MkdirAll(a.outputDir, 0755); err != nil {
|
||||
xlog.Error("Failed to create output directory", "path", a.outputDir, "error", err)
|
||||
}
|
||||
if a.cleanOnStart {
|
||||
entries, err := os.ReadDir(a.outputDir)
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
_ = os.Remove(filepath.Join(a.outputDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
type GenPDFAction struct {
|
||||
outputDir string
|
||||
cleanOnStart bool
|
||||
}
|
||||
|
||||
func (a *GenPDFAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Filename string `json:"filename"`
|
||||
}{}
|
||||
if err := params.Unmarshal(&result); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
if result.Content == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("content is required")
|
||||
}
|
||||
|
||||
if a.outputDir == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("outputDir is required for generate_pdf (configure the action with an output directory)")
|
||||
}
|
||||
|
||||
// Generate filename if not provided
|
||||
filename := result.Filename
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("document_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// Clean filename to prevent path traversal
|
||||
filename = filepath.Base(filename)
|
||||
|
||||
// Ensure filename has .pdf extension
|
||||
if !strings.HasSuffix(strings.ToLower(filename), ".pdf") {
|
||||
filename = filename + ".pdf"
|
||||
}
|
||||
|
||||
// Create PDF
|
||||
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||
pdf.AddPage()
|
||||
|
||||
// Add title if provided
|
||||
if result.Title != "" {
|
||||
pdf.SetFont("Arial", "B", 16)
|
||||
pdf.MultiCell(0, 10, result.Title, "", "", false)
|
||||
pdf.Ln(5)
|
||||
}
|
||||
|
||||
// Add content
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.MultiCell(0, 10, result.Content, "", "", false)
|
||||
|
||||
// Save PDF
|
||||
savedPath := filepath.Join(a.outputDir, filename)
|
||||
if err := pdf.OutputFileAndClose(savedPath); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to save PDF: %w", err)
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("PDF generated and saved to: %s", savedPath),
|
||||
Metadata: map[string]interface{}{
|
||||
MetadataPDFs: []string{savedPath},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *GenPDFAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: "generate_pdf",
|
||||
Description: "Generate a PDF document from text content. The PDF is saved locally and can be sent to the user by connectors.",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"title": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Title of the PDF document",
|
||||
},
|
||||
"content": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Text content to include in the PDF document",
|
||||
},
|
||||
"filename": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Optional custom filename (extension is optional - .pdf will be automatically added if missing)",
|
||||
},
|
||||
},
|
||||
Required: []string{"content"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GenPDFAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// GenPDFConfigMeta returns the metadata for GenPDF action configuration fields.
|
||||
func GenPDFConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "outputDir",
|
||||
Label: "Output directory",
|
||||
Type: config.FieldTypeText,
|
||||
Required: true,
|
||||
HelpText: "Directory where generated PDF files are saved",
|
||||
},
|
||||
{
|
||||
Name: "cleanOnStart",
|
||||
Label: "Clean output directory on start",
|
||||
Type: config.FieldTypeCheckbox,
|
||||
DefaultValue: false,
|
||||
HelpText: "If enabled, clear the output directory when the action is loaded",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("GenPDFAction", func() {
|
||||
var (
|
||||
tmpDir string
|
||||
action *actions.GenPDFAction
|
||||
ctx context.Context
|
||||
sharedState *types.AgentSharedState
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "genpdf_test_*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
action = actions.NewGenPDF(map[string]string{
|
||||
"outputDir": tmpDir,
|
||||
})
|
||||
|
||||
ctx = context.Background()
|
||||
sharedState = &types.AgentSharedState{}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
It("generates PDF with title and content", func() {
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"title": "Test Document",
|
||||
"content": "This is test content for the PDF.",
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
|
||||
Expect(result.Metadata).To(HaveKey(actions.MetadataPDFs))
|
||||
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
Expect(paths).To(HaveLen(1))
|
||||
Expect(paths[0]).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("requires content parameter", func() {
|
||||
_, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"title": "Test",
|
||||
})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("content is required"))
|
||||
})
|
||||
|
||||
It("uses custom filename when provided", func() {
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": "Test content",
|
||||
"filename": "custom_name",
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
Expect(filepath.Base(paths[0])).To(Equal("custom_name.pdf"))
|
||||
})
|
||||
|
||||
It("generates PDF with content only (no title)", func() {
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": "Just some content without a title.",
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
Expect(paths).To(HaveLen(1))
|
||||
Expect(paths[0]).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("automatically adds .pdf extension if missing", func() {
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": "Test content",
|
||||
"filename": "my_document",
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
Expect(filepath.Base(paths[0])).To(Equal("my_document.pdf"))
|
||||
})
|
||||
|
||||
It("does not double-add .pdf extension", func() {
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": "Test content",
|
||||
"filename": "document.pdf",
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
Expect(filepath.Base(paths[0])).To(Equal("document.pdf"))
|
||||
})
|
||||
|
||||
It("requires outputDir to be configured", func() {
|
||||
actionNoDir := actions.NewGenPDF(map[string]string{})
|
||||
_, err := actionNoDir.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": "Test content",
|
||||
})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("outputDir is required"))
|
||||
})
|
||||
|
||||
It("cleans output directory on start if cleanOnStart is enabled", func() {
|
||||
// Create a test file in the directory
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
err := os.WriteFile(testFile, []byte("test"), 0644)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(testFile).To(BeAnExistingFile())
|
||||
|
||||
// Create a new action with cleanOnStart enabled
|
||||
_ = actions.NewGenPDF(map[string]string{
|
||||
"outputDir": tmpDir,
|
||||
"cleanOnStart": "true",
|
||||
})
|
||||
|
||||
// The test file should be deleted
|
||||
Expect(testFile).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("does not clean output directory if cleanOnStart is disabled", func() {
|
||||
// Create a test file in the directory
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
err := os.WriteFile(testFile, []byte("test"), 0644)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(testFile).To(BeAnExistingFile())
|
||||
|
||||
// Create a new action with cleanOnStart disabled (default)
|
||||
_ = actions.NewGenPDF(map[string]string{
|
||||
"outputDir": tmpDir,
|
||||
})
|
||||
|
||||
// The test file should still exist
|
||||
Expect(testFile).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("prevents path traversal in filename", func() {
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": "Test content",
|
||||
"filename": "../../../etc/passwd",
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
// Should only use the base filename, not the path
|
||||
Expect(filepath.Base(paths[0])).To(Equal("passwd.pdf"))
|
||||
// Should be in the tmpDir, not in /etc
|
||||
Expect(filepath.Dir(paths[0])).To(Equal(tmpDir))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataSongs = "songs_paths"
|
||||
)
|
||||
|
||||
// audioExtensionFromContentType returns a file extension for common audio MIME types.
|
||||
// It strips parameters (e.g. "audio/flac; rate=44100" -> "flac").
|
||||
func audioExtensionFromContentType(contentType string) string {
|
||||
mediaType, _, _ := strings.Cut(strings.TrimSpace(contentType), ";")
|
||||
mediaType = strings.TrimSpace(strings.ToLower(mediaType))
|
||||
switch mediaType {
|
||||
case "audio/flac":
|
||||
return "flac"
|
||||
case "audio/mpeg", "audio/mp3":
|
||||
return "mp3"
|
||||
case "audio/wav", "audio/wave", "audio/x-wav":
|
||||
return "wav"
|
||||
case "audio/ogg":
|
||||
return "ogg"
|
||||
case "audio/webm":
|
||||
return "webm"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// audioExtensionFromTag uses github.com/dhowden/tag to identify format from audio bytes.
|
||||
// Identify works on raw audio (e.g. FLAC without vorbis comments) and returns FileType.
|
||||
func audioExtensionFromTag(data []byte) string {
|
||||
if len(data) < 11 {
|
||||
return ""
|
||||
}
|
||||
r := bytes.NewReader(data)
|
||||
_, fileType, err := tag.Identify(r)
|
||||
if err != nil || fileType == tag.UnknownFileType {
|
||||
return ""
|
||||
}
|
||||
switch fileType {
|
||||
case tag.FLAC:
|
||||
return "flac"
|
||||
case tag.MP3:
|
||||
return "mp3"
|
||||
case tag.OGG:
|
||||
return "ogg"
|
||||
case tag.M4A:
|
||||
return "m4a"
|
||||
case tag.M4B:
|
||||
return "m4b"
|
||||
case tag.M4P:
|
||||
return "m4p"
|
||||
case tag.ALAC:
|
||||
return "m4a"
|
||||
case tag.DSF:
|
||||
return "dsf"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// soundRequest matches LocalAI /sound endpoint (ACE-Step advanced mode) request body.
|
||||
// See: https://localai.io/features/text-to-audio/
|
||||
type soundRequest struct {
|
||||
Model string `json:"model"`
|
||||
Caption string `json:"caption"`
|
||||
Lyrics string `json:"lyrics,omitempty"`
|
||||
BPM *int `json:"bpm,omitempty"`
|
||||
Keyscale string `json:"keyscale,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
DurationSeconds *float64 `json:"duration_seconds,omitempty"`
|
||||
}
|
||||
|
||||
func NewGenSong(config map[string]string) *GenSongAction {
|
||||
model := config["model"]
|
||||
if model == "" {
|
||||
model = "ace-step-turbo"
|
||||
}
|
||||
a := &GenSongAction{
|
||||
apiURL: strings.TrimSuffix(config["apiURL"], "/"),
|
||||
apiKey: config["apiKey"],
|
||||
outputDir: config["outputDir"],
|
||||
model: model,
|
||||
cleanOnStart: config["cleanOnStart"] == "true" || config["cleanOnStart"] == "1",
|
||||
}
|
||||
|
||||
if a.outputDir != "" {
|
||||
if err := os.MkdirAll(a.outputDir, 0755); err != nil {
|
||||
// log but continue; Run will fail with a clear error when saving
|
||||
_ = err
|
||||
}
|
||||
if a.cleanOnStart {
|
||||
entries, err := os.ReadDir(a.outputDir)
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
_ = os.Remove(filepath.Join(a.outputDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
type GenSongAction struct {
|
||||
apiURL string
|
||||
apiKey string
|
||||
outputDir string
|
||||
model string
|
||||
cleanOnStart bool
|
||||
}
|
||||
|
||||
func (a *GenSongAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := struct {
|
||||
Caption string `json:"caption"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
BPM *int `json:"bpm"`
|
||||
Keyscale string `json:"keyscale"`
|
||||
Language string `json:"language"`
|
||||
Duration *float64 `json:"duration_seconds"`
|
||||
Model string `json:"model"`
|
||||
}{}
|
||||
if err := params.Unmarshal(&result); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
if result.Caption == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("caption is required")
|
||||
}
|
||||
|
||||
if a.outputDir == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("outputDir is required for generate_song (configure the action with an output directory)")
|
||||
}
|
||||
|
||||
reqBody := soundRequest{
|
||||
Model: a.model,
|
||||
Caption: result.Caption,
|
||||
Lyrics: result.Lyrics,
|
||||
Keyscale: result.Keyscale,
|
||||
Language: result.Language,
|
||||
DurationSeconds: result.Duration,
|
||||
BPM: result.BPM,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
url := a.apiURL + "/v1/sound-generation"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if a.apiKey != "" {
|
||||
req.Header.Set("xi-api-key", a.apiKey)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return types.ActionResult{Result: "Failed to generate song: " + err.Error()}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
msg, _ := io.ReadAll(resp.Body)
|
||||
return types.ActionResult{}, fmt.Errorf("sound endpoint failed: %s: %s", resp.Status, string(msg))
|
||||
}
|
||||
|
||||
audioBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
if len(audioBytes) == 0 {
|
||||
return types.ActionResult{}, fmt.Errorf("no audio data returned")
|
||||
}
|
||||
|
||||
ext := audioExtensionFromContentType(resp.Header.Get("Content-Type"))
|
||||
if ext == "" {
|
||||
ext = audioExtensionFromTag(audioBytes)
|
||||
}
|
||||
if ext == "" {
|
||||
ext = "flac" // default when unknown (e.g. ACE-Step)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("song_%d.%s", time.Now().UnixNano(), ext)
|
||||
savedPath := filepath.Join(a.outputDir, filename)
|
||||
if err := os.WriteFile(savedPath, audioBytes, 0644); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to save song: %w", err)
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("The song was generated and saved to: %s", savedPath),
|
||||
Metadata: map[string]interface{}{
|
||||
MetadataSongs: []string{savedPath},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *GenSongAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: "generate_song",
|
||||
Description: "Generate a song or music track using LocalAI /sound endpoint (ACE-Step advanced mode). Uses caption, optional lyrics, BPM, key scale, language and duration. The file is saved locally and can be sent to the user by connectors.",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"caption": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Description of the song or music to generate (e.g. 'A funky Japanese disco track').",
|
||||
},
|
||||
"lyrics": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Lyrics or structure (e.g. '[Verse 1]\\n...').",
|
||||
},
|
||||
"bpm": {
|
||||
Type: jsonschema.Integer,
|
||||
Description: "Beats per minute (e.g. 120). Optional.",
|
||||
},
|
||||
"keyscale": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Key and scale (e.g. 'Ab major'). Optional.",
|
||||
},
|
||||
"language": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Language code for vocals (e.g. 'ja', 'en'). Optional.",
|
||||
},
|
||||
"duration_seconds": {
|
||||
Type: jsonschema.Number,
|
||||
Description: "Duration of the generated audio in seconds (e.g. 225). Optional.",
|
||||
},
|
||||
"model": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Model name (e.g. ace-step-turbo). Optional; uses action config default if omitted.",
|
||||
},
|
||||
},
|
||||
Required: []string{"caption"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GenSongAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// GenSongConfigMeta returns the metadata for GenSong action configuration fields.
|
||||
func GenSongConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "apiURL",
|
||||
Label: "API URL",
|
||||
Type: config.FieldTypeText,
|
||||
Required: true,
|
||||
DefaultValue: "http://localhost:8080",
|
||||
HelpText: "LocalAI base URL (e.g. http://localhost:8080) for /sound endpoint",
|
||||
},
|
||||
{
|
||||
Name: "model",
|
||||
Label: "Model",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
DefaultValue: "ace-step-turbo",
|
||||
HelpText: "Default model for sound generation (e.g. ace-step-turbo)",
|
||||
},
|
||||
{
|
||||
Name: "apiKey",
|
||||
Label: "API Key",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Optional API key if the endpoint requires authentication",
|
||||
},
|
||||
{
|
||||
Name: "outputDir",
|
||||
Label: "Output directory",
|
||||
Type: config.FieldTypeText,
|
||||
Required: true,
|
||||
HelpText: "Directory where generated song files are saved (required for connectors to send files)",
|
||||
},
|
||||
{
|
||||
Name: "cleanOnStart",
|
||||
Label: "Clean output directory on start",
|
||||
Type: config.FieldTypeCheckbox,
|
||||
DefaultValue: false,
|
||||
HelpText: "If enabled, clear the output directory when the action is loaded",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/google/go-github/v69/github"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/google/go-github/v69/github"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/google/go-github/v69/github"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/google/go-github/v69/github"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// indexCache avoids opening the same Bleve index path multiple times, which would
|
||||
// deadlock (Bleve uses file locks; a second Open() on the same path blocks).
|
||||
var (
|
||||
indexCache = map[string]bleve.Index{}
|
||||
indexCacheMu sync.Mutex
|
||||
)
|
||||
|
||||
type MemoryActions struct {
|
||||
index bleve.Index
|
||||
indexPath string
|
||||
customName string
|
||||
customDescription string
|
||||
}
|
||||
|
||||
type AddToMemoryAction struct{ *MemoryActions }
|
||||
type ListMemoryAction struct{ *MemoryActions }
|
||||
type RemoveFromMemoryAction struct{ *MemoryActions }
|
||||
type SearchMemoryAction struct{ *MemoryActions }
|
||||
|
||||
// MemoryEntry matches the MCP memory structure (Bleve-backed).
|
||||
type MemoryEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// NewMemoryActions returns the four memory actions (Add, List, Remove, Search) using a Bleve index at indexPath.
|
||||
func NewMemoryActions(indexPath string, config map[string]string) (*AddToMemoryAction, *ListMemoryAction, *RemoveFromMemoryAction, *SearchMemoryAction) {
|
||||
ma := &MemoryActions{indexPath: indexPath}
|
||||
if config != nil {
|
||||
ma.customName = config["custom_name"]
|
||||
ma.customDescription = config["custom_description"]
|
||||
}
|
||||
idx, err := openOrCreateBleveIndex(indexPath)
|
||||
if err != nil {
|
||||
// Allow lazy init: index will be nil and operations will return this error
|
||||
ma.index = nil
|
||||
} else {
|
||||
ma.index = idx
|
||||
}
|
||||
return &AddToMemoryAction{ma}, &ListMemoryAction{ma}, &RemoveFromMemoryAction{ma}, &SearchMemoryAction{ma}
|
||||
}
|
||||
|
||||
func openOrCreateBleveIndex(indexPath string) (bleve.Index, error) {
|
||||
indexCacheMu.Lock()
|
||||
if idx, ok := indexCache[indexPath]; ok {
|
||||
indexCacheMu.Unlock()
|
||||
return idx, nil
|
||||
}
|
||||
indexCacheMu.Unlock()
|
||||
|
||||
var idx bleve.Index
|
||||
var err error
|
||||
if _, statErr := os.Stat(indexPath); statErr == nil {
|
||||
idx, err = bleve.Open(indexPath)
|
||||
} else {
|
||||
os.MkdirAll(filepath.Dir(indexPath), 0755)
|
||||
mapping := bleve.NewIndexMapping()
|
||||
entryMapping := bleve.NewDocumentMapping()
|
||||
|
||||
nameFieldMapping := bleve.NewTextFieldMapping()
|
||||
nameFieldMapping.Analyzer = "standard"
|
||||
nameFieldMapping.Store = true
|
||||
entryMapping.AddFieldMappingsAt("name", nameFieldMapping)
|
||||
|
||||
contentFieldMapping := bleve.NewTextFieldMapping()
|
||||
contentFieldMapping.Analyzer = "standard"
|
||||
contentFieldMapping.Store = true
|
||||
entryMapping.AddFieldMappingsAt("content", contentFieldMapping)
|
||||
|
||||
dateFieldMapping := bleve.NewDateTimeFieldMapping()
|
||||
dateFieldMapping.Store = true
|
||||
entryMapping.AddFieldMappingsAt("created_at", dateFieldMapping)
|
||||
|
||||
mapping.AddDocumentMapping("_default", entryMapping)
|
||||
idx, err = bleve.New(indexPath, mapping)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
indexCacheMu.Lock()
|
||||
indexCache[indexPath] = idx
|
||||
indexCacheMu.Unlock()
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (m *MemoryActions) ensureIndex() error {
|
||||
if m.index != nil {
|
||||
return nil
|
||||
}
|
||||
idx, err := openOrCreateBleveIndex(m.indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.index = idx
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
type addToMemoryParams struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type removeFromMemoryParams struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type searchMemoryParams struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
func (a *AddToMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if err := a.ensureIndex(); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
var req addToMemoryParams
|
||||
if err := params.Unmarshal(&req); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
if req.Name == "" && req.Content == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("name or content cannot both be empty")
|
||||
}
|
||||
entry := MemoryEntry{
|
||||
ID: generateID(),
|
||||
Name: req.Name,
|
||||
Content: req.Content,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := a.index.Index(entry.ID, entry); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to index memory entry: %w", err)
|
||||
}
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Added memory entry: id=%s name=%q", entry.ID, entry.Name),
|
||||
Metadata: map[string]any{"id": entry.ID, "name": entry.Name, "content": entry.Content, "created_at": entry.CreatedAt},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ListMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if err := a.ensureIndex(); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
query := bleve.NewMatchAllQuery()
|
||||
searchRequest := bleve.NewSearchRequest(query)
|
||||
searchRequest.Size = 10000
|
||||
searchRequest.Fields = []string{"name", "created_at"}
|
||||
searchRequest.SortBy([]string{"-created_at"})
|
||||
|
||||
searchResult, err := a.index.Search(searchRequest)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to search index: %w", err)
|
||||
}
|
||||
|
||||
type listEntry struct {
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
entries := make([]listEntry, 0, len(searchResult.Hits))
|
||||
for _, hit := range searchResult.Hits {
|
||||
e := listEntry{}
|
||||
if v, ok := hit.Fields["name"].(string); ok {
|
||||
e.Name = v
|
||||
}
|
||||
if v, ok := hit.Fields["created_at"].(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
e.CreatedAt = t
|
||||
}
|
||||
} else if v, ok := hit.Fields["created_at"].(time.Time); ok {
|
||||
e.CreatedAt = v
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
outputResult := "Number of items in memory: " + strconv.Itoa(len(entries)) + "\n"
|
||||
for i, e := range entries {
|
||||
createdStr := e.CreatedAt.Format(time.RFC3339)
|
||||
outputResult += fmt.Sprintf("%d) %s (created_at: %s)\n", i, e.Name, createdStr)
|
||||
}
|
||||
|
||||
names := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
names[i] = e.Name
|
||||
}
|
||||
return types.ActionResult{
|
||||
Result: outputResult,
|
||||
Metadata: map[string]any{"names": names, "entries": entries, "count": len(entries)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *RemoveFromMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if err := a.ensureIndex(); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
var req removeFromMemoryParams
|
||||
if err := params.Unmarshal(&req); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
if req.ID == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("id is required to remove a memory entry")
|
||||
}
|
||||
doc, err := a.index.Document(req.ID)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to check document: %w", err)
|
||||
}
|
||||
if doc == nil {
|
||||
return types.ActionResult{}, fmt.Errorf("memory entry with ID %q not found", req.ID)
|
||||
}
|
||||
if err := a.index.Delete(req.ID); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to delete memory entry: %w", err)
|
||||
}
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Removed memory entry with ID %q", req.ID),
|
||||
Metadata: map[string]any{"removed_id": req.ID},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *SearchMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if err := a.ensureIndex(); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
var req searchMemoryParams
|
||||
if err := params.Unmarshal(&req); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
if req.Query == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("query cannot be empty")
|
||||
}
|
||||
nameQuery := bleve.NewMatchQuery(req.Query)
|
||||
nameQuery.SetField("name")
|
||||
contentQuery := bleve.NewMatchQuery(req.Query)
|
||||
contentQuery.SetField("content")
|
||||
disjunctionQuery := bleve.NewDisjunctionQuery(nameQuery, contentQuery)
|
||||
|
||||
searchRequest := bleve.NewSearchRequest(disjunctionQuery)
|
||||
searchRequest.Size = 100
|
||||
searchRequest.Fields = []string{"name", "content", "created_at"}
|
||||
|
||||
searchResult, err := a.index.Search(searchRequest)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to search index: %w", err)
|
||||
}
|
||||
|
||||
results := make([]MemoryEntry, 0, len(searchResult.Hits))
|
||||
for _, hit := range searchResult.Hits {
|
||||
e := MemoryEntry{ID: hit.ID}
|
||||
if v, ok := hit.Fields["name"].(string); ok {
|
||||
e.Name = v
|
||||
}
|
||||
if v, ok := hit.Fields["content"].(string); ok {
|
||||
e.Content = v
|
||||
}
|
||||
if v, ok := hit.Fields["created_at"].(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
e.CreatedAt = t
|
||||
}
|
||||
} else if v, ok := hit.Fields["created_at"].(time.Time); ok {
|
||||
e.CreatedAt = v
|
||||
}
|
||||
results = append(results, e)
|
||||
}
|
||||
|
||||
outputResult := fmt.Sprintf("Query: %q — %d result(s)\n", req.Query, len(results))
|
||||
for i, e := range results {
|
||||
outputResult += fmt.Sprintf("%d) [%s] %s — %s\n", i, e.ID, e.Name, e.Content)
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: outputResult,
|
||||
Metadata: map[string]any{"query": req.Query, "results": results, "count": len(results)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AddToMemoryAction) Definition() types.ActionDefinition {
|
||||
name := "add_to_memory"
|
||||
description := "Add a new entry to memory storage (name and/or content). Stored in a Bleve index."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
if a.customDescription != "" {
|
||||
description = a.customDescription
|
||||
}
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"name": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The name/title of the memory entry.",
|
||||
},
|
||||
"content": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The content to store in memory.",
|
||||
},
|
||||
},
|
||||
Required: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ListMemoryAction) Definition() types.ActionDefinition {
|
||||
name := "list_memory"
|
||||
description := "List all memory entry names."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
if a.customDescription != "" {
|
||||
description = a.customDescription
|
||||
}
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{},
|
||||
Required: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *RemoveFromMemoryAction) Definition() types.ActionDefinition {
|
||||
name := "remove_from_memory"
|
||||
description := "Remove a memory entry by ID."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
if a.customDescription != "" {
|
||||
description = a.customDescription
|
||||
}
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"id": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The ID of the memory entry to remove.",
|
||||
},
|
||||
},
|
||||
Required: []string{"id"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *SearchMemoryAction) Definition() types.ActionDefinition {
|
||||
name := "search_memory"
|
||||
description := "Search memory entries by name and content using full-text search."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
if a.customDescription != "" {
|
||||
description = a.customDescription
|
||||
}
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"query": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The search query to find matching memory entries.",
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AddToMemoryAction) Plannable() bool { return true }
|
||||
func (a *ListMemoryAction) Plannable() bool { return true }
|
||||
func (a *RemoveFromMemoryAction) Plannable() bool { return true }
|
||||
func (a *SearchMemoryAction) Plannable() bool { return true }
|
||||
|
||||
// AddToMemoryConfigMeta returns the metadata for AddToMemory action configuration fields
|
||||
func AddToMemoryConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "custom_name",
|
||||
Label: "Custom Name",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom name for the action (optional, defaults to 'add_to_memory')",
|
||||
},
|
||||
{
|
||||
Name: "custom_description",
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for the action (optional)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ListMemoryConfigMeta returns the metadata for ListMemory action configuration fields
|
||||
func ListMemoryConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "custom_name",
|
||||
Label: "Custom Name",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom name for the action (optional, defaults to 'list_memory')",
|
||||
},
|
||||
{
|
||||
Name: "custom_description",
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for the action (optional)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveFromMemoryConfigMeta returns the metadata for RemoveFromMemory action configuration fields
|
||||
func RemoveFromMemoryConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "custom_name",
|
||||
Label: "Custom Name",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom name for the action (optional, defaults to 'remove_from_memory')",
|
||||
},
|
||||
{
|
||||
Name: "custom_description",
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for the action (optional)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SearchMemoryConfigMeta returns the metadata for SearchMemory action configuration fields
|
||||
func SearchMemoryConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "custom_name",
|
||||
Label: "Custom Name",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom name for the action (optional, defaults to 'search_memory')",
|
||||
},
|
||||
{
|
||||
Name: "custom_description",
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for the action (optional)",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MemoryActions", func() {
|
||||
var (
|
||||
tmpDir string
|
||||
indexPath string
|
||||
aAdd *actions.AddToMemoryAction
|
||||
aList *actions.ListMemoryAction
|
||||
aRemove *actions.RemoveFromMemoryAction
|
||||
aSearch *actions.SearchMemoryAction
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "memory_test_*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
indexPath = filepath.Join(tmpDir, "memory.bleve")
|
||||
aAdd, aList, aRemove, aSearch = actions.NewMemoryActions(indexPath, map[string]string{})
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
It("adds and lists entries by name", func() {
|
||||
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "baz", "content": "qux"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
res, err := aList.Run(context.TODO(), nil, types.ActionParams{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Metadata["names"]).To(ContainElements("foo", "baz"))
|
||||
Expect(res.Metadata["count"]).To(Equal(2))
|
||||
})
|
||||
|
||||
It("removes by id", func() {
|
||||
addRes, _ := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
|
||||
id, ok := addRes.Metadata["id"].(string)
|
||||
Expect(ok).To(BeTrue())
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "baz", "content": "qux"})
|
||||
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"id": id})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
res, _ := aList.Run(context.TODO(), nil, types.ActionParams{})
|
||||
Expect(res.Metadata["names"]).To(ConsistOf("baz"))
|
||||
})
|
||||
|
||||
It("returns error for missing id on remove", func() {
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
|
||||
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for unknown id on remove", func() {
|
||||
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"id": "nonexistent"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for empty name and content on add", func() {
|
||||
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "", "content": ""})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("search returns matching entries", func() {
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "meeting", "content": "discussed project X"})
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "lunch", "content": "ate pizza"})
|
||||
res, err := aSearch.Run(context.TODO(), nil, types.ActionParams{"query": "project"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Metadata["count"]).To(Equal(1))
|
||||
results, ok := res.Metadata["results"].([]actions.MemoryEntry)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Name).To(Equal("meeting"))
|
||||
Expect(results[0].Content).To(Equal("discussed project X"))
|
||||
})
|
||||
|
||||
It("search returns error for empty query", func() {
|
||||
_, err := aSearch.Run(context.TODO(), nil, types.ActionParams{"query": ""})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
func NewPiKVMAction(config map[string]string) *PiKVMAction {
|
||||
return &PiKVMAction{
|
||||
hostname: config["hostname"],
|
||||
username: config["username"],
|
||||
password: config["password"],
|
||||
customName: config["custom_name"],
|
||||
customDescription: config["custom_description"],
|
||||
insecure: config["insecure"] == "true",
|
||||
}
|
||||
}
|
||||
|
||||
type PiKVMAction struct {
|
||||
hostname string
|
||||
username string
|
||||
password string
|
||||
customName string
|
||||
customDescription string
|
||||
insecure bool
|
||||
}
|
||||
|
||||
type pikvmPowerParams struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
func (a *PiKVMAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
var req pikvmPowerParams
|
||||
if err := params.Unmarshal(&req); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
|
||||
// Validate action parameter
|
||||
validActions := map[string]bool{
|
||||
"on": true,
|
||||
"off": true,
|
||||
"off_hard": true,
|
||||
"reset_hard": true,
|
||||
}
|
||||
if !validActions[req.Action] {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid action: %s. Valid actions are: on, off, off_hard, reset_hard", req.Action)
|
||||
}
|
||||
|
||||
// Check if required config is provided
|
||||
if a.hostname == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("hostname is required in action configuration")
|
||||
}
|
||||
if a.username == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("username is required in action configuration")
|
||||
}
|
||||
if a.password == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("password is required in action configuration")
|
||||
}
|
||||
|
||||
// Build the API URL
|
||||
apiURL := fmt.Sprintf("https://%s/api/atx/power", a.hostname)
|
||||
|
||||
insecure := false
|
||||
if a.insecure {
|
||||
insecure = true
|
||||
}
|
||||
// Create HTTP client with basic auth
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
|
||||
}
|
||||
client := &http.Client{Transport: tr}
|
||||
|
||||
reqHTTP, err := http.NewRequestWithContext(ctx, "POST", apiURL, nil)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
// Set basic authentication
|
||||
reqHTTP.SetBasicAuth(a.username, a.password)
|
||||
|
||||
// Add query parameters
|
||||
q := reqHTTP.URL.Query()
|
||||
q.Add("action", req.Action)
|
||||
reqHTTP.URL.RawQuery = q.Encode()
|
||||
|
||||
// Make the request
|
||||
resp, err := client.Do(reqHTTP)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to make HTTP request to PiKVM: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return types.ActionResult{}, fmt.Errorf("PiKVM API returned status %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// Determine action description for user-friendly response
|
||||
actionDesc := map[string]string{
|
||||
"on": "power on",
|
||||
"off": "power off",
|
||||
"off_hard": "hard power off",
|
||||
"reset_hard": "hard reset",
|
||||
}
|
||||
|
||||
result := fmt.Sprintf("Successfully sent %s command to PiKVM at %s", actionDesc[req.Action], a.hostname)
|
||||
|
||||
return types.ActionResult{
|
||||
Result: result,
|
||||
Metadata: map[string]any{
|
||||
"action": req.Action,
|
||||
"hostname": a.hostname,
|
||||
"status": "success",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *PiKVMAction) Definition() types.ActionDefinition {
|
||||
name := "pikvm_power_control"
|
||||
description := "Control power state of a PiKVM device using ATX power management."
|
||||
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
if a.customDescription != "" {
|
||||
description = a.customDescription
|
||||
}
|
||||
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"action": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The power action to perform on the PiKVM device.",
|
||||
Enum: []string{"on", "off", "off_hard", "reset_hard"},
|
||||
},
|
||||
},
|
||||
Required: []string{"action"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *PiKVMAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// PiKVMConfigMeta returns the metadata for PiKVM action configuration fields
|
||||
func PiKVMConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "hostname",
|
||||
Label: "PiKVM Hostname",
|
||||
Type: config.FieldTypeText,
|
||||
Required: true,
|
||||
HelpText: "The hostname or IP address of the PiKVM device (e.g., pikvm.local or 192.168.1.100)",
|
||||
},
|
||||
{
|
||||
Name: "username",
|
||||
Label: "Username",
|
||||
Type: config.FieldTypeText,
|
||||
Required: true,
|
||||
HelpText: "Username for PiKVM authentication (usually 'admin')",
|
||||
},
|
||||
{
|
||||
Name: "password",
|
||||
Label: "Password",
|
||||
Type: config.FieldTypeText,
|
||||
Required: true,
|
||||
HelpText: "Password for PiKVM authentication",
|
||||
},
|
||||
{
|
||||
Name: "custom_name",
|
||||
Label: "Custom Action Name",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom name for this action (optional, defaults to 'pikvm_power_control')",
|
||||
},
|
||||
{
|
||||
Name: "custom_description",
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for this action (optional)",
|
||||
},
|
||||
{
|
||||
Name: "insecure",
|
||||
Label: "Insecure",
|
||||
Type: config.FieldTypeCheckbox,
|
||||
Required: false,
|
||||
HelpText: "Skip certificate verification (optional)",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Package actions contains action implementations used by LocalAGI.
|
||||
// This file implements the "webhook" action which can send an HTTP request
|
||||
// to an external service with a configurable method, content type, and payload.
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// NewWebhook constructs a WebhookAction using provided configuration values:
|
||||
// - url: Destination endpoint for the HTTP request (required).
|
||||
// - method: HTTP method to use (GET, POST, PUT, DELETE, ...). Defaults to POST.
|
||||
// - contentType: Value for the Content-Type header (e.g., application/json).
|
||||
// - payloadTemplate: Optional template for the request body; the runtime parameter
|
||||
// "payload" (if provided) will replace the "{{payload}}" placeholder inside this template.
|
||||
func NewWebhook(cfg map[string]string) *WebhookAction {
|
||||
wa := &WebhookAction{
|
||||
url: strings.TrimSpace(cfg["url"]),
|
||||
method: strings.ToUpper(strings.TrimSpace(cfg["method"])),
|
||||
contentType: strings.TrimSpace(cfg["contentType"]),
|
||||
payloadTemplate: cfg["payloadTemplate"],
|
||||
}
|
||||
// Optional custom overrides
|
||||
if cfg != nil {
|
||||
wa.customName = cfg["custom_name"]
|
||||
wa.customDescription = cfg["custom_description"]
|
||||
wa.customPayloadDescription = cfg["custom_payload_description"]
|
||||
}
|
||||
if wa.method == "" {
|
||||
wa.method = http.MethodPost
|
||||
}
|
||||
return wa
|
||||
}
|
||||
|
||||
// WebhookAction holds the static configuration for the webhook.
|
||||
// These values come from the action configuration (UI/agent config),
|
||||
// while the runtime parameter only carries the dynamic payload.
|
||||
// - url: Target endpoint for the request.
|
||||
// - method: HTTP method to use. Defaults to POST if not provided.
|
||||
// - contentType: Sets the Content-Type header when a body is sent.
|
||||
// - payloadTemplate: Optional template used to build the request body; occurrences
|
||||
// of "{{payload}}" get replaced with the runtime payload string.
|
||||
// If no placeholder is present, the template is used as-is.
|
||||
// For GET requests the body is omitted regardless of payload.
|
||||
//
|
||||
// Note: This action does not follow redirects!
|
||||
type WebhookAction struct {
|
||||
url string
|
||||
method string
|
||||
contentType string
|
||||
payloadTemplate string
|
||||
customName string
|
||||
customDescription string
|
||||
customPayloadDescription string
|
||||
}
|
||||
|
||||
// Run executes the webhook call.
|
||||
// It reads the runtime parameter "payload" (optional), merges it into the
|
||||
// configured payloadTemplate (if any), constructs an HTTP request using the
|
||||
// configured URL, method and content type, and then returns a summary with the
|
||||
// response status and body (truncated to 4KiB for safety).
|
||||
func (a *WebhookAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
// Runtime parameters: only payload
|
||||
type input struct {
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
var in input
|
||||
if err := params.Unmarshal(&in); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
// Validate essential configuration. The URL must be provided via the
|
||||
// action configuration (not via runtime parameters).
|
||||
if a.url == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("configuration.url is required")
|
||||
}
|
||||
|
||||
method := a.method
|
||||
|
||||
// Build the request body based on template and payload:
|
||||
// - If a payloadTemplate is provided, replace occurrences of "{{payload}}"
|
||||
// with the runtime payload value.
|
||||
// - If the template does not contain the placeholder but is provided, we use
|
||||
// the template as-is (common for static JSON bodies prepared at config time).
|
||||
// - If no template is configured, we send the runtime payload as-is.
|
||||
// - For GET requests the body is omitted regardless of payload.
|
||||
var payload string
|
||||
if a.payloadTemplate != "" {
|
||||
payload = strings.ReplaceAll(a.payloadTemplate, "{{payload}}", in.Payload)
|
||||
if payload == a.payloadTemplate && in.Payload != "" {
|
||||
// If no placeholder found, fallback to template or payload alone
|
||||
payload = a.payloadTemplate
|
||||
}
|
||||
} else {
|
||||
payload = in.Payload
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
if method != http.MethodGet && payload != "" {
|
||||
body = bytes.NewBufferString(payload)
|
||||
}
|
||||
|
||||
// Create the HTTP request bound to the provided context so that cancellation
|
||||
// or timeouts from the caller propagate to the outbound call.
|
||||
req, err := http.NewRequestWithContext(ctx, method, a.url, body)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
// Set Content-Type header if configured. For GET requests this header is
|
||||
// typically ignored by servers as there is no body.
|
||||
if a.contentType != "" {
|
||||
req.Header.Set("Content-Type", a.contentType)
|
||||
}
|
||||
|
||||
// Use a new http.Client with default settings. Consider configuring timeouts
|
||||
// at the caller level via the context, or wiring a custom client if needed.
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read and safely truncate the response body to avoid flooding the agent's
|
||||
// context with very large payloads. Errors on ReadAll are ignored here as
|
||||
// we already have the status code.
|
||||
respBytes, _ := io.ReadAll(resp.Body)
|
||||
respBody := string(respBytes)
|
||||
if len(respBody) > 4096 {
|
||||
respBody = respBody[:4096] + "... (truncated)"
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
// Return the response body as the result.
|
||||
// If the response body is empty, use the status text as the result (e.g. "OK" for status code 200).
|
||||
Result: func() string {
|
||||
if respBody == "" {
|
||||
return http.StatusText(resp.StatusCode)
|
||||
}
|
||||
return respBody
|
||||
}(),
|
||||
// Include the response status code in the metadata.
|
||||
Metadata: map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Definition returns the action schema exposed to the planner/runtime.
|
||||
// Only the runtime parameter "payload" is accepted; all connection details
|
||||
// are configured statically via the action configuration UI.
|
||||
func (a *WebhookAction) Definition() types.ActionDefinition {
|
||||
name := "webhook"
|
||||
description := "Send an HTTP request to a configured URL/method/content-type. Accepts a runtime payload parameter optionally inserted into the configured payload template."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
if a.customDescription != "" {
|
||||
description = a.customDescription
|
||||
}
|
||||
payloadDesc := "Payload/body to send with the request at runtime. If a payloadTemplate is configured, '{{payload}}' will be replaced by this value."
|
||||
if a.customPayloadDescription != "" {
|
||||
payloadDesc = a.customPayloadDescription
|
||||
}
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"payload": {
|
||||
Type: jsonschema.String,
|
||||
Description: payloadDesc,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Plannable indicates the action can be suggested/used by planners without
|
||||
// requiring hidden context; inputs are straightforward and safe.
|
||||
func (a *WebhookAction) Plannable() bool { return true }
|
||||
|
||||
// WebhookConfigMeta returns the metadata for Webhook action configuration fields:
|
||||
// - url: The endpoint to send requests to (required).
|
||||
// - method: One of GET/POST/PUT/DELETE. Defaults to POST.
|
||||
// - contentType: Common content types selectable from a dropdown.
|
||||
// - payloadTemplate: Optional body template. At runtime, "{{payload}}" is
|
||||
// replaced by the provided payload parameter. If missing, the template is used
|
||||
// as-is; for GET, no body is sent regardless.
|
||||
func WebhookConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "custom_name",
|
||||
Label: "Custom Name",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom name for the action (optional, defaults to 'webhook')",
|
||||
},
|
||||
{
|
||||
Name: "custom_description",
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for the action (optional)",
|
||||
},
|
||||
{
|
||||
Name: "custom_payload_description",
|
||||
Label: "Custom Payload Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Override the payload parameter description shown in the UI/schema (optional).",
|
||||
},
|
||||
{
|
||||
Name: "url",
|
||||
Label: "URL",
|
||||
Type: config.FieldTypeText,
|
||||
Required: true,
|
||||
HelpText: "Destination URL for the webhook",
|
||||
},
|
||||
{
|
||||
Name: "method",
|
||||
Label: "HTTP Method",
|
||||
Type: config.FieldTypeSelect,
|
||||
Options: []config.FieldOption{{Value: http.MethodGet, Label: "GET"}, {Value: http.MethodPost, Label: "POST"}, {Value: http.MethodPut, Label: "PUT"}, {Value: http.MethodDelete, Label: "DELETE"}},
|
||||
DefaultValue: http.MethodPost,
|
||||
Required: true,
|
||||
HelpText: "HTTP method to use",
|
||||
},
|
||||
{
|
||||
Name: "contentType",
|
||||
Label: "Content Type",
|
||||
Type: config.FieldTypeSelect,
|
||||
Options: []config.FieldOption{
|
||||
{Value: "application/json", Label: "application/json"},
|
||||
{Value: "text/plain", Label: "text/plain"},
|
||||
{Value: "application/x-www-form-urlencoded", Label: "application/x-www-form-urlencoded"},
|
||||
},
|
||||
Required: true,
|
||||
HelpText: "Content-Type header to send",
|
||||
},
|
||||
{
|
||||
Name: "payloadTemplate",
|
||||
Label: "Payload Template",
|
||||
Type: config.FieldTypeTextarea,
|
||||
HelpText: "Optional template used to craft the request body. Use '{{payload}}' as placeholder for the runtime payload.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
func memoryPath(agentName string, actionsConfigs map[string]string) string {
|
||||
// Compose memory file path based on stateDir and agentName, using a subdirectory
|
||||
memoryFilePath := "memory.json"
|
||||
if actionsConfigs != nil {
|
||||
if stateDir, ok := actionsConfigs[ConfigStateDir]; ok && stateDir != "" {
|
||||
memoryDir := fmt.Sprintf("%s/memory", stateDir)
|
||||
err := os.MkdirAll(memoryDir, 0755) // ensure the directory exists
|
||||
if err != nil {
|
||||
xlog.Error("Error creating memory directory", "error", err)
|
||||
return memoryFilePath
|
||||
}
|
||||
memoryFilePath = fmt.Sprintf("%s/%s.json", memoryDir, agentName)
|
||||
} else {
|
||||
memoryFilePath = fmt.Sprintf("%s.memory.json", agentName)
|
||||
}
|
||||
}
|
||||
|
||||
return memoryFilePath
|
||||
}
|
||||
|
||||
// memoryIndexPath returns the directory path for the Bleve index (used by memory actions).
|
||||
func memoryIndexPath(agentName string, actionsConfigs map[string]string) string {
|
||||
indexPath := "memory.bleve"
|
||||
if actionsConfigs != nil {
|
||||
if stateDir, ok := actionsConfigs[ConfigStateDir]; ok && stateDir != "" {
|
||||
memoryDir := fmt.Sprintf("%s/memory", stateDir)
|
||||
if err := os.MkdirAll(memoryDir, 0755); err != nil {
|
||||
xlog.Error("Error creating memory directory", "error", err)
|
||||
return indexPath
|
||||
}
|
||||
indexPath = filepath.Join(memoryDir, agentName+".bleve")
|
||||
} else {
|
||||
indexPath = agentName + ".memory.bleve"
|
||||
}
|
||||
}
|
||||
if dir := filepath.Dir(indexPath); dir != "." {
|
||||
os.MkdirAll(dir, 0755)
|
||||
}
|
||||
return indexPath
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services/connectors"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -85,11 +85,11 @@ func (d *Discord) Start(a *agent.Agent) {
|
||||
|
||||
if d.defaultChannel != "" {
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(discord)", "message", ccm.Content)
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(discord)", "message", ccm.Message.Content)
|
||||
|
||||
// Send the message to the default channel
|
||||
_, err := dg.ChannelMessageSend(d.defaultChannel, ccm.Content)
|
||||
_, err := dg.ChannelMessageSend(d.defaultChannel, ccm.Message.Content)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error sending message: %v", err))
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func (d *Discord) Start(a *agent.Agent) {
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("discord:%s", d.defaultChannel),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -377,14 +377,14 @@ func (e *Email) Start(a *agent.Agent) {
|
||||
go func() {
|
||||
if e.defaultEmail != "" {
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(email)", "message", ccm.Content)
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(email)", "message", ccm.Message.Content)
|
||||
|
||||
// Send the message to the default email
|
||||
e.sendMail(
|
||||
e.defaultEmail,
|
||||
"Message from LocalAGI",
|
||||
ccm.Content,
|
||||
ccm.Message.Content,
|
||||
"",
|
||||
"",
|
||||
[]string{e.defaultEmail},
|
||||
@@ -394,7 +394,7 @@ func (e *Email) Start(a *agent.Agent) {
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("email:%s", e.defaultEmail),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
irc "github.com/thoj/go-ircevent"
|
||||
)
|
||||
@@ -73,12 +73,12 @@ func (i *IRC) Start(a *agent.Agent) {
|
||||
|
||||
if i.channel != "" {
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(irc)", "message", ccm.Content)
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(irc)", "message", ccm.Message.Content)
|
||||
|
||||
// Split the response into multiple messages if it's too long
|
||||
maxLength := 400 // Safe limit for most IRC servers
|
||||
response := ccm.Content
|
||||
response := ccm.Message.Content
|
||||
|
||||
// Handle multiline responses
|
||||
lines := strings.Split(response, "\n")
|
||||
@@ -109,7 +109,7 @@ func (i *IRC) Start(a *agent.Agent) {
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("irc:%s", i.channel),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
@@ -225,16 +225,16 @@ func (m *Matrix) Start(a *agent.Agent) {
|
||||
|
||||
if m.roomID != "" {
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(matrix)", "message", ccm.Content)
|
||||
_, err := m.client.SendText(context.Background(), id.RoomID(m.roomID), ccm.Content)
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(matrix)", "message", ccm.Message.Content)
|
||||
_, err := m.client.SendText(context.Background(), id.RoomID(m.roomID), ccm.Message.Content)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
|
||||
}
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("matrix:%s", m.roomID),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
+239
-173
@@ -6,14 +6,14 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/localoperator"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xstrings"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
@@ -152,34 +152,34 @@ func replaceUserIDsWithNamesInMessage(api *slack.Client, message string) string
|
||||
func generateAttachmentsFromJobResponse(j *types.JobResult, api *slack.Client, channelID, ts string) (attachments []slack.Attachment) {
|
||||
for _, state := range j.State {
|
||||
// coming from the browser agent
|
||||
if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
|
||||
if historyStruct, ok := history.(*localoperator.StateHistory); ok {
|
||||
state := historyStruct.States[len(historyStruct.States)-1]
|
||||
// Decode base64 screenshot and upload to Slack
|
||||
if state.Screenshot != "" {
|
||||
screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error decoding screenshot: %v", err))
|
||||
continue
|
||||
}
|
||||
// if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
|
||||
// if historyStruct, ok := history.(*localoperator.StateHistory); ok {
|
||||
// state := historyStruct.States[len(historyStruct.States)-1]
|
||||
// // Decode base64 screenshot and upload to Slack
|
||||
// if state.Screenshot != "" {
|
||||
// screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
|
||||
// if err != nil {
|
||||
// xlog.Error(fmt.Sprintf("Error decoding screenshot: %v", err))
|
||||
// continue
|
||||
// }
|
||||
|
||||
data := string(screenshotData)
|
||||
// Upload the file to Slack
|
||||
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
Reader: bytes.NewReader(screenshotData),
|
||||
FileSize: len(data),
|
||||
ThreadTimestamp: ts,
|
||||
Channel: channelID,
|
||||
Filename: "screenshot.png",
|
||||
InitialComment: "Browser Agent Screenshot",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error uploading screenshot: %v", err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// data := string(screenshotData)
|
||||
// // Upload the file to Slack
|
||||
// _, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
// Reader: bytes.NewReader(screenshotData),
|
||||
// FileSize: len(data),
|
||||
// ThreadTimestamp: ts,
|
||||
// Channel: channelID,
|
||||
// Filename: "screenshot.png",
|
||||
// InitialComment: "Browser Agent Screenshot",
|
||||
// })
|
||||
// if err != nil {
|
||||
// xlog.Error(fmt.Sprintf("Error uploading screenshot: %v", err))
|
||||
// continue
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// coming from the search action
|
||||
if urls, exists := state.Metadata[actions.MetadataUrls]; exists {
|
||||
@@ -204,13 +204,72 @@ func generateAttachmentsFromJobResponse(j *types.JobResult, api *slack.Client, c
|
||||
attachments = append(attachments, attachment)
|
||||
}
|
||||
}
|
||||
|
||||
// coming from the generate_song action (local file paths)
|
||||
if songPaths, exists := state.Metadata[actions.MetadataSongs]; exists {
|
||||
for _, path := range xstrings.UniqueSlice(songPaths.([]string)) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error reading song file %s: %v", path, err))
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(path)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "audio"
|
||||
}
|
||||
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
Reader: bytes.NewReader(data),
|
||||
FileSize: len(data),
|
||||
ThreadTimestamp: ts,
|
||||
Channel: channelID,
|
||||
Filename: filename,
|
||||
InitialComment: "Generated song",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error uploading song to Slack: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coming from the generate_pdf action (local file paths)
|
||||
if pdfPaths, exists := state.Metadata[actions.MetadataPDFs]; exists {
|
||||
for _, path := range xstrings.UniqueSlice(pdfPaths.([]string)) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error reading PDF file %s: %v", path, err))
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(path)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "document.pdf"
|
||||
}
|
||||
|
||||
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
Reader: bytes.NewReader(data),
|
||||
FileSize: len(data),
|
||||
ThreadTimestamp: ts,
|
||||
Channel: channelID,
|
||||
Filename: filename,
|
||||
InitialComment: "Generated PDF document",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error uploading PDF to Slack: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func scanImagesInMessages(api *slack.Client, ev *slackevents.MessageEvent) (*bytes.Buffer, string) {
|
||||
imageBytes := new(bytes.Buffer)
|
||||
mimeType := "image/jpeg"
|
||||
// ImageData represents a single image with its metadata
|
||||
type ImageData struct {
|
||||
Data []byte
|
||||
MimeType string
|
||||
}
|
||||
|
||||
// scanImagesInMessages scans for all images in a message and returns them as a slice
|
||||
func scanImagesInMessages(api *slack.Client, ev *slackevents.MessageEvent) []ImageData {
|
||||
var images []ImageData
|
||||
|
||||
// Fetch the message using the API
|
||||
messages, _, _, err := api.GetConversationReplies(&slack.GetConversationRepliesParameters{
|
||||
@@ -220,25 +279,142 @@ func scanImagesInMessages(api *slack.Client, ev *slackevents.MessageEvent) (*byt
|
||||
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error fetching messages: %v", err))
|
||||
} else {
|
||||
for _, msg := range messages {
|
||||
if len(msg.Files) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, attachment := range msg.Files {
|
||||
if attachment.URLPrivate != "" {
|
||||
xlog.Debug(fmt.Sprintf("Getting Attachment: %+v", attachment))
|
||||
// download image with slack api
|
||||
mimeType = attachment.Mimetype
|
||||
if err := api.GetFile(attachment.URLPrivate, imageBytes); err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error downloading image: %v", err))
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
xlog.Debug("Scanning images in messages", "messages", messages)
|
||||
for _, msg := range messages {
|
||||
if len(msg.Files) == 0 {
|
||||
xlog.Debug("No files in message", "message", msg.Text)
|
||||
continue
|
||||
}
|
||||
xlog.Debug("Files in message", "files", msg.Files)
|
||||
for _, attachment := range msg.Files {
|
||||
if attachment.URLPrivate != "" {
|
||||
xlog.Debug(fmt.Sprintf("Getting Attachment: %+v", attachment))
|
||||
// download image with slack api
|
||||
imageBytes := new(bytes.Buffer)
|
||||
if err := api.GetFile(attachment.URLPrivate, imageBytes); err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error downloading image: %v", err))
|
||||
continue
|
||||
}
|
||||
|
||||
images = append(images, ImageData{
|
||||
Data: imageBytes.Bytes(),
|
||||
MimeType: attachment.Mimetype,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return imageBytes, mimeType
|
||||
return images
|
||||
}
|
||||
|
||||
// scanImagesInAppMentionEvent scans for all images in an app mention event
|
||||
func scanImagesInAppMentionEvent(api *slack.Client, ev *slackevents.AppMentionEvent) []ImageData {
|
||||
var images []ImageData
|
||||
|
||||
// Fetch the message using the API
|
||||
messages, _, _, err := api.GetConversationReplies(&slack.GetConversationRepliesParameters{
|
||||
ChannelID: ev.Channel,
|
||||
Timestamp: ev.TimeStamp,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error fetching messages: %v", err))
|
||||
return images
|
||||
}
|
||||
|
||||
xlog.Debug("Scanning images in app mention event", "messages", messages)
|
||||
for _, msg := range messages {
|
||||
if len(msg.Files) == 0 {
|
||||
xlog.Debug("No files in message", "message", msg.Text)
|
||||
continue
|
||||
}
|
||||
xlog.Debug("Files in message", "files", msg.Files)
|
||||
for _, attachment := range msg.Files {
|
||||
if attachment.URLPrivate != "" {
|
||||
xlog.Debug(fmt.Sprintf("Getting Attachment: %+v", attachment))
|
||||
// download image with slack api
|
||||
imageBytes := new(bytes.Buffer)
|
||||
if err := api.GetFile(attachment.URLPrivate, imageBytes); err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error downloading image: %v", err))
|
||||
continue
|
||||
}
|
||||
|
||||
images = append(images, ImageData{
|
||||
Data: imageBytes.Bytes(),
|
||||
MimeType: attachment.Mimetype,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
// scanImagesInThreadMessage scans for all images in a single thread message
|
||||
func scanImagesInThreadMessage(api *slack.Client, msg slack.Message) []ImageData {
|
||||
var images []ImageData
|
||||
|
||||
if len(msg.Files) == 0 {
|
||||
return images
|
||||
}
|
||||
|
||||
xlog.Debug("found files in the message", "files", len(msg.Files))
|
||||
for _, attachment := range msg.Files {
|
||||
if attachment.URLPrivate != "" {
|
||||
xlog.Debug(fmt.Sprintf("Getting Attachment: %+v", attachment))
|
||||
// download image with slack api
|
||||
imageBytes := new(bytes.Buffer)
|
||||
if err := api.GetFile(attachment.URLPrivate, imageBytes); err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error downloading image: %v", err))
|
||||
continue
|
||||
}
|
||||
|
||||
images = append(images, ImageData{
|
||||
Data: imageBytes.Bytes(),
|
||||
MimeType: attachment.Mimetype,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
// createMultiContentMessage creates a ChatCompletionMessage with text and multiple images
|
||||
func createMultiContentMessage(role, text string, images []ImageData) openai.ChatCompletionMessage {
|
||||
multiContent := []openai.ChatMessagePart{
|
||||
{
|
||||
Text: text,
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
},
|
||||
}
|
||||
|
||||
for _, img := range images {
|
||||
imgBase64, err := encodeImageFromBytes(img.Data)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error encoding image to base64: %v", err))
|
||||
continue
|
||||
}
|
||||
|
||||
multiContent = append(multiContent, openai.ChatMessagePart{
|
||||
Type: openai.ChatMessagePartTypeImageURL,
|
||||
ImageURL: &openai.ChatMessageImageURL{
|
||||
URL: fmt.Sprintf("data:%s;base64,%s", img.MimeType, imgBase64),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return openai.ChatCompletionMessage{
|
||||
Role: role,
|
||||
MultiContent: multiContent,
|
||||
}
|
||||
}
|
||||
|
||||
// encodeImageFromBytes encodes image bytes to base64
|
||||
func encodeImageFromBytes(imageData []byte) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString(imageData), nil
|
||||
}
|
||||
|
||||
func (t *Slack) handleChannelMessage(
|
||||
@@ -266,37 +442,15 @@ func (t *Slack) handleChannelMessage(
|
||||
|
||||
go func() {
|
||||
|
||||
imageBytes, mimeType := scanImagesInMessages(api, ev)
|
||||
images := scanImagesInMessages(api, ev)
|
||||
|
||||
agentOptions := []types.JobOption{
|
||||
types.WithUUID(ev.ThreadTimeStamp),
|
||||
}
|
||||
|
||||
// If the last message has an image, we send it as a multi content message
|
||||
if len(imageBytes.Bytes()) > 0 {
|
||||
// // Encode the image to base64
|
||||
imgBase64, err := encodeImageFromURL(*imageBytes)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error encoding image to base64: %v", err))
|
||||
} else {
|
||||
currentConv = append(currentConv,
|
||||
openai.ChatCompletionMessage{
|
||||
Role: "user",
|
||||
MultiContent: []openai.ChatMessagePart{
|
||||
{
|
||||
Text: message,
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
},
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeImageURL,
|
||||
ImageURL: &openai.ChatMessageImageURL{
|
||||
URL: fmt.Sprintf("data:%s;base64,%s", mimeType, imgBase64),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
if len(images) > 0 {
|
||||
currentConv = append(currentConv, createMultiContentMessage("user", message, images))
|
||||
} else {
|
||||
currentConv = append(currentConv, openai.ChatCompletionMessage{
|
||||
Role: "user",
|
||||
@@ -367,14 +521,6 @@ func (t *Slack) handleChannelMessage(
|
||||
}()
|
||||
}
|
||||
|
||||
// Function to download the image from a URL and encode it to base64
|
||||
func encodeImageFromURL(imageBytes bytes.Buffer) (string, error) {
|
||||
|
||||
// Encode the image data to base64
|
||||
base64Image := base64.StdEncoding.EncodeToString(imageBytes.Bytes())
|
||||
return base64Image, nil
|
||||
}
|
||||
|
||||
func replyWithPostMessage(finalResponse string, api *slack.Client, ev *slackevents.MessageEvent, postMessageParams slack.PostMessageParameters, res *types.JobResult) {
|
||||
if len(finalResponse) > 4000 {
|
||||
// split response in multiple messages, and update the first
|
||||
@@ -528,7 +674,7 @@ func (t *Slack) handleMention(
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error fetching thread messages: %v", err))
|
||||
} else {
|
||||
for i, msg := range messages {
|
||||
for _, msg := range messages {
|
||||
// Skip our placeholder message
|
||||
if msg.Timestamp == msgTs {
|
||||
continue
|
||||
@@ -539,52 +685,19 @@ func (t *Slack) handleMention(
|
||||
role = "user"
|
||||
}
|
||||
|
||||
imageBytes := new(bytes.Buffer)
|
||||
mimeType := "image/jpeg"
|
||||
images := scanImagesInThreadMessage(api, msg)
|
||||
|
||||
xlog.Debug(fmt.Sprintf("Message: %+v", msg))
|
||||
if len(msg.Files) > 0 {
|
||||
for _, attachment := range msg.Files {
|
||||
|
||||
if attachment.URLPrivate != "" {
|
||||
xlog.Debug(fmt.Sprintf("Getting Attachment: %+v", attachment))
|
||||
mimeType = attachment.Mimetype
|
||||
// download image with slack api
|
||||
if err := api.GetFile(attachment.URLPrivate, imageBytes); err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error downloading image: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If the last message has an image, we send it as a multi content message
|
||||
if len(imageBytes.Bytes()) > 0 && i == len(messages)-1 {
|
||||
if len(images) > 0 {
|
||||
|
||||
// // Encode the image to base64
|
||||
imgBase64, err := encodeImageFromURL(*imageBytes)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error encoding image to base64: %v", err))
|
||||
}
|
||||
xlog.Debug("found image in an existing thread", "image", len(images))
|
||||
|
||||
threadMessages = append(
|
||||
threadMessages,
|
||||
openai.ChatCompletionMessage{
|
||||
Role: role,
|
||||
MultiContent: []openai.ChatMessagePart{
|
||||
{
|
||||
Text: replaceUserIDsWithNamesInMessage(api, cleanUpUsernameFromMessage(msg.Text, b)),
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
},
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeImageURL,
|
||||
ImageURL: &openai.ChatMessageImageURL{
|
||||
URL: fmt.Sprintf("data:%s;base64,%s", mimeType, imgBase64),
|
||||
// URL: imgUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
createMultiContentMessage(role, replaceUserIDsWithNamesInMessage(api, cleanUpUsernameFromMessage(msg.Text, b)), images),
|
||||
)
|
||||
} else {
|
||||
xlog.Debug("no image in the last message of the thread", "message", msg.Text)
|
||||
threadMessages = append(
|
||||
threadMessages,
|
||||
openai.ChatCompletionMessage{
|
||||
@@ -597,62 +710,15 @@ func (t *Slack) handleMention(
|
||||
}
|
||||
} else {
|
||||
|
||||
imageBytes := new(bytes.Buffer)
|
||||
mimeType := "image/jpeg"
|
||||
|
||||
// Fetch the message using the API
|
||||
messages, _, _, err := api.GetConversationReplies(&slack.GetConversationRepliesParameters{
|
||||
ChannelID: ev.Channel,
|
||||
Timestamp: ev.TimeStamp,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error fetching messages: %v", err))
|
||||
} else {
|
||||
for _, msg := range messages {
|
||||
if len(msg.Files) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, attachment := range msg.Files {
|
||||
if attachment.URLPrivate != "" {
|
||||
xlog.Debug(fmt.Sprintf("Getting Attachment: %+v", attachment))
|
||||
// download image with slack api
|
||||
mimeType = attachment.Mimetype
|
||||
if err := api.GetFile(attachment.URLPrivate, imageBytes); err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error downloading image: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
images := scanImagesInAppMentionEvent(api, ev)
|
||||
|
||||
// If the last message has an image, we send it as a multi content message
|
||||
if len(imageBytes.Bytes()) > 0 {
|
||||
|
||||
// // Encode the image to base64
|
||||
imgBase64, err := encodeImageFromURL(*imageBytes)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error encoding image to base64: %v", err))
|
||||
}
|
||||
if len(images) > 0 {
|
||||
|
||||
xlog.Debug("found image in the last message of the thread", "image", len(images))
|
||||
threadMessages = append(
|
||||
threadMessages,
|
||||
openai.ChatCompletionMessage{
|
||||
Role: "user",
|
||||
MultiContent: []openai.ChatMessagePart{
|
||||
{
|
||||
Text: replaceUserIDsWithNamesInMessage(api, cleanUpUsernameFromMessage(message, b)),
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
},
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeImageURL,
|
||||
ImageURL: &openai.ChatMessageImageURL{
|
||||
// URL: imgURL,
|
||||
URL: fmt.Sprintf("data:%s;base64,%s", mimeType, imgBase64),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
createMultiContentMessage("user", replaceUserIDsWithNamesInMessage(api, cleanUpUsernameFromMessage(message, b)), images),
|
||||
)
|
||||
} else {
|
||||
threadMessages = append(threadMessages, openai.ChatCompletionMessage{
|
||||
@@ -722,12 +788,12 @@ func (t *Slack) Start(a *agent.Agent) {
|
||||
if t.channelID != "" {
|
||||
xlog.Debug(fmt.Sprintf("Listening for messages in channel %s", t.channelID))
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(slack)", "message", ccm.Content)
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(slack)", "message", ccm.Message.Content)
|
||||
_, _, err := api.PostMessage(t.channelID,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(ccm.Content, true),
|
||||
slack.MsgOptionText(ccm.Message.Content, true),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -736,7 +802,7 @@ func (t *Slack) Start(a *agent.Agent) {
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("slack:%s", t.channelID),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
+400
-90
@@ -10,19 +10,21 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/localoperator"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xstrings"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -54,6 +56,156 @@ func (t *Telegram) isBotMentioned(message string, botUsername string) bool {
|
||||
return strings.Contains(message, "@"+botUsername)
|
||||
}
|
||||
|
||||
func (t *Telegram) chatFromMessage(update *models.Update) (openai.ChatCompletionMessage, error) {
|
||||
// Handle audio messages
|
||||
if update.Message.Voice != nil || update.Message.Audio != nil {
|
||||
return t.handleAudioMessage(update)
|
||||
}
|
||||
|
||||
// Handle photo messages
|
||||
if len(update.Message.Photo) > 0 {
|
||||
return t.handlePhotoMessage(update)
|
||||
}
|
||||
|
||||
// Handle text messages
|
||||
return openai.ChatCompletionMessage{
|
||||
Content: update.Message.Text,
|
||||
Role: "user",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *Telegram) handlePhotoMessage(update *models.Update) (openai.ChatCompletionMessage, error) {
|
||||
xlog.Debug("Image", "found image")
|
||||
// Get the largest photo
|
||||
photo := update.Message.Photo[len(update.Message.Photo)-1]
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
// Download the photo
|
||||
file, err := t.bot.GetFile(ctx, &bot.GetFileParams{
|
||||
FileID: photo.FileID,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error getting file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
|
||||
// Construct the full URL for downloading the file
|
||||
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", t.Token, file.FilePath)
|
||||
|
||||
// Download the file content
|
||||
resp, err := http.Get(fileURL)
|
||||
if err != nil {
|
||||
xlog.Error("Error downloading file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
imageBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading image", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
|
||||
// Encode to base64
|
||||
imgBase64 := base64.StdEncoding.EncodeToString(imageBytes)
|
||||
xlog.Debug("Image", "sending encoded image")
|
||||
// Add to conversation as multi-content message
|
||||
return openai.ChatCompletionMessage{
|
||||
Role: "user",
|
||||
MultiContent: []openai.ChatMessagePart{
|
||||
{
|
||||
Text: update.Message.Caption,
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
},
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeImageURL,
|
||||
ImageURL: &openai.ChatMessageImageURL{
|
||||
URL: fmt.Sprintf("data:image/jpeg;base64,%s", imgBase64),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *Telegram) handleAudioMessage(update *models.Update) (openai.ChatCompletionMessage, error) {
|
||||
var fileID string
|
||||
var audioType string
|
||||
|
||||
if update.Message.Voice != nil {
|
||||
fileID = update.Message.Voice.FileID
|
||||
audioType = "voice"
|
||||
} else if update.Message.Audio != nil {
|
||||
fileID = update.Message.Audio.FileID
|
||||
audioType = "audio"
|
||||
}
|
||||
|
||||
xlog.Debug("Audio message received", "type", audioType, "fileID", fileID)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Download the audio file
|
||||
file, err := t.bot.GetFile(ctx, &bot.GetFileParams{
|
||||
FileID: fileID,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error getting audio file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
|
||||
// Construct the full URL for downloading the file
|
||||
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", t.Token, file.FilePath)
|
||||
|
||||
// Download the file content
|
||||
resp, err := http.Get(fileURL)
|
||||
if err != nil {
|
||||
xlog.Error("Error downloading audio file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
audioBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading audio file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
|
||||
// Create a temporary file for transcription
|
||||
tempFile, err := os.CreateTemp("", "telegram_audio_*.ogg")
|
||||
if err != nil {
|
||||
xlog.Error("Error creating temp file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
defer os.Remove(tempFile.Name())
|
||||
|
||||
// Write audio data to temp file
|
||||
if _, err := tempFile.Write(audioBytes); err != nil {
|
||||
tempFile.Close()
|
||||
xlog.Error("Error writing audio to temp file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
tempFile.Close()
|
||||
|
||||
// Transcribe the audio using the agent's Transcribe method
|
||||
transcription, err := t.agent.Transcribe(ctx, tempFile.Name())
|
||||
if err != nil {
|
||||
xlog.Error("Error transcribing audio", "error", err)
|
||||
return openai.ChatCompletionMessage{
|
||||
Content: fmt.Sprintf("I received an audio message but couldn't transcribe it: %v", err),
|
||||
Role: "user",
|
||||
}, nil
|
||||
}
|
||||
|
||||
xlog.Debug("Audio transcribed successfully", "transcription", transcription)
|
||||
return openai.ChatCompletionMessage{
|
||||
Content: transcription,
|
||||
Role: "user",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleGroupMessage handles messages in group chats
|
||||
func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.Agent, update *models.Update) {
|
||||
xlog.Debug("Handling group message", "update", update)
|
||||
@@ -83,11 +235,9 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
// Cancel any active job for this chat before starting a new one
|
||||
t.cancelActiveJobForChat(update.Message.Chat.ID)
|
||||
|
||||
currentConv := a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("telegram:%d", update.Message.Chat.ID))
|
||||
|
||||
// Clean up the message by removing bot mentions
|
||||
message := strings.ReplaceAll(update.Message.Text, "@"+botInfo.Username, "")
|
||||
message = strings.TrimSpace(message)
|
||||
update.Message.Text = strings.TrimSpace(message)
|
||||
|
||||
// Send initial placeholder message
|
||||
msg, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
@@ -115,62 +265,23 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
"chatID": update.Message.Chat.ID,
|
||||
}
|
||||
|
||||
// Handle images if present
|
||||
if len(update.Message.Photo) > 0 {
|
||||
// Get the largest photo
|
||||
photo := update.Message.Photo[len(update.Message.Photo)-1]
|
||||
// Track if the original message was audio for TTS response
|
||||
if update.Message.Voice != nil || update.Message.Audio != nil {
|
||||
metadata["originalMessageType"] = "audio"
|
||||
}
|
||||
|
||||
// Download the photo
|
||||
file, err := b.GetFile(ctx, &bot.GetFileParams{
|
||||
FileID: photo.FileID,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error getting file", "error", err)
|
||||
} else {
|
||||
// Download the file content
|
||||
resp, err := http.Get(file.FilePath)
|
||||
if err != nil {
|
||||
xlog.Error("Error downloading file", "error", err)
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
imageBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading image", "error", err)
|
||||
} else {
|
||||
// Encode to base64
|
||||
imgBase64 := base64.StdEncoding.EncodeToString(imageBytes)
|
||||
|
||||
// Add to conversation as multi-content message
|
||||
currentConv = append(currentConv, openai.ChatCompletionMessage{
|
||||
Role: "user",
|
||||
MultiContent: []openai.ChatMessagePart{
|
||||
{
|
||||
Text: message,
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
},
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeImageURL,
|
||||
ImageURL: &openai.ChatMessageImageURL{
|
||||
URL: fmt.Sprintf("data:image/jpeg;base64,%s", imgBase64),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
currentConv = append(currentConv, openai.ChatCompletionMessage{
|
||||
Content: message,
|
||||
Role: "user",
|
||||
})
|
||||
chatMessage, err := t.chatFromMessage(update)
|
||||
if err != nil {
|
||||
xlog.Error("Error extracting chat message", "error", err)
|
||||
}
|
||||
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("telegram:%d", update.Message.Chat.ID),
|
||||
currentConv[len(currentConv)-1],
|
||||
chatMessage,
|
||||
)
|
||||
|
||||
currentConv := a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("telegram:%d", update.Message.Chat.ID))
|
||||
|
||||
// Create a new job with the conversation history and metadata
|
||||
job := types.NewJob(
|
||||
types.WithConversationHistory(currentConv),
|
||||
@@ -234,6 +345,34 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
xlog.Error("Error handling multimedia content", "error", err)
|
||||
}
|
||||
|
||||
// Check if original message was audio and generate TTS response
|
||||
if metadata["originalMessageType"] == "audio" && res.Response != "" {
|
||||
|
||||
xlog.Debug("Original message was audio, generating TTS response")
|
||||
audioData, err := t.agent.TTS(ctx, res.Response)
|
||||
if err != nil {
|
||||
xlog.Error("Error generating TTS", "error", err)
|
||||
} else {
|
||||
// Send audio response
|
||||
err = sendAudioToTelegram(ctx, t.bot, update.Message.Chat.ID, audioData, res.Response)
|
||||
if err != nil {
|
||||
xlog.Error("Error sending audio response", "error", err)
|
||||
} else {
|
||||
xlog.Debug("Audio response sent successfully")
|
||||
// Remove the thinking placeholder message before returning
|
||||
_, err := t.bot.DeleteMessage(ctx, &bot.DeleteMessageParams{
|
||||
ChatID: update.Message.Chat.ID,
|
||||
MessageID: msg.ID,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error deleting thinking placeholder", "error", err)
|
||||
}
|
||||
// Don't send text response if audio was sent successfully
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the message with the final response
|
||||
formattedResponse := formatResponseWithURLs(res.Response, urls)
|
||||
|
||||
@@ -379,6 +518,48 @@ func sendImageToTelegram(ctx context.Context, b *bot.Bot, chatID int64, url stri
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendAudioToTelegram sends audio data to Telegram
|
||||
func sendAudioToTelegram(ctx context.Context, b *bot.Bot, chatID int64, audioData []byte, caption string) error {
|
||||
// Send audio with caption
|
||||
_, err := b.SendVoice(ctx, &bot.SendVoiceParams{
|
||||
ChatID: chatID,
|
||||
Voice: &models.InputFileUpload{
|
||||
Filename: "response.mp3",
|
||||
Data: bytes.NewReader(audioData),
|
||||
},
|
||||
Caption: caption,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending audio: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSongToTelegram reads a song file from path and sends it to Telegram as audio.
|
||||
func sendSongToTelegram(ctx context.Context, b *bot.Bot, chatID int64, path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading song file: %w", err)
|
||||
}
|
||||
filename := filepath.Base(path)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "audio"
|
||||
}
|
||||
_, err = b.SendAudio(ctx, &bot.SendAudioParams{
|
||||
ChatID: chatID,
|
||||
Audio: &models.InputFileUpload{
|
||||
Filename: filename,
|
||||
Data: bytes.NewReader(data),
|
||||
},
|
||||
Caption: "Generated song",
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending audio: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleMultimediaContent processes and sends multimedia content from the agent's response
|
||||
func (t *Telegram) handleMultimediaContent(ctx context.Context, chatID int64, res *types.JobResult) ([]string, error) {
|
||||
var urls []string
|
||||
@@ -399,33 +580,72 @@ func (t *Telegram) handleMultimediaContent(ctx context.Context, chatID int64, re
|
||||
}
|
||||
}
|
||||
|
||||
// Handle browser agent screenshots
|
||||
if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
|
||||
if historyStruct, ok := history.(*localoperator.StateHistory); ok {
|
||||
state := historyStruct.States[len(historyStruct.States)-1]
|
||||
if state.Screenshot != "" {
|
||||
// Decode base64 screenshot
|
||||
screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
|
||||
if err != nil {
|
||||
xlog.Error("Error decoding screenshot", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send screenshot with caption
|
||||
_, err = t.bot.SendPhoto(ctx, &bot.SendPhotoParams{
|
||||
ChatID: chatID,
|
||||
Photo: &models.InputFileUpload{
|
||||
Filename: "screenshot.png",
|
||||
Data: bytes.NewReader(screenshotData),
|
||||
},
|
||||
Caption: "Browser Agent Screenshot",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error sending screenshot", "error", err)
|
||||
}
|
||||
// Handle songs from generate_song action (local file paths)
|
||||
if songPaths, exists := state.Metadata[actions.MetadataSongs]; exists {
|
||||
for _, path := range xstrings.UniqueSlice(songPaths.([]string)) {
|
||||
xlog.Debug("Sending song", "path", path)
|
||||
if err := sendSongToTelegram(ctx, t.bot, chatID, path); err != nil {
|
||||
xlog.Error("Error sending song", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle PDFs from generate_pdf action (local file paths)
|
||||
if pdfPaths, exists := state.Metadata[actions.MetadataPDFs]; exists {
|
||||
for _, path := range xstrings.UniqueSlice(pdfPaths.([]string)) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading PDF file", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
filename := filepath.Base(path)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "document.pdf"
|
||||
}
|
||||
|
||||
xlog.Debug("Sending PDF document", "filename", filename, "size", len(data))
|
||||
_, err = t.bot.SendDocument(ctx, &bot.SendDocumentParams{
|
||||
ChatID: chatID,
|
||||
Document: &models.InputFileUpload{
|
||||
Filename: filename,
|
||||
Data: bytes.NewReader(data),
|
||||
},
|
||||
Caption: "Generated PDF",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error sending PDF", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle browser agent screenshots
|
||||
// if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
|
||||
// if historyStruct, ok := history.(*localoperator.StateHistory); ok {
|
||||
// state := historyStruct.States[len(historyStruct.States)-1]
|
||||
// if state.Screenshot != "" {
|
||||
// // Decode base64 screenshot
|
||||
// screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
|
||||
// if err != nil {
|
||||
// xlog.Error("Error decoding screenshot", "error", err)
|
||||
// continue
|
||||
// }
|
||||
|
||||
// // Send screenshot with caption
|
||||
// _, err = t.bot.SendPhoto(ctx, &bot.SendPhotoParams{
|
||||
// ChatID: chatID,
|
||||
// Photo: &models.InputFileUpload{
|
||||
// Filename: "screenshot.png",
|
||||
// Data: bytes.NewReader(screenshotData),
|
||||
// },
|
||||
// Caption: "Browser Agent Screenshot",
|
||||
// })
|
||||
// if err != nil {
|
||||
// xlog.Error("Error sending screenshot", "error", err)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
return urls, nil
|
||||
@@ -486,17 +706,18 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
t.cancelActiveJobForChat(update.Message.Chat.ID)
|
||||
|
||||
currentConv := a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("telegram:%d", update.Message.From.ID))
|
||||
currentConv = append(currentConv, openai.ChatCompletionMessage{
|
||||
Content: update.Message.Text,
|
||||
Role: "user",
|
||||
})
|
||||
|
||||
message, err := t.chatFromMessage(update)
|
||||
if err != nil {
|
||||
xlog.Error("Error extracting chat message", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
currentConv = append(currentConv, message)
|
||||
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("telegram:%d", update.Message.From.ID),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: update.Message.Text,
|
||||
Role: "user",
|
||||
},
|
||||
message,
|
||||
)
|
||||
|
||||
// Send initial placeholder message
|
||||
@@ -522,6 +743,11 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
"chatID": update.Message.Chat.ID,
|
||||
}
|
||||
|
||||
// Track if the original message was audio for TTS response
|
||||
if update.Message.Voice != nil || update.Message.Audio != nil {
|
||||
metadata["originalMessageType"] = "audio"
|
||||
}
|
||||
|
||||
// Create a new job with the conversation history and metadata
|
||||
job := types.NewJob(
|
||||
types.WithConversationHistory(currentConv),
|
||||
@@ -585,6 +811,33 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
xlog.Error("Error handling multimedia content", "error", err)
|
||||
}
|
||||
|
||||
// Check if original message was audio and generate TTS response
|
||||
if metadata["originalMessageType"] == "audio" && res.Response != "" {
|
||||
xlog.Debug("Original message was audio, generating TTS response")
|
||||
audioData, err := t.agent.TTS(ctx, res.Response)
|
||||
if err != nil {
|
||||
xlog.Error("Error generating TTS", "error", err)
|
||||
} else {
|
||||
// Send audio response
|
||||
err = sendAudioToTelegram(ctx, t.bot, update.Message.Chat.ID, audioData, res.Response)
|
||||
if err != nil {
|
||||
xlog.Error("Error sending audio response", "error", err)
|
||||
} else {
|
||||
xlog.Debug("Audio response sent successfully")
|
||||
// Remove the thinking placeholder message before returning
|
||||
_, err := t.bot.DeleteMessage(ctx, &bot.DeleteMessageParams{
|
||||
ChatID: update.Message.Chat.ID,
|
||||
MessageID: msg.ID,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error deleting thinking placeholder", "error", err)
|
||||
}
|
||||
// Don't send text response if audio was sent successfully
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the message with the final response
|
||||
formattedResponse := formatResponseWithURLs(res.Response, urls)
|
||||
|
||||
@@ -666,11 +919,68 @@ func (t *Telegram) Start(a *agent.Agent) {
|
||||
|
||||
if t.channelID != "" {
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(telegram)", "message", ccm.Content)
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(telegram)", "message", ccm.Message.Content)
|
||||
|
||||
// First, handle any multimedia content from metadata
|
||||
if ccm.Metadata != nil {
|
||||
// Handle images from gen image actions
|
||||
if imagesUrls, exists := ccm.Metadata[actions.MetadataImages]; exists {
|
||||
for _, url := range xstrings.UniqueSlice(imagesUrls.([]string)) {
|
||||
xlog.Debug("Sending photo from new conversation", "url", url)
|
||||
chatID, _ := strconv.ParseInt(t.channelID, 10, 64)
|
||||
if err := sendImageToTelegram(ctx, t.bot, chatID, url); err != nil {
|
||||
xlog.Error("Error handling image", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle songs from generate_song action (local file paths)
|
||||
if songPaths, exists := ccm.Metadata[actions.MetadataSongs]; exists {
|
||||
for _, path := range xstrings.UniqueSlice(songPaths.([]string)) {
|
||||
xlog.Debug("Sending song from new conversation", "path", path)
|
||||
chatID, _ := strconv.ParseInt(t.channelID, 10, 64)
|
||||
if err := sendSongToTelegram(ctx, t.bot, chatID, path); err != nil {
|
||||
xlog.Error("Error sending song", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle PDFs from generate_pdf action (local file paths)
|
||||
if pdfPaths, exists := ccm.Metadata[actions.MetadataPDFs]; exists {
|
||||
for _, path := range xstrings.UniqueSlice(pdfPaths.([]string)) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading PDF file", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
filename := filepath.Base(path)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "document.pdf"
|
||||
}
|
||||
|
||||
xlog.Debug("Sending PDF document from new conversation", "filename", filename, "size", len(data))
|
||||
chatID, _ := strconv.ParseInt(t.channelID, 10, 64)
|
||||
_, err = t.bot.SendDocument(ctx, &bot.SendDocumentParams{
|
||||
ChatID: chatID,
|
||||
Document: &models.InputFileUpload{
|
||||
Filename: filename,
|
||||
Data: bytes.NewReader(data),
|
||||
},
|
||||
Caption: "Generated PDF",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error sending PDF", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then send the text message
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: t.channelID,
|
||||
Text: ccm.Content,
|
||||
Text: ccm.Message.Content,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error sending message", "error", err)
|
||||
@@ -680,7 +990,7 @@ func (t *Telegram) Start(a *agent.Agent) {
|
||||
t.agent.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("telegram:%s", t.channelID),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services/connectors/twitter"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services/filters"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
func Filters(a *state.AgentConfig) types.JobFilters {
|
||||
|
||||
+130
-21
@@ -1,11 +1,16 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
"github.com/mudler/LocalAGI/services/prompts"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
@@ -13,36 +18,140 @@ import (
|
||||
|
||||
const (
|
||||
DynamicPromptCustom = "custom"
|
||||
DynamicPromptMemory = "memory"
|
||||
)
|
||||
|
||||
var AvailableBlockPrompts = []string{
|
||||
DynamicPromptCustom,
|
||||
DynamicPromptMemory,
|
||||
}
|
||||
|
||||
func DynamicPromptsConfigMeta() []config.FieldGroup {
|
||||
return []config.FieldGroup{
|
||||
prompts.NewDynamicPromptConfigMeta(),
|
||||
type dynamicPrompt struct {
|
||||
agent.DynamicPrompt
|
||||
Name string
|
||||
}
|
||||
|
||||
func dynamicPrompts(customDirectory string, existingConfigs map[string]map[string]string) (allPrompts []dynamicPrompt) {
|
||||
files, err := os.ReadDir(customDirectory)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading custom actions directory", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func DynamicPrompts(a *state.AgentConfig) []agent.DynamicPrompt {
|
||||
promptblocks := []agent.DynamicPrompt{}
|
||||
|
||||
for _, c := range a.DynamicPrompts {
|
||||
var config map[string]string
|
||||
if err := json.Unmarshal([]byte(c.Config), &config); err != nil {
|
||||
xlog.Info("Error unmarshalling connector config", err)
|
||||
for _, file := range files {
|
||||
if filepath.Ext(file.Name()) != ".go" {
|
||||
continue
|
||||
}
|
||||
switch c.Type {
|
||||
case DynamicPromptCustom:
|
||||
prompt, err := prompts.NewDynamicPrompt(config, "")
|
||||
if err != nil {
|
||||
xlog.Error("Error creating custom prompt", "error", err)
|
||||
continue
|
||||
}
|
||||
promptblocks = append(promptblocks, prompt)
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(customDirectory, file.Name()))
|
||||
if err != nil {
|
||||
xlog.Error("Error reading custom action file", "error", err, "file", file.Name())
|
||||
continue
|
||||
}
|
||||
dynamicPromptName := strings.TrimSuffix(file.Name(), ".go")
|
||||
|
||||
dynamicPromptConfig := map[string]string{
|
||||
"name": dynamicPromptName,
|
||||
"code": string(content),
|
||||
}
|
||||
|
||||
if c, exists := existingConfigs[dynamicPromptName]; exists {
|
||||
dynamicPromptConfig["configuration"] = c["configuration"]
|
||||
}
|
||||
|
||||
a, err := prompts.NewDynamicCustomPrompt(dynamicPromptConfig, "")
|
||||
if err != nil {
|
||||
xlog.Error("Error creating custom dynamic prompt", "error", err, "file", file.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
if !a.CanRender() {
|
||||
continue
|
||||
}
|
||||
|
||||
allPrompts = append(allPrompts, dynamicPrompt{
|
||||
DynamicPrompt: a,
|
||||
Name: dynamicPromptName,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func DynamicPromptsConfigMeta(customDirectory string) []config.FieldGroup {
|
||||
defaultDynamicPrompts := []config.FieldGroup{
|
||||
prompts.NewDynamicPromptConfigMeta(),
|
||||
prompts.NewMemoryPromptConfigMeta(),
|
||||
}
|
||||
|
||||
if customDirectory != "" {
|
||||
prompts := dynamicPrompts(customDirectory, map[string]map[string]string{})
|
||||
for _, p := range prompts {
|
||||
defaultDynamicPrompts = append(defaultDynamicPrompts, config.FieldGroup{
|
||||
Name: p.Name,
|
||||
Label: p.Name,
|
||||
Fields: []config.Field{
|
||||
{
|
||||
Name: "configuration",
|
||||
Label: "Configuration",
|
||||
Type: config.FieldTypeTextarea,
|
||||
HelpText: "Configuration for the custom prompt",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return defaultDynamicPrompts
|
||||
}
|
||||
|
||||
func DynamicPrompts(dynamicConfig map[string]string) func(*state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []agent.DynamicPrompt {
|
||||
return func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []agent.DynamicPrompt {
|
||||
return func(ctx context.Context, pool *state.AgentPool) []agent.DynamicPrompt {
|
||||
customDirectory := dynamicConfig[CustomActionsDir]
|
||||
|
||||
existingDynamicPromptsConfigs := map[string]map[string]string{}
|
||||
for _, c := range a.DynamicPrompts {
|
||||
var config map[string]string
|
||||
if err := json.Unmarshal([]byte(c.Config), &config); err != nil {
|
||||
xlog.Info("Error unmarshalling connector config", err)
|
||||
continue
|
||||
}
|
||||
|
||||
existingDynamicPromptsConfigs[c.Type] = config
|
||||
}
|
||||
|
||||
dynamicPromptsFound := dynamicPrompts(customDirectory, existingDynamicPromptsConfigs)
|
||||
|
||||
memoryIdxPath := memoryIndexPath(a.Name, dynamicConfig)
|
||||
promptblocks := []agent.DynamicPrompt{}
|
||||
|
||||
for _, c := range a.DynamicPrompts {
|
||||
config := existingDynamicPromptsConfigs[c.Type]
|
||||
|
||||
switch c.Type {
|
||||
case DynamicPromptCustom:
|
||||
prompt, err := prompts.NewDynamicCustomPrompt(config, "")
|
||||
if err != nil {
|
||||
xlog.Error("Error creating custom prompt", "error", err)
|
||||
continue
|
||||
}
|
||||
promptblocks = append(promptblocks, prompt)
|
||||
case DynamicPromptMemory:
|
||||
_, memory, _, _ := actions.NewMemoryActions(memoryIdxPath, dynamicConfig)
|
||||
|
||||
promptblocks = append(promptblocks,
|
||||
prompts.NewMemoryPrompt(config, memory),
|
||||
)
|
||||
default:
|
||||
// Check if we have configured a custom dynamic prompt coming from a directory
|
||||
for _, p := range dynamicPromptsFound {
|
||||
if p.Name == c.Type {
|
||||
promptblocks = append(promptblocks, p.DynamicPrompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return promptblocks
|
||||
}
|
||||
}
|
||||
return promptblocks
|
||||
}
|
||||
|
||||
+52
-16
@@ -2,23 +2,26 @@ package prompts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/traefik/yaegi/interp"
|
||||
"github.com/traefik/yaegi/stdlib"
|
||||
)
|
||||
|
||||
type DynamicPrompt struct {
|
||||
type DynamicCustomPrompt struct {
|
||||
config map[string]string
|
||||
goPkgPath string
|
||||
i *interp.Interpreter
|
||||
}
|
||||
|
||||
func NewDynamicPrompt(config map[string]string, goPkgPath string) (*DynamicPrompt, error) {
|
||||
a := &DynamicPrompt{
|
||||
func NewDynamicCustomPrompt(config map[string]string, goPkgPath string) (*DynamicCustomPrompt, error) {
|
||||
a := &DynamicCustomPrompt{
|
||||
config: config,
|
||||
goPkgPath: goPkgPath,
|
||||
}
|
||||
@@ -34,19 +37,20 @@ func NewDynamicPrompt(config map[string]string, goPkgPath string) (*DynamicPromp
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *DynamicPrompt) callInit() error {
|
||||
func (a *DynamicCustomPrompt) callInit() error {
|
||||
if a.i == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
v, err := a.i.Eval(fmt.Sprintf("%s.Init", a.config["name"]))
|
||||
if err != nil {
|
||||
return err
|
||||
xlog.Warn("No init function found for custom prompt", "error", err, "action", a.config["name"])
|
||||
return nil
|
||||
}
|
||||
|
||||
run := v.Interface().(func() error)
|
||||
run := v.Interface().(func(string) error)
|
||||
|
||||
return run()
|
||||
return run(a.config["configuration"])
|
||||
}
|
||||
|
||||
func NewDynamicPromptConfigMeta() config.FieldGroup {
|
||||
@@ -77,16 +81,23 @@ func NewDynamicPromptConfigMeta() config.FieldGroup {
|
||||
Required: false,
|
||||
HelpText: "Enable if the code needs to use unsafe Go features",
|
||||
},
|
||||
{
|
||||
Name: "configuration",
|
||||
Label: "Configuration",
|
||||
Type: config.FieldTypeTextarea,
|
||||
HelpText: "Configuration for the custom prompt",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *DynamicPrompt) initializeInterpreter() error {
|
||||
func (a *DynamicCustomPrompt) initializeInterpreter() error {
|
||||
if _, exists := a.config["code"]; exists && a.i == nil {
|
||||
unsafe := strings.ToLower(a.config["unsafe"]) == "true"
|
||||
i := interp.New(interp.Options{
|
||||
GoPath: a.goPkgPath,
|
||||
Unrestricted: unsafe,
|
||||
Env: os.Environ(),
|
||||
})
|
||||
if err := i.Use(stdlib.Symbols); err != nil {
|
||||
return err
|
||||
@@ -96,6 +107,15 @@ func (a *DynamicPrompt) initializeInterpreter() error {
|
||||
a.config["name"] = "custom"
|
||||
}
|
||||
|
||||
// let's find first if there is already a package declarated in the code
|
||||
// the user might want to specify it to not break syntax with IDEs
|
||||
re := regexp.MustCompile("package (\\w+)")
|
||||
packageName := re.FindStringSubmatch(a.config["code"])
|
||||
if len(packageName) > 1 {
|
||||
// remove it from the code, normalize to `name`
|
||||
a.config["code"] = re.ReplaceAllString(a.config["code"], "")
|
||||
}
|
||||
|
||||
_, err := i.Eval(fmt.Sprintf("package %s\n%s", a.config["name"], a.config["code"]))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -107,18 +127,34 @@ func (a *DynamicPrompt) initializeInterpreter() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *DynamicPrompt) Render(c *agent.Agent) (string, error) {
|
||||
v, err := a.i.Eval(fmt.Sprintf("%s.Render", a.config["name"]))
|
||||
func (a *DynamicCustomPrompt) CanRender() bool {
|
||||
_, err := a.i.Eval(fmt.Sprintf("%s.Render", a.config["name"]))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return false
|
||||
}
|
||||
|
||||
run := v.Interface().(func() (string, error))
|
||||
|
||||
return run()
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *DynamicPrompt) Role() string {
|
||||
func (a *DynamicCustomPrompt) Render(c *agent.Agent) (types.PromptResult, error) {
|
||||
v, err := a.i.Eval(fmt.Sprintf("%s.Render", a.config["name"]))
|
||||
if err != nil {
|
||||
return types.PromptResult{}, err
|
||||
}
|
||||
|
||||
run := v.Interface().(func() (string, string, error))
|
||||
content, image, err := run()
|
||||
if err != nil {
|
||||
return types.PromptResult{}, err
|
||||
}
|
||||
|
||||
return types.PromptResult{
|
||||
Content: content,
|
||||
ImageBase64: image,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *DynamicCustomPrompt) Role() string {
|
||||
v, err := a.i.Eval(fmt.Sprintf("%s.Role", a.config["name"]))
|
||||
if err != nil {
|
||||
return "system"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package prompts
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
)
|
||||
|
||||
type MemoryLayer interface {
|
||||
Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error)
|
||||
}
|
||||
type MemoryPrompt struct {
|
||||
config map[string]string
|
||||
memory MemoryLayer
|
||||
}
|
||||
|
||||
func NewMemoryPrompt(config map[string]string, memory MemoryLayer) *MemoryPrompt {
|
||||
return &MemoryPrompt{
|
||||
config: config,
|
||||
memory: memory,
|
||||
}
|
||||
}
|
||||
|
||||
func NewMemoryPromptConfigMeta() config.FieldGroup {
|
||||
return config.FieldGroup{
|
||||
Name: "memory",
|
||||
Label: "Memory",
|
||||
}
|
||||
}
|
||||
|
||||
func (a *MemoryPrompt) Render(c *agent.Agent) (types.PromptResult, error) {
|
||||
result, err := a.memory.Run(c.Context(), c.SharedState(), types.ActionParams{})
|
||||
if err != nil {
|
||||
return types.PromptResult{}, err
|
||||
}
|
||||
|
||||
return types.PromptResult{
|
||||
Content: result.Result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *MemoryPrompt) Role() string {
|
||||
return "system"
|
||||
}
|
||||
+320
-5
@@ -1,37 +1,352 @@
|
||||
package e2e_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
localagi "github.com/mudler/LocalAGI/pkg/client"
|
||||
"github.com/mudler/LocalAGI/pkg/utils/ptr"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Agent test", func() {
|
||||
Context("Creates an agent and it answers", func() {
|
||||
var _ = Describe("E2E test", func() {
|
||||
Context("Creates an agent and it answers", Label("E2E"), func() {
|
||||
BeforeEach(func() {
|
||||
Eventually(func() error {
|
||||
// test apiURL is working and available
|
||||
_, err := http.Get(apiURL + "/readyz")
|
||||
_, err := http.Get(localagiURL + "/readyz")
|
||||
return err
|
||||
}, "10m", "10s").ShouldNot(HaveOccurred())
|
||||
|
||||
client := localagi.NewClient(localagiURL, "", 5*time.Minute)
|
||||
err := client.DeleteAgent("testagent1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("create agent", func() {
|
||||
client := localagi.NewClient(localagiURL, "", 5*time.Minute)
|
||||
|
||||
err := client.CreateAgent(&localagi.AgentConfig{
|
||||
Name: "testagent",
|
||||
Name: "testagent1",
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
result, err := client.SimpleAIResponse("testagent", "hello")
|
||||
result, err := client.SimpleAIResponse("testagent1", "hello")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(result).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("Can do user defined tool calls", Ordered, Label("E2E"), func() {
|
||||
BeforeAll(func() {
|
||||
Eventually(func() error {
|
||||
// test apiURL is working and available
|
||||
_, err := http.Get(localagiURL + "/readyz")
|
||||
return err
|
||||
}, "10m", "10s").ShouldNot(HaveOccurred())
|
||||
|
||||
client := localagi.NewClient(localagiURL, "", time.Minute)
|
||||
err := client.DeleteAgent("testagent2")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = client.CreateAgent(&localagi.AgentConfig{
|
||||
Name: "testagent2",
|
||||
EnableReasoning: true,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("can create a task", func() {
|
||||
client := localagi.NewClient(localagiURL, "", 10*time.Minute)
|
||||
|
||||
req := localagi.RequestBody{
|
||||
Model: "testagent2",
|
||||
Input: "Create an appointment next week on wednesday at 10:00 am for the whole day. The topic is about AI and you include ABC and DEF to the appointment.",
|
||||
Tools: []localagi.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Name: ptr.To("CreateTask"),
|
||||
Description: ptr.To("Write the needed details whenever you're asked to create something like an info, appointment, e-mail or when you're asked to remind of anything or create a remainder. Also use this if you're supposed to answer an e-mail."),
|
||||
Parameters: ptr.To(jsonschema.Definition{
|
||||
Type: "object",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"task": {
|
||||
Type: "string",
|
||||
Description: "Look for the name of the task you're supposed to do or create ",
|
||||
Enum: []string{
|
||||
"appointment",
|
||||
"E-mail",
|
||||
},
|
||||
},
|
||||
"subject": {
|
||||
Type: "string",
|
||||
Description: "A subject the task is about. Infer this from the given context data and user prompt.",
|
||||
},
|
||||
"reply": {
|
||||
Type: "string",
|
||||
Description: "A sharp and short reply to the contextual data given. Use a friendly and neutral general greeting.",
|
||||
},
|
||||
"recipient": {
|
||||
Type: "array",
|
||||
Description: "A list of names and abbreviations to send our task to. Abbreviations always have to match exactly. If the user gives you first names you can deduce the last name.",
|
||||
Items: &jsonschema.Definition{
|
||||
Type: "string",
|
||||
Enum: []string{
|
||||
"ABC",
|
||||
"DEF",
|
||||
},
|
||||
},
|
||||
},
|
||||
"datestart": {
|
||||
Type: "string",
|
||||
Description: "The date and time when the task should start. Discard any older dates than today. Use tomorrow as default. Use the format DD/MM/YYYY HH:MM",
|
||||
},
|
||||
"dateend": {
|
||||
Type: "string",
|
||||
Description: "The date and time when a meeting should end. Default to start date. If the duration of an appointment is given, calculate the end with the start date. Use the format DD/MM/YYYY HH:MM",
|
||||
},
|
||||
"datedone": {
|
||||
Type: "string",
|
||||
Description: "The date and time when the task should be done. Use the format DD/MM/YYYY HH:MM",
|
||||
},
|
||||
"private": {
|
||||
Type: "boolean",
|
||||
Description: "Whether the task should be private or not. Default to false.",
|
||||
},
|
||||
"includeall": {
|
||||
Type: "boolean",
|
||||
Description: "Whether the task should include every mentioned person or not. Default to true. If you find explicitly mentioned people in the prompt whilst ignoring the contextual xml schema you choose false unless it is mentioned that you should include everyone.",
|
||||
},
|
||||
"wholedayappointment": {
|
||||
Type: "boolean",
|
||||
Description: "Whether the appointment should be done for the whole days. Default to false unless mentioned by the user prompt. Ignore the xml schema for this.",
|
||||
},
|
||||
"remainder": {
|
||||
Type: "boolean",
|
||||
Description: "Whether you are explicitly supposed to remind of something or not. Default to false. Ignore the xml schema for this.",
|
||||
},
|
||||
},
|
||||
Required: []string{
|
||||
"task",
|
||||
"recipient",
|
||||
"datestart",
|
||||
"dateend",
|
||||
"datedone",
|
||||
"private",
|
||||
"wholedayappointment",
|
||||
"remainder",
|
||||
"subject",
|
||||
"reply",
|
||||
"includeall",
|
||||
},
|
||||
}),
|
||||
},
|
||||
}}
|
||||
result, err := client.GetAIResponse(&req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
var call localagi.ResponseFunctionToolCall
|
||||
var args struct {
|
||||
Task string `json:"task"`
|
||||
Subject string `json:"subject"`
|
||||
Reply string `json:"reply"`
|
||||
Recipient []string `json:"recipient"`
|
||||
DateStart string `json:"datestart"`
|
||||
DateEnd string `json:"dateend"`
|
||||
DateDone string `json:"datedone"`
|
||||
Private bool `json:"private"`
|
||||
IncludeAll bool `json:"includeall"`
|
||||
WholeDayAppointment bool `json:"wholedayappointment"`
|
||||
Remainder bool `json:"remainder"`
|
||||
}
|
||||
|
||||
for _, out := range result.Output {
|
||||
msg, err := out.ToMessage()
|
||||
if err == nil && msg.Role == "assistant" {
|
||||
xlog.Info("Agent returned message", "message", msg)
|
||||
Expect(len(result.Output)).To(BeNumerically(">", 1), fmt.Sprintf("%+v", result.Output))
|
||||
continue
|
||||
}
|
||||
fnc, err := out.ToFunctionToolCall()
|
||||
call = fnc
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(fnc.Type)).To(Equal("function_call"))
|
||||
Expect(fnc.Name).To(Equal("CreateTask"))
|
||||
|
||||
err = json.Unmarshal([]byte(fnc.Arguments), &args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(args.Task).To(Equal("appointment"))
|
||||
Expect(args.Subject).ToNot(BeEmpty())
|
||||
Expect(args.Reply).ToNot(BeEmpty())
|
||||
}
|
||||
|
||||
req = localagi.RequestBody{
|
||||
Model: "testagent2",
|
||||
Input: []any{
|
||||
localagi.InputMessage{
|
||||
Type: "message",
|
||||
Role: "user",
|
||||
Content: "Create an appointment next week on wednesday at 10:00 am for the whole day. The topic is about AI and you include ABC and DEF to the appointment.",
|
||||
},
|
||||
call,
|
||||
localagi.InputFunctionToolCallOutput{
|
||||
Type: "function_call_output",
|
||||
CallID: call.CallID,
|
||||
Output: fmt.Sprintf("Successfully created %s: %s", args.Task, args.Subject),
|
||||
},
|
||||
localagi.InputMessage{
|
||||
Type: "message",
|
||||
Role: "user",
|
||||
Content: "Was the appointment created? Reply using the ChooseAnswer tool.",
|
||||
},
|
||||
},
|
||||
Tools: []localagi.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Name: ptr.To("ChooseAnswer"),
|
||||
Description: ptr.To("Select Yes or No"),
|
||||
Parameters: ptr.To(jsonschema.Definition{
|
||||
Type: "object",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"answer": {
|
||||
Type: "boolean",
|
||||
Description: "Set true for Yes and false for no",
|
||||
},
|
||||
},
|
||||
Required: []string{
|
||||
"answer",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
ToolChoice: &localagi.ToolChoice{
|
||||
Type: "function",
|
||||
Name: "ChooseAnswer",
|
||||
},
|
||||
}
|
||||
result, err = client.GetAIResponse(&req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(result.Output)).To(BeNumerically(">", 0))
|
||||
fnc, err := result.Output[len(result.Output)-1].ToFunctionToolCall()
|
||||
Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("%+v", result))
|
||||
Expect(fnc.Arguments).To(ContainSubstring("true"))
|
||||
})
|
||||
|
||||
It("can tool call; web search", func() {
|
||||
client := localagi.NewClient(localagiURL, "", 10*time.Minute)
|
||||
|
||||
req := localagi.RequestBody{
|
||||
Model: "testagent2",
|
||||
Input: "What's the weather like in Berlin?",
|
||||
Tools: []localagi.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Name: ptr.To("WebSearch"),
|
||||
Description: ptr.To("Search the internet for up-to-date information"),
|
||||
Parameters: ptr.To(jsonschema.Definition{
|
||||
Type: "object",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"SearchPrompt": {
|
||||
Type: "string",
|
||||
Description: "The query that is sent to a search engine",
|
||||
},
|
||||
},
|
||||
Required: []string{
|
||||
"SearchPrompt",
|
||||
},
|
||||
}),
|
||||
},
|
||||
}}
|
||||
result, err := client.GetAIResponse(&req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
var call localagi.ResponseFunctionToolCall
|
||||
var args struct {
|
||||
SearchPrompt string `json:"SearchPrompt"`
|
||||
}
|
||||
|
||||
for _, out := range result.Output {
|
||||
msg, err := out.ToMessage()
|
||||
if err == nil && msg.Role == "assistant" {
|
||||
xlog.Info("Agent returned message", "message", msg)
|
||||
continue
|
||||
}
|
||||
fnc, err := out.ToFunctionToolCall()
|
||||
call = fnc
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(fnc.Type)).To(Equal("function_call"))
|
||||
Expect(fnc.Name).To(Equal("WebSearch"))
|
||||
|
||||
err = json.Unmarshal([]byte(fnc.Arguments), &args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(args.SearchPrompt).To(ContainSubstring("Berlin"))
|
||||
}
|
||||
|
||||
req = localagi.RequestBody{
|
||||
Model: "testagent2",
|
||||
Input: []any{
|
||||
localagi.InputMessage{
|
||||
Type: "message",
|
||||
Role: "user",
|
||||
Content: "What's the weather like in Berlin compared to normal for Spring/Autumn?",
|
||||
},
|
||||
call,
|
||||
localagi.InputFunctionToolCallOutput{
|
||||
Type: "function_call_output",
|
||||
CallID: call.CallID,
|
||||
Output: "The current weather in Berlin: temp 30C!, heavy rain with risk of flooding, light wind, Humidity 90%, Pressure 900mb, visibility poor",
|
||||
},
|
||||
},
|
||||
Tools: []localagi.Tool{
|
||||
{
|
||||
Type: "function",
|
||||
Name: ptr.To("ChooseAnswer"),
|
||||
Description: ptr.To("Select a multiple choice answer from a predefined list"),
|
||||
Parameters: ptr.To(jsonschema.Definition{
|
||||
Type: "object",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"answer": {
|
||||
Type: "string",
|
||||
Description: "Choice of answer",
|
||||
Enum: []string{
|
||||
"Very hot and wet",
|
||||
"Very hot and dry",
|
||||
"About Average temp and very wet",
|
||||
"About Average",
|
||||
"About Average temp and very dry",
|
||||
"Very cold and wet",
|
||||
"Very cold and dry",
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{
|
||||
"answer",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
ToolChoice: &localagi.ToolChoice{
|
||||
Type: "function",
|
||||
Name: "ChooseAnswer",
|
||||
},
|
||||
}
|
||||
result, err = client.GetAIResponse(&req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(result.Output)).To(BeNumerically(">", 0))
|
||||
fnc, err := result.Output[len(result.Output)-1].ToFunctionToolCall()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(fnc.Arguments).To(ContainSubstring("Very hot and wet"))
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
+113
-24
@@ -15,9 +15,9 @@ import (
|
||||
coreTypes "github.com/mudler/LocalAGI/core/types"
|
||||
internalTypes "github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/llm"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services"
|
||||
"github.com/mudler/LocalAGI/webui/types"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
@@ -25,14 +25,11 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/sse"
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
|
||||
"github.com/donseba/go-htmx"
|
||||
fiber "github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/template/html/v2"
|
||||
)
|
||||
|
||||
type (
|
||||
App struct {
|
||||
htmx *htmx.HTMX
|
||||
config *Config
|
||||
*fiber.App
|
||||
sharedState *internalTypes.AgentSharedState
|
||||
@@ -41,16 +38,12 @@ type (
|
||||
|
||||
func NewApp(opts ...Option) *App {
|
||||
config := NewConfig(opts...)
|
||||
engine := html.NewFileSystem(http.FS(viewsfs), ".html")
|
||||
|
||||
// Initialize a new Fiber app
|
||||
// Pass the engine to the Views
|
||||
webapp := fiber.New(fiber.Config{
|
||||
Views: engine,
|
||||
})
|
||||
webapp := fiber.New(fiber.Config{})
|
||||
|
||||
a := &App{
|
||||
htmx: htmx.New(),
|
||||
config: config,
|
||||
App: webapp,
|
||||
sharedState: internalTypes.NewAgentSharedState(5 * time.Minute),
|
||||
@@ -488,6 +481,63 @@ func (a *App) ListActions() func(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
// createToolCallResponse generates a proper tool call response for user-defined actions
|
||||
func (a *App) createToolCallResponse(id, agentName string, actionState coreTypes.ActionState, conv []openai.ChatCompletionMessage) types.ResponseBody {
|
||||
// Create tool call ID
|
||||
toolCallID := fmt.Sprintf("call_%d", time.Now().UnixNano())
|
||||
|
||||
// Get function name and arguments
|
||||
functionName := actionState.Action.Definition().Name.String()
|
||||
argumentsJSON, err := json.Marshal(actionState.Params)
|
||||
if err != nil {
|
||||
xlog.Error("Error marshaling action params for tool call", "error", err)
|
||||
// Fallback to empty arguments
|
||||
argumentsJSON = []byte("{}")
|
||||
}
|
||||
|
||||
// Create message object with reasoning
|
||||
messageObj := types.ResponseMessage{
|
||||
Type: "message",
|
||||
ID: fmt.Sprintf("msg_%d", time.Now().UnixNano()),
|
||||
Status: "completed",
|
||||
Role: "assistant",
|
||||
Content: []types.MessageContentItem{
|
||||
{
|
||||
Type: "output_text",
|
||||
Text: actionState.Reasoning,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create function tool call object
|
||||
functionToolCall := types.FunctionToolCall{
|
||||
Arguments: string(argumentsJSON),
|
||||
CallID: toolCallID,
|
||||
Name: functionName,
|
||||
Type: "function_call",
|
||||
ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()),
|
||||
Status: "completed",
|
||||
}
|
||||
|
||||
// Create response with both message and tool call in output array
|
||||
return types.ResponseBody{
|
||||
ID: id,
|
||||
Object: "response",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Status: "completed",
|
||||
Model: agentName,
|
||||
Output: []interface{}{
|
||||
messageObj,
|
||||
functionToolCall,
|
||||
},
|
||||
Usage: types.UsageInfo{
|
||||
InputTokens: 0, // TODO: calculate actual usage
|
||||
OutputTokens: 0,
|
||||
TotalTokens: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) Responses(pool *state.AgentPool, tracker *conversations.ConversationTracker[string]) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var request types.RequestBody
|
||||
@@ -507,15 +557,38 @@ func (a *App) Responses(pool *state.AgentPool, tracker *conversations.Conversati
|
||||
agentName := request.Model
|
||||
messages := append(conv, request.ToChatCompletionMessages()...)
|
||||
|
||||
a := pool.GetAgent(agentName)
|
||||
if a == nil {
|
||||
agent := pool.GetAgent(agentName)
|
||||
if agent == nil {
|
||||
xlog.Info("Agent not found in pool", c.Params("name"))
|
||||
return c.Status(http.StatusInternalServerError).JSON(types.ResponseBody{Error: "Agent not found"})
|
||||
}
|
||||
|
||||
res := a.Ask(
|
||||
// Prepare job options
|
||||
jobOptions := []coreTypes.JobOption{
|
||||
coreTypes.WithConversationHistory(messages),
|
||||
)
|
||||
}
|
||||
|
||||
// Add tools if present in the request
|
||||
if len(request.Tools) > 0 {
|
||||
builtinTools, userTools := types.SeparateTools(request.Tools)
|
||||
if len(builtinTools) > 0 {
|
||||
jobOptions = append(jobOptions, coreTypes.WithBuiltinTools(builtinTools))
|
||||
xlog.Debug("Adding builtin tools to job", "count", len(builtinTools), "agent", agentName)
|
||||
}
|
||||
if len(userTools) > 0 {
|
||||
jobOptions = append(jobOptions, coreTypes.WithUserTools(userTools))
|
||||
xlog.Debug("Adding user tools to job", "count", len(userTools), "agent", agentName)
|
||||
}
|
||||
}
|
||||
|
||||
var choice types.ToolChoice
|
||||
if err := json.Unmarshal(request.ToolChoice, &choice); err == nil {
|
||||
if choice.Type == "function" {
|
||||
jobOptions = append(jobOptions, coreTypes.WithToolChoice(choice.Name))
|
||||
}
|
||||
}
|
||||
|
||||
res := agent.Ask(jobOptions...)
|
||||
if res.Error != nil {
|
||||
xlog.Error("Error asking agent", "agent", agentName, "error", res.Error)
|
||||
|
||||
@@ -524,28 +597,44 @@ func (a *App) Responses(pool *state.AgentPool, tracker *conversations.Conversati
|
||||
xlog.Info("we got a response from the agent", "agent", agentName, "response", res.Response)
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
|
||||
// Check if this is a user-defined tool call
|
||||
if res.Response == "" && len(res.State) > 0 {
|
||||
// Get the last action from state
|
||||
lastAction := res.State[len(res.State)-1]
|
||||
if coreTypes.IsActionUserDefined(lastAction.Action) {
|
||||
xlog.Debug("Detected user-defined action, creating tool call response", "action", lastAction.Action.Definition().Name)
|
||||
|
||||
// Generate tool call response
|
||||
response := a.createToolCallResponse(id, agentName, lastAction, conv)
|
||||
tracker.SetConversation(id, conv) // Save conversation without adding assistant message
|
||||
return c.JSON(response)
|
||||
}
|
||||
}
|
||||
|
||||
// Regular text response
|
||||
conv = append(conv, openai.ChatCompletionMessage{
|
||||
Role: "assistant",
|
||||
Content: res.Response,
|
||||
})
|
||||
|
||||
id := uuid.New().String()
|
||||
|
||||
tracker.SetConversation(id, conv)
|
||||
|
||||
response := types.ResponseBody{
|
||||
ID: id,
|
||||
Object: "response",
|
||||
// "created_at": 1741476542,
|
||||
ID: id,
|
||||
Object: "response",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Status: "completed",
|
||||
Output: []types.ResponseMessage{
|
||||
{
|
||||
Model: agentName,
|
||||
Output: []interface{}{
|
||||
types.ResponseMessage{
|
||||
Type: "message",
|
||||
ID: fmt.Sprintf("msg_%d", time.Now().UnixNano()),
|
||||
Status: "completed",
|
||||
Role: "assistant",
|
||||
Content: []types.MessageContentItem{
|
||||
types.MessageContentItem{
|
||||
{
|
||||
Type: "output_text",
|
||||
Text: res.Response,
|
||||
},
|
||||
@@ -641,13 +730,13 @@ func (a *App) CreateGroup(pool *state.AgentPool) func(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// GetAgentConfigMeta returns the metadata for agent configuration fields
|
||||
func (a *App) GetAgentConfigMeta() func(c *fiber.Ctx) error {
|
||||
func (a *App) GetAgentConfigMeta(customDirectory string) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Create a new instance of AgentConfigMeta
|
||||
configMeta := state.NewAgentConfigMeta(
|
||||
services.ActionsConfigMeta(),
|
||||
services.ActionsConfigMeta(customDirectory),
|
||||
services.ConnectorsConfigMeta(),
|
||||
services.DynamicPromptsConfigMeta(),
|
||||
services.DynamicPromptsConfigMeta(customDirectory),
|
||||
services.FiltersConfigMeta(),
|
||||
)
|
||||
return c.JSON(configMeta)
|
||||
|
||||
@@ -1,714 +0,0 @@
|
||||
:root {
|
||||
--primary: #00ff95;
|
||||
--secondary: #ff00b1;
|
||||
--tertiary: #5e00ff;
|
||||
--dark-bg: #111111;
|
||||
--darker-bg: #0a0a0a;
|
||||
--medium-bg: #222222;
|
||||
--light-bg: #333333;
|
||||
--neon-glow: 0 0 8px rgba(0, 255, 149, 0.7);
|
||||
--pink-glow: 0 0 8px rgba(255, 0, 177, 0.7);
|
||||
--purple-glow: 0 0 8px rgba(94, 0, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Glitch effect animation */
|
||||
@keyframes glitch {
|
||||
0% { transform: translate(0); }
|
||||
20% { transform: translate(-2px, 2px); }
|
||||
40% { transform: translate(-2px, -2px); }
|
||||
60% { transform: translate(2px, 2px); }
|
||||
80% { transform: translate(2px, -2px); }
|
||||
100% { transform: translate(0); }
|
||||
}
|
||||
|
||||
/* Neon pulse animation */
|
||||
@keyframes neonPulse {
|
||||
0% { text-shadow: 0 0 7px var(--primary), 0 0 10px var(--primary); }
|
||||
50% { text-shadow: 0 0 15px var(--primary), 0 0 25px var(--primary); }
|
||||
100% { text-shadow: 0 0 7px var(--primary), 0 0 10px var(--primary); }
|
||||
}
|
||||
|
||||
/* Scanning line effect */
|
||||
@keyframes scanline {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100%); }
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background-color: var(--dark-bg);
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 20%, rgba(0, 255, 149, 0.05) 0%, transparent 20%),
|
||||
radial-gradient(circle at 90% 80%, rgba(255, 0, 177, 0.05) 0%, transparent 20%),
|
||||
radial-gradient(circle at 50% 50%, rgba(94, 0, 255, 0.05) 0%, transparent 30%),
|
||||
linear-gradient(180deg, var(--darker-bg) 0%, var(--dark-bg) 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: repeating-linear-gradient(
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.1) 2px,
|
||||
rgba(0, 0, 0, 0.1) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, var(--primary), var(--secondary));
|
||||
opacity: 0.7;
|
||||
z-index: 1001;
|
||||
animation: scanline 6s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Permanent Marker', cursive;
|
||||
color: var(--primary);
|
||||
text-shadow: var(--neon-glow);
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
animation: neonPulse 2s infinite;
|
||||
}
|
||||
|
||||
h1:hover {
|
||||
animation: glitch 0.3s infinite;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--secondary);
|
||||
text-shadow: var(--pink-glow);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-box {
|
||||
background-color: rgba(17, 17, 17, 0.85);
|
||||
border: 1px solid var(--primary);
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--primary), inset 0 0 20px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-box::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--primary), var(--secondary), var(--tertiary), var(--primary));
|
||||
background-size: 200% 100%;
|
||||
animation: gradientMove 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gradientMove {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
input, button, textarea, select {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--medium-bg);
|
||||
background-color: var(--light-bg);
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="file"], textarea {
|
||||
background-color: var(--light-bg);
|
||||
border-left: 3px solid var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--neon-glow);
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, var(--tertiary), var(--secondary));
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 7px 14px rgba(0, 0, 0, 0.3), 0 0 10px rgba(94, 0, 255, 0.5);
|
||||
}
|
||||
|
||||
button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 200px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Select styling */
|
||||
select {
|
||||
appearance: none;
|
||||
background-color: var(--light-bg);
|
||||
border-left: 3px solid var(--tertiary);
|
||||
color: white;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg fill="%23ffffff" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M7 10l5 5 5-5z"/></svg>');
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
background-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select:hover {
|
||||
border-color: var(--secondary);
|
||||
box-shadow: 0 0 0 1px var(--secondary);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: var(--tertiary);
|
||||
box-shadow: var(--purple-glow);
|
||||
}
|
||||
|
||||
select {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--medium-bg);
|
||||
color: white;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* Custom Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--medium-bg);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(var(--primary), var(--secondary));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--tertiary);
|
||||
}
|
||||
|
||||
/* Checkbox styling */
|
||||
.checkbox-custom {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 5px;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.checkbox-custom input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.checkbox-custom .checkmark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
background-color: var(--light-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--medium-bg);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.checkbox-custom:hover .checkmark {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--neon-glow);
|
||||
}
|
||||
|
||||
.checkbox-custom input:checked ~ .checkmark {
|
||||
background: linear-gradient(135deg, var(--primary), var(--tertiary));
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.checkbox-custom .checkmark:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkbox-custom input:checked ~ .checkmark:after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.checkbox-custom .checkmark:after {
|
||||
left: 8px;
|
||||
top: 4px;
|
||||
width: 6px;
|
||||
height: 12px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Card styling */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(145deg, rgba(34, 34, 34, 0.9), rgba(17, 17, 17, 0.9));
|
||||
border: 1px solid rgba(94, 0, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin: 25px auto;
|
||||
text-align: left;
|
||||
width: 90%;
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary), var(--secondary), var(--tertiary));
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.4s ease-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.4), 0 0 15px rgba(94, 0, 255, 0.3);
|
||||
}
|
||||
|
||||
.card:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
margin-bottom: 0.8em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.card a {
|
||||
color: var(--secondary);
|
||||
transition: color 0.3s;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.card a::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--primary);
|
||||
transform: scaleX(0);
|
||||
transform-origin: right;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card a:hover::after {
|
||||
transform: scaleX(1);
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: #cccccc;
|
||||
font-size: 1em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Button container */
|
||||
.button-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Alert and Toast styling */
|
||||
.alert {
|
||||
padding: 12px 15px;
|
||||
border-radius: 4px;
|
||||
margin: 15px 0;
|
||||
display: none;
|
||||
position: relative;
|
||||
border-left: 4px solid;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: rgba(0, 255, 149, 0.1);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: rgba(255, 0, 177, 0.1);
|
||||
border-color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
max-width: 350px;
|
||||
padding: 15px 20px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 2000;
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast::before {
|
||||
content: "";
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 15px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: linear-gradient(135deg, rgba(0, 255, 149, 0.9), rgba(0, 255, 149, 0.7));
|
||||
color: #111111;
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
|
||||
.toast-success::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23111111'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: linear-gradient(135deg, rgba(255, 0, 177, 0.9), rgba(255, 0, 177, 0.7));
|
||||
color: #ffffff;
|
||||
border-left: 4px solid var(--secondary);
|
||||
}
|
||||
|
||||
.toast-error::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Cpath d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.toast-visible {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.action-btn {
|
||||
background: var(--medium-bg);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 8px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
background: linear-gradient(135deg, var(--primary), rgba(0, 255, 149, 0.7));
|
||||
color: #111111;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
box-shadow: 0 0 15px rgba(0, 255, 149, 0.5);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.pause-btn {
|
||||
background: linear-gradient(135deg, var(--tertiary), rgba(94, 0, 255, 0.7));
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.pause-btn:hover {
|
||||
box-shadow: 0 0 15px rgba(94, 0, 255, 0.5);
|
||||
background: var(--tertiary);
|
||||
}
|
||||
|
||||
/* Badge styling */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: var(--primary);
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-tertiary {
|
||||
background-color: var(--tertiary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Data display tables */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin: 20px 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-table th, .data-table td {
|
||||
text-align: left;
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--medium-bg);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: rgba(94, 0, 255, 0.2);
|
||||
color: var(--tertiary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(odd) td {
|
||||
background-color: rgba(17, 17, 17, 0.6);
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(even) td {
|
||||
background-color: rgba(34, 34, 34, 0.6);
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
background-color: rgba(94, 0, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Terminal-style code display */
|
||||
.code-terminal {
|
||||
background-color: #0a0a0a;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #00ff95;
|
||||
margin: 20px 0;
|
||||
position: relative;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-terminal::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 25px;
|
||||
background: #222;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.code-terminal::after {
|
||||
content: "• • •";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 12px;
|
||||
height: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
font-size: 20px;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.code-terminal pre {
|
||||
margin-top: 25px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.code-terminal .prompt {
|
||||
color: var(--secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* User info badge */
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(17, 17, 17, 0.8), rgba(34, 34, 34, 0.8));
|
||||
border: 1px solid var(--tertiary);
|
||||
border-radius: 30px;
|
||||
padding: 6px 15px;
|
||||
margin: 10px 0;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--purple-glow);
|
||||
}
|
||||
|
||||
.user-info::before {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(0, 255, 149, 0.7); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(0, 255, 149, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(0, 255, 149, 0); }
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
margin-left: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
/* Responsive design adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.section-box {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
/* Agent Form Wizard Styles */
|
||||
.agent-form-container {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Wizard Sidebar */
|
||||
.wizard-sidebar {
|
||||
width: 250px;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
padding: 1.5rem 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wizard-nav {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wizard-nav-item {
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-left: 4px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wizard-nav-item i {
|
||||
margin-right: 10px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wizard-nav-item:hover {
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.wizard-nav-item.active {
|
||||
background: rgba(var(--primary-rgb), 0.15);
|
||||
border-left-color: var(--primary);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Form Content Area */
|
||||
.form-content-area {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(var(--border-rgb), 0.5);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-section.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Improved input styles */
|
||||
.mb-4 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-section label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-section input[type="text"],
|
||||
.form-section input[type="number"],
|
||||
.form-section textarea,
|
||||
.form-section select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(var(--border-rgb), 0.8);
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-section textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-section input[type="text"]:focus,
|
||||
.form-section input[type="number"]:focus,
|
||||
.form-section textarea:focus,
|
||||
.form-section select:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.button-container {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
/* Navigation controls - improved layout */
|
||||
.wizard-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(var(--border-rgb), 0.5);
|
||||
}
|
||||
|
||||
.wizard-controls-left {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wizard-controls-center {
|
||||
flex: 2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wizard-controls-right {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(var(--border-rgb), 0.8);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-btn i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.nav-btn:last-child i {
|
||||
margin-right: 0;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.progress-indicator {
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.progress-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(var(--border-rgb), 0.4);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.progress-dot.active {
|
||||
background-color: var(--primary);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.wizard-controls {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wizard-controls-left,
|
||||
.wizard-controls-center,
|
||||
.wizard-controls-right {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-dots {
|
||||
margin: 12px 0;
|
||||
}
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
// Common utility functions for agent forms
|
||||
const AgentFormUtils = {
|
||||
// Add dynamic component based on template
|
||||
addDynamicComponent: function(sectionId, templateFunction, dataItems) {
|
||||
const section = document.getElementById(sectionId);
|
||||
const newIndex = section.getElementsByClassName(dataItems.className).length;
|
||||
|
||||
// Generate HTML from template function
|
||||
const newHtml = templateFunction(newIndex, dataItems);
|
||||
|
||||
// Add to DOM
|
||||
section.insertAdjacentHTML('beforeend', newHtml);
|
||||
},
|
||||
|
||||
// Process form data into JSON structure
|
||||
processFormData: function(formData) {
|
||||
const jsonData = {};
|
||||
|
||||
// Process basic form fields
|
||||
for (const [key, value] of formData.entries()) {
|
||||
// Skip the array fields as they'll be processed separately
|
||||
if (!key.includes('[') && !key.includes('].')) {
|
||||
// Handle checkboxes
|
||||
if (value === 'on') {
|
||||
jsonData[key] = true;
|
||||
}
|
||||
// Handle numeric fields - specifically kb_results
|
||||
else if (key === 'kb_results') {
|
||||
// Convert to integer or default to 3 if empty
|
||||
jsonData[key] = value ? parseInt(value, 10) : 3;
|
||||
|
||||
// Check if the parse was successful
|
||||
if (isNaN(jsonData[key])) {
|
||||
showToast('Knowledge Base Results must be a number', 'error');
|
||||
return null; // Indicate validation error
|
||||
}
|
||||
}
|
||||
// Handle other numeric fields if needed
|
||||
else if (key === 'periodic_runs' && value) {
|
||||
// Try to parse as number if it looks like one
|
||||
const numValue = parseInt(value, 10);
|
||||
if (!isNaN(numValue) && String(numValue) === value) {
|
||||
jsonData[key] = numValue;
|
||||
} else {
|
||||
jsonData[key] = value;
|
||||
}
|
||||
}
|
||||
else {
|
||||
jsonData[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return jsonData;
|
||||
},
|
||||
|
||||
// Process connectors from form
|
||||
processConnectors: function(button) {
|
||||
const connectors = [];
|
||||
const connectorElements = document.querySelectorAll('.connector');
|
||||
|
||||
for (let i = 0; i < connectorElements.length; i++) {
|
||||
const typeSelect = document.getElementById(`connectorType${i}`);
|
||||
if (!typeSelect) {
|
||||
showToast(`Error: Could not find connector type select for index ${i}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
|
||||
const type = typeSelect.value;
|
||||
if (!type) {
|
||||
showToast(`Please select a connector type for connector ${i+1}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
|
||||
// Get all config fields for this connector
|
||||
const connector = {
|
||||
type: type,
|
||||
config: {}
|
||||
};
|
||||
|
||||
// Find all config inputs for this connector
|
||||
const configInputs = document.querySelectorAll(`[name^="connectors[${i}][config]"]`);
|
||||
|
||||
// Check if we have a JSON textarea (fallback template)
|
||||
const jsonTextarea = document.getElementById(`connectorConfig${i}`);
|
||||
if (jsonTextarea && jsonTextarea.value) {
|
||||
try {
|
||||
// If it's a JSON textarea, parse it and use the result
|
||||
const jsonConfig = JSON.parse(jsonTextarea.value);
|
||||
// Convert the parsed JSON back to a string for the backend
|
||||
connector.config = JSON.stringify(jsonConfig);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use it as is
|
||||
connector.config = jsonTextarea.value;
|
||||
}
|
||||
} else {
|
||||
// Process individual form fields
|
||||
configInputs.forEach(input => {
|
||||
// Extract the key from the name attribute
|
||||
// Format: connectors[0][config][key]
|
||||
const keyMatch = input.name.match(/\[config\]\[([^\]]+)\]/);
|
||||
if (keyMatch && keyMatch[1]) {
|
||||
const key = keyMatch[1];
|
||||
// For checkboxes, set true/false based on checked state
|
||||
if (input.type === 'checkbox') {
|
||||
connector.config[key] = input.checked ? 'true' : 'false';
|
||||
} else {
|
||||
connector.config[key] = input.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convert the config object to a JSON string for the backend
|
||||
connector.config = JSON.stringify(connector.config);
|
||||
}
|
||||
|
||||
connectors.push(connector);
|
||||
}
|
||||
|
||||
return connectors;
|
||||
},
|
||||
|
||||
// Process MCP servers from form
|
||||
processMCPServers: function() {
|
||||
const mcpServers = [];
|
||||
const mcpElements = document.querySelectorAll('.mcp_server');
|
||||
|
||||
for (let i = 0; i < mcpElements.length; i++) {
|
||||
const urlInput = document.getElementById(`mcpURL${i}`);
|
||||
const tokenInput = document.getElementById(`mcpToken${i}`);
|
||||
|
||||
if (urlInput && urlInput.value) {
|
||||
const server = {
|
||||
url: urlInput.value
|
||||
};
|
||||
|
||||
// Add token if present
|
||||
if (tokenInput && tokenInput.value) {
|
||||
server.token = tokenInput.value;
|
||||
}
|
||||
|
||||
mcpServers.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
return mcpServers;
|
||||
},
|
||||
|
||||
// Process actions from form
|
||||
processActions: function(button) {
|
||||
const actions = [];
|
||||
const actionElements = document.querySelectorAll('.action');
|
||||
|
||||
for (let i = 0; i < actionElements.length; i++) {
|
||||
const nameSelect = document.getElementById(`actionsName${i}`);
|
||||
const configTextarea = document.getElementById(`actionsConfig${i}`);
|
||||
|
||||
if (!nameSelect) {
|
||||
showToast(`Error: Could not find action name select for index ${i}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
|
||||
const name = nameSelect.value;
|
||||
if (!name) {
|
||||
showToast(`Please select an action type for action ${i+1}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
|
||||
let config = {};
|
||||
if (configTextarea && configTextarea.value) {
|
||||
try {
|
||||
config = JSON.parse(configTextarea.value);
|
||||
} catch (e) {
|
||||
showToast(`Invalid JSON in action ${i+1} config: ${e.message}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
}
|
||||
|
||||
actions.push({
|
||||
name: name,
|
||||
config: JSON.stringify(config) // Convert to JSON string for backend
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
|
||||
// Process prompt blocks from form
|
||||
processPromptBlocks: function(button) {
|
||||
const promptBlocks = [];
|
||||
const promptElements = document.querySelectorAll('.prompt_block');
|
||||
|
||||
for (let i = 0; i < promptElements.length; i++) {
|
||||
const nameSelect = document.getElementById(`promptName${i}`);
|
||||
const configTextarea = document.getElementById(`promptConfig${i}`);
|
||||
|
||||
if (!nameSelect) {
|
||||
showToast(`Error: Could not find prompt block name select for index ${i}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
|
||||
const name = nameSelect.value;
|
||||
if (!name) {
|
||||
showToast(`Please select a prompt block type for block ${i+1}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
|
||||
let config = {};
|
||||
if (configTextarea && configTextarea.value) {
|
||||
try {
|
||||
config = JSON.parse(configTextarea.value);
|
||||
} catch (e) {
|
||||
showToast(`Invalid JSON in prompt block ${i+1} config: ${e.message}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
}
|
||||
|
||||
promptBlocks.push({
|
||||
name: name,
|
||||
config: JSON.stringify(config) // Convert to JSON string for backend
|
||||
});
|
||||
}
|
||||
|
||||
return promptBlocks;
|
||||
},
|
||||
|
||||
// Helper function to format config values (for edit form)
|
||||
formatConfigValue: function(configElement, configValue) {
|
||||
if (!configElement) return;
|
||||
|
||||
// If configValue is an object, stringify it
|
||||
if (typeof configValue === 'object' && configValue !== null) {
|
||||
try {
|
||||
configElement.value = JSON.stringify(configValue, null, 2);
|
||||
} catch (e) {
|
||||
console.error('Error stringifying config value:', e);
|
||||
configElement.value = '{}';
|
||||
}
|
||||
}
|
||||
// If it's a string that looks like JSON, try to parse and pretty print it
|
||||
else if (typeof configValue === 'string' && (configValue.startsWith('{') || configValue.startsWith('['))) {
|
||||
try {
|
||||
const parsed = JSON.parse(configValue);
|
||||
configElement.value = JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, just use the string as is
|
||||
configElement.value = configValue;
|
||||
}
|
||||
}
|
||||
// Otherwise, just use the value as is
|
||||
else {
|
||||
configElement.value = configValue || '';
|
||||
}
|
||||
},
|
||||
|
||||
// Helper function to set select value (with fallback if option doesn't exist)
|
||||
setSelectValue: function(selectElement, value) {
|
||||
if (!selectElement) return;
|
||||
|
||||
// Check if the option exists
|
||||
let optionExists = false;
|
||||
for (let i = 0; i < selectElement.options.length; i++) {
|
||||
if (selectElement.options[i].value === value) {
|
||||
optionExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the value if the option exists
|
||||
if (optionExists) {
|
||||
selectElement.value = value;
|
||||
} else if (selectElement.options.length > 0) {
|
||||
// Otherwise, select the first option
|
||||
selectElement.selectedIndex = 0;
|
||||
}
|
||||
},
|
||||
|
||||
// Render connector form based on type
|
||||
renderConnectorForm: function(index, type, config = {}) {
|
||||
const formContainer = document.getElementById(`connectorFormContainer${index}`);
|
||||
if (!formContainer) return;
|
||||
|
||||
// Clear existing form
|
||||
formContainer.innerHTML = '';
|
||||
|
||||
// Debug log to see what's happening
|
||||
console.log(`Rendering connector form for type: ${type}`);
|
||||
console.log(`Config for connector:`, config);
|
||||
console.log(`Available templates:`, ConnectorTemplates ? Object.keys(ConnectorTemplates) : 'None');
|
||||
|
||||
// Ensure config is an object
|
||||
let configObj = config;
|
||||
if (typeof config === 'string') {
|
||||
try {
|
||||
configObj = JSON.parse(config);
|
||||
} catch (e) {
|
||||
console.error('Error parsing connector config string:', e);
|
||||
configObj = {};
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a template for this connector type in the global ConnectorTemplates object
|
||||
if (ConnectorTemplates && type && ConnectorTemplates[type]) {
|
||||
console.log(`Found template for ${type}`);
|
||||
// Get the template result which contains HTML and setValues function
|
||||
const templateResult = ConnectorTemplates[type](configObj, index);
|
||||
|
||||
// Set the HTML content
|
||||
formContainer.innerHTML = templateResult.html;
|
||||
|
||||
// Call the setValues function to set input values safely
|
||||
if (typeof templateResult.setValues === 'function') {
|
||||
setTimeout(templateResult.setValues, 0);
|
||||
}
|
||||
} else {
|
||||
console.log(`No template found for ${type}, using fallback`);
|
||||
// Use the fallback template
|
||||
if (ConnectorTemplates && ConnectorTemplates.fallback) {
|
||||
const fallbackResult = ConnectorTemplates.fallback(configObj, index);
|
||||
formContainer.innerHTML = fallbackResult.html;
|
||||
|
||||
if (typeof fallbackResult.setValues === 'function') {
|
||||
setTimeout(fallbackResult.setValues, 0);
|
||||
}
|
||||
} else {
|
||||
// Fallback to generic JSON textarea if no fallback template
|
||||
formContainer.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label for="connectorConfig${index}">Connector Config (JSON)</label>
|
||||
<textarea id="connectorConfig${index}"
|
||||
name="connectors[${index}][config]"
|
||||
class="form-control"
|
||||
placeholder='{"key":"value"}'></textarea>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Set the value safely after DOM is created
|
||||
setTimeout(function() {
|
||||
const configTextarea = document.getElementById(`connectorConfig${index}`);
|
||||
if (configTextarea) {
|
||||
if (typeof configObj === 'object' && configObj !== null) {
|
||||
configTextarea.value = JSON.stringify(configObj, null, 2);
|
||||
} else if (typeof config === 'string') {
|
||||
configTextarea.value = config;
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// HTML Templates for dynamic elements
|
||||
const AgentFormTemplates = {
|
||||
// Connector template
|
||||
connectorTemplate: function(index, data) {
|
||||
return `
|
||||
<div class="connector mb-4 section-box" style="margin-top: 15px; padding: 15px;">
|
||||
<h2>Connector ${index + 1}</h2>
|
||||
<div class="mb-4">
|
||||
<label for="connectorType${index}">Connector Type</label>
|
||||
<select name="connectors[${index}][type]"
|
||||
id="connectorType${index}"
|
||||
class="form-control"
|
||||
onchange="AgentFormUtils.renderConnectorForm(${index}, this.value)">
|
||||
<option value="">Select Connector Type</option>
|
||||
${data.options}
|
||||
</select>
|
||||
</div>
|
||||
<div id="connectorFormContainer${index}">
|
||||
<!-- Connector form will be dynamically inserted here -->
|
||||
<div class="form-group">
|
||||
<div class="placeholder-text">Select a connector type to configure</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="remove-btn" onclick="this.closest('.connector').remove()">
|
||||
<i class="fas fa-trash"></i> Remove Connector
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// MCP Server template
|
||||
mcpServerTemplate: function(index, data) {
|
||||
return `
|
||||
<div class="mcp_server mb-4 section-box" style="margin-top: 15px; padding: 15px;">
|
||||
<h2>MCP Server ${index + 1}</h2>
|
||||
<div class="mb-4">
|
||||
<label for="mcpURL${index}">Server URL</label>
|
||||
<input type="text" id="mcpURL${index}" name="mcp_servers[${index}][url]" placeholder="https://example.com">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="mcpToken${index}">API Token (Optional)</label>
|
||||
<input type="text" id="mcpToken${index}" name="mcp_servers[${index}][token]" placeholder="API token">
|
||||
</div>
|
||||
<button type="button" class="remove-btn" onclick="this.closest('.mcp_server').remove()">
|
||||
<i class="fas fa-trash"></i> Remove Server
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Action template
|
||||
actionTemplate: function(index, data) {
|
||||
return `
|
||||
<div class="action mb-4 section-box" style="margin-top: 15px; padding: 15px;">
|
||||
<h2>Action ${index + 1}</h2>
|
||||
<div class="mb-4">
|
||||
<label for="actionsName${index}">Action Type</label>
|
||||
<select name="actions[${index}][name]" id="actionsName${index}">
|
||||
${data.options}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="actionsConfig${index}">Action Config (JSON)</label>
|
||||
<textarea id="actionsConfig${index}" name="actions[${index}][config]" placeholder='{"key":"value"}'>{}</textarea>
|
||||
</div>
|
||||
<button type="button" class="remove-btn" onclick="this.closest('.action').remove()">
|
||||
<i class="fas fa-trash"></i> Remove Action
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Prompt Block template
|
||||
promptBlockTemplate: function(index, data) {
|
||||
return `
|
||||
<div class="prompt_block mb-4 section-box" style="margin-top: 15px; padding: 15px;">
|
||||
<h2>Prompt Block ${index + 1}</h2>
|
||||
<div class="mb-4">
|
||||
<label for="promptName${index}">Prompt Block Type</label>
|
||||
<select name="promptblocks[${index}][name]" id="promptName${index}">
|
||||
${data.options}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="promptConfig${index}">Prompt Block Config (JSON)</label>
|
||||
<textarea id="promptConfig${index}" name="promptblocks[${index}][config]" placeholder='{"key":"value"}'>{}</textarea>
|
||||
</div>
|
||||
<button type="button" class="remove-btn" onclick="this.closest('.prompt_block').remove()">
|
||||
<i class="fas fa-trash"></i> Remove Prompt Block
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize form event listeners
|
||||
function initAgentFormCommon(options = {}) {
|
||||
// Add connector button
|
||||
const addConnectorButton = document.getElementById('addConnectorButton');
|
||||
if (addConnectorButton) {
|
||||
addConnectorButton.addEventListener('click', function() {
|
||||
// Create options string
|
||||
let optionsHtml = '';
|
||||
if (options.connectors) {
|
||||
optionsHtml = options.connectors;
|
||||
}
|
||||
|
||||
// Add new connector form
|
||||
AgentFormUtils.addDynamicComponent('connectorsSection', AgentFormTemplates.connectorTemplate, {
|
||||
className: 'connector',
|
||||
options: optionsHtml
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add MCP server button
|
||||
const addMCPButton = document.getElementById('addMCPButton');
|
||||
if (addMCPButton) {
|
||||
addMCPButton.addEventListener('click', function() {
|
||||
// Add new MCP server form
|
||||
AgentFormUtils.addDynamicComponent('mcpSection', AgentFormTemplates.mcpServerTemplate, {
|
||||
className: 'mcp_server'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add action button
|
||||
const actionButton = document.getElementById('action_button');
|
||||
if (actionButton) {
|
||||
actionButton.addEventListener('click', function() {
|
||||
// Create options string
|
||||
let optionsHtml = '';
|
||||
if (options.actions) {
|
||||
optionsHtml = options.actions;
|
||||
}
|
||||
|
||||
// Add new action form
|
||||
AgentFormUtils.addDynamicComponent('action_box', AgentFormTemplates.actionTemplate, {
|
||||
className: 'action',
|
||||
options: optionsHtml
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add prompt block button
|
||||
const dynamicButton = document.getElementById('dynamic_button');
|
||||
if (dynamicButton) {
|
||||
dynamicButton.addEventListener('click', function() {
|
||||
// Create options string
|
||||
let optionsHtml = '';
|
||||
if (options.promptBlocks) {
|
||||
optionsHtml = options.promptBlocks;
|
||||
}
|
||||
|
||||
// Add new prompt block form
|
||||
AgentFormUtils.addDynamicComponent('dynamic_box', AgentFormTemplates.promptBlockTemplate, {
|
||||
className: 'prompt_block',
|
||||
options: optionsHtml
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Simple toast notification function
|
||||
function showToast(message, type) {
|
||||
// Check if toast container exists, if not create it
|
||||
let toast = document.getElementById('toast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'toast';
|
||||
toast.className = 'toast';
|
||||
|
||||
const toastMessage = document.createElement('div');
|
||||
toastMessage.id = 'toast-message';
|
||||
toast.appendChild(toastMessage);
|
||||
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
const toastMessage = document.getElementById('toast-message');
|
||||
|
||||
// Set message
|
||||
toastMessage.textContent = message;
|
||||
|
||||
// Set type class
|
||||
toast.className = 'toast';
|
||||
toast.classList.add(`toast-${type}`);
|
||||
|
||||
// Show toast
|
||||
toast.classList.add('show');
|
||||
|
||||
// Hide after 3 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
}, 3000);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user