mirror of
https://github.com/mudler/LocalAGI.git
synced 2026-07-25 19:55:24 -04:00
Compare commits
221 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3ab843e70 | |||
| b485b77037 | |||
| 07caa0b95d | |||
| 43c65ec7e8 | |||
| cc2d2838ca | |||
| da286065e1 | |||
| 9438d39f70 | |||
| e38f13ab8c | |||
| 2d2da7df95 | |||
| dc21ee83bc | |||
| 9b744d2bac | |||
| 4177479f82 | |||
| 34db83dc32 | |||
| 5a27c471ca | |||
| 3ecbbf3eac | |||
| d9bf193457 | |||
| 2acdc6688c | |||
| fb20d12ae1 | |||
| 7bbec7b6dd | |||
| 905ffcb185 | |||
| cb82322df2 | |||
| 56ecd1921e | |||
| a0faa14ffe | |||
| 7494a1559e | |||
| 3dad5acf1f | |||
| 99f7e3ab60 | |||
| 5b90d4c3e2 | |||
| 9406d699cc | |||
| 78defbad32 | |||
| 2efadbf6c4 | |||
| b46c300307 | |||
| d2407105a4 | |||
| 32a1a59389 | |||
| 61a89aaf58 | |||
| c5e3df41ee | |||
| cb37f80724 | |||
| bc256fbbe2 | |||
| 8014d22588 | |||
| 410ba7a467 | |||
| c56cc43552 | |||
| bc567ef7dd | |||
| f29e98bccb | |||
| 73d8304e2e | |||
| 52e8df6599 | |||
| 3828dd7ccc | |||
| cea2d9d618 | |||
| 5daf4bda8e | |||
| 508a307e8b | |||
| 8a16ad5407 | |||
| 27cc58a25f | |||
| a8decbcc85 | |||
| 1c083abc60 | |||
| 8f64d246d8 | |||
| 700047c6b9 | |||
| 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 |
@@ -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: |
|
||||
|
||||
+14
-38
@@ -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
|
||||
|
||||
go-version: '>=1.26.0'
|
||||
- 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.26.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
|
||||
|
||||
+15
-6
@@ -16,8 +16,8 @@ COPY webui/react-ui/ ./
|
||||
# Build the React UI
|
||||
RUN bun run build
|
||||
|
||||
# Use a temporary build image based on Golang 1.24-alpine
|
||||
FROM golang:1.24-alpine AS builder
|
||||
# Use a temporary build image based on Golang 1.26-alpine
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
# Define argument for linker flags
|
||||
ARG LDFLAGS="-s -w"
|
||||
@@ -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"]
|
||||
ENTRYPOINT ["/localagi", "serve"]
|
||||
|
||||
@@ -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-4b-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 use built-in **Skills** (manage skills in the Web UI and enable them per agent). 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). Skills follow the [skillserver](https://github.com/mudler/skillserver) format and can be created, imported, or synced from git.
|
||||
|
||||
## 🛡️ Take Back Your Privacy
|
||||
|
||||
@@ -31,14 +31,15 @@ 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).
|
||||
- 📚 **Short & Long-Term Memory**: Built-in knowledge base (RAG) for collections, file uploads, and semantic search. Manage collections in the Web UI under **Knowledge base**; agents with "Knowledge base" enabled use it automatically (implementation uses [LocalRecall](https://github.com/mudler/LocalRecall) libraries).
|
||||
- 🧠 **Planning & Reasoning**: Agents intelligently plan, reason, and adapt.
|
||||
- 🔄 **Periodic Tasks**: Schedule tasks with cron-like syntax.
|
||||
- 💾 **Memory Management**: Control memory usage with options for long-term and summary memory.
|
||||
- 🖼 **Multimodal Support**: Ready for vision, text, and more.
|
||||
- 🔧 **Extensible Custom Actions**: Easily script dynamic agent behaviors in Go (interpreted, no compilation!).
|
||||
- 📚 **Built-in Skills**: Manage reusable agent skills in the Web UI (create, edit, import/export, git sync). Enable "Skills" per agent to inject skill tools and the skill list into the agent.
|
||||
- 🛠 **Fully Customizable Models**: Use your own models or integrate seamlessly with [LocalAI](https://github.com/mudler/LocalAI).
|
||||
- 📊 **Observability**: Monitor agent status and view detailed observable updates in real-time.
|
||||
|
||||
@@ -58,6 +59,9 @@ 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
|
||||
|
||||
@@ -104,7 +108,7 @@ Still having issues? see this Youtube video: https://youtu.be/HtVwIxW3ePg
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3><a href="https://github.com/mudler/LocalRecall">LocalRecall</a></h3>
|
||||
<p>A REST-ful API and knowledge base management system that provides persistent memory and storage capabilities for AI agents.</p>
|
||||
<p>A REST-ful API and knowledge base management system. LocalAGI embeds this functionality: the Web UI includes a <strong>Knowledge base</strong> section and the same collections API, so you no longer need to run LocalRecall separately.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -170,6 +174,9 @@ MODEL_NAME=gemma-3-12b-it \
|
||||
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:
|
||||
@@ -189,7 +196,7 @@ Good (relatively small) models that have been tested are:
|
||||
- **✓ Flexible Model Integration**: Supports GGUF, GGML, and more thanks to [LocalAI](https://github.com/mudler/LocalAI).
|
||||
- **✓ Developer-Friendly**: Rich APIs and intuitive interfaces.
|
||||
- **✓ Effortless Setup**: Simple Docker compose setups and pre-built binaries.
|
||||
- **✓ Feature-Rich**: From planning to multimodal capabilities, connectors for Slack, MCP support, LocalAGI has it all.
|
||||
- **✓ Feature-Rich**: From planning to multimodal capabilities, connectors for Slack, MCP support, built-in Skills, LocalAGI has it all.
|
||||
|
||||
## 🌟 Screenshots
|
||||
|
||||
@@ -218,6 +225,7 @@ Explore detailed documentation including:
|
||||
- [REST API Documentation](#rest-api)
|
||||
- [Connector Configuration](#connectors)
|
||||
- [Agent Configuration](#agent-configuration-reference)
|
||||
- [Skills](#3-skills)
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
@@ -231,9 +239,14 @@ LocalAGI supports environment configurations. Note that these environment variab
|
||||
| `LOCALAGI_LLM_API_KEY` | API authentication |
|
||||
| `LOCALAGI_TIMEOUT` | Request timeout settings |
|
||||
| `LOCALAGI_STATE_DIR` | Where state gets stored |
|
||||
| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection |
|
||||
| `LOCALAGI_BASE_URL` | Optional base URL for the app (only relevant when using an external LocalRAG URL; not used for built-in knowledge base) |
|
||||
| `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 |
|
||||
|
||||
For the built-in knowledge base, optional env (defaults use `LOCALAGI_STATE_DIR`): `COLLECTION_DB_PATH`, `FILE_ASSETS`, `VECTOR_ENGINE` (e.g. `chromem`, `postgres`), `EMBEDDING_MODEL`, `DATABASE_URL` (when `VECTOR_ENGINE=postgres`).
|
||||
|
||||
Skills are stored in a fixed `skills` subdirectory under `LOCALAGI_STATE_DIR` (e.g. `/pool/skills` in Docker). Git repo config for skills lives in that directory. No extra environment variables are required.
|
||||
|
||||
## Installation Options
|
||||
|
||||
@@ -328,16 +341,16 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
)
|
||||
|
||||
// Create a new agent pool
|
||||
// Create a new agent pool (call pool.SetRAGProvider(...) for knowledge base; see main.go)
|
||||
pool, err := state.NewAgentPool(
|
||||
"default-model", // default model name
|
||||
"default-multimodal-model", // default multimodal model
|
||||
"image-model", // image generation model
|
||||
"transcription-model", // default transcription model
|
||||
"en", // default transcription language
|
||||
"tts-model", // default TTS model
|
||||
"http://localhost:8080", // API URL
|
||||
"your-api-key", // API key
|
||||
"./state", // state directory
|
||||
"", // MCP box URL (optional)
|
||||
"http://localhost:8081", // LocalRAG API URL
|
||||
"your-api-key", // API key
|
||||
"./state", // state directory
|
||||
func(config *AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action {
|
||||
// Define available actions for agents
|
||||
return func(ctx context.Context, pool *AgentPool) []types.Action {
|
||||
@@ -364,8 +377,9 @@ pool, err := state.NewAgentPool(
|
||||
// Add your custom filters here
|
||||
}
|
||||
},
|
||||
"10m", // timeout
|
||||
true, // enable conversation logs
|
||||
"10m", // timeout
|
||||
true, // enable conversation logs
|
||||
nil, // skills service (optional)
|
||||
)
|
||||
|
||||
// Create a new agent in the pool
|
||||
@@ -422,6 +436,30 @@ LocalAGI provides two powerful ways to extend its functionality with custom acti
|
||||
|
||||
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.
|
||||
@@ -663,6 +701,16 @@ You can create MCP servers in any language that supports the MCP protocol and ad
|
||||
- **Testing**: Test your MCP servers independently before integrating with LocalAGI
|
||||
- **Resource Management**: Ensure your MCP servers properly clean up resources
|
||||
|
||||
### 3. Skills
|
||||
|
||||
LocalAGI includes built-in **Skills** management. Skills are reusable instructions and resources (scripts, references, assets) that agents can use when "Enable Skills" is turned on for that agent.
|
||||
|
||||
- **Skills section (Web UI)**: Open **Skills** in the sidebar. Skills are stored under the state directory (`STATE_DIR/skills`). Create, edit, search, import, and export skills. You can also add git repositories to sync skills from.
|
||||
- **Per-agent**: In agent creation or settings, enable **Enable Skills** in Advanced Settings. The agent will receive a list of available skills in its context and have access to skill tools (list, read, search, resources) via the built-in skills MCP.
|
||||
- Skills use the same format as [skillserver](https://github.com/mudler/skillserver) (e.g. `SKILL.md` in a directory). You can export skills from LocalAGI and use them with the standalone skillserver, or import skills created elsewhere.
|
||||
|
||||
In Docker, the state directory is persisted (`/pool`), so skills are stored in `/pool/skills`. To use a host folder for skills, mount it over that path in your compose file (e.g. `- ./my-skills:/pool/skills`).
|
||||
|
||||
### Development
|
||||
|
||||
The development workflow is similar to the source build, but with additional steps for hot reloading of the frontend:
|
||||
@@ -672,15 +720,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
|
||||
# Knowledge base is built-in; no separate LocalRecall service needed
|
||||
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
|
||||
@@ -977,11 +1049,11 @@ LocalAGI supports environment configurations. Note that these environment variab
|
||||
| `LOCALAGI_LLM_API_KEY` | API authentication |
|
||||
| `LOCALAGI_TIMEOUT` | Request timeout settings |
|
||||
| `LOCALAGI_STATE_DIR` | Where state gets stored |
|
||||
| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection |
|
||||
| `LOCALAGI_BASE_URL` | Optional base URL for built-in knowledge base (default `http://localhost:3000`) |
|
||||
| `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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var agentCmd = &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Manage agents",
|
||||
Long: "Commands for managing and running LocalAGI agents.",
|
||||
}
|
||||
|
||||
func init() {
|
||||
agentCmd.AddCommand(agentRunCmd)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/services"
|
||||
"github.com/mudler/LocalAGI/services/skills"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
configFile string
|
||||
prompt string
|
||||
)
|
||||
|
||||
var agentRunCmd = &cobra.Command{
|
||||
Use: "run [agent_name]",
|
||||
Short: "Run an agent standalone",
|
||||
Long: `Run an agent without starting the web server.
|
||||
|
||||
Two modes are supported:
|
||||
1. Run an agent by name from the registry (pool.json):
|
||||
local-agi agent run my-agent
|
||||
2. Run an agent from a JSON config file:
|
||||
local-agi agent run --config agent.json
|
||||
3. Run an agent in foreground mode with a prompt:
|
||||
local-agi agent run my-agent --prompt "Your question here"
|
||||
|
||||
The agent runs in the foreground until interrupted (Ctrl+C).`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runAgent,
|
||||
}
|
||||
|
||||
func init() {
|
||||
agentRunCmd.Flags().StringVarP(&configFile, "config", "c", "", "path to agent JSON config file")
|
||||
agentRunCmd.Flags().StringVarP(&prompt, "prompt", "p", "", "run in foreground mode with the given prompt and exit after response")
|
||||
}
|
||||
|
||||
func runAgent(cmd *cobra.Command, args []string) error {
|
||||
agentName, agentConfig, err := resolveAgentConfig(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If --prompt is provided, run in foreground mode
|
||||
if prompt != "" {
|
||||
return runAgentForeground(agentName, agentConfig, prompt)
|
||||
}
|
||||
|
||||
return startStandaloneAgent(agentName, agentConfig)
|
||||
}
|
||||
|
||||
// runAgentForeground runs an agent in foreground mode with a single prompt,
|
||||
// prints the response, and exits.
|
||||
func runAgentForeground(agentName string, agentConfig *state.AgentConfig, promptText string) error {
|
||||
// Load all environment variables
|
||||
env := LoadEnv()
|
||||
|
||||
if env.Model == "" {
|
||||
env.Model = agentConfig.Model
|
||||
}
|
||||
if env.LLMAPIURL == "" {
|
||||
env.LLMAPIURL = agentConfig.APIURL
|
||||
}
|
||||
if env.LLMAPIKey == "" {
|
||||
env.LLMAPIKey = agentConfig.APIKey
|
||||
}
|
||||
|
||||
if env.Model == "" {
|
||||
return fmt.Errorf("model not set: provide 'model' in config or set LOCALAGI_MODEL")
|
||||
}
|
||||
if env.LLMAPIURL == "" {
|
||||
return fmt.Errorf("API URL not set: provide 'api_url' in config or set LOCALAGI_LLM_API_URL")
|
||||
}
|
||||
|
||||
if env.StateDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
env.StateDir = filepath.Join(cwd, "pool")
|
||||
}
|
||||
os.MkdirAll(env.StateDir, 0755)
|
||||
|
||||
// Override config with resolved values
|
||||
agentConfig.Model = env.Model
|
||||
agentConfig.APIURL = env.LLMAPIURL
|
||||
agentConfig.APIKey = env.LLMAPIKey
|
||||
agentConfig.MultimodalModel = env.MultimodalModel
|
||||
agentConfig.TranscriptionModel = env.TranscriptionModel
|
||||
agentConfig.TranscriptionLanguage = env.TranscriptionLanguage
|
||||
agentConfig.TTSModel = env.TTSModel
|
||||
|
||||
// Initialize skills service
|
||||
skillsService, err := skills.NewService(env.StateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize skills service: %w", err)
|
||||
}
|
||||
|
||||
// Build service factories
|
||||
actionsFactory := services.Actions(map[string]string{
|
||||
services.ActionConfigSSHBoxURL: env.SSHBoxURL,
|
||||
services.ConfigStateDir: env.StateDir,
|
||||
services.CustomActionsDir: env.CustomActionsDir,
|
||||
})
|
||||
dynamicPromptsFactory := services.DynamicPrompts(map[string]string{
|
||||
services.ConfigStateDir: env.StateDir,
|
||||
services.CustomActionsDir: env.CustomActionsDir,
|
||||
})
|
||||
|
||||
// Create the pool
|
||||
pool, err := state.NewAgentPool(
|
||||
env.Model, env.MultimodalModel, env.TranscriptionModel, env.TranscriptionLanguage, env.TTSModel,
|
||||
env.LLMAPIURL, env.LLMAPIKey, env.StateDir,
|
||||
actionsFactory, services.Connectors, dynamicPromptsFactory, services.Filters,
|
||||
env.Timeout, false, skillsService,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create agent pool: %w", err)
|
||||
}
|
||||
|
||||
if env.LocalRAGURL != "" {
|
||||
pool.SetRAGProvider(state.NewHTTPRAGProvider(env.LocalRAGURL, env.LLMAPIKey))
|
||||
}
|
||||
|
||||
// Start the agent
|
||||
if err := pool.StartAgentStandalone(agentName, agentConfig); err != nil {
|
||||
return fmt.Errorf("failed to start agent: %w", err)
|
||||
}
|
||||
|
||||
a := pool.GetAgent(agentName)
|
||||
if a == nil {
|
||||
return fmt.Errorf("agent %q was not found after starting", agentName)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Running agent %q in foreground mode with prompt...\n", agentName)
|
||||
|
||||
// Execute Ask with the prompt using WithText option
|
||||
result := a.Ask(types.WithText(promptText))
|
||||
|
||||
// Print the result
|
||||
if result.Error != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %s\n", result.Error.Error())
|
||||
pool.Stop(agentName)
|
||||
return fmt.Errorf("agent error: %s", result.Error.Error())
|
||||
}
|
||||
|
||||
// Print the response
|
||||
fmt.Println(result.Response)
|
||||
|
||||
// Clean up
|
||||
pool.Stop(agentName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAgentConfig determines the agent name and config from either
|
||||
// a registry lookup or a JSON config file.
|
||||
func resolveAgentConfig(args []string) (string, *state.AgentConfig, error) {
|
||||
if configFile != "" && len(args) > 0 {
|
||||
return "", nil, fmt.Errorf("cannot specify both --config and agent name; use one or the other")
|
||||
}
|
||||
|
||||
if configFile == "" && len(args) == 0 {
|
||||
return "", nil, fmt.Errorf("either an agent name or --config <file> is required")
|
||||
}
|
||||
|
||||
if configFile != "" {
|
||||
return loadConfigFromFile(configFile)
|
||||
}
|
||||
|
||||
return loadConfigFromRegistry(args[0])
|
||||
}
|
||||
|
||||
// loadConfigFromFile reads and validates an agent config from a JSON file.
|
||||
func loadConfigFromFile(path string) (string, *state.AgentConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to read config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
var config state.AgentConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
if err := validateConfig(&config); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid config in %q: %w", path, err)
|
||||
}
|
||||
|
||||
name := config.Name
|
||||
if name == "" {
|
||||
// Derive name from filename
|
||||
base := filepath.Base(path)
|
||||
name = base[:len(base)-len(filepath.Ext(base))]
|
||||
config.Name = name
|
||||
}
|
||||
|
||||
return name, &config, nil
|
||||
}
|
||||
|
||||
// loadConfigFromRegistry loads an agent config from the pool.json registry.
|
||||
func loadConfigFromRegistry(name string) (string, *state.AgentConfig, error) {
|
||||
stateDir := os.Getenv("LOCALAGI_STATE_DIR")
|
||||
if stateDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
stateDir = filepath.Join(cwd, "pool")
|
||||
}
|
||||
|
||||
poolFile := filepath.Join(stateDir, "pool.json")
|
||||
data, err := os.ReadFile(poolFile)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to read pool file %q: %w\nEnsure LOCALAGI_STATE_DIR is set or a pool/ directory exists", poolFile, err)
|
||||
}
|
||||
|
||||
var pool map[string]state.AgentConfig
|
||||
if err := json.Unmarshal(data, &pool); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to parse pool file %q: %w", poolFile, err)
|
||||
}
|
||||
|
||||
config, exists := pool[name]
|
||||
if !exists {
|
||||
available := make([]string, 0, len(pool))
|
||||
for k := range pool {
|
||||
available = append(available, k)
|
||||
}
|
||||
return "", nil, fmt.Errorf("agent %q not found in registry\nAvailable agents: %v", name, available)
|
||||
}
|
||||
|
||||
return name, &config, nil
|
||||
}
|
||||
|
||||
// validateConfig checks that required fields are present in the config.
|
||||
func validateConfig(config *state.AgentConfig) error {
|
||||
// Model and API URL can come from env vars, so they're not strictly required in config.
|
||||
// But we validate that the config is at least parseable (already done by JSON unmarshal).
|
||||
return nil
|
||||
}
|
||||
|
||||
// startStandaloneAgent creates and runs a single agent using the pool,
|
||||
// without starting the web server.
|
||||
func startStandaloneAgent(name string, config *state.AgentConfig) error {
|
||||
// Load all environment variables
|
||||
env := LoadEnv()
|
||||
|
||||
if env.Model == "" {
|
||||
env.Model = config.Model
|
||||
}
|
||||
if env.LLMAPIURL == "" {
|
||||
env.LLMAPIURL = config.APIURL
|
||||
}
|
||||
if env.LLMAPIKey == "" {
|
||||
env.LLMAPIKey = config.APIKey
|
||||
}
|
||||
|
||||
if env.Model == "" {
|
||||
return fmt.Errorf("model not set: provide 'model' in config or set LOCALAGI_MODEL")
|
||||
}
|
||||
if env.LLMAPIURL == "" {
|
||||
return fmt.Errorf("API URL not set: provide 'api_url' in config or set LOCALAGI_LLM_API_URL")
|
||||
}
|
||||
|
||||
if env.StateDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
env.StateDir = filepath.Join(cwd, "pool")
|
||||
}
|
||||
os.MkdirAll(env.StateDir, 0755)
|
||||
|
||||
// Override config with resolved values
|
||||
config.Model = env.Model
|
||||
config.APIURL = env.LLMAPIURL
|
||||
config.APIKey = env.LLMAPIKey
|
||||
config.MultimodalModel = env.MultimodalModel
|
||||
config.TranscriptionModel = env.TranscriptionModel
|
||||
config.TranscriptionLanguage = env.TranscriptionLanguage
|
||||
config.TTSModel = env.TTSModel
|
||||
|
||||
if config.PeriodicRuns == "" {
|
||||
config.PeriodicRuns = "10m"
|
||||
}
|
||||
if config.SchedulerPollInterval == "" {
|
||||
config.SchedulerPollInterval = "30s"
|
||||
}
|
||||
|
||||
// Initialize skills service
|
||||
skillsService, err := skills.NewService(env.StateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize skills service: %w", err)
|
||||
}
|
||||
|
||||
// Build service factories
|
||||
actionsFactory := services.Actions(map[string]string{
|
||||
services.ActionConfigSSHBoxURL: env.SSHBoxURL,
|
||||
services.ConfigStateDir: env.StateDir,
|
||||
services.CustomActionsDir: env.CustomActionsDir,
|
||||
})
|
||||
dynamicPromptsFactory := services.DynamicPrompts(map[string]string{
|
||||
services.ConfigStateDir: env.StateDir,
|
||||
services.CustomActionsDir: env.CustomActionsDir,
|
||||
})
|
||||
|
||||
// Create the pool and use it to start the agent
|
||||
pool, err := state.NewAgentPool(
|
||||
env.Model, env.MultimodalModel, env.TranscriptionModel, env.TranscriptionLanguage, env.TTSModel,
|
||||
env.LLMAPIURL, env.LLMAPIKey, env.StateDir,
|
||||
actionsFactory, services.Connectors, dynamicPromptsFactory, services.Filters,
|
||||
env.Timeout, false, skillsService,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create agent pool: %w", err)
|
||||
}
|
||||
|
||||
if env.LocalRAGURL != "" {
|
||||
pool.SetRAGProvider(state.NewHTTPRAGProvider(env.LocalRAGURL, env.LLMAPIKey))
|
||||
}
|
||||
|
||||
// Start the agent via the pool (handles all option building, connectors, etc.)
|
||||
if err := pool.StartAgentStandalone(name, config); err != nil {
|
||||
return fmt.Errorf("failed to start agent: %w", err)
|
||||
}
|
||||
|
||||
a := pool.GetAgent(name)
|
||||
if a == nil {
|
||||
return fmt.Errorf("agent %q was not found after starting", name)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Starting agent %q (model: %s, api: %s)\n", name, env.Model, env.LLMAPIURL)
|
||||
fmt.Fprintf(os.Stderr, "Press Ctrl+C to stop\n")
|
||||
|
||||
// Wait for interrupt
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
sig := <-sigCh
|
||||
fmt.Fprintf(os.Stderr, "\nReceived %v, stopping agent...\n", sig)
|
||||
pool.Stop(name)
|
||||
|
||||
// Give agent a moment to clean up
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
return nil
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Env contains all environment variables used by LocalAGI
|
||||
type Env struct {
|
||||
// Model and API configuration
|
||||
Model string
|
||||
LLMAPIURL string
|
||||
LLMAPIKey string
|
||||
MultimodalModel string
|
||||
TranscriptionModel string
|
||||
TranscriptionLanguage string
|
||||
TTSModel string
|
||||
Timeout string
|
||||
|
||||
// Directories and paths
|
||||
StateDir string
|
||||
LocalRAGURL string
|
||||
CustomActionsDir string
|
||||
SSHBoxURL string
|
||||
CollectionDBPath string
|
||||
FileAssets string
|
||||
|
||||
// Conversation settings
|
||||
EnableConversationsLogging bool
|
||||
APIKeys []string
|
||||
ConversationDuration string
|
||||
|
||||
// RAG/Vector settings
|
||||
VectorEngine string
|
||||
EmbeddingModel string
|
||||
MaxChunkingSize int
|
||||
ChunkOverlap int
|
||||
DatabaseURL string
|
||||
}
|
||||
|
||||
// LoadEnv reads all environment variables and returns an Env struct
|
||||
func LoadEnv() Env {
|
||||
env := Env{
|
||||
Model: envOrDefault("LOCALAGI_MODEL", ""),
|
||||
LLMAPIURL: envOrDefault("LOCALAGI_LLM_API_URL", ""),
|
||||
LLMAPIKey: envOrDefault("LOCALAGI_LLM_API_KEY", ""),
|
||||
MultimodalModel: envOrDefault("LOCALAGI_MULTIMODAL_MODEL", ""),
|
||||
TranscriptionModel: envOrDefault("LOCALAGI_TRANSCRIPTION_MODEL", ""),
|
||||
TranscriptionLanguage: envOrDefault("LOCALAGI_TRANSCRIPTION_LANGUAGE", ""),
|
||||
TTSModel: envOrDefault("LOCALAGI_TTS_MODEL", ""),
|
||||
Timeout: envOrDefault("LOCALAGI_TIMEOUT", "5m"),
|
||||
StateDir: envOrDefault("LOCALAGI_STATE_DIR", ""),
|
||||
LocalRAGURL: os.Getenv("LOCALAGI_LOCALRAG_URL"),
|
||||
CustomActionsDir: os.Getenv("LOCALAGI_CUSTOM_ACTIONS_DIR"),
|
||||
SSHBoxURL: os.Getenv("LOCALAGI_SSHBOX_URL"),
|
||||
EnableConversationsLogging: os.Getenv("LOCALAGI_ENABLE_CONVERSATIONS_LOGGING") == "true",
|
||||
ConversationDuration: os.Getenv("LOCALAGI_CONVERSATION_DURATION"),
|
||||
CollectionDBPath: os.Getenv("COLLECTION_DB_PATH"),
|
||||
FileAssets: os.Getenv("FILE_ASSETS"),
|
||||
VectorEngine: os.Getenv("VECTOR_ENGINE"),
|
||||
EmbeddingModel: os.Getenv("EMBEDDING_MODEL"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
}
|
||||
|
||||
// Parse APIKeys from comma-separated string
|
||||
if apiKeysEnv := os.Getenv("LOCALAGI_API_KEYS"); apiKeysEnv != "" {
|
||||
env.APIKeys = strings.Split(apiKeysEnv, ",")
|
||||
}
|
||||
|
||||
// Parse numeric values
|
||||
if maxChunkingSizeEnv := os.Getenv("MAX_CHUNKING_SIZE"); maxChunkingSizeEnv != "" {
|
||||
if n, err := strconv.Atoi(maxChunkingSizeEnv); err == nil {
|
||||
env.MaxChunkingSize = n
|
||||
}
|
||||
}
|
||||
|
||||
if chunkOverlapEnv := os.Getenv("CHUNK_OVERLAP"); chunkOverlapEnv != "" {
|
||||
if n, err := strconv.Atoi(chunkOverlapEnv); err == nil {
|
||||
env.ChunkOverlap = n
|
||||
}
|
||||
}
|
||||
|
||||
// Set defaults for empty values
|
||||
if env.VectorEngine == "" {
|
||||
env.VectorEngine = "chromem"
|
||||
}
|
||||
if env.EmbeddingModel == "" {
|
||||
env.EmbeddingModel = "granite-embedding-107m-multilingual"
|
||||
}
|
||||
if env.MaxChunkingSize == 0 {
|
||||
env.MaxChunkingSize = 400
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
// envOrDefault returns the environment variable value if set, otherwise the fallback.
|
||||
func envOrDefault(envKey, fallback string) string {
|
||||
if v := os.Getenv(envKey); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "local-agi",
|
||||
Short: "LocalAGI - Self-hosted AI Agent platform",
|
||||
Long: "LocalAGI is a self-hosted AI Agent platform that allows running autonomous agents with various connectors, actions, and tools.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// If no subcommand is provided, default to serving the web server
|
||||
// This ensures the container starts the web server by default
|
||||
return serveCmd.RunE(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
// Execute runs the root command.
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
rootCmd.AddCommand(agentCmd)
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/services"
|
||||
"github.com/mudler/LocalAGI/services/skills"
|
||||
"github.com/mudler/LocalAGI/webui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the LocalAGI web server",
|
||||
Long: "Start the LocalAGI web server with the agent pool and web UI.",
|
||||
RunE: runServe,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
}
|
||||
|
||||
func runServe(cmd *cobra.Command, args []string) error {
|
||||
// Load all environment variables
|
||||
env := LoadEnv()
|
||||
|
||||
if env.Model == "" {
|
||||
return cmd.Help()
|
||||
}
|
||||
if env.LLMAPIURL == "" {
|
||||
return cmd.Help()
|
||||
}
|
||||
|
||||
if env.StateDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env.StateDir = filepath.Join(cwd, "pool")
|
||||
}
|
||||
|
||||
os.MkdirAll(env.StateDir, 0755)
|
||||
|
||||
if env.CollectionDBPath == "" {
|
||||
env.CollectionDBPath = filepath.Join(env.StateDir, "collections")
|
||||
}
|
||||
if env.FileAssets == "" {
|
||||
env.FileAssets = filepath.Join(env.StateDir, "assets")
|
||||
}
|
||||
|
||||
apiKeys := env.APIKeys
|
||||
if len(apiKeys) == 0 {
|
||||
apiKeys = []string{}
|
||||
}
|
||||
|
||||
skillsService, err := skills.NewService(env.StateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pool, err := state.NewAgentPool(
|
||||
env.Model,
|
||||
env.MultimodalModel,
|
||||
env.TranscriptionModel,
|
||||
env.TranscriptionLanguage,
|
||||
env.TTSModel,
|
||||
env.LLMAPIURL,
|
||||
env.LLMAPIKey,
|
||||
env.StateDir,
|
||||
services.Actions(map[string]string{
|
||||
services.ActionConfigSSHBoxURL: env.SSHBoxURL,
|
||||
services.ConfigStateDir: env.StateDir,
|
||||
services.CustomActionsDir: env.CustomActionsDir,
|
||||
}),
|
||||
services.Connectors,
|
||||
services.DynamicPrompts(map[string]string{
|
||||
services.ConfigStateDir: env.StateDir,
|
||||
services.CustomActionsDir: env.CustomActionsDir,
|
||||
}),
|
||||
services.Filters,
|
||||
env.Timeout,
|
||||
env.EnableConversationsLogging,
|
||||
skillsService,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app := webui.NewApp(
|
||||
webui.WithPool(pool),
|
||||
webui.WithSkillsService(skillsService),
|
||||
webui.WithConversationStoreduration(env.ConversationDuration),
|
||||
webui.WithApiKeys(apiKeys...),
|
||||
webui.WithLLMAPIUrl(env.LLMAPIURL),
|
||||
webui.WithLLMAPIKey(env.LLMAPIKey),
|
||||
webui.WithLLMModel(env.Model),
|
||||
webui.WithCustomActionsDir(env.CustomActionsDir),
|
||||
webui.WithStateDir(env.StateDir),
|
||||
webui.WithCollectionDBPath(env.CollectionDBPath),
|
||||
webui.WithFileAssets(env.FileAssets),
|
||||
webui.WithVectorEngine(env.VectorEngine),
|
||||
webui.WithEmbeddingModel(env.EmbeddingModel),
|
||||
webui.WithMaxChunkingSize(env.MaxChunkingSize),
|
||||
webui.WithChunkOverlap(env.ChunkOverlap),
|
||||
webui.WithDatabaseURL(env.DatabaseURL),
|
||||
webui.WithLocalRAGURL(env.LocalRAGURL),
|
||||
)
|
||||
|
||||
if env.LocalRAGURL != "" {
|
||||
pool.SetRAGProvider(state.NewHTTPRAGProvider(env.LocalRAGURL, env.LLMAPIKey))
|
||||
} else {
|
||||
embedded := app.CollectionsRAGProvider()
|
||||
pool.SetRAGProvider(func(collectionName, _, _ string) (agent.RAGDB, state.KBCompactionClient, bool) {
|
||||
return embedded(collectionName)
|
||||
})
|
||||
}
|
||||
|
||||
if err := pool.StartAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Fatal(app.Listen(":3000"))
|
||||
return nil
|
||||
}
|
||||
+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"},
|
||||
}
|
||||
}
|
||||
+41
-502
@@ -1,155 +1,19 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const parameterReasoningPrompt = `You are tasked with generating the optimal parameters for the action "%s". The action requires the following parameters:
|
||||
%s
|
||||
|
||||
Your task is to:
|
||||
1. Generate the best possible values for each required parameter
|
||||
2. If the parameter requires code, provide complete, working code
|
||||
3. If the parameter requires text or documentation, provide comprehensive, well-structured content
|
||||
4. Ensure all parameters are complete and ready to be used
|
||||
|
||||
Focus on quality and completeness. Do not explain your reasoning or analyze the action's purpose - just provide the best possible parameter values.`
|
||||
|
||||
type decisionResult struct {
|
||||
actionParams types.ActionParams
|
||||
message string
|
||||
actionName 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, actionName: 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 {
|
||||
@@ -228,210 +92,48 @@ func (m Messages) GetLatestUserMessage() *openai.ChatCompletionMessage {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Messages) IsLastMessageFromRole(role string) bool {
|
||||
if len(m) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return m[len(m)-1].Role == role
|
||||
}
|
||||
|
||||
func (a *Agent) generateParameters(job *types.Job, pickTemplate string, act types.Action, c []openai.ChatCompletionMessage, reasoning string, maxAttempts int) (*decisionResult, error) {
|
||||
if act == nil {
|
||||
return nil, fmt.Errorf("action is nil")
|
||||
}
|
||||
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 {
|
||||
// First, get the LLM to reason about optimal parameter usage
|
||||
parameterReasoningPrompt := fmt.Sprintf(parameterReasoningPrompt,
|
||||
act.Definition().Name,
|
||||
formatProperties(act.Definition().Properties))
|
||||
|
||||
// Get initial reasoning about parameters using askLLM
|
||||
paramReasoningMsg, err := a.askLLM(job.GetContext(),
|
||||
append(conversation, openai.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: parameterReasoningPrompt,
|
||||
}),
|
||||
maxAttempts,
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Warn("Failed to get parameter reasoning", "error", err)
|
||||
// mergeLeadingSystemMessages replaces all leading system messages with a single
|
||||
// system message. prefixBlocks are prepended in order (e.g. self-eval, then HUD).
|
||||
// Only non-empty prefixBlocks are joined. Mid-conversation system messages are unchanged.
|
||||
func (conv Messages) mergeLeadingSystemMessages(prefixBlocks ...string) Messages {
|
||||
var leading []string
|
||||
for _, s := range prefixBlocks {
|
||||
if s != "" {
|
||||
leading = append(leading, s)
|
||||
}
|
||||
|
||||
// Combine original reasoning with parameter-specific reasoning
|
||||
enhancedReasoning := reasoning
|
||||
if paramReasoningMsg.Content != "" {
|
||||
enhancedReasoning = fmt.Sprintf("%s\n\nParameter Analysis:\n%s", reasoning, paramReasoningMsg.Content)
|
||||
}
|
||||
i := 0
|
||||
for i < len(conv) && conv[i].Role == SystemRole {
|
||||
content := conv[i].Content
|
||||
if content == "" && conv[i].MultiContent != nil {
|
||||
for _, part := range conv[i].MultiContent {
|
||||
if part.Type == openai.ChatMessagePartTypeText && part.Text != "" {
|
||||
content = part.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, enhancedReasoning),
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
if content != "" {
|
||||
leading = append(leading, content)
|
||||
}
|
||||
xlog.Warn("Attempt to generate parameters failed", "attempt", attempts+1, "error", attemptErr)
|
||||
i++
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to generate parameters after %d attempts: %w", maxAttempts, attemptErr)
|
||||
}
|
||||
|
||||
// Helper function to format properties for the prompt
|
||||
func formatProperties(props map[string]jsonschema.Definition) string {
|
||||
var result strings.Builder
|
||||
for name, prop := range props {
|
||||
result.WriteString(fmt.Sprintf("- %s: %s\n", name, prop.Description))
|
||||
if len(leading) == 0 {
|
||||
return conv
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
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
|
||||
combined := strings.Join(leading, "\n\n")
|
||||
single := openai.ChatCompletionMessage{
|
||||
Role: SystemRole,
|
||||
Content: combined,
|
||||
}
|
||||
|
||||
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)
|
||||
if subTaskAction == nil {
|
||||
xlog.Error("Action not found: %s", subtask.Action)
|
||||
return conv, fmt.Errorf("action %s not found", 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
|
||||
return append([]openai.ChatCompletionMessage{single}, conv[i:]...)
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
baseActions := a.availableActions(job)
|
||||
|
||||
// Add user-defined actions from the job
|
||||
userTools := job.GetUserTools()
|
||||
if len(userTools) > 0 {
|
||||
@@ -439,31 +141,15 @@ func (a *Agent) getAvailableActionsForJob(job *types.Job) types.Actions {
|
||||
baseActions = append(baseActions, userDefinedActions...)
|
||||
xlog.Debug("Added user-defined actions", "definitions", userTools)
|
||||
}
|
||||
|
||||
|
||||
return baseActions
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -472,7 +158,7 @@ func (a *Agent) availableActions() types.Actions {
|
||||
// acts = append(acts, action.NewStop())
|
||||
// }
|
||||
|
||||
return addPlanAction(acts)
|
||||
return acts
|
||||
}
|
||||
|
||||
if a.options.canStopItself {
|
||||
@@ -480,14 +166,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) {
|
||||
@@ -502,150 +188,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)
|
||||
|
||||
// Get available actions including user-defined ones
|
||||
availableActions := a.getAvailableActionsForJob(job)
|
||||
|
||||
// Identify the goal of this conversation
|
||||
|
||||
if !a.options.forceReasoning || job.ToolChoice != "" {
|
||||
xlog.Debug("not forcing reasoning", "forceReasoning", a.options.forceReasoning, "ToolChoice", job.ToolChoice)
|
||||
// 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,
|
||||
availableActions.ToTools(),
|
||||
job.ToolChoice,
|
||||
maxRetries)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
xlog.Debug("thought action Name", "actionName", thought.actionName)
|
||||
xlog.Debug("thought message", "message", thought.message)
|
||||
|
||||
// Find the action
|
||||
chosenAction := availableActions.Find(thought.actionName)
|
||||
if chosenAction == nil || thought.actionName == "" {
|
||||
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
|
||||
}
|
||||
|
||||
// Force the LLM to think and we extract a "reasoning" to pick a specific action and with which parameters
|
||||
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...)
|
||||
}
|
||||
|
||||
// Create a detailed prompt for reasoning that includes available actions and their properties
|
||||
reasoningPrompt := "Analyze the current situation and determine the best course of action. Consider the following:\n\n"
|
||||
reasoningPrompt += "Available Actions:\n"
|
||||
for _, act := range a.availableActions() {
|
||||
reasoningPrompt += fmt.Sprintf("- %s: %s\n", act.Definition().Name, act.Definition().Description)
|
||||
if len(act.Definition().Properties) > 0 {
|
||||
reasoningPrompt += " Properties:\n"
|
||||
for name, prop := range act.Definition().Properties {
|
||||
reasoningPrompt += fmt.Sprintf(" - %s: %s\n", name, prop.Description)
|
||||
}
|
||||
}
|
||||
reasoningPrompt += "\n"
|
||||
}
|
||||
reasoningPrompt += "\nProvide a detailed reasoning about what action would be most appropriate in this situation and why. You can also just reply with a simple message by choosing the 'reply' or 'answer' action."
|
||||
|
||||
// Get reasoning using askLLM
|
||||
reasoningMsg, err := a.askLLM(job.GetContext(),
|
||||
append(c, openai.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: reasoningPrompt,
|
||||
}),
|
||||
maxRetries)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to get reasoning: %w", err)
|
||||
}
|
||||
|
||||
originalReasoning := reasoningMsg.Content
|
||||
|
||||
xlog.Debug("[pickAction] picking action", "messages", 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
|
||||
}
|
||||
|
||||
+772
-628
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"},
|
||||
}
|
||||
}
|
||||
|
||||
+139
-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,67 @@ 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
|
||||
// Pre-connected MCP sessions (e.g. in-process skills server); already in a.mcpSessions after closeMCPServers()
|
||||
for _, session := range a.options.extraMCPSessions {
|
||||
actions, err := a.addTools(session)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to add tools for extra MCP session", "error", err.Error())
|
||||
continue
|
||||
}
|
||||
a.mcpSessions = append(a.mcpSessions, session)
|
||||
generatedActions = append(generatedActions, actions...)
|
||||
}
|
||||
|
||||
a.mcpActionDefinitions = generatedActions
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Agent) closeMCPSTDIOServers() {
|
||||
client := stdio.NewClient(a.options.mcpBoxURL)
|
||||
client.StopGroup(a.Character.Name)
|
||||
func (a *Agent) closeMCPServers() {
|
||||
extraSet := make(map[*mcp.ClientSession]bool)
|
||||
for _, e := range a.options.extraMCPSessions {
|
||||
extraSet[e] = true
|
||||
}
|
||||
var keep []*mcp.ClientSession
|
||||
for _, s := range a.mcpSessions {
|
||||
if extraSet[s] {
|
||||
keep = append(keep, s)
|
||||
} else {
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
a.mcpSessions = keep
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("mergeLeadingSystemMessages", func() {
|
||||
It("merges multiple leading system messages into one", func() {
|
||||
conv := Messages{
|
||||
{Role: SystemRole, Content: "You are a helper."},
|
||||
{Role: SystemRole, Content: "Given the user input you have the following in memory:\n- fact1"},
|
||||
{Role: "user", Content: "hello"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages()
|
||||
Expect(out).To(HaveLen(2))
|
||||
Expect(out[0].Role).To(Equal(SystemRole))
|
||||
Expect(out[0].Content).To(Equal("You are a helper.\n\nGiven the user input you have the following in memory:\n- fact1"))
|
||||
Expect(out[1].Role).To(Equal("user"))
|
||||
Expect(out[1].Content).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("prepends prefix blocks in order (self-eval then HUD)", func() {
|
||||
conv := Messages{
|
||||
{Role: SystemRole, Content: "Main system prompt."},
|
||||
{Role: "user", Content: "hi"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages("Self-eval block.", "HUD block.")
|
||||
Expect(out).To(HaveLen(2))
|
||||
Expect(out[0].Role).To(Equal(SystemRole))
|
||||
Expect(out[0].Content).To(Equal("Self-eval block.\n\nHUD block.\n\nMain system prompt."))
|
||||
Expect(out[1].Role).To(Equal("user"))
|
||||
})
|
||||
|
||||
It("skips empty prefix blocks", func() {
|
||||
conv := Messages{
|
||||
{Role: SystemRole, Content: "Only this."},
|
||||
{Role: "user", Content: "hi"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages("", "HUD.", "")
|
||||
Expect(out[0].Content).To(Equal("HUD.\n\nOnly this."))
|
||||
})
|
||||
|
||||
It("leaves mid-conversation system messages unchanged", func() {
|
||||
conv := Messages{
|
||||
{Role: SystemRole, Content: "Leading system."},
|
||||
{Role: "user", Content: "message with images"},
|
||||
{Role: SystemRole, Content: "Image explainer (would be rectified elsewhere)."},
|
||||
{Role: "assistant", Content: "reply"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages()
|
||||
Expect(out).To(HaveLen(4))
|
||||
Expect(out[0].Role).To(Equal(SystemRole))
|
||||
Expect(out[0].Content).To(Equal("Leading system."))
|
||||
Expect(out[1].Role).To(Equal("user"))
|
||||
Expect(out[2].Role).To(Equal(SystemRole))
|
||||
Expect(out[2].Content).To(Equal("Image explainer (would be rectified elsewhere)."))
|
||||
Expect(out[3].Role).To(Equal("assistant"))
|
||||
})
|
||||
|
||||
It("returns conv unchanged when there are no leading system messages and no prefix blocks", func() {
|
||||
conv := Messages{
|
||||
{Role: "user", Content: "hi"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages()
|
||||
Expect(out).To(Equal(conv))
|
||||
})
|
||||
|
||||
It("returns only prefix blocks as single system message when conv has no leading system messages", func() {
|
||||
conv := Messages{
|
||||
{Role: "user", Content: "hi"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages("Self-eval.", "HUD.")
|
||||
Expect(out).To(HaveLen(2))
|
||||
Expect(out[0].Role).To(Equal(SystemRole))
|
||||
Expect(out[0].Content).To(Equal("Self-eval.\n\nHUD."))
|
||||
Expect(out[1].Role).To(Equal("user"))
|
||||
})
|
||||
|
||||
It("produces exactly one leading system message with config + RAG + HUD content", func() {
|
||||
conv := Messages{
|
||||
{Role: SystemRole, Content: "RAG: memory context"},
|
||||
{Role: SystemRole, Content: "Config system prompt."},
|
||||
{Role: "user", Content: "hello"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages("Self-eval.", "HUD.")
|
||||
Expect(out).To(HaveLen(2))
|
||||
Expect(out[0].Role).To(Equal(SystemRole))
|
||||
Expect(out[0].Content).To(ContainSubstring("Self-eval."))
|
||||
Expect(out[0].Content).To(ContainSubstring("HUD."))
|
||||
Expect(out[0].Content).To(ContainSubstring("RAG: memory context"))
|
||||
Expect(out[0].Content).To(ContainSubstring("Config system prompt."))
|
||||
systemCount := 0
|
||||
for _, m := range out {
|
||||
if m.Role == SystemRole {
|
||||
systemCount++
|
||||
}
|
||||
}
|
||||
Expect(systemCount).To(Equal(1))
|
||||
})
|
||||
})
|
||||
+20
-6
@@ -7,22 +7,28 @@ 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()
|
||||
}
|
||||
|
||||
// historyRingSize is the number of observables kept in the ring buffer. When full,
|
||||
// the oldest entry is overwritten. The UI builds a tree from parent_id; if a parent
|
||||
// is evicted before its children, those children will appear as roots or be omitted.
|
||||
const historyRingSize = 500
|
||||
|
||||
type SSEObserver struct {
|
||||
agent string
|
||||
maxID int32
|
||||
manager sse.Manager
|
||||
|
||||
mutex sync.Mutex
|
||||
history []types.Observable
|
||||
mutex sync.Mutex
|
||||
history []types.Observable
|
||||
historyLast int
|
||||
}
|
||||
|
||||
@@ -31,13 +37,13 @@ func NewSSEObserver(agent string, manager sse.Manager) *SSEObserver {
|
||||
agent: agent,
|
||||
maxID: 1,
|
||||
manager: manager,
|
||||
history: make([]types.Observable, 100),
|
||||
history: make([]types.Observable, historyRingSize),
|
||||
}
|
||||
}
|
||||
|
||||
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 +92,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, historyRingSize)
|
||||
s.historyLast = 0
|
||||
}
|
||||
|
||||
+223
-33
@@ -5,17 +5,34 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/mudler/cogito"
|
||||
)
|
||||
|
||||
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,28 +44,38 @@ 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
|
||||
forceReasoningTool 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
|
||||
|
||||
// Evaluation settings
|
||||
maxEvaluationLoops int
|
||||
loopDetection int
|
||||
enableEvaluation bool
|
||||
|
||||
prompts []DynamicPrompt
|
||||
|
||||
systemPrompt string
|
||||
systemPrompt string
|
||||
innerMonologueTemplate string
|
||||
skillPromptTemplate string
|
||||
schedulerTaskTemplate string
|
||||
|
||||
// callbacks
|
||||
reasoningCallback func(types.ActionCurrentState) bool
|
||||
@@ -58,14 +85,25 @@ type options struct {
|
||||
|
||||
mcpServers []MCPServer
|
||||
mcpStdioServers []MCPSTDIOServer
|
||||
mcpBoxURL string
|
||||
mcpPrepareScript string
|
||||
newConversationsSubscribers []func(openai.ChatCompletionMessage)
|
||||
extraMCPSessions []*mcp.ClientSession
|
||||
newConversationsSubscribers []func(*types.ConversationMessage)
|
||||
|
||||
observer Observer
|
||||
observer Observer
|
||||
enableAutoCompaction bool
|
||||
autoCompactionThreshold int
|
||||
parallelJobs int
|
||||
|
||||
lastMessageDuration time.Duration
|
||||
|
||||
// cancelPreviousOnNewMessage: when true (or nil), Enqueue cancels the running job for the same conversation_id. When false, jobs are queued.
|
||||
cancelPreviousOnNewMessage *bool
|
||||
|
||||
// maxAttempts: on ExecuteTools failure, retry up to this many times before surfacing the error to the user (1 = no retries).
|
||||
maxAttempts int
|
||||
|
||||
// streamCallback receives streaming events from cogito during final answer generation.
|
||||
streamCallback func(cogito.StreamEvent)
|
||||
}
|
||||
|
||||
func (o *options) SeparatedMultimodalModel() bool {
|
||||
@@ -74,14 +112,20 @@ func (o *options) SeparatedMultimodalModel() bool {
|
||||
|
||||
func defaultOptions() *options {
|
||||
return &options{
|
||||
parallelJobs: 1,
|
||||
periodicRuns: 15 * time.Minute,
|
||||
loopDetectionSteps: 10,
|
||||
maxEvaluationLoops: 2,
|
||||
enableEvaluation: false,
|
||||
parallelJobs: 1,
|
||||
maxAttempts: 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 +157,16 @@ var EnableForceReasoning = func(o *options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var EnableGuidedTools = func(o *options) error {
|
||||
o.enableGuidedTools = true
|
||||
return nil
|
||||
}
|
||||
|
||||
var EnableForceReasoningTool = func(o *options) error {
|
||||
o.forceReasoningTool = true
|
||||
return nil
|
||||
}
|
||||
|
||||
var EnableKnowledgeBase = func(o *options) error {
|
||||
o.enableKB = true
|
||||
o.kbResults = 5
|
||||
@@ -131,13 +185,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 +218,30 @@ func WithParallelJobs(jobs int) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithNewConversationSubscriber(sub func(openai.ChatCompletionMessage)) Option {
|
||||
// WithCancelPreviousOnNewMessage sets whether a new job with the same conversation_id cancels the currently running job (true) or is queued (false). Nil/default means true.
|
||||
func WithCancelPreviousOnNewMessage(cancel bool) Option {
|
||||
return func(o *options) error {
|
||||
o.cancelPreviousOnNewMessage = &cancel
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxAttempts sets how many times to attempt execution on failure before surfacing the error to the user (1 = no retries).
|
||||
func WithMaxAttempts(attempts int) Option {
|
||||
return func(o *options) error {
|
||||
o.maxAttempts = attempts
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithLoopDetection(loops int) Option {
|
||||
return func(o *options) error {
|
||||
o.loopDetection = loops
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithNewConversationSubscriber(sub func(*types.ConversationMessage)) Option {
|
||||
return func(o *options) error {
|
||||
o.newConversationsSubscribers = append(o.newConversationsSubscribers, sub)
|
||||
return nil
|
||||
@@ -188,6 +258,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 +299,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
|
||||
@@ -224,6 +319,22 @@ func WithSystemPrompt(prompt string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithInnerMonologueTemplate sets the prompt used for periodic/standalone runs. If empty, the default template is used.
|
||||
func WithInnerMonologueTemplate(template string) Option {
|
||||
return func(o *options) error {
|
||||
o.innerMonologueTemplate = template
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithSkillPromptTemplate sets the template for rendering skills in the prompt. If empty, the default template is used.
|
||||
func WithSkillPromptTemplate(template string) Option {
|
||||
return func(o *options) error {
|
||||
o.skillPromptTemplate = template
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithMCPServers(servers ...MCPServer) Option {
|
||||
return func(o *options) error {
|
||||
o.mcpServers = servers
|
||||
@@ -238,13 +349,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
|
||||
@@ -283,6 +387,14 @@ func WithPrompts(prompts ...DynamicPrompt) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithMCPSession adds a pre-connected MCP client session (e.g. in-process skills MCP) to the agent.
|
||||
func WithMCPSession(session *mcp.ClientSession) Option {
|
||||
return func(o *options) error {
|
||||
o.extraMCPSessions = append(o.extraMCPSessions, session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithDynamicPrompts is a helper function to create dynamic prompts
|
||||
// Dynamic prompts contains golang code which is executed dynamically
|
||||
// // to render a prompt to the LLM
|
||||
@@ -331,6 +443,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 +512,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 +549,69 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
// WithSchedulerTaskTemplate sets the prompt used for scheduled/recurring tasks run by the scheduler.
|
||||
// If empty, the default inner monologue template is used with the task injected.
|
||||
func WithSchedulerTaskTemplate(template string) Option {
|
||||
return func(o *options) error {
|
||||
o.schedulerTaskTemplate = template
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var EnableAutoCompaction = func(o *options) error {
|
||||
o.enableAutoCompaction = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func WithAutoCompactionThreshold(threshold int) Option {
|
||||
return func(o *options) error {
|
||||
o.autoCompactionThreshold = threshold
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithStreamCallback sets a callback to receive streaming events from cogito
|
||||
// during final answer generation. This enables live token-by-token delivery.
|
||||
func WithStreamCallback(fn func(cogito.StreamEvent)) Option {
|
||||
return func(o *options) error {
|
||||
o.streamCallback = fn
|
||||
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,84 @@
|
||||
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) {
|
||||
// Render the scheduler task template - if custom template is set, it will include {{.Task}}
|
||||
// If no custom scheduler template is set, fall back to default inner monologue template
|
||||
innerMonologue := 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)
|
||||
|
||||
if e.agent.options.schedulerTaskTemplate != "" {
|
||||
tmpl, err := templateBase("taskTemplate", e.agent.options.schedulerTaskTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to render scheduler task template: %w", err)
|
||||
}
|
||||
|
||||
innerMonologue, err = templateExecute(tmpl, &InnerMonologueTemplateData{
|
||||
CommonTemplateData: CommonTemplateData{
|
||||
AgentName: agentName,
|
||||
},
|
||||
Task: prompt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to render scheduler task template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a job for the reminder with the rendered inner monologue
|
||||
reminderJob := types.NewJob(
|
||||
types.WithText(innerMonologue),
|
||||
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
|
||||
}
|
||||
}
|
||||
+28
-44
@@ -2,18 +2,41 @@ 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"
|
||||
)
|
||||
|
||||
type CommonTemplateData struct {
|
||||
AgentName string
|
||||
}
|
||||
|
||||
type InnerMonologueTemplateData struct {
|
||||
CommonTemplateData
|
||||
Task string
|
||||
}
|
||||
|
||||
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 +57,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 +103,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 +117,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 +127,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(),
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ type (
|
||||
Manager interface {
|
||||
Send(message Envelope)
|
||||
Handle(ctx *fiber.Ctx, cl Listener)
|
||||
Register(cl Listener)
|
||||
Unregister(id string)
|
||||
Clients() []string
|
||||
}
|
||||
|
||||
@@ -110,6 +112,17 @@ func (manager *broadcastManager) Send(message Envelope) {
|
||||
manager.broadcast <- message
|
||||
}
|
||||
|
||||
// Register adds a client to the broadcast list and sends message history.
|
||||
func (manager *broadcastManager) Register(cl Listener) {
|
||||
manager.register(cl)
|
||||
manager.messageHistory.Send(cl)
|
||||
}
|
||||
|
||||
// Unregister removes a client from the broadcast list and closes its channel.
|
||||
func (manager *broadcastManager) Unregister(id string) {
|
||||
manager.unregister(id)
|
||||
}
|
||||
|
||||
// Handle sets up a new client and handles the connection.
|
||||
func (manager *broadcastManager) Handle(c *fiber.Ctx, cl Listener) {
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// KBCompactionClient is the interface used by compaction. It can be implemented by the HTTP RAG client adapter or by the in-process collection adapter.
|
||||
type KBCompactionClient interface {
|
||||
Collection() string
|
||||
ListEntries() ([]string, error)
|
||||
GetEntryContent(entry string) (content string, chunkCount int, err error)
|
||||
Store(filePath string) error
|
||||
DeleteEntry(entry string) error
|
||||
}
|
||||
|
||||
// wrappedClientCompactionAdapter adapts *localrag.WrappedClient to KBCompactionClient.
|
||||
type wrappedClientCompactionAdapter struct {
|
||||
*localrag.WrappedClient
|
||||
}
|
||||
|
||||
func (a *wrappedClientCompactionAdapter) ListEntries() ([]string, error) {
|
||||
return a.Client.ListEntries(a.Collection())
|
||||
}
|
||||
|
||||
func (a *wrappedClientCompactionAdapter) Store(filePath string) error {
|
||||
_, err := a.Client.Store(a.Collection(), filePath)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *wrappedClientCompactionAdapter) DeleteEntry(entry string) error {
|
||||
_, err := a.Client.DeleteEntry(a.Collection(), entry)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 KBCompactionClient, period string, summarize bool, apiURL, apiKey, model string) error {
|
||||
collection := client.Collection()
|
||||
entries, err := client.ListEntries()
|
||||
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.Store(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.DeleteEntry(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 KBCompactionClient, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+280
-41
@@ -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,40 +59,61 @@ 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"`
|
||||
StandaloneJob bool `json:"standalone_job" form:"standalone_job"`
|
||||
RandomIdentity bool `json:"random_identity" form:"random_identity"`
|
||||
InitiateConversations bool `json:"initiate_conversations" form:"initiate_conversations"`
|
||||
CanPlan bool `json:"enable_planning" form:"enable_planning"`
|
||||
IdentityGuidance string `json:"identity_guidance" form:"identity_guidance"`
|
||||
PeriodicRuns string `json:"periodic_runs" form:"periodic_runs"`
|
||||
PermanentGoal string `json:"permanent_goal" form:"permanent_goal"`
|
||||
EnableKnowledgeBase bool `json:"enable_kb" form:"enable_kb"`
|
||||
EnableReasoning bool `json:"enable_reasoning" form:"enable_reasoning"`
|
||||
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"`
|
||||
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"`
|
||||
Name string `json:"name" form:"name"`
|
||||
HUD bool `json:"hud" form:"hud"`
|
||||
StandaloneJob bool `json:"standalone_job" form:"standalone_job"`
|
||||
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"`
|
||||
SchedulerTaskTemplate string `json:"scheduler_task_template" form:"scheduler_task_template"`
|
||||
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"`
|
||||
EnableForceReasoningTool bool `json:"enable_reasoning_tool" form:"enable_reasoning_tool"`
|
||||
EnableGuidedTools bool `json:"enable_guided_tools" form:"enable_guided_tools"`
|
||||
EnableSkills bool `json:"enable_skills" form:"enable_skills"`
|
||||
KnowledgeBaseResults int `json:"kb_results" form:"kb_results"`
|
||||
CanStopItself bool `json:"can_stop_itself" form:"can_stop_itself"`
|
||||
SystemPrompt string `json:"system_prompt" form:"system_prompt"`
|
||||
SkillsPrompt string `json:"skills_prompt" form:"skills_prompt"`
|
||||
InnerMonologueTemplate string `json:"inner_monologue_template" form:"inner_monologue_template"`
|
||||
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"`
|
||||
CancelPreviousOnNewMessage *bool `json:"cancel_previous_on_new_message" form:"cancel_previous_on_new_message"`
|
||||
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"`
|
||||
MaxAttempts int `json:"max_attempts" form:"max_attempts"`
|
||||
LoopDetection int `json:"loop_detection" form:"loop_detection"`
|
||||
EnableAutoCompaction bool `json:"enable_auto_compaction" form:"enable_auto_compaction"`
|
||||
AutoCompactionThreshold int `json:"auto_compaction_threshold" form:"auto_compaction_threshold"`
|
||||
}
|
||||
|
||||
type AgentConfigMeta struct {
|
||||
@@ -146,6 +183,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 +255,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 +294,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",
|
||||
@@ -220,6 +339,30 @@ func NewAgentConfigMeta(
|
||||
HelpText: "Long-term objective for the agent to pursue",
|
||||
Tags: config.Tags{Section: "PromptsGoals"},
|
||||
},
|
||||
{
|
||||
Name: "skills_prompt",
|
||||
Label: "Skills Prompt",
|
||||
Type: "textarea",
|
||||
DefaultValue: "",
|
||||
HelpText: "Optional instructions for using skills. Used when Enable Skills is on. If empty, default instructions are used.",
|
||||
Tags: config.Tags{Section: "PromptsGoals"},
|
||||
},
|
||||
{
|
||||
Name: "inner_monologue_template",
|
||||
Label: "Inner Monologue Template",
|
||||
Type: "textarea",
|
||||
DefaultValue: "",
|
||||
HelpText: "Prompt used for periodic/standalone runs when the agent evaluates what to do next. If empty, the default autonomous agent instructions are used.",
|
||||
Tags: config.Tags{Section: "PromptsGoals"},
|
||||
},
|
||||
{
|
||||
Name: "scheduler_task_template",
|
||||
Label: "Scheduler Task Template",
|
||||
Type: "textarea",
|
||||
DefaultValue: "",
|
||||
HelpText: "Template for scheduled/recurring tasks. Use {{.Task}} to reference the task. Example: \"Execute: {{.Task}}\". If empty, the default inner monologue template is used with the task injected.",
|
||||
Tags: config.Tags{Section: "PromptsGoals"},
|
||||
},
|
||||
{
|
||||
Name: "standalone_job",
|
||||
Label: "Standalone Job",
|
||||
@@ -244,6 +387,24 @@ func NewAgentConfigMeta(
|
||||
HelpText: "Enable agent to create and execute plans",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "cancel_previous_on_new_message",
|
||||
Label: "Cancel previous message on new message",
|
||||
Type: "checkbox",
|
||||
DefaultValue: true,
|
||||
HelpText: "When a new message arrives for the same conversation, cancel the currently running job and start the new one. If disabled, new messages are queued.",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "loop_detection",
|
||||
Label: "Loop Detection",
|
||||
Type: "number",
|
||||
DefaultValue: 5,
|
||||
Min: 1,
|
||||
Step: 1,
|
||||
HelpText: "Number of messages to check for loop detection. If a message is the same as the previous message, the job is cancelled.",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "can_stop_itself",
|
||||
Label: "Can Stop Itself",
|
||||
@@ -261,21 +422,45 @@ 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",
|
||||
Type: "checkbox",
|
||||
DefaultValue: true,
|
||||
DefaultValue: false,
|
||||
HelpText: "Enable agent to explain its reasoning process",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "loop_detection_steps",
|
||||
Label: "Max Loop Detection Steps",
|
||||
Type: "number",
|
||||
DefaultValue: 5,
|
||||
Min: 1,
|
||||
Step: 1,
|
||||
Name: "enable_reasoning_tool",
|
||||
Label: "Enable Reasoning for tools",
|
||||
Type: "checkbox",
|
||||
DefaultValue: true,
|
||||
HelpText: "Enable agent to reason more on tools",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
{
|
||||
Name: "enable_skills",
|
||||
Label: "Enable Skills",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Inject available skills into the agent and expose skill tools (list, read, search, resources) via MCP",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
@@ -288,21 +473,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",
|
||||
@@ -312,6 +505,24 @@ func NewAgentConfigMeta(
|
||||
HelpText: "Remove content between <thinking></thinking> and <think></think> tags from agent responses",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "enable_auto_compaction",
|
||||
Label: "Enable Auto Compaction",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Enable automatic conversation compaction when token threshold is reached",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "auto_compaction_threshold",
|
||||
Label: "Auto Compaction Threshold (tokens)",
|
||||
Type: "number",
|
||||
DefaultValue: 4096,
|
||||
Min: 1,
|
||||
Step: 1,
|
||||
HelpText: "Number of tokens to trigger automatic compaction",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "enable_evaluation",
|
||||
Label: "Enable Evaluation",
|
||||
@@ -330,6 +541,16 @@ func NewAgentConfigMeta(
|
||||
HelpText: "Maximum number of evaluation loops to perform when addressing gaps in responses",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "max_attempts",
|
||||
Label: "Max Attempts",
|
||||
Type: "number",
|
||||
DefaultValue: 1,
|
||||
Min: 1,
|
||||
Step: 1,
|
||||
HelpText: "Number of attempts on failure before surfacing the error to the user (1 = no retries)",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "last_message_duration",
|
||||
Label: "Last Message Duration",
|
||||
@@ -373,6 +594,10 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
aux := &struct {
|
||||
*Alias
|
||||
MCPSTDIOServersConfig interface{} `json:"mcp_stdio_servers"`
|
||||
MaxEvaluationLoops interface{} `json:"max_evaluation_loops"`
|
||||
MaxAttempts interface{} `json:"max_attempts"`
|
||||
ParallelJobs interface{} `json:"parallel_jobs"`
|
||||
KnowledgeBaseResults interface{} `json:"kb_results"`
|
||||
}{
|
||||
Alias: (*Alias)(a),
|
||||
}
|
||||
@@ -381,6 +606,13 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse integer fields that may come as strings
|
||||
a.MaxEvaluationLoops = parseIntField(aux.MaxEvaluationLoops)
|
||||
a.MaxAttempts = parseIntField(aux.MaxAttempts)
|
||||
a.ParallelJobs = parseIntField(aux.ParallelJobs)
|
||||
a.KnowledgeBaseResults = parseIntField(aux.KnowledgeBaseResults)
|
||||
a.LoopDetection = parseIntField(aux.LoopDetection)
|
||||
|
||||
// Handle MCP STDIO servers configuration
|
||||
if aux.MCPSTDIOServersConfig != nil {
|
||||
switch v := aux.MCPSTDIOServersConfig.(type) {
|
||||
@@ -399,7 +631,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 +639,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 +654,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 +675,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 +724,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"`
|
||||
|
||||
+321
-211
@@ -2,7 +2,6 @@ package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -13,34 +12,68 @@ import (
|
||||
"time"
|
||||
|
||||
. "github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/sse"
|
||||
sseLib "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/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// SkillsProvider supplies the skills dynamic prompt and MCP session when skills are enabled for an agent.
|
||||
type SkillsProvider interface {
|
||||
GetSkillsPrompt(config *AgentConfig) (DynamicPrompt, error)
|
||||
GetMCPSession(ctx context.Context) (*mcp.ClientSession, error)
|
||||
}
|
||||
|
||||
// RAGProvider returns a RAGDB and optional compaction client for a collection (e.g. agent name).
|
||||
// effectiveRAGURL/Key are pool/agent defaults; implementation may use them (HTTP) or ignore them (embedded).
|
||||
type RAGProvider func(collectionName, effectiveRAGURL, effectiveRAGKey string) (RAGDB, KBCompactionClient, bool)
|
||||
|
||||
// NewHTTPRAGProvider returns a RAGProvider that uses the LocalRAG HTTP API. When effective URL/key are empty, baseURL/baseKey are used.
|
||||
func NewHTTPRAGProvider(baseURL, baseKey string) RAGProvider {
|
||||
return func(collectionName, effectiveURL, effectiveKey string) (RAGDB, KBCompactionClient, bool) {
|
||||
url := effectiveURL
|
||||
if url == "" {
|
||||
url = baseURL
|
||||
}
|
||||
key := effectiveKey
|
||||
if key == "" {
|
||||
key = baseKey
|
||||
}
|
||||
wc := localrag.NewWrappedClient(url, key, collectionName)
|
||||
return wc, &wrappedClientCompactionAdapter{WrappedClient: wc}, true
|
||||
}
|
||||
}
|
||||
|
||||
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]sseLib.Manager
|
||||
agentStatus map[string]*Status
|
||||
apiURL, defaultModel, defaultMultimodalModel, defaultTTSModel string
|
||||
defaultTranscriptionModel, defaultTranscriptionLanguage string
|
||||
apiKey string
|
||||
ragProvider RAGProvider
|
||||
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
|
||||
skillsService SkillsProvider
|
||||
}
|
||||
|
||||
// SetRAGProvider sets the single RAG provider (HTTP or embedded). Must be called after pool creation.
|
||||
func (a *AgentPool) SetRAGProvider(fn RAGProvider) {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
a.ragProvider = fn
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
@@ -74,14 +107,14 @@ func loadPoolFromFile(path string) (*AgentPoolData, error) {
|
||||
}
|
||||
|
||||
func NewAgentPool(
|
||||
defaultModel, defaultMultimodalModel, imageModel, apiURL, apiKey, directory, mcpBoxURL string,
|
||||
LocalRAGAPI string,
|
||||
defaultModel, defaultMultimodalModel, defaultTranscriptionModel, defaultTranscriptionLanguage, defaultTTSModel, apiURL, apiKey, directory 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,
|
||||
skillsService SkillsProvider,
|
||||
) (*AgentPool, error) {
|
||||
// if file exists, try to load an existing pool.
|
||||
// if file does not exist, create a new pool.
|
||||
@@ -92,56 +125,67 @@ 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,
|
||||
apiKey: apiKey,
|
||||
agents: make(map[string]*Agent),
|
||||
pool: make(map[string]AgentConfig),
|
||||
agentStatus: make(map[string]*Status),
|
||||
managers: make(map[string]sseLib.Manager),
|
||||
connectors: connectors,
|
||||
availableActions: availableActions,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
skillsService: skillsService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
poolData, err := loadPoolFromFile(poolfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
bakPath := poolfile + ".bak"
|
||||
poolData, err = loadPoolFromFile(bakPath)
|
||||
if err != nil {
|
||||
xlog.Warn("Pool file invalid and backup missing or invalid, starting with empty pool", "poolfile", poolfile, "error", err)
|
||||
poolData = &AgentPoolData{}
|
||||
} else {
|
||||
xlog.Info("Recovered pool from backup, repairing main file", "poolfile", poolfile)
|
||||
if repairData, _ := json.MarshalIndent(poolData, "", " "); len(repairData) > 0 {
|
||||
_ = os.WriteFile(poolfile, repairData, 0644)
|
||||
}
|
||||
}
|
||||
}
|
||||
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]sseLib.Manager),
|
||||
agentStatus: map[string]*Status{},
|
||||
pool: *poolData,
|
||||
connectors: connectors,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
availableActions: availableActions,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
skillsService: skillsService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -150,6 +194,14 @@ func replaceInvalidChars(s string) string {
|
||||
return strings.ReplaceAll(s, " ", "_")
|
||||
}
|
||||
|
||||
// StartAgentStandalone starts an agent without saving it to the pool registry.
|
||||
// It is intended for running a single agent from the CLI without the web server.
|
||||
func (a *AgentPool) StartAgentStandalone(name string, agentConfig *AgentConfig) error {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
return a.startAgentWithConfig(name, a.pooldir, agentConfig, nil)
|
||||
}
|
||||
|
||||
// CreateAgent adds a new agent to the pool
|
||||
// and starts it.
|
||||
// It also saves the state to the file.
|
||||
@@ -166,14 +218,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 {
|
||||
@@ -182,22 +227,22 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
|
||||
oldAgent := a.agents[name]
|
||||
var o *types.Observable
|
||||
obs := oldAgent.Observer()
|
||||
if obs != nil {
|
||||
o = obs.NewObservable()
|
||||
o.Name = "Restarting Agent"
|
||||
o.Icon = "sync"
|
||||
o.Creation = &types.Creation{}
|
||||
obs.Update(*o)
|
||||
var obs Observer
|
||||
if oldAgent != nil {
|
||||
obs = oldAgent.Observer()
|
||||
if obs != nil {
|
||||
o = obs.NewObservable()
|
||||
o.Name = "Restarting Agent"
|
||||
o.Icon = "sync"
|
||||
o.Creation = &types.Creation{}
|
||||
obs.Update(*o)
|
||||
}
|
||||
stateFile, characterFile := a.stateFiles(name)
|
||||
os.Remove(stateFile)
|
||||
os.Remove(characterFile)
|
||||
oldAgent.Stop()
|
||||
}
|
||||
|
||||
stateFile, characterFile := a.stateFiles(name)
|
||||
|
||||
os.Remove(stateFile)
|
||||
os.Remove(characterFile)
|
||||
|
||||
oldAgent.Stop()
|
||||
|
||||
a.pool[name] = *agentConfig
|
||||
delete(a.agents, name)
|
||||
|
||||
@@ -209,7 +254,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 +270,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,58 +291,72 @@ func (a *AgentPool) GetStatusHistory(name string) *Status {
|
||||
return a.agentStatus[name]
|
||||
}
|
||||
|
||||
func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs Observer) error {
|
||||
var manager sse.Manager
|
||||
func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConfig, obs Observer) error {
|
||||
var manager sseLib.Manager
|
||||
if m, ok := a.managers[name]; ok {
|
||||
manager = m
|
||||
} else {
|
||||
manager = sse.NewManager(5)
|
||||
manager = sseLib.NewManager(5)
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
// XXX: Why do we update the pool config from an Agent's config?
|
||||
if config.SchedulerPollInterval == "" {
|
||||
config.SchedulerPollInterval = "30s"
|
||||
}
|
||||
|
||||
// Use agent-specific config when set, otherwise pool defaults. Do not update pool from agent config.
|
||||
effectiveAPIURL := a.apiURL
|
||||
if config.APIURL != "" {
|
||||
a.apiURL = config.APIURL
|
||||
effectiveAPIURL = config.APIURL
|
||||
} else {
|
||||
config.APIURL = a.apiURL
|
||||
}
|
||||
|
||||
effectiveAPIKey := a.apiKey
|
||||
if config.APIKey != "" {
|
||||
a.apiKey = config.APIKey
|
||||
effectiveAPIKey = config.APIKey
|
||||
} else {
|
||||
config.APIKey = a.apiKey
|
||||
}
|
||||
|
||||
if config.LocalRAGURL != "" {
|
||||
a.localRAGAPI = config.LocalRAGURL
|
||||
}
|
||||
|
||||
if config.LocalRAGAPIKey != "" {
|
||||
a.localRAGKey = config.LocalRAGAPIKey
|
||||
}
|
||||
effectiveLocalRAGAPI := config.LocalRAGURL
|
||||
effectiveLocalRAGKey := config.LocalRAGAPIKey
|
||||
|
||||
connectors := a.connectors(config)
|
||||
promptBlocks := a.dynamicPrompt(config)
|
||||
promptBlocks := a.dynamicPrompt(config)(ctx, a)
|
||||
if a.skillsService != nil && config.EnableSkills {
|
||||
if prompt, err := a.skillsService.GetSkillsPrompt(config); err == nil && prompt != nil {
|
||||
promptBlocks = append(promptBlocks, prompt)
|
||||
}
|
||||
}
|
||||
actions := a.availableActions(config)(ctx, a)
|
||||
filters := a.filters(config)
|
||||
stateFile, characterFile := a.stateFiles(name)
|
||||
@@ -399,7 +380,7 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
"Creating agent",
|
||||
"name", name,
|
||||
"model", model,
|
||||
"api_url", a.apiURL,
|
||||
"api_url", effectiveAPIURL,
|
||||
"actions", actionsLog,
|
||||
"connectors", connectorLog,
|
||||
"filters", filtersLog,
|
||||
@@ -415,14 +396,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),
|
||||
WithLLMAPIURL(effectiveAPIURL),
|
||||
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),
|
||||
@@ -435,20 +420,23 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
),
|
||||
WithStateFile(stateFile),
|
||||
WithCharacterFile(characterFile),
|
||||
WithLLMAPIKey(a.apiKey),
|
||||
WithLLMAPIKey(effectiveAPIKey),
|
||||
WithTimeout(a.timeout),
|
||||
WithRAGDB(localrag.NewWrappedClient(a.localRAGAPI, a.localRAGKey, name)),
|
||||
WithAgentReasoningCallback(func(state types.ActionCurrentState) bool {
|
||||
var actionName types.ActionDefinitionName
|
||||
if state.Action != nil {
|
||||
actionName = state.Action.Definition().Name
|
||||
}
|
||||
xlog.Info(
|
||||
"Agent is thinking",
|
||||
"agent", name,
|
||||
"reasoning", state.Reasoning,
|
||||
"action", state.Action.Definition().Name,
|
||||
"action", actionName,
|
||||
"params", state.Params,
|
||||
)
|
||||
|
||||
manager.Send(
|
||||
sse.NewMessage(
|
||||
sseLib.NewMessage(
|
||||
fmt.Sprintf(`Thinking: %s`, utils.HTMLify(state.Reasoning)),
|
||||
).WithEvent("status"),
|
||||
)
|
||||
@@ -461,6 +449,8 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
return true
|
||||
}),
|
||||
WithSystemPrompt(config.SystemPrompt),
|
||||
WithInnerMonologueTemplate(config.InnerMonologueTemplate),
|
||||
WithSchedulerTaskTemplate(config.SchedulerTaskTemplate),
|
||||
WithMultimodalModel(multimodalModel),
|
||||
WithLastMessageDuration(config.LastMessageDuration),
|
||||
WithAgentResultCallback(func(state types.ActionState) {
|
||||
@@ -475,16 +465,20 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
"Calling agent result callback",
|
||||
)
|
||||
|
||||
var actionName types.ActionDefinitionName
|
||||
if state.ActionCurrentState.Action != nil {
|
||||
actionName = state.ActionCurrentState.Action.Definition().Name
|
||||
}
|
||||
text := fmt.Sprintf(`Reasoning: %s
|
||||
Action taken: %+v
|
||||
Parameters: %+v
|
||||
Result: %s`,
|
||||
state.Reasoning,
|
||||
state.ActionCurrentState.Action.Definition().Name,
|
||||
actionName,
|
||||
state.ActionCurrentState.Params,
|
||||
state.Result)
|
||||
manager.Send(
|
||||
sse.NewMessage(
|
||||
sseLib.NewMessage(
|
||||
utils.HTMLify(
|
||||
text,
|
||||
),
|
||||
@@ -518,6 +512,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 +524,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,37 +544,125 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableKnowledgeBase {
|
||||
opts = append(opts, EnableKnowledgeBase)
|
||||
if a.skillsService != nil && config.EnableSkills {
|
||||
if session, err := a.skillsService.GetMCPSession(ctx); err == nil && session != nil {
|
||||
opts = append(opts, WithMCPSession(session))
|
||||
}
|
||||
}
|
||||
|
||||
var ragDB RAGDB
|
||||
var compactionClient KBCompactionClient
|
||||
if config.EnableKnowledgeBase && a.ragProvider != nil {
|
||||
if db, comp, ok := a.ragProvider(name, effectiveLocalRAGAPI, effectiveLocalRAGKey); ok && db != nil {
|
||||
ragDB = db
|
||||
compactionClient = comp
|
||||
}
|
||||
}
|
||||
if ragDB != nil {
|
||||
opts = append(opts, WithRAGDB(ragDB), EnableKnowledgeBase)
|
||||
kbAutoSearch := config.KBAutoSearch
|
||||
if !config.KBAutoSearch && !config.KBAsTools {
|
||||
kbAutoSearch = true
|
||||
}
|
||||
opts = append(opts, WithKBAutoSearch(kbAutoSearch))
|
||||
if config.KBAsTools {
|
||||
kbResults := config.KnowledgeBaseResults
|
||||
if kbResults <= 0 {
|
||||
kbResults = 5
|
||||
}
|
||||
searchAction, addAction := NewKBWrapperActions(ragDB, 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)
|
||||
}
|
||||
|
||||
if config.EnableAutoCompaction {
|
||||
opts = append(opts, EnableAutoCompaction)
|
||||
}
|
||||
|
||||
if config.AutoCompactionThreshold > 0 {
|
||||
opts = append(opts, WithAutoCompactionThreshold(config.AutoCompactionThreshold))
|
||||
}
|
||||
|
||||
if config.KnowledgeBaseResults > 0 {
|
||||
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.CancelPreviousOnNewMessage != nil {
|
||||
opts = append(opts, WithCancelPreviousOnNewMessage(*config.CancelPreviousOnNewMessage))
|
||||
} else {
|
||||
opts = append(opts, WithCancelPreviousOnNewMessage(true))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
if config.MaxAttempts > 0 {
|
||||
opts = append(opts, WithMaxAttempts(config.MaxAttempts))
|
||||
}
|
||||
|
||||
if config.LoopDetection > 0 {
|
||||
opts = append(opts, WithLoopDetection(config.LoopDetection))
|
||||
}
|
||||
|
||||
if config.EnableForceReasoningTool {
|
||||
opts = append(opts, EnableForceReasoningTool)
|
||||
}
|
||||
|
||||
// Wire cogito streaming events into the SSE manager for live token delivery
|
||||
opts = append(opts, WithStreamCallback(func(ev cogito.StreamEvent) {
|
||||
switch ev.Type {
|
||||
case cogito.StreamEventReasoning:
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "reasoning",
|
||||
"content": ev.Content,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
manager.Send(sseLib.NewMessage(string(data)).WithEvent("stream_event"))
|
||||
case cogito.StreamEventContent:
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "content",
|
||||
"content": ev.Content,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
manager.Send(sseLib.NewMessage(string(data)).WithEvent("stream_event"))
|
||||
case cogito.StreamEventToolCall:
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "tool_call",
|
||||
"tool_name": ev.ToolName,
|
||||
"tool_args": ev.ToolArgs,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
manager.Send(sseLib.NewMessage(string(data)).WithEvent("stream_event"))
|
||||
case cogito.StreamEventDone:
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "done",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
manager.Send(sseLib.NewMessage(string(data)).WithEvent("stream_event"))
|
||||
}
|
||||
}))
|
||||
|
||||
xlog.Info("Starting agent", "name", name, "config", config)
|
||||
|
||||
agent, err := New(opts...)
|
||||
@@ -585,6 +679,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
}
|
||||
}()
|
||||
|
||||
if config.EnableKnowledgeBase && config.EnableKBCompaction && compactionClient != nil {
|
||||
go runCompactionTicker(ctx, compactionClient, config, effectiveAPIURL, effectiveAPIKey, model)
|
||||
}
|
||||
|
||||
xlog.Info("Starting connectors", "name", name, "config", config)
|
||||
|
||||
for _, c := range connectors {
|
||||
@@ -594,7 +692,7 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(1 * time.Second) // Send a message every seconds
|
||||
manager.Send(sse.NewMessage(
|
||||
manager.Send(sseLib.NewMessage(
|
||||
utils.HTMLify(agent.State().String()),
|
||||
).WithEvent("hud"))
|
||||
}
|
||||
@@ -613,7 +711,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 +749,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 +775,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
|
||||
}
|
||||
@@ -697,7 +792,21 @@ func (a *AgentPool) save() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(a.file, data, 0644)
|
||||
tmpPath := a.file + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, a.file); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
bakPath := a.file + ".bak"
|
||||
if err := os.WriteFile(bakPath, data, 0644); err != nil {
|
||||
// best-effort; main file is already good
|
||||
xlog.Warn("Failed to write pool backup", "path", bakPath, "error", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AgentPool) GetAgent(name string) *Agent {
|
||||
@@ -726,8 +835,9 @@ func (a *AgentPool) GetConfig(name string) *AgentConfig {
|
||||
return &agent
|
||||
}
|
||||
|
||||
func (a *AgentPool) GetManager(name string) sse.Manager {
|
||||
func (a *AgentPool) GetManager(name string) sseLib.Manager {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
return a.managers[name]
|
||||
}
|
||||
|
||||
|
||||
+42
-7
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
@@ -30,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 {
|
||||
@@ -87,11 +89,36 @@ 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
|
||||
@@ -141,9 +168,9 @@ func (u *UserDefinedAction) IsUserDefined() bool {
|
||||
func CreateUserDefinedActions(userTools []ActionDefinition) []Action {
|
||||
var actions []Action
|
||||
for _, tool := range userTools {
|
||||
actions = append(actions, &UserDefinedAction{
|
||||
ActionDef: &tool,
|
||||
})
|
||||
actions = append(actions, &UserDefinedAction{
|
||||
ActionDef: &tool,
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
@@ -161,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
|
||||
}
|
||||
+11
-40
@@ -5,9 +5,15 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
// MetadataKeyConversationID is the job metadata key for per-conversation identity.
|
||||
// When set (e.g. "slack:CHANNEL_ID", "telegram:CHAT_ID"), the agent may cancel the
|
||||
// currently running job for that conversation before enqueueing a new one.
|
||||
const MetadataKeyConversationID = "conversation_id"
|
||||
|
||||
// Job is a request to the agent to do something
|
||||
type Job struct {
|
||||
// The job is a request to the agent to do something
|
||||
@@ -20,19 +26,15 @@ type Job struct {
|
||||
UUID string
|
||||
Metadata map[string]interface{}
|
||||
DoneFilter bool
|
||||
|
||||
|
||||
// Tools available for this job
|
||||
BuiltinTools []ActionDefinition // Built-in tools like web search
|
||||
UserTools []ActionDefinition // User-defined function tools
|
||||
ToolChoice string
|
||||
|
||||
pastActions []*ActionRequest
|
||||
nextAction *Action
|
||||
nextActionParams *ActionParams
|
||||
nextActionReasoning string
|
||||
|
||||
context context.Context
|
||||
cancel context.CancelFunc
|
||||
context context.Context
|
||||
fragment *cogito.Fragment
|
||||
cancel context.CancelFunc
|
||||
|
||||
Obs *Observable
|
||||
}
|
||||
@@ -80,7 +82,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
|
||||
}
|
||||
@@ -108,37 +110,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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+11
-4
@@ -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)
|
||||
@@ -28,7 +31,7 @@ func (j *JobResult) SetResult(text ActionState) {
|
||||
j.State = append(j.State, text)
|
||||
}
|
||||
|
||||
// SetResult sets the result of a job
|
||||
// Finish marks the job as done and closes the ready channel.
|
||||
func (j *JobResult) Finish(e error) {
|
||||
j.Lock()
|
||||
j.Error = e
|
||||
@@ -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,27 @@
|
||||
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
|
||||
|
||||
postgres:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: postgres
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: dind
|
||||
|
||||
localagi:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localagi
|
||||
@@ -6,32 +6,22 @@ services:
|
||||
environment:
|
||||
- LOCALAI_SINGLE_ACTIVE_BACKEND=true
|
||||
- DEBUG=true
|
||||
image: localai/localai:master-gpu-intel-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:
|
||||
postgres:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: mcpbox
|
||||
service: postgres
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: dind
|
||||
|
||||
localrecall:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall
|
||||
|
||||
localrecall-healthcheck:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall-healthcheck
|
||||
|
||||
localagi:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
environment:
|
||||
- LOCALAI_SINGLE_ACTIVE_BACKEND=true
|
||||
- DEBUG=true
|
||||
image: localai/localai:master-gpu-nvidia-cuda12
|
||||
image: localai/localai:master-gpu-nvidia-cuda-12
|
||||
# For images with python backends, use:
|
||||
# image: localai/localai:master-cublas-cuda12-ffmpeg
|
||||
deploy:
|
||||
@@ -17,26 +17,16 @@ services:
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
mcpbox:
|
||||
postgres:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: mcpbox
|
||||
|
||||
service: postgres
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: dind
|
||||
|
||||
localrecall:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall
|
||||
|
||||
localrecall-healthcheck:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall-healthcheck
|
||||
|
||||
localagi:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
+38
-49
@@ -8,8 +8,7 @@ services:
|
||||
image: localai/localai:master
|
||||
command:
|
||||
- ${MODEL_NAME:-gemma-3-4b-it-qat}
|
||||
- ${MULTIMODAL_MODEL:-moondream2-20250414}
|
||||
- ${IMAGE_MODEL:-sd-1.5-ggml}
|
||||
- ${MULTIMODAL_MODEL:-gemma-3-4b-it-qat}
|
||||
- granite-embedding-107m-multilingual
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/readyz"]
|
||||
@@ -22,30 +21,25 @@ services:
|
||||
- DEBUG=true
|
||||
#- LOCALAI_API_KEY=sk-1234567890
|
||||
volumes:
|
||||
- ./volumes/models:/build/models:cached
|
||||
- ./volumes/backends:/build/backends:cached
|
||||
- ./volumes/images:/tmp/generated/images
|
||||
- models:/models
|
||||
- backends:/backends
|
||||
- images:/tmp/generated/images
|
||||
|
||||
localrecall:
|
||||
image: quay.io/mudler/localrecall:main
|
||||
ports:
|
||||
- 8080
|
||||
postgres:
|
||||
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.2}-postgresql
|
||||
environment:
|
||||
- COLLECTION_DB_PATH=/db
|
||||
- EMBEDDING_MODEL=granite-embedding-107m-multilingual
|
||||
- FILE_ASSETS=/assets
|
||||
- OPENAI_API_KEY=sk-1234567890
|
||||
- OPENAI_BASE_URL=http://localai:8080
|
||||
- POSTGRES_DB=localrecall
|
||||
- POSTGRES_USER=localrecall
|
||||
- POSTGRES_PASSWORD=localrecall
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- ./volumes/localrag/db:/db
|
||||
- ./volumes/localrag/assets/:/assets
|
||||
|
||||
localrecall-healthcheck:
|
||||
depends_on:
|
||||
localrecall:
|
||||
condition: service_started
|
||||
image: busybox
|
||||
command: ["sh", "-c", "until wget -q -O - http://localrecall:8080 > /dev/null 2>&1; do echo 'Waiting for localrecall...'; sleep 1; done; echo 'localrecall is up!'"]
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U localrecall"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
sshbox:
|
||||
build:
|
||||
@@ -61,30 +55,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
|
||||
@@ -95,9 +73,9 @@ services:
|
||||
depends_on:
|
||||
localai:
|
||||
condition: service_healthy
|
||||
localrecall-healthcheck:
|
||||
condition: service_completed_successfully
|
||||
mcpbox:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
dind:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: .
|
||||
@@ -108,16 +86,27 @@ services:
|
||||
environment:
|
||||
- LOCALAGI_MODEL=${MODEL_NAME:-gemma-3-4b-it-qat}
|
||||
- LOCALAGI_MULTIMODAL_MODEL=${MULTIMODAL_MODEL:-moondream2-20250414}
|
||||
- LOCALAGI_IMAGE_MODEL=${IMAGE_MODEL:-sd-1.5-ggml}
|
||||
- LOCALAGI_LLM_API_URL=http://localai:8080
|
||||
#- LOCALAGI_LLM_API_KEY=sk-1234567890
|
||||
- LOCALAGI_LOCALRAG_URL=http://localrecall:8080
|
||||
- LOCALAGI_STATE_DIR=/pool
|
||||
# Knowledge base (collections) with PostgreSQL by default
|
||||
- VECTOR_ENGINE=postgres
|
||||
- DATABASE_URL=postgresql://localrecall:localrecall@postgres:5432/localrecall?sslmode=disable
|
||||
- EMBEDDING_MODEL=granite-embedding-107m-multilingual
|
||||
- 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
|
||||
# Optional: mount a host directory for skills (replaces the default state-dir/skills path)
|
||||
# - ./skills:/pool/skills
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
models:
|
||||
backends:
|
||||
images:
|
||||
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,146 @@
|
||||
module github.com/mudler/LocalAGI
|
||||
|
||||
go 1.24
|
||||
|
||||
toolchain go1.24.2
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
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/go-telegram/bot v1.17.0
|
||||
github.com/gofiber/fiber/v2 v2.52.9
|
||||
github.com/gofiber/template/html/v2 v2.1.3
|
||||
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.2.0
|
||||
github.com/mudler/cogito v0.9.5-0.20260315222927-63abdec7189b
|
||||
github.com/mudler/localrecall v0.5.9-0.20260321003356-422f3b1fff45
|
||||
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49
|
||||
github.com/mudler/xlog v0.0.5
|
||||
github.com/onsi/ginkgo/v2 v2.27.5
|
||||
github.com/onsi/gomega v1.39.0
|
||||
github.com/philippgille/chromem-go v0.7.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sashabaranov/go-openai v1.40.1
|
||||
github.com/slack-go/slack v0.17.1
|
||||
github.com/sashabaranov/go-openai v1.41.2
|
||||
github.com/slack-go/slack v0.17.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
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.39.0
|
||||
github.com/valyala/fasthttp v1.68.0
|
||||
golang.org/x/crypto v0.47.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/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // 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/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/dslipak/pdf v0.0.2 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-git/go-git/v5 v5.16.4 // indirect
|
||||
github.com/gofiber/template v1.8.3 // indirect
|
||||
github.com/gofiber/utils v1.1.0 // 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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // 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/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // 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/mod v0.31.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.3
|
||||
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 +151,15 @@ 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.26.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.40.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,13 +1,32 @@
|
||||
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.3 h1:r3fokGFRDk/8pHmwLwJ8zsX4qiqfS1/1TZm2BH8ueY8=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.3/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.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
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/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ=
|
||||
github.com/antchfx/htmlquery v1.3.4/go.mod h1:K9os0BwIEmLAvTqaNSua8tXLWRWZpocZIH73OzWQbwM=
|
||||
github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg=
|
||||
@@ -15,79 +34,158 @@ 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/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
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/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/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/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
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/dslipak/pdf v0.0.2 h1:djAvcM5neg9Ush+zR6QXB+VMJzR6TdnX766HPIg1JmI=
|
||||
github.com/dslipak/pdf v0.0.2/go.mod h1:2L3SnkI9cQwnAS9gfPz2iUoLC0rUZwbucpbKi5R1mUo=
|
||||
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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
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/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
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/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
|
||||
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
|
||||
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
|
||||
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
||||
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
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-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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
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/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||
github.com/gofiber/fiber/v2 v2.52.9/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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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,6 +193,8 @@ 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=
|
||||
@@ -107,6 +207,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=
|
||||
@@ -114,26 +216,50 @@ 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
|
||||
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
|
||||
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/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
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/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
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/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
|
||||
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
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=
|
||||
@@ -144,65 +270,124 @@ 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/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
|
||||
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.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.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.9.5-0.20260315222927-63abdec7189b h1:A74T2Lauvg61KodYqsjTYDY05kPLcW+efVZjd23dghU=
|
||||
github.com/mudler/cogito v0.9.5-0.20260315222927-63abdec7189b/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/localrecall v0.5.9-0.20260321003356-422f3b1fff45 h1:+zTrbYk70wHrtvpsO2k7gMPvHYnWYCnXNxAtMex+7yg=
|
||||
github.com/mudler/localrecall v0.5.9-0.20260321003356-422f3b1fff45/go.mod h1:/d2bG9H8G/HzsnXTTQl2bOD+ui74XwpeiSDJ+2gdkGc=
|
||||
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49 h1:dAF1ALXqqapRZo80x56BIBBcPrPbRNerbd66rdyO8J4=
|
||||
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49/go.mod h1:z3yFhcL9bSykmmh6xgGu0hyoItd4CnxgtWMEWw8uFJU=
|
||||
github.com/mudler/xlog v0.0.5 h1:2unBuVC5rNGhCC86UaA94TElWFml80NL5XLK+kAmNuU=
|
||||
github.com/mudler/xlog v0.0.5/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.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
|
||||
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
|
||||
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
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/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 h1:2vmb32OdDhjZf2ETGDlr9n8RYXx7c+jXPxMiPbwnA+8=
|
||||
github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4/go.mod h1:2JQx4jDHmWrbABvpOayg/+OTU6ehN0IyK2EHzceXpJo=
|
||||
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/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||
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/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
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/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
|
||||
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
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.1 h1:bJ08Iwct5mHBVkuvG6FEcb9MDTfsXdTYPGjYLRdeTEU=
|
||||
github.com/sashabaranov/go-openai v1.40.1/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.17.1 h1:x0Mnc6biHBea5vfxLR+x4JFl/Rm3eIo0iS3xDZenX+o=
|
||||
github.com/slack-go/slack v0.17.1/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||
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.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
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/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
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=
|
||||
@@ -215,52 +400,73 @@ 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/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
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=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
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.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
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=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
@@ -268,8 +474,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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
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=
|
||||
@@ -277,11 +485,16 @@ 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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -291,8 +504,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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.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=
|
||||
@@ -302,8 +515,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.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
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=
|
||||
@@ -315,30 +528,37 @@ 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.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
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.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
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/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
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=
|
||||
@@ -349,6 +569,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=
|
||||
|
||||
@@ -1,101 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/services"
|
||||
"github.com/mudler/LocalAGI/webui"
|
||||
"github.com/mudler/LocalAGI/cmd"
|
||||
)
|
||||
|
||||
var baseModel = os.Getenv("LOCALAGI_MODEL")
|
||||
var multimodalModel = os.Getenv("LOCALAGI_MULTIMODAL_MODEL")
|
||||
var apiURL = os.Getenv("LOCALAGI_LLM_API_URL")
|
||||
var apiKey = os.Getenv("LOCALAGI_LLM_API_KEY")
|
||||
var timeout = os.Getenv("LOCALAGI_TIMEOUT")
|
||||
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 sshBoxURL = os.Getenv("LOCALAGI_SSHBOX_URL")
|
||||
|
||||
func init() {
|
||||
if baseModel == "" {
|
||||
panic("LOCALAGI_MODEL not set")
|
||||
}
|
||||
if apiURL == "" {
|
||||
panic("LOCALAGI_LLM_API_URL not set")
|
||||
}
|
||||
if timeout == "" {
|
||||
timeout = "5m"
|
||||
}
|
||||
if stateDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
stateDir = filepath.Join(cwd, "pool")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// make sure state dir exists
|
||||
os.MkdirAll(stateDir, 0755)
|
||||
|
||||
apiKeys := []string{}
|
||||
if apiKeysEnv != "" {
|
||||
apiKeys = strings.Split(apiKeysEnv, ",")
|
||||
}
|
||||
|
||||
// Create the agent pool
|
||||
pool, err := state.NewAgentPool(
|
||||
baseModel,
|
||||
multimodalModel,
|
||||
imageModel,
|
||||
apiURL,
|
||||
apiKey,
|
||||
stateDir,
|
||||
mcpboxURL,
|
||||
localRAG,
|
||||
services.Actions(map[string]string{
|
||||
services.ActionConfigBrowserAgentRunner: localOperatorBaseURL,
|
||||
services.ActionConfigDeepResearchRunner: localOperatorBaseURL,
|
||||
services.ActionConfigSSHBoxURL: sshBoxURL,
|
||||
}),
|
||||
services.Connectors,
|
||||
services.DynamicPrompts,
|
||||
services.Filters,
|
||||
timeout,
|
||||
withLogs,
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create the application
|
||||
app := webui.NewApp(
|
||||
webui.WithPool(pool),
|
||||
webui.WithConversationStoreduration(conversationDuration),
|
||||
webui.WithApiKeys(apiKeys...),
|
||||
webui.WithLLMAPIUrl(apiURL),
|
||||
webui.WithLLMAPIKey(apiKey),
|
||||
webui.WithLLMModel(baseModel),
|
||||
webui.WithStateDir(stateDir),
|
||||
)
|
||||
|
||||
// Start the agents
|
||||
if err := pool.StartAll(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Start the web server
|
||||
log.Fatal(app.Listen(":3000"))
|
||||
cmd.Execute()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+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
|
||||
}
|
||||
+303
-59
@@ -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 {
|
||||
@@ -85,7 +93,39 @@ func (c *WrappedClient) Store(s string) error {
|
||||
}
|
||||
|
||||
defer os.Remove(f)
|
||||
return c.Client.Store(c.collection, f)
|
||||
_, err = c.Client.Store(c.collection, f)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -101,6 +141,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 +198,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 +222,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 +265,79 @@ 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"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data.Keys) > 0 {
|
||||
return data.Keys, nil
|
||||
}
|
||||
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 +367,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 +421,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,21 +465,20 @@ 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
|
||||
}
|
||||
|
||||
// Store uploads a file to a collection
|
||||
func (c *Client) Store(collection, filePath string) error {
|
||||
// Store uploads a file to a collection and returns the assigned entry key.
|
||||
func (c *Client) Store(collection, filePath string) (string, error) {
|
||||
url := fmt.Sprintf("%s/api/collections/%s/upload", c.BaseURL, collection)
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
@@ -341,22 +487,22 @@ func (c *Client) Store(collection, filePath string) error {
|
||||
|
||||
part, err := writer.CreateFormFile("file", file.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = io.Copy(part, file)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, url, body)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
c.addAuthHeader(req)
|
||||
@@ -364,26 +510,124 @@ func (c *Client) Store(collection, filePath string) error {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Status string `json:"status"`
|
||||
Filename string `json:"filename"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", nil // upload succeeded, can't parse key
|
||||
}
|
||||
return result.Key, nil
|
||||
}
|
||||
|
||||
// SourceInfo represents an external source for a collection (LocalRecall API contract).
|
||||
type SourceInfo struct {
|
||||
URL string `json:"url"`
|
||||
UpdateInterval int `json:"update_interval"` // minutes
|
||||
LastUpdate string `json:"last_update"` // RFC3339
|
||||
}
|
||||
|
||||
// AddSource registers an external source for a collection.
|
||||
func (c *Client) AddSource(collection, url string, updateIntervalMinutes int) error {
|
||||
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
UpdateInterval int `json:"update_interval"`
|
||||
}
|
||||
body.URL = url
|
||||
body.UpdateInterval = updateIntervalMinutes
|
||||
if body.UpdateInterval < 1 {
|
||||
body.UpdateInterval = 60
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.addAuthHeader(req)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return parseAPIError(resp, b, "failed to add source")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveSource removes an external source from a collection.
|
||||
func (c *Client) RemoveSource(collection, url string) error {
|
||||
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
|
||||
payload, err := json.Marshal(map[string]string{"url": url})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodDelete, reqURL, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.addAuthHeader(req)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return parseAPIError(resp, b, "failed to remove source")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSources returns external sources for a collection.
|
||||
func (c *Client) ListSources(collection string) ([]SourceInfo, error) {
|
||||
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.addAuthHeader(req)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, parseAPIError(resp, body, "failed to list sources")
|
||||
}
|
||||
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 {
|
||||
Sources []SourceInfo `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.Sources, 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,
|
||||
})
|
||||
}
|
||||
@@ -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,165 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"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()
|
||||
tr := pdf.UnicodeTranslatorFromDescriptor("")
|
||||
|
||||
// Add title if provided
|
||||
if result.Title != "" {
|
||||
pdf.SetFont("Arial", "B", 16)
|
||||
pdf.MultiCell(0, 10, tr(result.Title), "", "", false)
|
||||
pdf.Ln(5)
|
||||
}
|
||||
|
||||
// Add content: parse as markdown and render, or fall back to plain text
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
p := parser.NewWithExtensions(parser.CommonExtensions)
|
||||
doc := p.Parse([]byte(result.Content))
|
||||
if doc != nil && ast.GetFirstChild(doc) != nil {
|
||||
renderMarkdownToPDF(pdf, tr, doc)
|
||||
} else {
|
||||
pdf.MultiCell(0, 10, tr(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 or Markdown content to include in the PDF (headings, bold, lists, code blocks, etc. are rendered)",
|
||||
},
|
||||
"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,412 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/jung-kurt/gofpdf"
|
||||
)
|
||||
|
||||
const (
|
||||
pdfLineHeight = 6.0
|
||||
pdfBlockMargin = 4.0
|
||||
)
|
||||
|
||||
// renderMarkdownToPDF walks the markdown AST and renders it to the PDF using tr for all text.
|
||||
func renderMarkdownToPDF(pdf *gofpdf.Fpdf, tr func(string) string, doc ast.Node) {
|
||||
for child := ast.GetFirstChild(doc); child != nil; child = ast.GetNextNode(child) {
|
||||
renderBlock(pdf, tr, child)
|
||||
}
|
||||
}
|
||||
|
||||
func renderBlock(pdf *gofpdf.Fpdf, tr func(string) string, node ast.Node) {
|
||||
switch n := node.(type) {
|
||||
case *ast.Document:
|
||||
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
|
||||
renderBlock(pdf, tr, child)
|
||||
}
|
||||
case *ast.Heading:
|
||||
level := n.Level
|
||||
if level > 6 {
|
||||
level = 6
|
||||
}
|
||||
size := float64(22 - level*2)
|
||||
if size < 12 {
|
||||
size = 12
|
||||
}
|
||||
pdf.SetFont("Arial", "B", size)
|
||||
writeInlineContent(pdf, tr, n)
|
||||
pdf.Ln(pdfLineHeight + pdfBlockMargin)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
case *ast.Paragraph:
|
||||
writeInlineContent(pdf, tr, n)
|
||||
pdf.Ln(pdfLineHeight + pdfBlockMargin)
|
||||
case *ast.List:
|
||||
listType := n.ListFlags
|
||||
ordered := (listType & ast.ListTypeOrdered) != 0
|
||||
start := n.Start
|
||||
if start <= 0 {
|
||||
start = 1
|
||||
}
|
||||
itemNum := 0
|
||||
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
|
||||
if item, ok := child.(*ast.ListItem); ok {
|
||||
itemNum++
|
||||
var bullet string
|
||||
if ordered {
|
||||
bullet = tr(fmt.Sprintf("%d. ", start+itemNum-1))
|
||||
} else {
|
||||
bullet = tr("• ")
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.CellFormat(8, pdfLineHeight, bullet, "", 0, "", false, 0, "")
|
||||
for inner := ast.GetFirstChild(item); inner != nil; inner = ast.GetNextNode(inner) {
|
||||
renderBlock(pdf, tr, inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.ListItem:
|
||||
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
|
||||
renderBlock(pdf, tr, child)
|
||||
}
|
||||
case *ast.CodeBlock:
|
||||
pdf.SetFont("Courier", "", 10)
|
||||
lit := n.Literal
|
||||
if lit == nil {
|
||||
lit = n.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
pdf.MultiCell(0, pdfLineHeight-1, tr(string(lit)), "", "", false)
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.BlockQuote:
|
||||
left, _, _, _ := pdf.GetMargins()
|
||||
saveLeft := left
|
||||
pdf.SetLeftMargin(saveLeft + 4)
|
||||
pdf.SetX(saveLeft + 4)
|
||||
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
|
||||
renderBlock(pdf, tr, child)
|
||||
}
|
||||
pdf.SetLeftMargin(saveLeft)
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.HorizontalRule:
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
pdf.Line(pdf.GetX(), pdf.GetY(), pdf.GetX()+190, pdf.GetY())
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.Table:
|
||||
renderTable(pdf, tr, n)
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.MathBlock:
|
||||
pdf.SetFont("Courier", "", 10)
|
||||
lit := n.Literal
|
||||
if lit == nil {
|
||||
lit = n.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
pdf.MultiCell(0, pdfLineHeight-1, tr(string(lit)), "", "", false)
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.HTMLBlock:
|
||||
lit := n.Literal
|
||||
if lit == nil {
|
||||
lit = n.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
pdf.SetFont("Courier", "", 9)
|
||||
pdf.MultiCell(0, pdfLineHeight-1, tr(string(lit)), "", "", false)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.Aside:
|
||||
left, _, _, _ := pdf.GetMargins()
|
||||
saveLeft := left
|
||||
pdf.SetLeftMargin(saveLeft + 4)
|
||||
pdf.SetX(saveLeft + 4)
|
||||
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
|
||||
renderBlock(pdf, tr, child)
|
||||
}
|
||||
pdf.SetLeftMargin(saveLeft)
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
default:
|
||||
// Unknown block: try to render as inline content (e.g. paragraph-like)
|
||||
if ast.GetFirstChild(node) != nil {
|
||||
writeInlineContent(pdf, tr, node)
|
||||
pdf.Ln(pdfLineHeight + pdfBlockMargin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
pdfTableLineHt = 7.0
|
||||
pdfTableHeaderR = 72
|
||||
pdfTableHeaderG = 72
|
||||
pdfTableHeaderB = 72
|
||||
pdfTableBorderR = 200
|
||||
pdfTableBorderG = 200
|
||||
pdfTableBorderB = 200
|
||||
pdfTableStripR = 248
|
||||
pdfTableStripG = 248
|
||||
pdfTableStripB = 248
|
||||
)
|
||||
|
||||
// renderTable draws a markdown table. Table contains TableHeader and TableBody, each with TableRows of TableCells.
|
||||
func renderTable(pdf *gofpdf.Fpdf, tr func(string) string, table *ast.Table) {
|
||||
left, _, right, _ := pdf.GetMargins()
|
||||
pageW := 210.0
|
||||
tblW := pageW - left - right
|
||||
|
||||
// Collect all rows: header rows first, then body (and footer if any)
|
||||
var rows [][]string
|
||||
var numCols int
|
||||
for section := ast.GetFirstChild(table); section != nil; section = ast.GetNextNode(section) {
|
||||
for rowNode := ast.GetFirstChild(section); rowNode != nil; rowNode = ast.GetNextNode(rowNode) {
|
||||
row, ok := rowNode.(*ast.TableRow)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var cells []string
|
||||
for c := ast.GetFirstChild(row); c != nil; c = ast.GetNextNode(c) {
|
||||
if cell, ok := c.(*ast.TableCell); ok {
|
||||
cells = append(cells, tr(getCellText(cell)))
|
||||
}
|
||||
}
|
||||
if len(cells) > 0 {
|
||||
rows = append(rows, cells)
|
||||
if len(cells) > numCols {
|
||||
numCols = len(cells)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if numCols == 0 {
|
||||
return
|
||||
}
|
||||
colW := tblW / float64(numCols)
|
||||
lineHt := pdfTableLineHt
|
||||
|
||||
// Save current colors and set light gray borders for the table
|
||||
saveDrawR, saveDrawG, saveDrawB := pdf.GetDrawColor()
|
||||
saveFillR, saveFillG, saveFillB := pdf.GetFillColor()
|
||||
saveTextR, saveTextG, saveTextB := pdf.GetTextColor()
|
||||
pdf.SetDrawColor(pdfTableBorderR, pdfTableBorderG, pdfTableBorderB)
|
||||
|
||||
for i, row := range rows {
|
||||
isHeader := i == 0
|
||||
lastRow := i == len(rows) - 1
|
||||
// Header: dark gray background, white text, bold
|
||||
if isHeader {
|
||||
pdf.SetFont("Arial", "B", 12)
|
||||
pdf.SetFillColor(pdfTableHeaderR, pdfTableHeaderG, pdfTableHeaderB)
|
||||
pdf.SetTextColor(255, 255, 255)
|
||||
} else {
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.SetTextColor(0, 0, 0)
|
||||
if i%2 == 1 {
|
||||
pdf.SetFillColor(pdfTableStripR, pdfTableStripG, pdfTableStripB)
|
||||
} else {
|
||||
pdf.SetFillColor(255, 255, 255)
|
||||
}
|
||||
}
|
||||
border := "LTR"
|
||||
if lastRow {
|
||||
border = "LTRB"
|
||||
}
|
||||
fill := true
|
||||
for j, cellText := range row {
|
||||
w := colW
|
||||
if j == numCols-1 {
|
||||
w = 0
|
||||
}
|
||||
pdf.CellFormat(w, lineHt, cellText, border, 0, "L", fill, 0, "")
|
||||
}
|
||||
pdf.Ln(lineHt)
|
||||
}
|
||||
|
||||
// Restore colors and font
|
||||
pdf.SetDrawColor(saveDrawR, saveDrawG, saveDrawB)
|
||||
pdf.SetFillColor(saveFillR, saveFillG, saveFillB)
|
||||
pdf.SetTextColor(saveTextR, saveTextG, saveTextB)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
|
||||
// getInlineText returns plain text from an inline container (e.g. Image alt text).
|
||||
func getInlineText(node ast.Node) string {
|
||||
var b []byte
|
||||
for child := ast.GetFirstChild(node); child != nil; child = ast.GetNextNode(child) {
|
||||
if leaf, ok := child.(*ast.Leaf); ok && len(leaf.Literal) > 0 {
|
||||
b = append(b, leaf.Literal...)
|
||||
} else if text, ok := child.(*ast.Text); ok {
|
||||
lit := text.Literal
|
||||
if lit == nil {
|
||||
lit = text.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
b = append(b, lit...)
|
||||
}
|
||||
} else {
|
||||
b = append(b, getInlineText(child)...)
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// getCellText returns plain text from a table cell (walks Paragraph/Text and Leaf nodes).
|
||||
func getCellText(node ast.Node) string {
|
||||
var b []byte
|
||||
for child := ast.GetFirstChild(node); child != nil; child = ast.GetNextNode(child) {
|
||||
if leaf, ok := child.(*ast.Leaf); ok && len(leaf.Literal) > 0 {
|
||||
b = append(b, leaf.Literal...)
|
||||
} else if text, ok := child.(*ast.Text); ok {
|
||||
lit := text.Literal
|
||||
if lit == nil {
|
||||
lit = text.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
b = append(b, lit...)
|
||||
}
|
||||
} else {
|
||||
b = append(b, getCellText(child)...)
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// writeInlineContent outputs inline content (text, strong, emph, code) with correct font changes.
|
||||
func writeInlineContent(pdf *gofpdf.Fpdf, tr func(string) string, node ast.Node) {
|
||||
lineHt := pdfLineHeight
|
||||
left, _, right, _ := pdf.GetMargins()
|
||||
pageW := 210.0 // A4 mm
|
||||
maxW := pageW - left - right
|
||||
|
||||
for child := ast.GetFirstChild(node); child != nil; child = ast.GetNextNode(child) {
|
||||
writeInline(pdf, tr, child, lineHt, maxW)
|
||||
}
|
||||
}
|
||||
|
||||
func writeInline(pdf *gofpdf.Fpdf, tr func(string) string, node ast.Node, lineHt, maxW float64) {
|
||||
switch n := node.(type) {
|
||||
case *ast.Text:
|
||||
lit := n.Literal
|
||||
if lit == nil {
|
||||
lit = n.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
cellWrap(pdf, tr(string(lit)), lineHt, maxW)
|
||||
}
|
||||
case *ast.Strong:
|
||||
pdf.SetFont("Arial", "B", 12)
|
||||
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
|
||||
writeInline(pdf, tr, c, lineHt, maxW)
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
case *ast.Emph:
|
||||
pdf.SetFont("Arial", "I", 12)
|
||||
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
|
||||
writeInline(pdf, tr, c, lineHt, maxW)
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
case *ast.Code:
|
||||
lit := n.Literal
|
||||
if lit == nil {
|
||||
lit = n.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
pdf.SetFont("Courier", "", 11)
|
||||
cellWrap(pdf, tr(string(lit)), lineHt, maxW)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
case *ast.Link:
|
||||
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
|
||||
writeInline(pdf, tr, c, lineHt, maxW)
|
||||
}
|
||||
if len(n.Destination) > 0 {
|
||||
pdf.SetFont("Arial", "I", 10)
|
||||
cellWrap(pdf, tr(" ("+string(n.Destination)+")"), lineHt, maxW)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
case *ast.Image:
|
||||
alt := getInlineText(n)
|
||||
if alt != "" {
|
||||
cellWrap(pdf, tr(alt), lineHt, maxW)
|
||||
}
|
||||
if len(n.Destination) > 0 {
|
||||
pdf.SetFont("Arial", "I", 10)
|
||||
cellWrap(pdf, tr(" [Image: "+string(n.Destination)+"]"), lineHt, maxW)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
case *ast.Del:
|
||||
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
|
||||
writeInline(pdf, tr, c, lineHt, maxW)
|
||||
}
|
||||
case *ast.Subscript:
|
||||
pdf.SetFont("Arial", "", 9)
|
||||
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
|
||||
writeInline(pdf, tr, c, lineHt, maxW)
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
case *ast.Superscript:
|
||||
pdf.SetFont("Arial", "", 9)
|
||||
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
|
||||
writeInline(pdf, tr, c, lineHt, maxW)
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
case *ast.Math:
|
||||
lit := n.Literal
|
||||
if lit == nil {
|
||||
lit = n.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
pdf.SetFont("Courier", "", 10)
|
||||
cellWrap(pdf, tr(string(lit)), lineHt, maxW)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
case *ast.Hardbreak:
|
||||
pdf.Ln(lineHt)
|
||||
case *ast.Softbreak:
|
||||
pdf.Ln(lineHt)
|
||||
default:
|
||||
if leaf, ok := node.(*ast.Leaf); ok && len(leaf.Literal) > 0 {
|
||||
cellWrap(pdf, tr(string(leaf.Literal)), lineHt, maxW)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cellWrap outputs text with word-wrap: splits on spaces and starts a new line when the next word would overflow.
|
||||
func cellWrap(pdf *gofpdf.Fpdf, s string, lineHt, maxW float64) {
|
||||
left, _, _, _ := pdf.GetMargins()
|
||||
words := strings.Fields(s)
|
||||
for i, word := range words {
|
||||
wordW := pdf.GetStringWidth(word)
|
||||
spaceW := 0.0
|
||||
if i > 0 {
|
||||
spaceW = pdf.GetStringWidth(" ")
|
||||
}
|
||||
x := pdf.GetX()
|
||||
// If this word (and preceding space) would overflow, start a new line first.
|
||||
if i > 0 {
|
||||
if x+spaceW+wordW > maxW && x > left {
|
||||
pdf.Ln(lineHt)
|
||||
x = pdf.GetX()
|
||||
} else {
|
||||
pdf.CellFormat(spaceW, lineHt, " ", "", 0, "", false, 0, "")
|
||||
x = pdf.GetX()
|
||||
}
|
||||
} else if wordW > 0 && x+wordW > maxW && x > left {
|
||||
pdf.Ln(lineHt)
|
||||
x = pdf.GetX()
|
||||
}
|
||||
// Single word longer than line width: use MultiCell so it wraps.
|
||||
if wordW > maxW-left {
|
||||
pdf.MultiCell(0, lineHt, word, "", "", false)
|
||||
} else {
|
||||
if x+wordW > maxW && x > left {
|
||||
pdf.Ln(lineHt)
|
||||
}
|
||||
pdf.CellFormat(wordW, lineHt, word, "", 0, "", false, 0, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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))
|
||||
})
|
||||
|
||||
It("generates PDF with markdown content and renders structure", func() {
|
||||
content := "# Section\n\n**Bold** and *italic* and `code`.\n\n- Item one\n- Item two"
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": content,
|
||||
})
|
||||
|
||||
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())
|
||||
info, err := os.Stat(paths[0])
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.Size()).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("generates PDF with special characters", func() {
|
||||
content := "Café, \"quotes\", 2–3"
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": content,
|
||||
})
|
||||
|
||||
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())
|
||||
info, err := os.Stat(paths[0])
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.Size()).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("generates PDF with markdown table", func() {
|
||||
content := "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |"
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": content,
|
||||
})
|
||||
|
||||
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())
|
||||
info, err := os.Stat(paths[0])
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.Size()).To(BeNumerically(">", 0))
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxParamsLen is the maximum length for params in "Calling tool X with parameters: ..."
|
||||
MaxParamsLen = 400
|
||||
// MaxResultLen is the maximum length for result in "Result of X: ..."
|
||||
MaxResultLen = 500
|
||||
)
|
||||
|
||||
// StatusAccumulator holds accumulated status lines for a job's placeholder message.
|
||||
// Callers (connectors) are responsible for mutex and for clearing when the job ends.
|
||||
type StatusAccumulator struct {
|
||||
lines []string
|
||||
}
|
||||
|
||||
// NewStatusAccumulator returns a new accumulator.
|
||||
func NewStatusAccumulator() *StatusAccumulator {
|
||||
return &StatusAccumulator{lines: nil}
|
||||
}
|
||||
|
||||
// AppendReasoning appends a "Current thought: ..." line when reasoning is non-empty.
|
||||
func (a *StatusAccumulator) AppendReasoning(reasoning string) {
|
||||
if reasoning == "" {
|
||||
return
|
||||
}
|
||||
a.lines = append(a.lines, "Current thought process:\n"+reasoning)
|
||||
}
|
||||
|
||||
// AppendToolCall appends a "Calling tool X with parameters: ..." line (params truncated).
|
||||
func (a *StatusAccumulator) AppendToolCall(actionName string, params string) {
|
||||
if actionName == "" {
|
||||
actionName = "Tool"
|
||||
}
|
||||
truncated := Truncate(params, MaxParamsLen)
|
||||
a.lines = append(a.lines, fmt.Sprintf("Calling tool `%s` with parameters: %s", actionName, truncated))
|
||||
}
|
||||
|
||||
// AppendToolResult appends a "Result of X: ..." line (result truncated).
|
||||
func (a *StatusAccumulator) AppendToolResult(actionName string, result string) {
|
||||
if actionName == "" {
|
||||
actionName = "Tool"
|
||||
}
|
||||
truncated := Truncate(result, MaxResultLen)
|
||||
a.lines = append(a.lines, fmt.Sprintf("Result of `%s`: %s", actionName, truncated))
|
||||
}
|
||||
|
||||
// BuildMessage returns thinkingPrefix + "\n\n" + joined lines, truncated to maxTotalLen if needed.
|
||||
// If over the limit, the message is truncated from the start (oldest content dropped) so the latest lines stay visible.
|
||||
func (a *StatusAccumulator) BuildMessage(thinkingPrefix string, maxTotalLen int) string {
|
||||
if len(a.lines) == 0 {
|
||||
return thinkingPrefix
|
||||
}
|
||||
body := strings.Join(a.lines, "\n\n")
|
||||
full := thinkingPrefix + "\n\n" + body
|
||||
if maxTotalLen <= 0 || len(full) <= maxTotalLen {
|
||||
return full
|
||||
}
|
||||
// Keep prefix and truncate from the start of the body
|
||||
available := maxTotalLen - len(thinkingPrefix) - 2 // 2 for "\n\n"
|
||||
if available <= 0 {
|
||||
return Truncate(full, maxTotalLen)
|
||||
}
|
||||
if len(body) <= available {
|
||||
return full
|
||||
}
|
||||
// Drop oldest lines until we fit
|
||||
for i := 0; i < len(a.lines); i++ {
|
||||
trimmed := strings.Join(a.lines[i:], "\n\n")
|
||||
if len(trimmed) <= available {
|
||||
return thinkingPrefix + "\n\n" + trimmed
|
||||
}
|
||||
}
|
||||
// Single line too long
|
||||
return thinkingPrefix + "\n\n" + Truncate(body, available)
|
||||
}
|
||||
|
||||
// Truncate returns s truncated to maxLen with "..." suffix if truncated.
|
||||
func Truncate(s string, maxLen int) string {
|
||||
if maxLen <= 0 || len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return s[:maxLen]
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// ActionDisplayName returns the action's display name for status messages, or "Tool" if nil.
|
||||
func ActionDisplayName(action types.Action) string {
|
||||
if action == nil {
|
||||
return "Tool"
|
||||
}
|
||||
return action.Definition().Name.String()
|
||||
}
|
||||
@@ -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"
|
||||
@@ -93,25 +93,6 @@ func (m *Matrix) AgentReasoningCallback() func(state types.ActionCurrentState) b
|
||||
}
|
||||
}
|
||||
|
||||
// cancelActiveJobForRoom cancels any active job for the given room
|
||||
func (m *Matrix) cancelActiveJobForRoom(roomID string) {
|
||||
m.activeJobsMutex.RLock()
|
||||
ctxs, exists := m.activeJobs[roomID]
|
||||
m.activeJobsMutex.RUnlock()
|
||||
|
||||
if exists {
|
||||
xlog.Info(fmt.Sprintf("Cancelling active job for room: %s", roomID))
|
||||
|
||||
// Mark the job as inactive
|
||||
m.activeJobsMutex.Lock()
|
||||
for _, c := range ctxs {
|
||||
c.Cancel()
|
||||
}
|
||||
delete(m.activeJobs, roomID)
|
||||
m.activeJobsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Matrix) handleRoomMessage(a *agent.Agent, evt *event.Event) {
|
||||
if m.roomID != evt.RoomID.String() && m.roomMode { // If we have a roomID and it's not the same as the event room
|
||||
// Skip messages from other rooms
|
||||
@@ -136,9 +117,6 @@ func (m *Matrix) handleRoomMessage(a *agent.Agent, evt *event.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel any active job for this room before starting a new one
|
||||
m.cancelActiveJobForRoom(evt.RoomID.String())
|
||||
|
||||
currentConv := a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("matrix:%s", evt.RoomID.String()))
|
||||
|
||||
message := evt.Content.AsMessage().Body
|
||||
@@ -159,9 +137,10 @@ func (m *Matrix) handleRoomMessage(a *agent.Agent, evt *event.Event) {
|
||||
|
||||
agentOptions = append(agentOptions, types.WithConversationHistory(currentConv))
|
||||
|
||||
// Add room to metadata for tracking
|
||||
// Add room and conversation_id for tracking and cancel-previous-on-new-message
|
||||
metadata := map[string]any{
|
||||
"room": evt.RoomID.String(),
|
||||
"room": evt.RoomID.String(),
|
||||
types.MetadataKeyConversationID: "matrix:" + evt.RoomID.String(),
|
||||
}
|
||||
agentOptions = append(agentOptions, types.WithMetadata(metadata))
|
||||
|
||||
@@ -225,16 +204,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",
|
||||
},
|
||||
)
|
||||
|
||||
+309
-128
@@ -4,20 +4,23 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"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"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/services/connectors/common"
|
||||
|
||||
"github.com/slack-go/slack/socketmode"
|
||||
|
||||
@@ -36,6 +39,7 @@ type Slack struct {
|
||||
// To track placeholder messages
|
||||
placeholders map[string]string // map[jobUUID]messageTS
|
||||
placeholderMutex sync.RWMutex
|
||||
jobStatus map[string]*common.StatusAccumulator // map[jobUUID]accumulator
|
||||
apiClient *slack.Client
|
||||
|
||||
// Track active jobs for cancellation
|
||||
@@ -53,15 +57,39 @@ func NewSlack(config map[string]string) *Slack {
|
||||
channelID: config["channelID"],
|
||||
channelMode: config["channelMode"] == "true",
|
||||
placeholders: make(map[string]string),
|
||||
jobStatus: make(map[string]*common.StatusAccumulator),
|
||||
activeJobs: make(map[string][]*types.Job),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Slack) AgentResultCallback() func(state types.ActionState) {
|
||||
return func(state types.ActionState) {
|
||||
// Mark the job as completed when we get the final result
|
||||
if state.ActionCurrentState.Job != nil && state.ActionCurrentState.Job.Metadata != nil {
|
||||
if channel, ok := state.ActionCurrentState.Job.Metadata["channel"].(string); ok && channel != "" {
|
||||
// Update placeholder with tool result if still in progress
|
||||
job := state.ActionCurrentState.Job
|
||||
if job != nil && job.Metadata != nil {
|
||||
if channel, ok := job.Metadata["channel"].(string); ok && channel != "" {
|
||||
t.placeholderMutex.Lock()
|
||||
msgTs, exists := t.placeholders[job.UUID]
|
||||
if exists && msgTs != "" && t.apiClient != nil {
|
||||
acc, ok := t.jobStatus[job.UUID]
|
||||
if !ok {
|
||||
acc = common.NewStatusAccumulator()
|
||||
t.jobStatus[job.UUID] = acc
|
||||
}
|
||||
acc.AppendToolResult(common.ActionDisplayName(state.Action), state.Result)
|
||||
thought := acc.BuildMessage(thinkingMessage, 3000)
|
||||
t.placeholderMutex.Unlock()
|
||||
_, _, _, err := t.apiClient.UpdateMessage(
|
||||
channel,
|
||||
msgTs,
|
||||
slack.MsgOptionText(githubmarkdownconvertergo.Slack(thought), false),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error updating tool result message: %v", err))
|
||||
}
|
||||
t.placeholderMutex.Lock()
|
||||
}
|
||||
t.placeholderMutex.Unlock()
|
||||
t.activeJobsMutex.Lock()
|
||||
delete(t.activeJobs, channel)
|
||||
t.activeJobsMutex.Unlock()
|
||||
@@ -73,7 +101,7 @@ func (t *Slack) AgentResultCallback() func(state types.ActionState) {
|
||||
func (t *Slack) AgentReasoningCallback() func(state types.ActionCurrentState) bool {
|
||||
return func(state types.ActionCurrentState) bool {
|
||||
// Check if we have a placeholder message for this job
|
||||
t.placeholderMutex.RLock()
|
||||
t.placeholderMutex.Lock()
|
||||
msgTs, exists := t.placeholders[state.Job.UUID]
|
||||
channel := ""
|
||||
if state.Job.Metadata != nil {
|
||||
@@ -81,18 +109,31 @@ func (t *Slack) AgentReasoningCallback() func(state types.ActionCurrentState) bo
|
||||
channel = ch
|
||||
}
|
||||
}
|
||||
t.placeholderMutex.RUnlock()
|
||||
|
||||
if !exists || msgTs == "" || channel == "" || t.apiClient == nil {
|
||||
return true // Skip if we don't have a message to update
|
||||
t.placeholderMutex.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
thought := thinkingMessage + "\n\n"
|
||||
// Update when we have reasoning or a tool call to show
|
||||
if state.Reasoning == "" && state.Action == nil {
|
||||
t.placeholderMutex.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
acc, ok := t.jobStatus[state.Job.UUID]
|
||||
if !ok {
|
||||
acc = common.NewStatusAccumulator()
|
||||
t.jobStatus[state.Job.UUID] = acc
|
||||
}
|
||||
if state.Reasoning != "" {
|
||||
thought += "Current thought process:\n" + state.Reasoning
|
||||
acc.AppendReasoning(state.Reasoning)
|
||||
}
|
||||
if state.Action != nil {
|
||||
acc.AppendToolCall(common.ActionDisplayName(state.Action), state.Params.String())
|
||||
}
|
||||
thought := acc.BuildMessage(thinkingMessage, 3000)
|
||||
t.placeholderMutex.Unlock()
|
||||
|
||||
// Update the placeholder message with the current reasoning
|
||||
_, _, _, err := t.apiClient.UpdateMessage(
|
||||
channel,
|
||||
msgTs,
|
||||
@@ -105,25 +146,6 @@ func (t *Slack) AgentReasoningCallback() func(state types.ActionCurrentState) bo
|
||||
}
|
||||
}
|
||||
|
||||
// cancelActiveJobForChannel cancels any active job for the given channel
|
||||
func (t *Slack) cancelActiveJobForChannel(channelID string) {
|
||||
t.activeJobsMutex.RLock()
|
||||
ctxs, exists := t.activeJobs[channelID]
|
||||
t.activeJobsMutex.RUnlock()
|
||||
|
||||
if exists {
|
||||
xlog.Info(fmt.Sprintf("Cancelling active job for channel: %s", channelID))
|
||||
|
||||
// Mark the job as inactive
|
||||
t.activeJobsMutex.Lock()
|
||||
for _, c := range ctxs {
|
||||
c.Cancel()
|
||||
}
|
||||
delete(t.activeJobs, channelID)
|
||||
t.activeJobsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func cleanUpUsernameFromMessage(message string, b *slack.AuthTestResponse) string {
|
||||
cleaned := strings.ReplaceAll(message, "<@"+b.UserID+">", "")
|
||||
cleaned = strings.ReplaceAll(cleaned, "<@"+b.BotID+">", "")
|
||||
@@ -149,63 +171,193 @@ func replaceUserIDsWithNamesInMessage(api *slack.Client, message string) string
|
||||
return message
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// attachmentsFromMetadataOnly returns link/image attachments from metadata (no file uploads).
|
||||
// Used when posting a message so we can include URLs/images in the same post.
|
||||
func attachmentsFromMetadataOnly(metadata map[string]interface{}) (attachments []slack.Attachment) {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
if urls, exists := metadata[actions.MetadataUrls]; exists {
|
||||
for _, url := range xstrings.UniqueSlice(stringSliceFromMetadata(urls)) {
|
||||
attachments = append(attachments, slack.Attachment{
|
||||
Title: "URL",
|
||||
TitleLink: url,
|
||||
Text: url,
|
||||
})
|
||||
}
|
||||
}
|
||||
if imagesUrls, exists := metadata[actions.MetadataImages]; exists {
|
||||
for _, url := range xstrings.UniqueSlice(stringSliceFromMetadata(imagesUrls)) {
|
||||
attachments = append(attachments, slack.Attachment{
|
||||
Title: "Image",
|
||||
TitleLink: url,
|
||||
ImageURL: url,
|
||||
})
|
||||
}
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// stringSliceFromMetadata converts a metadata value to []string, supporting both
|
||||
// []string and []interface{} (e.g. from JSON). Returns nil if the value is not a supported slice type.
|
||||
func stringSliceFromMetadata(v interface{}) []string {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return []string{v}
|
||||
case []string:
|
||||
return v
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, e := range v {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// coming from the search action
|
||||
if urls, exists := state.Metadata[actions.MetadataUrls]; exists {
|
||||
for _, url := range xstrings.UniqueSlice(urls.([]string)) {
|
||||
attachment := slack.Attachment{
|
||||
Title: "URL",
|
||||
TitleLink: url,
|
||||
Text: url,
|
||||
}
|
||||
attachments = append(attachments, attachment)
|
||||
// uploadFilesFromMetadata uploads song and PDF files from metadata to the given thread.
|
||||
// Paths must be local filesystem paths; URLs will be skipped with a clear log.
|
||||
// Call after posting a message so threadTs is the message timestamp.
|
||||
func uploadFilesFromMetadata(metadata map[string]interface{}, api *slack.Client, channelID, threadTs string) {
|
||||
if metadata == nil {
|
||||
return
|
||||
}
|
||||
isURL := func(p string) bool {
|
||||
return strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://")
|
||||
}
|
||||
if songPaths, exists := metadata[actions.MetadataSongs]; exists {
|
||||
sl := stringSliceFromMetadata(songPaths)
|
||||
for _, path := range xstrings.UniqueSlice(sl) {
|
||||
if isURL(path) {
|
||||
xlog.Error("Slack upload skipped: song path is a URL, need local path", "path", path)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// coming from the gen image actions
|
||||
if imagesUrls, exists := state.Metadata[actions.MetadataImages]; exists {
|
||||
for _, url := range xstrings.UniqueSlice(imagesUrls.([]string)) {
|
||||
attachment := slack.Attachment{
|
||||
Title: "Image",
|
||||
TitleLink: url,
|
||||
ImageURL: url,
|
||||
}
|
||||
attachments = append(attachments, attachment)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading song file", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(path)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "audio"
|
||||
}
|
||||
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
Reader: bytes.NewReader(data),
|
||||
FileSize: len(data),
|
||||
ThreadTimestamp: threadTs,
|
||||
Channel: channelID,
|
||||
Filename: filename,
|
||||
Title: filename,
|
||||
InitialComment: "Generated song",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Slack UploadFileV2 failed for song", "error", err, "path", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
if pdfPaths, exists := metadata[actions.MetadataPDFs]; exists {
|
||||
sl := stringSliceFromMetadata(pdfPaths)
|
||||
for _, path := range xstrings.UniqueSlice(sl) {
|
||||
if isURL(path) {
|
||||
xlog.Error("Slack upload skipped: PDF path is a URL, need local path", "path", path)
|
||||
continue
|
||||
}
|
||||
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("Uploading PDF from metadata to Slack thread", "filename", filename, "path", path)
|
||||
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
Reader: bytes.NewReader(data),
|
||||
FileSize: len(data),
|
||||
ThreadTimestamp: threadTs,
|
||||
Channel: channelID,
|
||||
Filename: filename,
|
||||
Title: filename,
|
||||
InitialComment: "Generated PDF document",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Slack UploadFileV2 failed for PDF", "error", err, "path", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle generated images (download from URL and upload as file, so temporary URLs like DALL-E are preserved)
|
||||
if imageUrls, exists := metadata[actions.MetadataImages]; exists {
|
||||
sl := stringSliceFromMetadata(imageUrls)
|
||||
for _, imgURL := range xstrings.UniqueSlice(sl) {
|
||||
resp, err := http.Get(imgURL)
|
||||
if err != nil {
|
||||
xlog.Error("Error downloading image for Slack upload", "url", imgURL, "error", err)
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
xlog.Error("Error reading image body for Slack upload", "url", imgURL, "error", err)
|
||||
continue
|
||||
}
|
||||
if len(data) == 0 {
|
||||
xlog.Error("Empty image body for Slack upload", "url", imgURL)
|
||||
continue
|
||||
}
|
||||
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
Reader: bytes.NewReader(data),
|
||||
FileSize: len(data),
|
||||
ThreadTimestamp: threadTs,
|
||||
Channel: channelID,
|
||||
Filename: "image.png",
|
||||
Title: "Generated image",
|
||||
InitialComment: "Generated image",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Slack UploadFileV2 failed for image", "error", err, "url", imgURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attachmentsAndUploadsFromMetadata returns link/image attachments and uploads files (songs, PDFs)
|
||||
// from a metadata map. Used both by JobResult.State and by ConversationMessage.Metadata
|
||||
// (e.g. when newconversation/send_message is used so metadata is passed without going through State).
|
||||
func attachmentsAndUploadsFromMetadata(metadata map[string]interface{}, api *slack.Client, channelID, threadTs string) (attachments []slack.Attachment) {
|
||||
attachments = attachmentsFromMetadataOnly(metadata)
|
||||
uploadFilesFromMetadata(metadata, api, channelID, threadTs)
|
||||
return attachments
|
||||
}
|
||||
|
||||
// attachmentsFromJobResponseOnly returns link/image attachments from job response without uploading files.
|
||||
// Use with uploadJobResultFiles so uploads happen once per reply.
|
||||
func attachmentsFromJobResponseOnly(j *types.JobResult) []slack.Attachment {
|
||||
if j == nil {
|
||||
return nil
|
||||
}
|
||||
var out []slack.Attachment
|
||||
for _, state := range j.State {
|
||||
out = append(out, attachmentsFromMetadataOnly(state.Metadata)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uploadJobResultFiles uploads all song/PDF files from job result states to the given thread once.
|
||||
func uploadJobResultFiles(res *types.JobResult, api *slack.Client, channelID, threadTs string) {
|
||||
if res == nil {
|
||||
return
|
||||
}
|
||||
for _, state := range res.State {
|
||||
uploadFilesFromMetadata(state.Metadata, api, channelID, threadTs)
|
||||
}
|
||||
}
|
||||
|
||||
// ImageData represents a single image with its metadata
|
||||
@@ -380,9 +532,6 @@ func (t *Slack) handleChannelMessage(
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel any active job for this channel before starting a new one
|
||||
t.cancelActiveJobForChannel(ev.Channel)
|
||||
|
||||
currentConv := a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("slack:%s", t.channelID))
|
||||
|
||||
message := replaceUserIDsWithNamesInMessage(api, cleanUpUsernameFromMessage(ev.Text, b))
|
||||
@@ -411,9 +560,10 @@ func (t *Slack) handleChannelMessage(
|
||||
|
||||
agentOptions = append(agentOptions, types.WithConversationHistory(currentConv))
|
||||
|
||||
// Add channel to metadata for tracking
|
||||
// Add channel and conversation_id for tracking and cancel-previous-on-new-message
|
||||
metadata := map[string]interface{}{
|
||||
"channel": ev.Channel,
|
||||
"channel": ev.Channel,
|
||||
types.MetadataKeyConversationID: "slack:" + ev.Channel,
|
||||
}
|
||||
agentOptions = append(agentOptions, types.WithMetadata(metadata))
|
||||
|
||||
@@ -461,50 +611,61 @@ func (t *Slack) handleChannelMessage(
|
||||
|
||||
xlog.Debug("After adding message to conversation tracker", "conversation", a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("slack:%s", t.channelID)))
|
||||
|
||||
//res.Response = githubmarkdownconvertergo.Slack(res.Response)
|
||||
|
||||
replyWithPostMessage(res.Response, api, ev, postMessageParams, res)
|
||||
convertedResponse := githubmarkdownconvertergo.Slack(res.Response)
|
||||
replyWithPostMessage(convertedResponse, api, ev, postMessageParams, res)
|
||||
|
||||
}()
|
||||
}
|
||||
|
||||
func replyWithPostMessage(finalResponse string, api *slack.Client, ev *slackevents.MessageEvent, postMessageParams slack.PostMessageParameters, res *types.JobResult) {
|
||||
attachments := attachmentsFromJobResponseOnly(res)
|
||||
if len(finalResponse) > 4000 {
|
||||
// split response in multiple messages, and update the first
|
||||
|
||||
// Split response into multiple messages; post first to get thread ts, upload files once, then post rest in thread
|
||||
messages := xstrings.SplitParagraph(finalResponse, 3000)
|
||||
|
||||
for _, message := range messages {
|
||||
_, _, err := api.PostMessage(ev.Channel,
|
||||
var firstTs string
|
||||
for i, message := range messages {
|
||||
opts := []slack.MsgOption{
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(message, true),
|
||||
slack.MsgOptionText(message, false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, "")...),
|
||||
)
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
}
|
||||
if i > 0 && firstTs != "" {
|
||||
opts = append(opts, slack.MsgOptionTS(firstTs))
|
||||
}
|
||||
_, ts, err := api.PostMessage(ev.Channel, opts...)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
|
||||
xlog.Error("Error posting message", "error", err)
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
firstTs = ts
|
||||
uploadJobResultFiles(res, api, ev.Channel, firstTs)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_, _, err := api.PostMessage(ev.Channel,
|
||||
_, ts, err := api.PostMessage(ev.Channel,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(res.Response, true),
|
||||
slack.MsgOptionText(finalResponse, false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, "")...),
|
||||
// slack.MsgOptionTS(ts),
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
||||
xlog.Error("Error posting message", "error", err)
|
||||
return
|
||||
}
|
||||
uploadJobResultFiles(res, api, ev.Channel, ts)
|
||||
}
|
||||
}
|
||||
|
||||
func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackevents.AppMentionEvent, msgTs string, ts string, res *types.JobResult) {
|
||||
func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackevents.AppMentionEvent, msgTs string, ts string, postMessageParams slack.PostMessageParameters, res *types.JobResult) {
|
||||
attachments := attachmentsFromJobResponseOnly(res)
|
||||
// Use the thread root timestamp (ts), not the placeholder reply timestamp (msgTs).
|
||||
// Slack API: "Never use a reply's ts value; use its parent instead."
|
||||
uploadJobResultFiles(res, api, ev.Channel, ts)
|
||||
if len(finalResponse) > 3000 {
|
||||
// split response in multiple messages, and update the first
|
||||
|
||||
messages := xstrings.SplitParagraph(finalResponse, 3000)
|
||||
|
||||
_, _, _, err := api.UpdateMessage(
|
||||
@@ -512,11 +673,12 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
|
||||
msgTs,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(messages[0], true),
|
||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, msgTs)...),
|
||||
slack.MsgOptionText(messages[0], false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
||||
xlog.Error("Error updating final message", "error", err)
|
||||
}
|
||||
|
||||
for i, message := range messages {
|
||||
@@ -526,11 +688,12 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
|
||||
_, _, err = api.PostMessage(ev.Channel,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(message, true),
|
||||
slack.MsgOptionText(message, false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionTS(ts),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
|
||||
xlog.Error("Error posting message", "error", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -539,11 +702,12 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
|
||||
msgTs,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(finalResponse, true),
|
||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, msgTs)...),
|
||||
slack.MsgOptionText(finalResponse, false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
||||
xlog.Error("Error updating final message", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -675,9 +839,10 @@ func (t *Slack) handleMention(
|
||||
}
|
||||
}
|
||||
|
||||
// Add channel to job metadata for use in callbacks
|
||||
// Add channel and conversation_id for callbacks and cancel-previous-on-new-message
|
||||
metadata := map[string]interface{}{
|
||||
"channel": ev.Channel,
|
||||
"channel": ev.Channel,
|
||||
types.MetadataKeyConversationID: "slack:" + ev.Channel,
|
||||
}
|
||||
|
||||
// Call the agent with the conversation history
|
||||
@@ -687,9 +852,9 @@ func (t *Slack) handleMention(
|
||||
types.WithMetadata(metadata),
|
||||
)
|
||||
|
||||
if res.Response == "" {
|
||||
if res == nil || res.Response == "" {
|
||||
xlog.Debug(fmt.Sprintf("Empty response from agent"))
|
||||
replyToUpdateMessage("there was an internal error. try again!", api, ev, msgTs, ts, res)
|
||||
replyToUpdateMessage("there was an internal error. try again!", api, ev, msgTs, ts, postMessageParams, res)
|
||||
|
||||
// _, _, err := api.DeleteMessage(ev.Channel, msgTs)
|
||||
// if err != nil {
|
||||
@@ -700,20 +865,24 @@ func (t *Slack) handleMention(
|
||||
|
||||
// get user id
|
||||
user, err := api.GetUserInfo(ev.User)
|
||||
displayName := ev.User
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error getting user info: %v", err))
|
||||
} else if user != nil {
|
||||
displayName = user.Name
|
||||
}
|
||||
|
||||
// Format the final response
|
||||
//finalResponse := githubmarkdownconvertergo.Slack(res.Response)
|
||||
finalResponse := fmt.Sprintf("@%s %s", user.Name, res.Response)
|
||||
// Format the final response (convert GitHub markdown to Slack mrkdwn)
|
||||
convertedResponse := githubmarkdownconvertergo.Slack(res.Response)
|
||||
finalResponse := fmt.Sprintf("@%s %s", displayName, convertedResponse)
|
||||
xlog.Debug("Send final response to slack", "response", finalResponse)
|
||||
|
||||
replyToUpdateMessage(finalResponse, api, ev, msgTs, ts, res)
|
||||
replyToUpdateMessage(finalResponse, api, ev, msgTs, ts, postMessageParams, res)
|
||||
|
||||
// Clean up the placeholder map
|
||||
// Clean up the placeholder map and job status
|
||||
t.placeholderMutex.Lock()
|
||||
delete(t.placeholders, jobUUID)
|
||||
delete(t.jobStatus, jobUUID)
|
||||
t.placeholderMutex.Unlock()
|
||||
}()
|
||||
}
|
||||
@@ -734,22 +903,34 @@ 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)
|
||||
_, _, err := api.PostMessage(t.channelID,
|
||||
// handle new conversations (e.g. send_message / newconversation action)
|
||||
// Preserve metadata (PDFs, songs, images, URLs) so attachments are not lost
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(slack)", "message", ccm.Message.Content)
|
||||
convertedContent := githubmarkdownconvertergo.Slack(ccm.Message.Content)
|
||||
attachments := attachmentsFromMetadataOnly(ccm.Metadata)
|
||||
channelID, ts, err := api.PostMessage(t.channelID,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(ccm.Content, true),
|
||||
slack.MsgOptionText(convertedContent, false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
|
||||
}
|
||||
// Always upload files (PDFs, songs) when metadata is present—to the same thread if post succeeded, else to channel
|
||||
if ccm.Metadata != nil {
|
||||
ch, threadTs := t.channelID, ""
|
||||
if err == nil {
|
||||
ch, threadTs = channelID, ts
|
||||
}
|
||||
uploadFilesFromMetadata(ccm.Metadata, api, ch, threadTs)
|
||||
}
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("slack:%s", t.channelID),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
+432
-119
@@ -10,7 +10,9 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -20,10 +22,10 @@ import (
|
||||
"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/services/connectors/common"
|
||||
"github.com/mudler/LocalAGI/pkg/xstrings"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -40,6 +42,7 @@ type Telegram struct {
|
||||
// To track placeholder messages
|
||||
placeholders map[string]int // map[jobUUID]messageID
|
||||
placeholderMutex sync.RWMutex
|
||||
jobStatus map[string]*common.StatusAccumulator // map[jobUUID]accumulator
|
||||
|
||||
// Track active jobs for cancellation
|
||||
activeJobs map[int64][]*types.Job // map[chatID]bool to track if a chat has active processing
|
||||
@@ -56,14 +59,24 @@ func (t *Telegram) isBotMentioned(message string, botUsername string) bool {
|
||||
}
|
||||
|
||||
func (t *Telegram) chatFromMessage(update *models.Update) (openai.ChatCompletionMessage, error) {
|
||||
|
||||
if len(update.Message.Photo) == 0 {
|
||||
return openai.ChatCompletionMessage{
|
||||
Content: update.Message.Text,
|
||||
Role: "user",
|
||||
}, nil
|
||||
// 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]
|
||||
@@ -77,44 +90,122 @@ func (t *Telegram) chatFromMessage(update *models.Update) (openai.ChatCompletion
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error getting file", "error", err)
|
||||
} else {
|
||||
// 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)
|
||||
} 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)
|
||||
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
|
||||
}
|
||||
}
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
|
||||
return openai.ChatCompletionMessage{}, errors.New("no image found")
|
||||
// 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
|
||||
@@ -143,14 +234,9 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
return
|
||||
}
|
||||
|
||||
// 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{
|
||||
@@ -173,9 +259,15 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
t.placeholders[jobUUID] = msg.ID
|
||||
t.placeholderMutex.Unlock()
|
||||
|
||||
// Add chat ID to metadata for tracking
|
||||
// Add chat ID and conversation_id for tracking and cancel-previous-on-new-message
|
||||
metadata := map[string]interface{}{
|
||||
"chatID": update.Message.Chat.ID,
|
||||
types.MetadataKeyConversationID: fmt.Sprintf("telegram:%d", 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"
|
||||
}
|
||||
|
||||
chatMessage, err := t.chatFromMessage(update)
|
||||
@@ -188,6 +280,8 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
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),
|
||||
@@ -212,9 +306,10 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
}
|
||||
t.activeJobsMutex.Unlock()
|
||||
|
||||
// Clean up the placeholder map
|
||||
// Clean up the placeholder map and job status
|
||||
t.placeholderMutex.Lock()
|
||||
delete(t.placeholders, jobUUID)
|
||||
delete(t.jobStatus, jobUUID)
|
||||
t.placeholderMutex.Unlock()
|
||||
}()
|
||||
|
||||
@@ -251,6 +346,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)
|
||||
|
||||
@@ -301,21 +424,48 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
|
||||
func (t *Telegram) AgentResultCallback() func(state types.ActionState) {
|
||||
return func(state types.ActionState) {
|
||||
// Mark the job as completed when we get the final result
|
||||
if state.ActionCurrentState.Job != nil && state.ActionCurrentState.Job.Metadata != nil {
|
||||
if chatID, ok := state.ActionCurrentState.Job.Metadata["chatID"].(int64); ok && chatID != 0 {
|
||||
t.activeJobsMutex.Lock()
|
||||
delete(t.activeJobs, chatID)
|
||||
t.activeJobsMutex.Unlock()
|
||||
}
|
||||
job := state.ActionCurrentState.Job
|
||||
if job == nil || job.Metadata == nil {
|
||||
return
|
||||
}
|
||||
chatID, ok := job.Metadata["chatID"].(int64)
|
||||
if !ok || chatID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Update placeholder with tool result if still in progress
|
||||
t.placeholderMutex.Lock()
|
||||
msgID, exists := t.placeholders[job.UUID]
|
||||
if exists && msgID != 0 && t.bot != nil {
|
||||
acc, ok := t.jobStatus[job.UUID]
|
||||
if !ok {
|
||||
acc = common.NewStatusAccumulator()
|
||||
t.jobStatus[job.UUID] = acc
|
||||
}
|
||||
acc.AppendToolResult(common.ActionDisplayName(state.Action), state.Result)
|
||||
thought := acc.BuildMessage(telegramThinkingMessage, telegramMaxMessageLength)
|
||||
t.placeholderMutex.Unlock()
|
||||
_, err := t.bot.EditMessageText(t.agent.Context(), &bot.EditMessageTextParams{
|
||||
ChatID: chatID,
|
||||
MessageID: msgID,
|
||||
Text: thought,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error updating tool result message", "error", err)
|
||||
}
|
||||
t.placeholderMutex.Lock()
|
||||
}
|
||||
t.placeholderMutex.Unlock()
|
||||
|
||||
t.activeJobsMutex.Lock()
|
||||
delete(t.activeJobs, chatID)
|
||||
t.activeJobsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Telegram) AgentReasoningCallback() func(state types.ActionCurrentState) bool {
|
||||
return func(state types.ActionCurrentState) bool {
|
||||
// Check if we have a placeholder message for this job
|
||||
t.placeholderMutex.RLock()
|
||||
t.placeholderMutex.Lock()
|
||||
msgID, exists := t.placeholders[state.Job.UUID]
|
||||
chatID := int64(0)
|
||||
if state.Job.Metadata != nil {
|
||||
@@ -323,18 +473,30 @@ func (t *Telegram) AgentReasoningCallback() func(state types.ActionCurrentState)
|
||||
chatID = ch
|
||||
}
|
||||
}
|
||||
t.placeholderMutex.RUnlock()
|
||||
|
||||
if !exists || msgID == 0 || chatID == 0 || t.bot == nil {
|
||||
return true // Skip if we don't have a message to update
|
||||
t.placeholderMutex.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
thought := telegramThinkingMessage + "\n\n"
|
||||
if state.Reasoning == "" && state.Action == nil {
|
||||
t.placeholderMutex.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
acc, ok := t.jobStatus[state.Job.UUID]
|
||||
if !ok {
|
||||
acc = common.NewStatusAccumulator()
|
||||
t.jobStatus[state.Job.UUID] = acc
|
||||
}
|
||||
if state.Reasoning != "" {
|
||||
thought += "Current thought process:\n" + state.Reasoning
|
||||
acc.AppendReasoning(state.Reasoning)
|
||||
}
|
||||
if state.Action != nil {
|
||||
acc.AppendToolCall(common.ActionDisplayName(state.Action), state.Params.String())
|
||||
}
|
||||
thought := acc.BuildMessage(telegramThinkingMessage, telegramMaxMessageLength)
|
||||
t.placeholderMutex.Unlock()
|
||||
|
||||
// Update the placeholder message with the current reasoning
|
||||
_, err := t.bot.EditMessageText(t.agent.Context(), &bot.EditMessageTextParams{
|
||||
ChatID: chatID,
|
||||
MessageID: msgID,
|
||||
@@ -347,25 +509,6 @@ func (t *Telegram) AgentReasoningCallback() func(state types.ActionCurrentState)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelActiveJobForChat cancels any active job for the given chat
|
||||
func (t *Telegram) cancelActiveJobForChat(chatID int64) {
|
||||
t.activeJobsMutex.RLock()
|
||||
ctxs, exists := t.activeJobs[chatID]
|
||||
t.activeJobsMutex.RUnlock()
|
||||
|
||||
if exists {
|
||||
xlog.Info("Cancelling active job for chat", "chatID", chatID)
|
||||
|
||||
// Mark the job as inactive
|
||||
t.activeJobsMutex.Lock()
|
||||
for _, c := range ctxs {
|
||||
c.Cancel()
|
||||
}
|
||||
delete(t.activeJobs, chatID)
|
||||
t.activeJobsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// sendImageToTelegram downloads and sends an image to Telegram
|
||||
func sendImageToTelegram(ctx context.Context, b *bot.Bot, chatID int64, url string) error {
|
||||
resp, err := http.Get(url)
|
||||
@@ -396,6 +539,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
|
||||
@@ -416,33 +601,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
|
||||
@@ -499,9 +723,6 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
return
|
||||
}
|
||||
|
||||
// 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.From.ID))
|
||||
|
||||
message, err := t.chatFromMessage(update)
|
||||
@@ -535,9 +756,15 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
t.placeholders[jobUUID] = msg.ID
|
||||
t.placeholderMutex.Unlock()
|
||||
|
||||
// Add chat ID to metadata for tracking
|
||||
// Add chat ID and conversation_id for tracking and cancel-previous-on-new-message
|
||||
metadata := map[string]interface{}{
|
||||
"chatID": update.Message.Chat.ID,
|
||||
types.MetadataKeyConversationID: fmt.Sprintf("telegram:%d", 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
|
||||
@@ -564,9 +791,10 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
}
|
||||
t.activeJobsMutex.Unlock()
|
||||
|
||||
// Clean up the placeholder map
|
||||
// Clean up the placeholder map and job status
|
||||
t.placeholderMutex.Lock()
|
||||
delete(t.placeholders, jobUUID)
|
||||
delete(t.jobStatus, jobUUID)
|
||||
t.placeholderMutex.Unlock()
|
||||
}()
|
||||
|
||||
@@ -603,6 +831,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)
|
||||
|
||||
@@ -684,11 +939,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)
|
||||
@@ -698,7 +1010,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",
|
||||
},
|
||||
)
|
||||
@@ -724,6 +1036,7 @@ func NewTelegramConnector(config map[string]string) (*Telegram, error) {
|
||||
Token: token,
|
||||
admins: admins,
|
||||
placeholders: make(map[string]int),
|
||||
jobStatus: make(map[string]*common.StatusAccumulator),
|
||||
activeJobs: make(map[int64][]*types.Job),
|
||||
channelID: config["channel_id"],
|
||||
groupMode: config["group_mode"] == "true",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user