Compare commits

..

1 Commits

Author SHA1 Message Date
Ettore Di Giacinto a33425c707 fix(github*): pass by correctly owner and repository
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-04-17 22:58:59 +02:00
217 changed files with 9731 additions and 25001 deletions
+2 -2
View File
@@ -16,11 +16,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: 1.24
- name: Run GoReleaser
+5 -79
View File
@@ -11,11 +11,10 @@ concurrency:
cancel-in-progress: true
jobs:
containerImages:
#runs-on: ubuntu-latest
runs-on: arc-runner-localagent
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Prepare
id: prep
@@ -58,7 +57,7 @@ jobs:
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
with:
images: quay.io/mudler/localagi
tags: |
@@ -79,81 +78,8 @@ jobs:
VERSION=${{ steps.prep.outputs.binary_version }}
context: ./
file: ./Dockerfile.webui
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@v6
- name: Prepare
id: prep
run: |
DOCKER_IMAGE=quay.io/mudler/localagi-sshbox
# 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@c299e40c65443455700f0fdfc63efafe5b349051
with:
images: quay.io/mudler/localagi-sshbox
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.sshbox
platforms: linux/amd64,linux/arm64
#platforms: linux/amd64,linux/arm64
platforms: linux/amd64
push: true
#tags: ${{ steps.prep.outputs.tags }}
tags: ${{ steps.meta.outputs.tags }}
+30 -14
View File
@@ -1,4 +1,4 @@
name: Run Tests
name: Run Go Tests
on:
push:
@@ -15,20 +15,36 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- uses: actions/setup-go@v6
uses: actions/checkout@v2
- 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
docker version
docker run --rm hello-world
- uses: actions/setup-go@v5
with:
go-version: '>=1.26.0'
go-version: '>=1.17.0'
- name: Run tests
run: |
sudo apt-get update && sudo apt-get install -y make
make tests
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
#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 }}
-2
View File
@@ -8,5 +8,3 @@ LocalAGI
**/.env
.vscode
volumes/
example/scheduler/scheduler
example/scheduler/example_tasks.json
+1 -1
View File
@@ -1,5 +1,5 @@
# python
FROM python:3.14-slim
FROM python:3.13-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y python3-dev portaudio19-dev ffmpeg build-essential
-46
View File
@@ -1,46 +0,0 @@
# 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 \
openssh-server \
sudo
# Configure SSH
RUN mkdir /var/run/sshd
RUN echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config
# Create startup script
RUN echo '#!/bin/bash\n\
if [ -n "$SSH_USER" ]; then\n\
if [ "$SSH_USER" = "root" ]; then\n\
echo "PermitRootLogin yes" >> /etc/ssh/sshd_config\n\
if [ -n "$SSH_PASSWORD" ]; then\n\
echo "root:$SSH_PASSWORD" | chpasswd\n\
fi\n\
else\n\
echo "PermitRootLogin no" >> /etc/ssh/sshd_config\n\
useradd -m -s /bin/bash $SSH_USER\n\
if [ -n "$SSH_PASSWORD" ]; then\n\
echo "$SSH_USER:$SSH_PASSWORD" | chpasswd\n\
fi\n\
if [ -n "$SUDO_ACCESS" ] && [ "$SUDO_ACCESS" = "true" ]; then\n\
echo "$SSH_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$SSH_USER\n\
fi\n\
fi\n\
fi\n\
/usr/sbin/sshd -D' > /start.sh
RUN chmod +x /start.sh
EXPOSE 22
CMD ["/start.sh"]
+6 -15
View File
@@ -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.26-alpine
FROM golang:1.26-alpine AS builder
# Use a temporary build image based on Golang 1.24-alpine
FROM golang:1.24-alpine AS builder
# Define argument for linker flags
ARG LDFLAGS="-s -w"
@@ -44,21 +44,12 @@ 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 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
FROM scratch
# 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", "serve"]
ENTRYPOINT ["/localagi"]
+2 -5
View File
@@ -9,13 +9,13 @@ cleanup-tests:
docker compose down
tests: 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 ./...
LOCALAGI_MODEL="arcee-agent" LOCALAI_API_URL="http://localhost:8081" LOCALAGI_API_URL="http://localhost:8080" $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --fail-fast -v -r ./...
run-nokb:
$(MAKE) run KBDISABLEINDEX=true
webui/react-ui/dist:
docker run --entrypoint /bin/bash -v $(ROOT_DIR):/app:z oven/bun:1 -c "cd /app/webui/react-ui && bun install && bun run build"
docker run --entrypoint /bin/bash -v $(ROOT_DIR):/app oven/bun:1 -c "cd /app/webui/react-ui && bun install && bun run build"
.PHONY: build
build: webui/react-ui/dist
@@ -30,6 +30,3 @@ build-image:
image-push:
docker push $(IMAGE_NAME)
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/...
+39 -590
View File
@@ -2,7 +2,7 @@
<img src="./webui/react-ui/public/logo_1.png" alt="LocalAGI Logo" width="220"/>
</p>
<h3 align="center"><em>Your AI. Your Hardware. Your Rules</em></h3>
<h3 align="center"><em>Your AI. Your Hardware. Your Rules.</em></h3>
<div align="center">
@@ -11,14 +11,11 @@
[![GitHub stars](https://img.shields.io/github/stars/mudler/LocalAGI)](https://github.com/mudler/LocalAGI/stargazers)
[![GitHub issues](https://img.shields.io/github/issues/mudler/LocalAGI)](https://github.com/mudler/LocalAGI/issues)
Try on [![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/LocalAGI_bot)
</div>
Create customizable AI assistants, automations, chat bots and agents that run 100% locally. No need for agentic Python libraries or cloud service keys, just bring your GPU (or even just CPU) and a web browser.
We empower you building AI Agents that you can run locally, without coding.
**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.
**LocalAGI** is a powerful, self-hostable AI Agent platform designed for maximum privacy and flexibility. A complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU).
## 🛡️ Take Back Your Privacy
@@ -31,17 +28,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**: Built-in integrations with Discord, Slack, Telegram, GitHub Issues, and IRC.
- 📡 **Connectors Galore**: 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**: 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).
- 📚 **Short & Long-Term Memory**: Powered by [LocalRecall](https://github.com/mudler/LocalRecall).
- 🧠 **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.
## 🛠️ Quickstart
@@ -59,15 +54,12 @@ docker compose -f docker-compose.nvidia.yaml up
# Intel GPU setup (for Intel Arc and integrated GPUs)
docker compose -f docker-compose.intel.yaml up
# AMD GPU setup
docker compose -f docker-compose.amd.yaml up
# Start with a specific model (see available models in models.localai.io, or localai.io to use any model in huggingface)
MODEL_NAME=gemma-3-12b-it docker compose up
# NVIDIA GPU setup with custom multimodal and image models
MODEL_NAME=gemma-3-12b-it \
MULTIMODAL_MODEL=moondream2-20250414 \
MULTIMODAL_MODEL=minicpm-v-2_6 \
IMAGE_MODEL=flux.1-dev-ggml \
docker compose -f docker-compose.nvidia.yaml up
```
@@ -76,14 +68,6 @@ Now you can access and manage your agents at [http://localhost:8080](http://loca
Still having issues? see this Youtube video: https://youtu.be/HtVwIxW3ePg
## Videos
[![Creating a basic agent](https://img.youtube.com/vi/HtVwIxW3ePg/mqdefault.jpg)](https://youtu.be/HtVwIxW3ePg)
[![Agent Observability](https://img.youtube.com/vi/v82rswGJt_M/mqdefault.jpg)](https://youtu.be/v82rswGJt_M)
[![Filters and Triggers](https://img.youtube.com/vi/d_we-AYksSw/mqdefault.jpg)](https://youtu.be/d_we-AYksSw)
[![RAG and Matrix](https://img.youtube.com/vi/2Xvx78i5oBs/mqdefault.jpg)](https://youtu.be/2Xvx78i5oBs)
## 📚🆕 Local Stack Family
🆕 LocalAI is now part of a comprehensive suite of AI tools designed to work together:
@@ -108,7 +92,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. 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>
<p>A REST-ful API and knowledge base management system that provides persistent memory and storage capabilities for AI agents.</p>
</td>
</tr>
</table>
@@ -130,8 +114,8 @@ LocalAGI supports multiple hardware configurations through Docker Compose profil
- Supports text, multimodal, and image generation models
- Run with: `docker compose -f docker-compose.nvidia.yaml up`
- Default models:
- Text: `gemma-3-4b-it-qat`
- Multimodal: `moondream2-20250414`
- Text: `arcee-agent`
- Multimodal: `minicpm-v-2_6`
- Image: `sd-1.5-ggml`
- Environment variables:
- `MODEL_NAME`: Text model to use
@@ -146,8 +130,8 @@ LocalAGI supports multiple hardware configurations through Docker Compose profil
- Supports text, multimodal, and image generation models
- Run with: `docker compose -f docker-compose.intel.yaml up`
- Default models:
- Text: `gemma-3-4b-it-qat`
- Multimodal: `moondream2-20250414`
- Text: `arcee-agent`
- Multimodal: `minicpm-v-2_6`
- Image: `sd-1.5-ggml`
- Environment variables:
- `MODEL_NAME`: Text model to use
@@ -165,23 +149,20 @@ MODEL_NAME=gemma-3-12b-it docker compose up
# NVIDIA GPU with custom models
MODEL_NAME=gemma-3-12b-it \
MULTIMODAL_MODEL=moondream2-20250414 \
MULTIMODAL_MODEL=minicpm-v-2_6 \
IMAGE_MODEL=flux.1-dev-ggml \
docker compose -f docker-compose.nvidia.yaml up
# Intel GPU with custom models
MODEL_NAME=gemma-3-12b-it \
MULTIMODAL_MODEL=moondream2-20250414 \
MULTIMODAL_MODEL=minicpm-v-2_6 \
IMAGE_MODEL=sd-1.5-ggml \
docker compose -f docker-compose.intel.yaml up
# With custom actions directory
LOCALAGI_CUSTOM_ACTIONS_DIR=/app/custom-actions docker compose up
```
If no models are specified, it will use the defaults:
- Text model: `gemma-3-4b-it-qat`
- Multimodal model: `moondream2-20250414`
- Text model: `arcee-agent`
- Multimodal model: `minicpm-v-2_6`
- Image model: `sd-1.5-ggml`
Good (relatively small) models that have been tested are:
@@ -196,7 +177,15 @@ 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, built-in Skills, LocalAGI has it all.
- **✓ Feature-Rich**: From planning to multimodal capabilities, connectors for Slack, MCP support, LocalAGI has it all.
## 🌐 The Local Ecosystem
LocalAGI is part of the powerful Local family of privacy-focused AI tools:
- [**LocalAI**](https://github.com/mudler/LocalAI): Run Large Language Models locally.
- [**LocalRecall**](https://github.com/mudler/LocalRecall): Retrieval-Augmented Generation with local storage.
- [**LocalAGI**](https://github.com/mudler/LocalAGI): Deploy intelligent AI agents securely and privately.
## 🌟 Screenshots
@@ -205,8 +194,6 @@ Good (relatively small) models that have been tested are:
![Web UI Dashboard](https://github.com/user-attachments/assets/a40194f9-af3a-461f-8b39-5f4612fbf221)
![Web UI Agent Settings](https://github.com/user-attachments/assets/fb3c3e2a-cd53-4ca8-97aa-c5da51ff1f83)
![Web UI Create Group](https://github.com/user-attachments/assets/102189a2-0fba-4a1e-b0cb-f99268ef8062)
![Web UI Agent Observability](https://github.com/user-attachments/assets/f7359048-9d28-4cf1-9151-1f5556ce9235)
### Connectors Ready-to-Go
@@ -225,7 +212,6 @@ Explore detailed documentation including:
- [REST API Documentation](#rest-api)
- [Connector Configuration](#connectors)
- [Agent Configuration](#agent-configuration-reference)
- [Skills](#3-skills)
### Environment Configuration
@@ -239,14 +225,9 @@ 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_BASE_URL` | Optional base URL for the app (only relevant when using an external LocalRAG URL; not used for built-in knowledge base) |
| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection |
| `LOCALAGI_ENABLE_CONVERSATIONS_LOGGING` | Toggle conversation logs |
| `LOCALAGI_API_KEYS` | A comma separated list of api keys used for authentication |
| `LOCALAGI_CUSTOM_ACTIONS_DIR` | Directory containing custom Go action files to be automatically loaded |
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
@@ -275,442 +256,6 @@ go build -o localagi
./localagi
```
### Using as a Library
LocalAGI can be used as a Go library to programmatically create and manage AI agents. Let's start with a simple example of creating a single agent:
<details>
<summary><strong>Basic Usage: Single Agent</strong></summary>
```go
import (
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
)
// Create a new agent with basic configuration
agent, err := agent.New(
agent.WithModel("gpt-4"),
agent.WithLLMAPIURL("http://localhost:8080"),
agent.WithLLMAPIKey("your-api-key"),
agent.WithSystemPrompt("You are a helpful assistant."),
agent.WithCharacter(agent.Character{
Name: "my-agent",
}),
agent.WithActions(
// Add your custom actions here
),
agent.WithStateFile("./state/my-agent.state.json"),
agent.WithCharacterFile("./state/my-agent.character.json"),
agent.WithTimeout("10m"),
agent.EnableKnowledgeBase(),
agent.EnableReasoning(),
)
if err != nil {
log.Fatal(err)
}
// Start the agent
go func() {
if err := agent.Run(); err != nil {
log.Printf("Agent stopped: %v", err)
}
}()
// Stop the agent when done
agent.Stop()
```
This basic example shows how to:
- Create a single agent with essential configuration
- Set up the agent's model and API connection
- Configure basic features like knowledge base and reasoning
- Start and stop the agent
</details>
<details>
<summary><strong>Advanced Usage: Agent Pools</strong></summary>
For managing multiple agents, you can use the AgentPool system:
```go
import (
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/core/types"
)
// 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
"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
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 {
return []types.Action{
// Add your custom actions here
}
}
},
func(config *AgentConfig) []Connector {
// Define connectors for agents
return []Connector{
// Add your custom connectors here
}
},
func(config *AgentConfig) []DynamicPrompt {
// Define dynamic prompts for agents
return []DynamicPrompt{
// Add your custom prompts here
}
},
func(config *AgentConfig) types.JobFilters {
// Define job filters for agents
return types.JobFilters{
// Add your custom filters here
}
},
"10m", // timeout
true, // enable conversation logs
nil, // skills service (optional)
)
// Create a new agent in the pool
agentConfig := &AgentConfig{
Name: "my-agent",
Model: "gpt-4",
SystemPrompt: "You are a helpful assistant.",
EnableKnowledgeBase: true,
EnableReasoning: true,
// Add more configuration options as needed
}
err = pool.CreateAgent("my-agent", agentConfig)
// Start all agents
err = pool.StartAll()
// Get agent status
status := pool.GetStatusHistory("my-agent")
// Stop an agent
pool.Stop("my-agent")
// Remove an agent
err = pool.Remove("my-agent")
```
</details>
<details>
<summary><strong>Available Features</strong></summary>
Key features available through the library:
- **Single Agent Management**: Create and manage individual agents with basic configuration
- **Agent Pool Management**: Create, start, stop, and remove multiple agents
- **Configuration**: Customize agent behavior through AgentConfig
- **Actions**: Define custom actions for agents to perform
- **Connectors**: Add custom connectors for external services
- **Dynamic Prompts**: Create dynamic prompt templates
- **Job Filters**: Implement custom job filtering logic
- **Status Tracking**: Monitor agent status and history
- **State Persistence**: Automatic state saving and loading
For more details about available configuration options and features, refer to the [Agent Configuration Reference](#agent-configuration-reference) section.
</details>
## 🔧 Extending LocalAGI
LocalAGI provides two powerful ways to extend its functionality with custom actions:
### 1. Custom Actions (Go Code)
LocalAGI supports custom actions written in Go that can be defined inline when creating an agent. These actions are interpreted at runtime, so no compilation is required.
#### Automatic Custom Actions Loading
You can also place custom Go action files in a directory and have LocalAGI automatically load them. Set the `LOCALAGI_CUSTOM_ACTIONS_DIR` environment variable to point to a directory containing your custom action files. Each `.go` file in this directory will be automatically loaded and made available to all agents.
**Example setup:**
```bash
# Set the environment variable
export LOCALAGI_CUSTOM_ACTIONS_DIR="/path/to/custom/actions"
# Or in docker-compose.yaml
environment:
- LOCALAGI_CUSTOM_ACTIONS_DIR=/app/custom-actions
```
**Directory structure:**
```
custom-actions/
├── weather_action.go
├── file_processor.go
└── database_query.go
```
Each file should contain the three required functions (`Run`, `Definition`, `RequiredFields`) as described below.
#### How Custom Actions Work
When creating a new Agent, in the action sections select the "custom" action, you can add the Golang code directly there.
Custom actions in LocalAGI require three main functions:
1. **`Run(config map[string]interface{}) (string, map[string]interface{}, error)`** - The main execution function
2. **`Definition() map[string][]string`** - Defines the action's parameters and their types
3. **`RequiredFields() []string`** - Specifies which parameters are required
Note: You can't use additional modules, but just use libraries that are included in Go.
#### Example: Weather Information Action
Here's a practical example of a custom action that fetches weather information:
```go
import (
"encoding/json"
"fmt"
"net/http"
"io"
)
type WeatherParams struct {
City string `json:"city"`
Country string `json:"country"`
}
type WeatherResponse struct {
Main struct {
Temp float64 `json:"temp"`
Humidity int `json:"humidity"`
} `json:"main"`
Weather []struct {
Description string `json:"description"`
} `json:"weather"`
}
func Run(config map[string]interface{}) (string, map[string]interface{}, error) {
// Parse parameters
p := WeatherParams{}
b, err := json.Marshal(config)
if err != nil {
return "", map[string]interface{}{}, err
}
if err := json.Unmarshal(b, &p); err != nil {
return "", map[string]interface{}{}, err
}
// Make API call to weather service
url := fmt.Sprintf("http://api.openweathermap.org/data/2.5/weather?q=%s,%s&appid=YOUR_API_KEY&units=metric", p.City, p.Country)
resp, err := http.Get(url)
if err != nil {
return "", map[string]interface{}{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", map[string]interface{}{}, err
}
var weather WeatherResponse
if err := json.Unmarshal(body, &weather); err != nil {
return "", map[string]interface{}{}, err
}
// Format response
result := fmt.Sprintf("Weather in %s, %s: %.1f°C, %s, Humidity: %d%%",
p.City, p.Country, weather.Main.Temp, weather.Weather[0].Description, weather.Main.Humidity)
return result, map[string]interface{}{}, nil
}
func Definition() map[string][]string {
return map[string][]string{
"city": []string{
"string",
"The city name to get weather for",
},
"country": []string{
"string",
"The country code (e.g., US, UK, DE)",
},
}
}
func RequiredFields() []string {
return []string{"city", "country"}
}
```
#### Example: File System Action
Here's another example that demonstrates file system operations:
```go
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type FileParams struct {
Path string `json:"path"`
Action string `json:"action"`
Content string `json:"content,omitempty"`
}
func Run(config map[string]interface{}) (string, map[string]interface{}, error) {
p := FileParams{}
b, err := json.Marshal(config)
if err != nil {
return "", map[string]interface{}{}, err
}
if err := json.Unmarshal(b, &p); err != nil {
return "", map[string]interface{}{}, err
}
switch p.Action {
case "read":
content, err := os.ReadFile(p.Path)
if err != nil {
return "", map[string]interface{}{}, err
}
return string(content), map[string]interface{}{}, nil
case "write":
err := os.WriteFile(p.Path, []byte(p.Content), 0644)
if err != nil {
return "", map[string]interface{}{}, err
}
return fmt.Sprintf("Successfully wrote to %s", p.Path), map[string]interface{}{}, nil
case "list":
files, err := os.ReadDir(p.Path)
if err != nil {
return "", map[string]interface{}{}, err
}
var fileList []string
for _, file := range files {
fileList = append(fileList, file.Name())
}
result, _ := json.Marshal(fileList)
return string(result), map[string]interface{}{}, nil
default:
return "", map[string]interface{}{}, fmt.Errorf("unknown action: %s", p.Action)
}
}
func Definition() map[string][]string {
return map[string][]string{
"path": []string{
"string",
"The file or directory path",
},
"action": []string{
"string",
"The action to perform: read, write, or list",
},
"content": []string{
"string",
"Content to write (required for write action)",
},
}
}
func RequiredFields() []string {
return []string{"path", "action"}
}
```
#### Using Custom Actions in Agents
To use custom actions, add them to your agent configuration:
1. **Via Web UI**: In the agent creation form, add a "Custom" action and paste your Go code
2. **Via API**: Include the custom action in your agent configuration JSON
3. **Via Library**: Add the custom action to your agent's actions list
### 2. MCP (Model Context Protocol) Servers
LocalAGI supports both local and remote MCP servers, allowing you to extend functionality with external tools and services.
#### What is MCP?
The Model Context Protocol (MCP) is a standard for connecting AI applications to external data sources and tools. LocalAGI can connect to any MCP-compliant server to access additional capabilities.
#### Local MCP Servers
Local MCP servers run as processes that LocalAGI can spawn and communicate with via STDIO.
##### Example: GitHub MCP Server
```json
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "<YOUR_TOKEN>"
}
}
}
}
```
#### Remote MCP Servers
Remote MCP servers are HTTP-based and can be accessed over the network.
#### Creating Your Own MCP Server
You can create MCP servers in any language that supports the MCP protocol and add the URLs of the servers to LocalAGI.
#### Configuring MCP Servers in LocalAGI
1. **Via Web UI**: In the MCP Settings section of agent creation, add MCP servers
2. **Via API**: Include MCP server configuration in your agent config
#### Best Practices
- **Security**: Always validate inputs and use proper authentication for remote MCP servers
- **Error Handling**: Implement robust error handling in your MCP servers
- **Documentation**: Provide clear descriptions for all tools exposed by your MCP server
- **Testing**: Test your MCP servers independently before integrating with LocalAGI
- **Resource Management**: Ensure your MCP servers properly clean up resources
### 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:
@@ -720,39 +265,15 @@ The development workflow is similar to the source build, but with additional ste
git clone https://github.com/mudler/LocalAGI.git
cd LocalAGI
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
# Install dependencies and start frontend development server
cd webui/react-ui && bun i && 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
go run main.go
cd ../.. && go run main.go
```
> Note: see webui/react-ui/.vite.config.js for env vars that can be used to configure the backend URL
@@ -761,8 +282,7 @@ go run main.go
Link your agents to the services you already use. Configuration examples below.
<details>
<summary><strong>GitHub Issues</strong></summary>
### GitHub Issues
```json
{
@@ -772,10 +292,8 @@ Link your agents to the services you already use. Configuration examples below.
"botUserName": "bot-username"
}
```
</details>
<details>
<summary><strong>Discord</strong></summary>
### Discord
After [creating your Discord bot](https://discordpy.readthedocs.io/en/stable/discord.html):
@@ -787,10 +305,8 @@ After [creating your Discord bot](https://discordpy.readthedocs.io/en/stable/dis
```
> Don't forget to enable "Message Content Intent" in Bot(tab) settings!
> Enable " Message Content Intent " in the Bot tab!
</details>
<details>
<summary><strong>Slack</strong></summary>
### Slack
Use the included `slack.yaml` manifest to create your app, then configure:
@@ -803,39 +319,19 @@ Use the included `slack.yaml` manifest to create your app, then configure:
- Create Oauth token bot token from "OAuth & Permissions" -> "OAuth Tokens for Your Workspace"
- Create App level token (from "Basic Information" -> "App-Level Tokens" ( scope connections:writeRoute authorizations:read ))
</details>
<details>
<summary><strong>Telegram</strong></summary>
### Telegram
Get a token from @botfather, then:
```json
{
"token": "your-bot-father-token",
"group_mode": "true",
"mention_only": "true",
"admins": "username1,username2"
"token": "your-bot-father-token"
}
```
Configuration options:
- `token`: Your bot token from BotFather
- `group_mode`: Enable/disable group chat functionality
- `mention_only`: When enabled, bot only responds when mentioned in groups
- `admins`: Comma-separated list of Telegram usernames allowed to use the bot in private chats
- `channel_id`: Optional channel ID for the bot to send messages to
> **Important**: For group functionality to work properly:
> 1. Go to @BotFather
> 2. Select your bot
> 3. Go to "Bot Settings" > "Group Privacy"
> 4. Select "Turn off" to allow the bot to read all messages in groups
> 5. Restart your bot after changing this setting
</details>
<details>
<summary><strong>IRC</strong></summary>
### IRC
Connect to IRC networks:
@@ -848,29 +344,10 @@ Connect to IRC networks:
"alwaysReply": "false"
}
```
</details>
<details>
<summary><strong>Email</strong></summary>
```json
{
"smtpServer": "smtp.gmail.com:587",
"imapServer": "imap.gmail.com:993",
"smtpInsecure": "false",
"imapInsecure": "false",
"username": "user@gmail.com",
"email": "user@gmail.com",
"password": "correct-horse-battery-staple",
"name": "LogalAGI Agent"
}
```
</details>
## REST API
<details>
<summary><strong>Agent Management</strong></summary>
### Agent Management
| Endpoint | Method | Description | Example |
|----------|--------|-------------|---------|
@@ -885,10 +362,8 @@ Connect to IRC networks:
| `/api/meta/agent/config` | GET | Get agent configuration metadata | |
| `/settings/export/:name` | GET | Export agent config | [Example](#export-agent) |
| `/settings/import` | POST | Import agent config | [Example](#import-agent) |
</details>
<details>
<summary><strong>Actions and Groups</strong></summary>
### Actions and Groups
| Endpoint | Method | Description | Example |
|----------|--------|-------------|---------|
@@ -896,10 +371,8 @@ Connect to IRC networks:
| `/api/action/:name/run` | POST | Execute an action | |
| `/api/agent/group/generateProfiles` | POST | Generate group profiles | |
| `/api/agent/group/create` | POST | Create a new agent group | |
</details>
<details>
<summary><strong>Chat Interactions</strong></summary>
### Chat Interactions
| Endpoint | Method | Description | Example |
|----------|--------|-------------|---------|
@@ -907,7 +380,6 @@ Connect to IRC networks:
| `/api/notify/:name` | POST | Send notification to agent | [Example](#notify-agent) |
| `/api/sse/:name` | GET | Real-time agent event stream | [Example](#agent-sse-stream) |
| `/v1/responses` | POST | Send message & get response | [OpenAI's Responses](https://platform.openai.com/docs/api-reference/responses/create) |
</details>
<details>
<summary><strong>Curl Examples</strong></summary>
@@ -995,13 +467,11 @@ curl -X POST "http://localhost:3000/api/notify/my-agent" \
curl -N -X GET "http://localhost:3000/api/sse/my-agent"
```
Note: For proper SSE handling, you should use a client that supports SSE natively.
</details>
### Agent Configuration Reference
<details>
<summary><strong>Configuration Structure</strong></summary>
The agent configuration defines how an agent behaves and what capabilities it has. You can view the available configuration options and their descriptions by using the metadata endpoint:
```bash
@@ -1034,27 +504,6 @@ Here's an example of the agent configuration structure:
"summary_long_term_memory": false
}
```
</details>
<details>
<summary><strong>Environment Configuration</strong></summary>
LocalAGI supports environment configurations. Note that these environment variables needs to be specified in the localagi container in the docker-compose file to have effect.
| Variable | What It Does |
|----------|--------------|
| `LOCALAGI_MODEL` | Your go-to model |
| `LOCALAGI_MULTIMODAL_MODEL` | Optional model for multimodal capabilities |
| `LOCALAGI_LLM_API_URL` | OpenAI-compatible API server URL |
| `LOCALAGI_LLM_API_KEY` | API authentication |
| `LOCALAGI_TIMEOUT` | Request timeout settings |
| `LOCALAGI_STATE_DIR` | Where state gets stored |
| `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_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
-15
View File
@@ -1,15 +0,0 @@
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)
}
-355
View File
@@ -1,355 +0,0 @@
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
View File
@@ -1,104 +0,0 @@
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
}
-32
View File
@@ -1,32 +0,0 @@
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
View File
@@ -1,128 +0,0 @@
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
}
+7 -49
View File
@@ -3,13 +3,11 @@ package action
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/xlog"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
"github.com/traefik/yaegi/interp"
"github.com/traefik/yaegi/stdlib"
@@ -26,7 +24,7 @@ func NewCustom(config map[string]string, goPkgPath string) (*CustomAction, error
}
if err := a.callInit(); err != nil {
xlog.Warn("No init function found for custom action", "error", err, "action", a.config["name"])
xlog.Error("Error calling custom action init", "error", err)
}
return a, nil
@@ -45,23 +43,18 @@ func (a *CustomAction) callInit() error {
v, err := a.i.Eval(fmt.Sprintf("%s.Init", a.config["name"]))
if err != nil {
xlog.Warn("No init function found for custom action", "error", err, "action", a.config["name"])
return nil
return err
}
run, ok := v.Interface().(func(string) error)
if !ok {
return nil
}
run := v.Interface().(func() error)
return run(a.config["configuration"])
return run()
}
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,
})
@@ -73,15 +66,6 @@ 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
@@ -97,7 +81,7 @@ func (a *CustomAction) Plannable() bool {
return true
}
func (a *CustomAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (a *CustomAction) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
v, err := a.i.Eval(fmt.Sprintf("%s.Run", a.config["name"]))
if err != nil {
return types.ActionResult{}, err
@@ -111,32 +95,12 @@ func (a *CustomAction) Run(ctx context.Context, sharedState *types.AgentSharedSt
func (a *CustomAction) Definition() types.ActionDefinition {
if a.i == nil {
xlog.Error("Interpreter is not initialized for custom action", "action", a.config["name"])
return types.ActionDefinition{}
}
v, err := a.i.Eval(fmt.Sprintf("%s.Definition", a.config["name"]))
if err != nil {
xlog.Error("Error getting custom action definition", "error", err)
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"]))
@@ -161,7 +125,7 @@ func (a *CustomAction) Definition() types.ActionDefinition {
}
return types.ActionDefinition{
Name: types.ActionDefinitionName(a.config["name"]),
Description: description,
Description: a.config["description"],
Properties: prop,
Required: requiredFields(),
}
@@ -195,11 +159,5 @@ 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 -1
View File
@@ -76,7 +76,7 @@ return []string{"foo"}
Description: "A test action",
}))
runResult, err := customAction.Run(context.Background(), nil, types.ActionParams{
runResult, err := customAction.Run(context.Background(), types.ActionParams{
"Foo": "bar",
})
Expect(err).ToNot(HaveOccurred())
+48
View File
@@ -0,0 +1,48 @@
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(context.Context, 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"},
}
}
+50
View File
@@ -0,0 +1,50 @@
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(context.Context, 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"},
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"github.com/sashabaranov/go-openai/jsonschema"
)
const ConversationActionName = "send_message"
const ConversationActionName = "new_conversation"
func NewConversation() *ConversationAction {
return &ConversationAction{}
@@ -19,7 +19,7 @@ type ConversationActionResponse struct {
Message string `json:"message"`
}
func (a *ConversationAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (a *ConversationAction) Run(context.Context, types.ActionParams) (types.ActionResult, error) {
return types.ActionResult{}, nil
}
+1 -1
View File
@@ -16,7 +16,7 @@ func NewStop() *StopAction {
type StopAction struct{}
func (a *StopAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (a *StopAction) Run(context.Context, types.ActionParams) (types.ActionResult, error) {
return types.ActionResult{}, nil
}
+71
View File
@@ -0,0 +1,71 @@
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(context.Context, 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"},
}
}
+43
View File
@@ -0,0 +1,43 @@
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(context.Context, 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"},
}
}
-268
View File
@@ -1,268 +0,0 @@
package action
import (
"context"
"fmt"
"strings"
"time"
"github.com/mudler/LocalAGI/core/scheduler"
"github.com/mudler/LocalAGI/core/types"
"github.com/sashabaranov/go-openai/jsonschema"
)
const (
RecurringReminderActionName = "set_recurring_task"
OneTimeReminderActionName = "set_onetime_task"
ListRemindersName = "list_tasks"
RemoveReminderName = "remove_task"
)
func NewRecurringReminder() *RecurringReminderAction {
return &RecurringReminderAction{}
}
func NewOneTimeReminder() *OneTimeReminderAction {
return &OneTimeReminderAction{}
}
func NewListReminders() *ListRemindersAction {
return &ListRemindersAction{}
}
func NewRemoveReminder() *RemoveReminderAction {
return &RemoveReminderAction{}
}
type RecurringReminderAction struct{}
type OneTimeReminderAction struct{}
type ListRemindersAction struct{}
type RemoveReminderAction struct{}
type RemoveReminderParams struct {
Index int `json:"index"`
}
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
}
task, err := scheduler.NewTask(
sharedState.AgentName,
result.Message,
scheduler.ScheduleTypeCron,
result.CronExpr,
)
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("Recurring reminder set successfully (ID: %s). Next run: %s", task.ID, task.NextRun.Format(time.RFC3339)),
Metadata: map[string]interface{}{
"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) {
tasks, err := sharedState.Scheduler.GetAllTasks()
if err != nil {
return types.ActionResult{}, err
}
if len(tasks) == 0 {
return types.ActionResult{
Result: "No reminders set",
}, nil
}
var result strings.Builder
result.WriteString("Current reminders:\n")
for i, task := range tasks {
status := "one-time"
if task.ScheduleType == scheduler.ScheduleTypeCron || task.ScheduleType == scheduler.ScheduleTypeInterval {
status = "recurring"
}
result.WriteString(fmt.Sprintf("%d. %s (Next run: %s, Status: %s, ID: %s)\n",
i+1,
task.Prompt,
task.NextRun.Format(time.RFC3339),
status,
task.ID))
}
return types.ActionResult{
Result: result.String(),
Metadata: map[string]interface{}{
"tasks": tasks,
},
}, nil
}
func (a *RemoveReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
var removeParams RemoveReminderParams
err := params.Unmarshal(&removeParams)
if err != nil {
return types.ActionResult{}, err
}
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
}
// Convert from 1-based index to 0-based
index := removeParams.Index - 1
if index < 0 || index >= len(tasks) {
return types.ActionResult{}, fmt.Errorf("invalid reminder index: %d", removeParams.Index)
}
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", task.Prompt),
Metadata: map[string]interface{}{
"removed_task_id": task.ID,
},
}, nil
}
func (a *RecurringReminderAction) Plannable() bool {
return true
}
func (a *OneTimeReminderAction) Plannable() bool {
return true
}
func (a *ListRemindersAction) Plannable() bool {
return true
}
func (a *RemoveReminderAction) Plannable() bool {
return true
}
func (a *RecurringReminderAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
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,
Description: "The message or task to be reminded about",
},
"cron_expr": {
Type: jsonschema.String,
Description: "Cron expression for scheduling (e.g. '0 0 * * *' for daily at midnight). Format: 'minute hour day month weekday'",
},
},
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"},
}
}
func (a *ListRemindersAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: ListRemindersName,
Description: "List all currently set reminders with their next scheduled run times",
Properties: map[string]jsonschema.Definition{},
Required: []string{},
}
}
func (a *RemoveReminderAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: RemoveReminderName,
Description: "Remove a reminder by its index number (use list_reminders to see the index)",
Properties: map[string]jsonschema.Definition{
"index": {
Type: jsonschema.Integer,
Description: "The index number of the reminder to remove (1-based)",
},
},
Required: []string{"index"},
}
}
+45
View File
@@ -0,0 +1,45 @@
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(context.Context, 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"},
}
}
+40 -1
View File
@@ -2,6 +2,7 @@ package action
import (
"context"
"fmt"
"github.com/mudler/LocalAGI/core/types"
"github.com/sashabaranov/go-openai/jsonschema"
@@ -15,7 +16,25 @@ func NewState() *StateAction {
type StateAction struct{}
func (a *StateAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, 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
// Besides a long term memory that is accessible by the agent (With vector database),
// And a context memory (that is always powered by a vector database),
// this memory is the shorter one that the LLM keeps across conversation and across its
// reasoning process's and life time.
// TODO: A special action is then used to let the LLM itself update its memory
// periodically during self-processing, and the same action is ALSO exposed
// during the conversation to let the user put for example, a new goal to the agent.
type AgentInternalState struct {
NowDoing string `json:"doing_now"`
DoingNext string `json:"doing_next"`
DoneHistory []string `json:"done_history"`
Memories []string `json:"memories"`
Goal string `json:"goal"`
}
func (a *StateAction) Run(context.Context, types.ActionParams) (types.ActionResult, error) {
return types.ActionResult{Result: "internal state has been updated"}, nil
}
@@ -57,3 +76,23 @@ func (a *StateAction) Definition() types.ActionDefinition {
},
}
}
const fmtT = `=====================
NowDoing: %s
DoingNext: %s
Your current goal is: %s
You have done: %+v
You have a short memory with: %+v
=====================
`
func (c AgentInternalState) String() string {
return fmt.Sprintf(
fmtT,
c.NowDoing,
c.DoingNext,
c.Goal,
c.DoneHistory,
c.Memories,
)
}
+381 -57
View File
@@ -1,19 +1,93 @@
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/xlog"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/sashabaranov/go-openai"
)
type decisionResult struct {
actionParams types.ActionParams
message string
actioName string
}
// decision forces the agent to take one of the available actions
func (a *Agent) decision(
ctx context.Context,
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},
}
}
var lastErr error
for attempts := 0; attempts < maxRetries; attempts++ {
decision := openai.ChatCompletionRequest{
Model: a.options.LLMAPI.Model,
Messages: conversation,
Tools: tools,
}
if choice != nil {
decision.ToolChoice = *choice
}
resp, err := a.client.CreateChatCompletion(ctx, decision)
if err != nil {
lastErr = err
xlog.Warn("Attempt to make a decision failed", "attempt", attempts+1, "error", err)
continue
}
jsonResp, _ := json.Marshal(resp)
xlog.Debug("Decision response", "response", string(jsonResp))
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)
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)
}
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)
continue
}
if err := a.saveConversation(append(conversation, msg), "decision"); err != nil {
xlog.Error("Error saving conversation", "error", err)
}
return &decisionResult{actionParams: params, actioName: msg.ToolCalls[0].Function.Name, message: msg.Content}, nil
}
return nil, fmt.Errorf("failed to make a decision after %d attempts: %w", maxRetries, lastErr)
}
type Messages []openai.ChatCompletionMessage
func (m Messages) ToOpenAI() []openai.ChatCompletionMessage {
@@ -81,7 +155,6 @@ func (m Messages) Save(path string) error {
}
func (m Messages) GetLatestUserMessage() *openai.ChatCompletionMessage {
xlog.Debug("Getting latest user message", "messages", m)
for i := len(m) - 1; i >= 0; i-- {
msg := m[i]
if msg.Role == UserRole {
@@ -92,64 +165,179 @@ func (m Messages) GetLatestUserMessage() *openai.ChatCompletionMessage {
return nil
}
// 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)
}
func (m Messages) IsLastMessageFromRole(role string) bool {
if len(m) == 0 {
return false
}
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
}
}
}
if content != "" {
leading = append(leading, content)
}
i++
}
if len(leading) == 0 {
return conv
}
combined := strings.Join(leading, "\n\n")
single := openai.ChatCompletionMessage{
Role: SystemRole,
Content: combined,
}
return append([]openai.ChatCompletionMessage{single}, conv[i:]...)
return m[len(m)-1].Role == role
}
// getAvailableActionsForJob returns available actions including user-defined ones for a specific job
func (a *Agent) getAvailableActionsForJob(job *types.Job) types.Actions {
// Start with regular available actions
baseActions := a.availableActions(job)
// Add user-defined actions from the job
userTools := job.GetUserTools()
if len(userTools) > 0 {
userDefinedActions := types.CreateUserDefinedActions(userTools)
baseActions = append(baseActions, userDefinedActions...)
xlog.Debug("Added user-defined actions", "definitions", userTools)
func (a *Agent) generateParameters(ctx context.Context, pickTemplate string, act types.Action, c []openai.ChatCompletionMessage, reasoning string, maxAttempts int) (*decisionResult, error) {
stateHUD, err := renderTemplate(pickTemplate, a.prepareHUD(), a.availableActions(), reasoning)
if err != nil {
return nil, err
}
return baseActions
conversation := c
if !Messages(c).Exist(stateHUD) && a.options.enableHUD {
conversation = append([]openai.ChatCompletionMessage{
{
Role: "system",
Content: stateHUD,
},
}, conversation...)
}
cc := conversation
if a.options.forceReasoning {
cc = append(conversation, openai.ChatCompletionMessage{
Role: "system",
Content: fmt.Sprintf("The agent decided to use the tool %s with the following reasoning: %s", act.Definition().Name, reasoning),
})
}
var result *decisionResult
var attemptErr error
for attempts := 0; attempts < maxAttempts; attempts++ {
result, attemptErr = a.decision(ctx,
cc,
a.availableActions().ToTools(),
act.Definition().Name.String(),
maxAttempts,
)
if attemptErr == nil && result.actionParams != nil {
return result, nil
}
xlog.Warn("Attempt to generate parameters failed", "attempt", attempts+1, "error", attemptErr)
}
return nil, fmt.Errorf("failed to generate parameters after %d attempts: %w", maxAttempts, attemptErr)
}
func (a *Agent) availableActions(j *types.Job) types.Actions {
func (a *Agent) handlePlanning(ctx context.Context, job *types.Job, chosenAction types.Action, actionParams types.ActionParams, reasoning string, pickTemplate string, conv Messages) (Messages, error) {
// Planning: run all the actions in sequence
if !chosenAction.Definition().Name.Is(action.PlanActionName) {
xlog.Debug("no plan action")
return conv, nil
}
xlog.Debug("[planning]...")
planResult := action.PlanResult{}
if err := actionParams.Unmarshal(&planResult); err != nil {
return conv, fmt.Errorf("error unmarshalling plan result: %w", err)
}
stateResult := types.ActionState{
ActionCurrentState: types.ActionCurrentState{
Job: job,
Action: chosenAction,
Params: actionParams,
Reasoning: reasoning,
},
ActionResult: types.ActionResult{
Result: fmt.Sprintf("planning %s, subtasks: %+v", planResult.Goal, planResult.Subtasks),
},
}
job.Result.SetResult(stateResult)
job.CallbackWithResult(stateResult)
xlog.Info("[Planning] starts", "agent", a.Character.Name, "goal", planResult.Goal)
for _, s := range planResult.Subtasks {
xlog.Info("[Planning] subtask", "agent", a.Character.Name, "action", s.Action, "reasoning", s.Reasoning)
}
if len(planResult.Subtasks) == 0 {
return conv, fmt.Errorf("no subtasks")
}
// Execute all subtasks in sequence
for _, subtask := range planResult.Subtasks {
xlog.Info("[subtask] Generating parameters",
"agent", a.Character.Name,
"action", subtask.Action,
"reasoning", reasoning,
)
subTaskAction := a.availableActions().Find(subtask.Action)
subTaskReasoning := fmt.Sprintf("%s Overall goal is: %s", subtask.Reasoning, planResult.Goal)
params, err := a.generateParameters(ctx, 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(ctx, subTaskAction, actionParams)
if err != nil {
xlog.Error("error running action", "error", err)
return conv, fmt.Errorf("error running action: %w", err)
}
stateResult := types.ActionState{
ActionCurrentState: types.ActionCurrentState{
Job: job,
Action: subTaskAction,
Params: actionParams,
Reasoning: subTaskReasoning,
},
ActionResult: result,
}
job.Result.SetResult(stateResult)
job.CallbackWithResult(stateResult)
xlog.Debug("[subtask] Action executed", "agent", a.Character.Name, "action", subTaskAction.Definition().Name, "result", result)
conv = a.addFunctionResultToConversation(subTaskAction, actionParams, result, conv)
}
return conv, nil
}
func (a *Agent) availableActions() types.Actions {
// defaultActions := append(a.options.userActions, action.NewReply())
defaultActions := slices.Clone(a.options.userActions)
if j.Metadata["type"] == "scheduled" || (a.options.initiateConversations && a.selfEvaluationInProgress) { // && self-evaluation..
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..
acts := append(defaultActions, action.NewConversation())
if a.options.enableHUD {
acts = append(acts, action.NewState())
@@ -158,7 +346,7 @@ func (a *Agent) availableActions(j *types.Job) types.Actions {
// acts = append(acts, action.NewStop())
// }
return acts
return addPlanAction(acts)
}
if a.options.canStopItself {
@@ -166,14 +354,14 @@ func (a *Agent) availableActions(j *types.Job) types.Actions {
if a.options.enableHUD {
acts = append(acts, action.NewState())
}
return acts
return addPlanAction(acts)
}
if a.options.enableHUD {
return append(defaultActions, action.NewState())
return addPlanAction(append(defaultActions, action.NewState()))
}
return defaultActions
return addPlanAction(defaultActions)
}
func (a *Agent) prepareHUD() (promptHUD *PromptHUD) {
@@ -188,3 +376,139 @@ func (a *Agent) prepareHUD() (promptHUD *PromptHUD) {
ShowCharacter: a.options.showCharacter,
}
}
// pickAction picks an action based on the conversation
func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.ChatCompletionMessage, maxRetries int) (types.Action, types.ActionParams, string, error) {
c := messages
xlog.Debug("[pickAction] picking action starts", "messages", messages)
// Identify the goal of this conversation
if !a.options.forceReasoning {
xlog.Debug("not forcing reasoning")
// We also could avoid to use functions here and get just a reply from the LLM
// and then use the reply to get the action
thought, err := a.decision(ctx,
messages,
a.availableActions().ToTools(),
"",
maxRetries)
if err != nil {
return nil, nil, "", err
}
xlog.Debug(fmt.Sprintf("thought action Name: %v", thought.actioName))
xlog.Debug(fmt.Sprintf("thought message: %v", thought.message))
// Find the action
chosenAction := a.availableActions().Find(thought.actioName)
if chosenAction == nil || thought.actioName == "" {
xlog.Debug("no answer")
// LLM replied with an answer?
//fmt.Errorf("no action found for intent:" + thought.actioName)
return nil, nil, thought.message, nil
}
xlog.Debug(fmt.Sprintf("chosenAction: %v", chosenAction.Definition().Name))
return chosenAction, thought.actionParams, thought.message, nil
}
xlog.Debug("[pickAction] forcing reasoning")
prompt, err := renderTemplate(templ, a.prepareHUD(), a.availableActions(), "")
if err != nil {
return nil, nil, "", err
}
// Get the LLM to think on what to do
// and have a thought
if !Messages(c).Exist(prompt) {
c = append([]openai.ChatCompletionMessage{
{
Role: "system",
Content: prompt,
},
}, c...)
}
thought, err := a.decision(ctx,
c,
types.Actions{action.NewReasoning()}.ToTools(),
action.NewReasoning().Definition().Name.String(), maxRetries)
if err != nil {
return nil, nil, "", err
}
originalReasoning := ""
response := &action.ReasoningResponse{}
if thought.actionParams != nil {
if err := thought.actionParams.Unmarshal(response); err != nil {
return nil, nil, "", err
}
originalReasoning = response.Reasoning
}
if thought.message != "" {
originalReasoning = thought.message
}
xlog.Debug("[pickAction] picking action", "messages", c)
// thought, err := a.askLLM(ctx,
// c,
actionsID := []string{"reply"}
for _, m := range a.availableActions() {
actionsID = append(actionsID, m.Definition().Name.String())
}
xlog.Debug("[pickAction] actionsID", "actionsID", actionsID)
intentionsTools := action.NewIntention(actionsID...)
// TODO: FORCE to select ana ction here
// NOTE: we do not give the full conversation here to pick the action
// to avoid hallucinations
// Extract an action
params, err := a.decision(ctx,
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
}
+541 -1152
View File
File diff suppressed because it is too large Load Diff
+13 -48
View File
@@ -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,15 +37,14 @@ var debugOptions = []types.JobOption{
}
type TestAction struct {
response map[string]string
definition *types.ActionDefinition
response map[string]string
}
func (a *TestAction) Plannable() bool {
return true
}
func (a *TestAction) Run(c context.Context, sharedState *types.AgentSharedState, p types.ActionParams) (types.ActionResult, error) {
func (a *TestAction) Run(c context.Context, p types.ActionParams) (types.ActionResult, error) {
for k, r := range a.response {
if strings.Contains(strings.ToLower(p.String()), strings.ToLower(k)) {
return types.ActionResult{Result: r}, nil
@@ -56,7 +55,7 @@ func (a *TestAction) Run(c context.Context, sharedState *types.AgentSharedState,
}
func (a *TestAction) Definition() types.ActionDefinition {
def := types.ActionDefinition{
return types.ActionDefinition{
Name: "get_weather",
Description: "get current weather",
Properties: map[string]jsonschema.Definition{
@@ -72,11 +71,6 @@ func (a *TestAction) Definition() types.ActionDefinition {
Required: []string{"location"},
}
if a.definition != nil {
def = *a.definition
}
return def
}
type FakeStoreResultAction struct {
@@ -134,6 +128,7 @@ var _ = Describe("Agent test", func() {
WithModel(testModel),
EnableForceReasoning,
WithTimeout("10m"),
WithLoopDetectionSteps(3),
// WithRandomIdentity(),
WithActions(&TestAction{response: map[string]string{
"boston": testActionResult,
@@ -158,9 +153,6 @@ 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(
@@ -233,33 +225,8 @@ 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{
"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"},
},
},
actions.NewSearch(map[string]string{}),
),
EnablePlanning,
EnableForceReasoning,
@@ -271,20 +238,18 @@ var _ = Describe("Agent test", func() {
defer agent.Stop()
result := agent.Ask(
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."),
types.WithText("Thoroughly plan a trip to San Francisco from Venice, Italy; check flight times, visa requirements and whether electrical items are allowed in cabin luggage."),
)
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))
Expect(len(result.State)).To(BeNumerically(">", 1))
actionsExecuted := []string{}
for _, r := range result.State {
xlog.Info(r.Result)
actionsExecuted = append(actionsExecuted, r.Action.Definition().Name.String())
}
Expect(actionsExecuted).To(Or(ContainElement("search_internet"), ContainElement("get_weather")), fmt.Sprint(result))
Expect(actionsExecuted).To(ContainElement("search_internet"), fmt.Sprint(result))
Expect(actionsExecuted).To(ContainElement("plan"), fmt.Sprint(result))
})
It("Can initiate conversations", func() {
@@ -296,9 +261,9 @@ var _ = Describe("Agent test", func() {
WithModel(testModel),
WithLLMAPIKey(apiKeyURL),
WithTimeout("10m"),
WithNewConversationSubscriber(func(m *types.ConversationMessage) {
WithNewConversationSubscriber(func(m openai.ChatCompletionMessage) {
mu.Lock()
message = m.Message
message = m
mu.Unlock()
}),
WithActions(
+1 -1
View File
@@ -12,7 +12,7 @@ func (a *Agent) generateIdentity(guidance string) error {
guidance = "Generate a random character for roleplaying."
}
err := llm.GenerateTypedJSONWithGuidance(a.context.Context, a.client, "Generate a character as JSON data. "+guidance, a.options.LLMAPI.Model, a.options.character.ToJSONSchema(), &a.options.character)
err := llm.GenerateTypedJSON(a.context.Context, a.client, "Generate a character as JSON data. "+guidance, a.options.LLMAPI.Model, a.options.character.ToJSONSchema(), &a.options.character)
//err := llm.GenerateJSONFromStruct(a.context.Context, a.client, guidance, a.options.LLMAPI.Model, &a.options.character)
a.Character = a.options.character
if err != nil {
+26 -223
View File
@@ -1,42 +1,20 @@
package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/cogito"
"github.com/mudler/xlog"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
func (a *Agent) knowledgeBaseLookup(job *types.Job, conv Messages) Messages {
// 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 {
func (a *Agent) knowledgeBaseLookup(conv Messages) {
if (!a.options.enableKB && !a.options.enableLongTermMemory && !a.options.enableSummaryMemory) ||
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()
obs.Name = "Recall"
obs.Icon = "database"
obs.ParentID = job.Obs.ID
a.observer.Update(*obs)
return
}
// Walk conversation from bottom to top, and find the first message of the user
@@ -47,35 +25,17 @@ func (a *Agent) knowledgeBaseLookup(job *types.Job, conv Messages) Messages {
if userMessage == "" {
xlog.Info("[Knowledge Base Lookup] No user message found in conversation", "agent", a.Character.Name)
if obs != nil {
obs.Completion = &types.Completion{
Error: "No user message found in conversation",
}
a.observer.Update(*obs)
}
return conv
return
}
results, err := a.options.ragdb.Search(userMessage, a.options.kbResults)
if err != nil {
xlog.Info("Error finding similar strings inside KB:", "error", err)
if obs != nil {
obs.AddProgress(types.Progress{
Error: fmt.Sprintf("Error searching knowledge base: %v", err),
})
a.observer.Update(*obs)
}
}
if len(results) == 0 {
xlog.Info("[Knowledge Base Lookup] No similar strings found in KB", "agent", a.Character.Name)
if obs != nil {
obs.Completion = &types.Completion{
ActionResult: "No similar strings found in knowledge base",
}
a.observer.Update(*obs)
}
return conv
return
}
formatResults := ""
@@ -84,30 +44,17 @@ func (a *Agent) knowledgeBaseLookup(job *types.Job, conv Messages) Messages {
}
xlog.Info("[Knowledge Base Lookup] Found similar strings in KB", "agent", a.Character.Name, "results", formatResults)
if obs != nil {
obs.AddProgress(types.Progress{
ActionResult: fmt.Sprintf("Found %d results in knowledge base", len(results)),
})
a.observer.Update(*obs)
}
// Create the message to add to conversation
systemMessage := openai.ChatCompletionMessage{
Role: "system",
Content: fmt.Sprintf("Given the user input you have the following in memory:\n%s", formatResults),
}
// Add the message to the conversation
conv = append([]openai.ChatCompletionMessage{systemMessage}, conv...)
if obs != nil {
obs.Completion = &types.Completion{
Conversation: []openai.ChatCompletionMessage{systemMessage},
}
a.observer.Update(*obs)
}
return conv
// conv = append(conv,
// openai.ChatCompletionMessage{
// Role: "system",
// Content: fmt.Sprintf("Given the user input you have the following in memory:\n%s", formatResults),
// },
// )
conv = append([]openai.ChatCompletionMessage{
{
Role: "system",
Content: fmt.Sprintf("Given the user input you have the following in memory:\n%s", formatResults),
}}, conv...)
}
func (a *Agent) saveConversation(m Messages, prefix string) error {
@@ -137,168 +84,24 @@ 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 {
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)
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)
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 {
// 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)
}
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"},
}
}
+100 -180
View File
@@ -3,48 +3,60 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"time"
"errors"
"net/http"
"github.com/modelcontextprotocol/go-sdk/mcp"
mcp "github.com/metoro-io/mcp-golang"
"github.com/metoro-io/mcp-golang/transport/http"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/xlog"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
var _ types.Action = &mcpWrapperAction{}
var _ types.Action = &mcpAction{}
type MCPServer struct {
URL string `json:"url"`
Token string `json:"token"`
}
type MCPSTDIOServer struct {
Name string `json:"name,omitempty"`
Args []string `json:"args"`
Env []string `json:"env"`
Cmd string `json:"cmd"`
}
type mcpWrapperAction struct {
mcpClient *mcp.ClientSession
type mcpAction struct {
mcpClient *mcp.Client
inputSchema ToolInputSchema
toolName string
toolDescription string
}
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 (a *mcpAction) Plannable() bool {
return true
}
func (m *mcpWrapperAction) Definition() types.ActionDefinition {
func (m *mcpAction) Run(ctx context.Context, 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 {
props := map[string]jsonschema.Definition{}
dat, err := json.Marshal(m.inputSchema.Properties)
if err != nil {
@@ -67,178 +79,86 @@ type ToolInputSchema struct {
Required []string `json:"required,omitempty"`
}
func (a *Agent) addTools(client *mcp.ClientSession) (types.Actions, error) {
var generatedActions types.Actions
tools, err := client.ListTools(a.context, nil)
if err != nil {
xlog.Error("Failed to list tools", "error", err.Error())
return nil, err
}
for _, t := range tools.Tools {
desc := ""
if t.Description != "" {
desc = t.Description
}
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, &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.mcpActionDefinitions = nil
a.mcpActions = 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 {
// Create HTTP client with custom roundtripper for bearer token injection
httpclient := &http.Client{
Timeout: 360 * time.Second,
Transport: newBearerTokenRoundTripper(mcpServer.Token, http.DefaultTransport),
transport := http.NewHTTPClientTransport("/mcp")
transport.WithBaseURL(mcpServer.URL)
if mcpServer.Token != "" {
transport.WithHeader("Authorization", "Bearer "+mcpServer.Token)
}
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(session)
if err != nil {
xlog.Error("Failed to add tools for MCP server", "server", mcpServer, "error", err.Error())
}
generatedActions = append(generatedActions, actions...)
}
// MCP STDIO Servers
if a.options.mcpPrepareScript != "" {
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 {
command := exec.Command(mcpStdioServer.Cmd, mcpStdioServer.Args...)
command.Env = os.Environ()
command.Env = append(command.Env, mcpStdioServer.Env...)
// Create a new client
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())
client := mcp.NewClient(transport)
xlog.Debug("Initializing client", "server", mcpServer.URL)
// Initialize the client
response, e := client.Initialize(a.context)
if e != nil {
xlog.Error("Failed to initialize client", "error", e.Error(), "server", mcpServer)
if err == nil {
err = e
} else {
err = errors.Join(err, e)
}
continue
}
a.mcpSessions = append(a.mcpSessions, session)
xlog.Debug("Adding tools for MCP server (stdio)", "server", mcpStdioServer)
actions, err := a.addTools(session)
if err != nil {
xlog.Error("Failed to add tools for MCP server", "server", mcpStdioServer, "error", err.Error())
xlog.Debug("Client initialized: %v", response.Instructions)
var cursor *string
for {
tools, err := client.ListTools(a.context, cursor)
if err != nil {
xlog.Error("Failed to list tools", "error", err.Error())
return err
}
for _, t := range tools.Tools {
desc := ""
if t.Description != nil {
desc = *t.Description
}
xlog.Debug("Tool", "mcpServer", mcpServer, "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", "mcpServer", mcpServer, "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,
})
}
if tools.NextCursor == nil {
break // No more pages
}
cursor = tools.NextCursor
}
generatedActions = append(generatedActions, actions...)
}
// 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
a.mcpActions = generatedActions
return err
}
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
}
-101
View File
@@ -1,101 +0,0 @@
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))
})
})
-102
View File
@@ -1,102 +0,0 @@
package agent
import (
"encoding/json"
"sync"
"sync/atomic"
"github.com/mudler/LocalAGI/core/sse"
"github.com/mudler/LocalAGI/core/types"
"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
historyLast int
}
func NewSSEObserver(agent string, manager sse.Manager) *SSEObserver {
return &SSEObserver{
agent: agent,
maxID: 1,
manager: manager,
history: make([]types.Observable, historyRingSize),
}
}
func (s *SSEObserver) NewObservable() *types.Observable {
id := atomic.AddInt32(&s.maxID, 1)
return &types.Observable{
ID: id - 1,
Agent: s.agent,
}
}
func (s *SSEObserver) Update(obs types.Observable) {
data, err := json.Marshal(obs)
if err != nil {
xlog.Error("Error marshaling observable", "error", err)
return
}
msg := sse.NewMessage(string(data)).WithEvent("observable_update")
s.manager.Send(msg)
s.mutex.Lock()
defer s.mutex.Unlock()
for i, o := range s.history {
if o.ID == obs.ID {
s.history[i] = obs
return
}
}
s.history[s.historyLast] = obs
s.historyLast += 1
if s.historyLast >= len(s.history) {
s.historyLast = 0
}
}
func (s *SSEObserver) History() []types.Observable {
h := make([]types.Observable, 0, 20)
s.mutex.Lock()
defer s.mutex.Unlock()
for _, obs := range s.history {
if obs.ID == 0 {
continue
}
h = append(h, obs)
}
return h
}
func (s *SSEObserver) ClearHistory() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.history = make([]types.Observable, historyRingSize)
s.historyLast = 0
}
+21 -300
View File
@@ -5,34 +5,17 @@ import (
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/cogito"
"github.com/sashabaranov/go-openai"
)
type Option func(*options) error
// ConversationStorageMode defines how conversations are stored in the knowledge base
type ConversationStorageMode string
const (
// StoreUserOnly stores only user messages (default)
StoreUserOnly ConversationStorageMode = "user_only"
// StoreUserAndAssistant stores both user and assistant messages separately
StoreUserAndAssistant ConversationStorageMode = "user_and_assistant"
// StoreWholeConversation stores the entire conversation as a single block
StoreWholeConversation ConversationStorageMode = "whole_conversation"
)
type llmOptions struct {
APIURL string
APIKey string
Model string
MultimodalModel string
ReviewerModel string
TranscriptionModel string
TranscriptionLanguage string
TTSModel string
APIURL string
APIKey string
Model string
MultimodalModel string
}
type options struct {
@@ -41,41 +24,25 @@ type options struct {
randomIdentityGuidance string
randomIdentity bool
userActions types.Actions
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
innerMonologueTemplate string
skillPromptTemplate string
schedulerTaskTemplate string
systemPrompt string
// callbacks
reasoningCallback func(types.ActionCurrentState) bool
@@ -83,27 +50,9 @@ type options struct {
conversationsPath string
mcpServers []MCPServer
mcpStdioServers []MCPSTDIOServer
mcpPrepareScript string
extraMCPSessions []*mcp.ClientSession
newConversationsSubscribers []func(*types.ConversationMessage)
mcpServers []MCPServer
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)
newConversationsSubscribers []func(openai.ChatCompletionMessage)
}
func (o *options) SeparatedMultimodalModel() bool {
@@ -112,20 +61,10 @@ func (o *options) SeparatedMultimodalModel() bool {
func defaultOptions() *options {
return &options{
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
periodicRuns: 15 * time.Minute,
LLMAPI: llmOptions{
APIURL: "http://localhost:8080",
Model: "gpt-4",
TranscriptionModel: "whisper-1",
TranscriptionLanguage: "",
TTSModel: "tts-1",
APIURL: "http://localhost:8080",
Model: "gpt-4",
},
character: Character{
Name: "",
@@ -157,16 +96,6 @@ 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
@@ -185,6 +114,13 @@ 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
@@ -200,48 +136,7 @@ func EnableKnowledgeBaseWithResults(results int) Option {
}
}
func WithLastMessageDuration(duration string) Option {
return func(o *options) error {
d, err := time.ParseDuration(duration)
if err != nil {
d = types.DefaultLastMessageDuration
}
o.lastMessageDuration = d
return nil
}
}
func WithParallelJobs(jobs int) Option {
return func(o *options) error {
o.parallelJobs = jobs
return nil
}
}
// 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 {
func WithNewConversationSubscriber(sub func(openai.ChatCompletionMessage)) Option {
return func(o *options) error {
o.newConversationsSubscribers = append(o.newConversationsSubscribers, sub)
return nil
@@ -258,18 +153,6 @@ 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 {
@@ -299,19 +182,6 @@ 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
@@ -319,22 +189,6 @@ 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
@@ -342,20 +196,6 @@ func WithMCPServers(servers ...MCPServer) Option {
}
}
func WithMCPSTDIOServers(servers ...MCPSTDIOServer) Option {
return func(o *options) error {
o.mcpStdioServers = servers
return nil
}
}
func WithMCPPrepareScript(script string) Option {
return func(o *options) error {
o.mcpPrepareScript = script
return nil
}
}
func WithLLMAPIURL(url string) Option {
return func(o *options) error {
o.LLMAPI.APIURL = url
@@ -387,14 +227,6 @@ 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
@@ -443,18 +275,6 @@ 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
@@ -512,106 +332,7 @@ func WithRandomIdentity(guidance ...string) Option {
func WithActions(actions ...types.Action) Option {
return func(o *options) error {
o.userActions = append(o.userActions, actions...)
return nil
}
}
func WithJobFilters(filters ...types.JobFilter) Option {
return func(o *options) error {
o.jobFilters = filters
return nil
}
}
func WithObserver(observer Observer) Option {
return func(o *options) error {
o.observer = observer
return nil
}
}
var EnableStripThinkingTags = func(o *options) error {
o.stripThinkingTags = true
return nil
}
func WithMaxEvaluationLoops(loops int) Option {
return func(o *options) error {
o.maxEvaluationLoops = loops
return nil
}
}
func EnableEvaluation() Option {
return func(o *options) error {
o.enableEvaluation = true
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
o.userActions = actions
return nil
}
}
+1 -3
View File
@@ -1,8 +1,6 @@
package agent
import "github.com/mudler/LocalAGI/core/types"
type DynamicPrompt interface {
Render(a *Agent) (types.PromptResult, error)
Render(a *Agent) (string, error)
Role() string
}
-84
View File
@@ -1,84 +0,0 @@
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
}
}
+6 -6
View File
@@ -6,7 +6,7 @@ import (
"os"
"path/filepath"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/core/action"
"github.com/sashabaranov/go-openai/jsonschema"
)
@@ -14,10 +14,10 @@ import (
// all information that should be displayed to the LLM
// in the prompts
type PromptHUD struct {
Character Character `json:"character"`
CurrentState types.AgentInternalState `json:"current_state"`
PermanentGoal string `json:"permanent_goal"`
ShowCharacter bool `json:"show_character"`
Character Character `json:"character"`
CurrentState action.AgentInternalState `json:"current_state"`
PermanentGoal string `json:"permanent_goal"`
ShowCharacter bool `json:"show_character"`
}
type Character struct {
@@ -80,7 +80,7 @@ func Load(path string) (*Character, error) {
return &c, nil
}
func (a *Agent) State() types.AgentInternalState {
func (a *Agent) State() action.AgentInternalState {
return *a.currentState
}
-1
View File
@@ -25,7 +25,6 @@ var _ = Describe("Agent test", func() {
agent, err = New(
WithLLMAPIURL(apiURL),
WithModel(testModel),
WithTimeout("10m"),
WithRandomIdentity(),
)
Expect(err).ToNot(HaveOccurred())
+44 -28
View File
@@ -2,41 +2,18 @@ package agent
import (
"bytes"
"text/template"
"html/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 := templateBase("pickAction", templ)
promptTemplate, err := template.New("pickAction").Parse(templ)
if err != nil {
return "", err
}
@@ -57,7 +34,7 @@ func renderTemplate(templ string, hud *PromptHUD, actions types.Actions, reasoni
Actions: definitions,
HUD: hud,
Reasoning: reasoning,
Time: time.Now().UTC().Format(time.RFC1123),
Time: time.Now().Format(time.RFC3339),
})
if err != nil {
return "", err
@@ -103,8 +80,7 @@ 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 and Date: {{.Time}}`
Current Time: {{.Time}}`
const pickSelfTemplate = `
You are an autonomous AI agent with a defined character and state (as shown above).
@@ -117,6 +93,7 @@ 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
@@ -127,4 +104,43 @@ 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
@@ -1,13 +0,0 @@
package conversations_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestConversations(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Conversations test suite")
}
-49
View File
@@ -1,49 +0,0 @@
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
}
-219
View File
@@ -1,219 +0,0 @@
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)
}
-249
View File
@@ -1,249 +0,0 @@
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
}
-13
View File
@@ -1,13 +0,0 @@
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")
}
-395
View File
@@ -1,395 +0,0 @@
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())
})
})
})
-156
View File
@@ -1,156 +0,0 @@
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(),
}
}
-13
View File
@@ -27,8 +27,6 @@ type (
Manager interface {
Send(message Envelope)
Handle(ctx *fiber.Ctx, cl Listener)
Register(cl Listener)
Unregister(id string)
Clients() []string
}
@@ -112,17 +110,6 @@ 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) {
-241
View File
@@ -1,241 +0,0 @@
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)
}
}
}
}
+29 -492
View File
@@ -2,30 +2,12 @@ package state
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"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"`
@@ -47,77 +29,41 @@ func (d DynamicPromptsConfig) ToMap() map[string]string {
return config
}
type FiltersConfig struct {
Type string `json:"type"`
Config string `json:"config"`
}
type AgentConfig struct {
Connector []ConnectorConfig `json:"connectors" form:"connectors" `
Actions []ActionsConfig `json:"actions" form:"actions"`
DynamicPrompts []DynamicPromptsConfig `json:"dynamic_prompts" form:"dynamic_prompts"`
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"`
Filters []FiltersConfig `json:"filters" form:"filters"`
Connector []ConnectorConfig `json:"connectors" form:"connectors" `
Actions []ActionsConfig `json:"actions" form:"actions"`
DynamicPrompts []DynamicPromptsConfig `json:"dynamic_prompts" form:"dynamic_prompts"`
MCPServers []agent.MCPServer `json:"mcp_servers" form:"mcp_servers"`
Description string `json:"description" form:"description"`
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"`
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"`
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"`
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"`
}
type AgentConfigMeta struct {
Filters []config.FieldGroup
Fields []config.Field
Connectors []config.FieldGroup
Actions []config.FieldGroup
@@ -129,7 +75,6 @@ func NewAgentConfigMeta(
actionsConfig []config.FieldGroup,
connectorsConfig []config.FieldGroup,
dynamicPromptsConfig []config.FieldGroup,
filtersConfig []config.FieldGroup,
) AgentConfigMeta {
return AgentConfigMeta{
Fields: []config.Field{
@@ -183,34 +128,6 @@ 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",
@@ -255,31 +172,6 @@ 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",
@@ -294,35 +186,6 @@ 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",
@@ -339,30 +202,6 @@ 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",
@@ -387,24 +226,6 @@ 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",
@@ -422,15 +243,6 @@ 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",
@@ -440,123 +252,12 @@ func NewAgentConfigMeta(
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
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"},
},
{
Name: "parallel_jobs",
Label: "Parallel Jobs",
Name: "loop_detection_steps",
Label: "Max Loop Detection Steps",
Type: "number",
DefaultValue: 5,
Min: 1,
Step: 1,
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: "MCP"},
},
{
Name: "mcp_prepare_script",
Label: "MCP Prepare Script",
Type: "textarea",
DefaultValue: "",
HelpText: "Script to prepare for running MCP servers",
Tags: config.Tags{Section: "MCP"},
},
{
Name: "strip_thinking_tags",
Label: "Strip Thinking Tags",
Type: "checkbox",
DefaultValue: false,
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",
Type: "checkbox",
DefaultValue: false,
HelpText: "Enable automatic evaluation of agent responses to ensure they meet user requirements",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "max_evaluation_loops",
Label: "Max Evaluation Loops",
Type: "number",
DefaultValue: 2,
Min: 1,
Step: 1,
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",
Type: "text",
DefaultValue: "5m",
HelpText: "Duration for the last message to be considered in the conversation",
Tags: config.Tags{Section: "AdvancedSettings"},
},
},
@@ -577,7 +278,6 @@ func NewAgentConfigMeta(
DynamicPrompts: dynamicPromptsConfig,
Connectors: connectorsConfig,
Actions: actionsConfig,
Filters: filtersConfig,
}
}
@@ -586,166 +286,3 @@ type Connector interface {
AgentReasoningCallback() func(state types.ActionCurrentState) bool
Start(a *agent.Agent)
}
// UnmarshalJSON implements json.Unmarshaler for AgentConfig
func (a *AgentConfig) UnmarshalJSON(data []byte) error {
// Create a temporary type to avoid infinite recursion
type Alias AgentConfig
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),
}
if err := json.Unmarshal(data, &aux); err != nil {
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) {
case string:
// Parse string configuration
var mcpConfig struct {
MCPServers map[string]struct {
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
} `json:"mcpServers"`
}
if err := json.Unmarshal([]byte(v), &mcpConfig); err != nil {
return fmt.Errorf("failed to parse MCP STDIO servers configuration: %w", err)
}
a.MCPSTDIOServers = make([]agent.MCPSTDIOServer, 0, len(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 {
envSlice = append(envSlice, fmt.Sprintf("%s=%s", k, v))
}
a.MCPSTDIOServers = append(a.MCPSTDIOServers, agent.MCPSTDIOServer{
Name: name,
Cmd: server.Command,
Args: server.Args,
Env: envSlice,
})
}
case []interface{}:
// Parse array configuration
a.MCPSTDIOServers = make([]agent.MCPSTDIOServer, 0, len(v))
for _, server := range v {
serverMap, ok := server.(map[string]interface{})
if !ok {
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 {
for _, arg := range argsInterface {
if argStr, ok := arg.(string); ok {
args = append(args, argStr)
}
}
}
env := make([]string, 0)
if envInterface, ok := serverMap["env"].([]interface{}); ok {
for _, e := range envInterface {
if envStr, ok := e.(string); ok {
env = append(env, envStr)
}
}
}
a.MCPSTDIOServers = append(a.MCPSTDIOServers, agent.MCPSTDIOServer{
Name: name,
Cmd: cmd,
Args: args,
Env: env,
})
}
}
}
return nil
}
// MarshalJSON implements json.Marshaler for AgentConfig
func (a *AgentConfig) MarshalJSON() ([]byte, error) {
// Create a temporary type to avoid infinite recursion
type Alias AgentConfig
aux := &struct {
*Alias
MCPSTDIOServersConfig string `json:"mcp_stdio_servers,omitempty"`
}{
Alias: (*Alias)(a),
}
// Convert MCPSTDIOServers back to the expected JSON format
if len(a.MCPSTDIOServers) > 0 {
mcpConfig := struct {
MCPServers map[string]struct {
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
} `json:"mcpServers"`
}{
MCPServers: make(map[string]struct {
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
}),
}
// Convert each MCPSTDIOServer to the expected format
for i, server := range a.MCPSTDIOServers {
// Convert env slice back to map
envMap := make(map[string]string)
for _, env := range server.Env {
if parts := strings.SplitN(env, "=", 2); len(parts) == 2 {
envMap[parts[0]] = parts[1]
}
}
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"`
}{
Command: server.Cmd,
Args: server.Args,
Env: envMap,
}
}
// Marshal the MCP config to JSON string
mcpConfigJSON, err := json.Marshal(mcpConfig)
if err != nil {
return nil, fmt.Errorf("failed to marshal MCP STDIO servers configuration: %w", err)
}
aux.MCPSTDIOServersConfig = string(mcpConfigJSON)
}
return json.Marshal(aux)
}
+169 -492
View File
@@ -2,6 +2,7 @@ package state
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
@@ -12,68 +13,32 @@ import (
"time"
. "github.com/mudler/LocalAGI/core/agent"
sseLib "github.com/mudler/LocalAGI/core/sse"
"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/modelcontextprotocol/go-sdk/mcp"
"github.com/mudler/cogito"
"github.com/mudler/xlog"
"github.com/mudler/LocalAGI/pkg/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]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
file string
pooldir string
pool AgentPoolData
agents map[string]*Agent
managers map[string]sse.Manager
agentStatus map[string]*Status
apiURL, defaultModel, defaultMultimodalModel 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
timeout string
conversationLogs string
}
type Status struct {
@@ -107,14 +72,13 @@ func loadPoolFromFile(path string) (*AgentPoolData, error) {
}
func NewAgentPool(
defaultModel, defaultMultimodalModel, defaultTranscriptionModel, defaultTranscriptionLanguage, defaultTTSModel, apiURL, apiKey, directory string,
defaultModel, defaultMultimodalModel, imageModel, apiURL, apiKey, directory string,
LocalRAGAPI string,
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action,
connectors func(*AgentConfig) []Connector,
promptBlocks func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []DynamicPrompt,
filters func(*AgentConfig) types.JobFilters,
promptBlocks func(*AgentConfig) []DynamicPrompt,
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.
@@ -125,67 +89,52 @@ 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,
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,
file: poolfile,
pooldir: directory,
apiURL: apiURL,
defaultModel: defaultModel,
defaultMultimodalModel: defaultMultimodalModel,
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,
timeout: timeout,
conversationLogs: conversationPath,
}, nil
}
poolData, err := loadPoolFromFile(poolfile)
if err != nil {
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 nil, err
}
return &AgentPool{
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,
file: poolfile,
apiURL: apiURL,
pooldir: directory,
defaultModel: defaultModel,
defaultMultimodalModel: defaultMultimodalModel,
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,
availableActions: availableActions,
timeout: timeout,
conversationLogs: conversationPath,
}, nil
}
@@ -194,14 +143,6 @@ 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.
@@ -218,56 +159,92 @@ func (a *AgentPool) CreateAgent(name string, agentConfig *AgentConfig) error {
return err
}
return a.startAgentWithConfig(name, a.pooldir, agentConfig, nil)
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)
}
func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
a.Lock()
defer a.Unlock()
func createAgentAvatar(APIURL, APIKey, model, imageModel, avatarDir string, agent AgentConfig) error {
client := llm.NewClient(APIKey, APIURL+"/v1", "10m")
oldAgent := a.agents[name]
var o *types.Observable
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()
if imageModel == "" {
return fmt.Errorf("image model not set")
}
a.pool[name] = *agentConfig
delete(a.agents, name)
if model == "" {
return fmt.Errorf("default model not set")
}
if err := a.save(); err != nil {
if obs != nil {
o.Completion = &types.Completion{Error: err.Error()}
obs.Update(*o)
}
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.GenerateTypedJSON(
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
}
if err := a.startAgentWithConfig(name, a.pooldir, agentConfig, obs); err != nil {
if obs != nil {
o.Completion = &types.Completion{Error: err.Error()}
obs.Update(*o)
}
return err
}
if obs != nil {
o.Completion = &types.Completion{}
obs.Update(*o)
}
return nil
return os.WriteFile(imagePath, imageData, 0644)
}
func (a *AgentPool) List() []string {
@@ -291,74 +268,43 @@ func (a *AgentPool) GetStatusHistory(name string) *Status {
return a.agentStatus[name]
}
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 = sseLib.NewManager(5)
}
func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig) error {
manager := sse.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.PeriodicRuns == "" {
config.PeriodicRuns = "10m"
}
if config.SchedulerPollInterval == "" {
config.SchedulerPollInterval = "30s"
if config.APIURL != "" {
a.apiURL = config.APIURL
}
// Use agent-specific config when set, otherwise pool defaults. Do not update pool from agent config.
effectiveAPIURL := a.apiURL
if config.APIURL != "" {
effectiveAPIURL = config.APIURL
} else {
config.APIURL = a.apiURL
}
effectiveAPIKey := a.apiKey
if config.APIKey != "" {
effectiveAPIKey = config.APIKey
} else {
config.APIKey = a.apiKey
a.apiKey = config.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)(ctx, a)
if a.skillsService != nil && config.EnableSkills {
if prompt, err := a.skillsService.GetSkillsPrompt(config); err == nil && prompt != nil {
promptBlocks = append(promptBlocks, prompt)
}
}
promptBlocks := a.dynamicPrompt(config)
actions := a.availableActions(config)(ctx, a)
filters := a.filters(config)
stateFile, characterFile := a.stateFiles(name)
actionsLog := []string{}
@@ -371,19 +317,13 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
connectorLog = append(connectorLog, fmt.Sprintf("%+v", connector))
}
filtersLog := []string{}
for _, filter := range filters {
filtersLog = append(filtersLog, filter.Name())
}
xlog.Info(
"Creating agent",
"name", name,
"model", model,
"api_url", effectiveAPIURL,
"api_url", a.apiURL,
"actions", actionsLog,
"connectors", connectorLog,
"filters", filtersLog,
)
// dynamicPrompts := []map[string]string{}
@@ -391,26 +331,14 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
// dynamicPrompts = append(dynamicPrompts, p.ToMap())
// }
if obs == nil {
obs = NewSSEObserver(name, manager)
}
opts := []Option{
WithSchedulerStorePath(filepath.Join(pooldir, fmt.Sprintf("scheduler-%s.json", name))),
WithModel(model),
WithLLMAPIURL(effectiveAPIURL),
WithLLMAPIURL(a.apiURL),
WithContext(ctx),
WithMCPServers(config.MCPServers...),
WithTranscriptionModel(transcriptionModel),
WithTranscriptionLanguage(transcriptionLanguage),
WithTTSModel(ttsModel),
WithPeriodicRuns(config.PeriodicRuns),
WithSchedulerPollInterval(config.SchedulerPollInterval),
WithPermanentGoal(config.PermanentGoal),
WithMCPSTDIOServers(config.MCPSTDIOServers...),
WithPrompts(promptBlocks...),
WithJobFilters(filters...),
WithMCPPrepareScript(config.MCPPrepareScript),
// WithDynamicPrompts(dynamicPrompts...),
WithCharacter(Character{
Name: name,
@@ -420,23 +348,20 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
),
WithStateFile(stateFile),
WithCharacterFile(characterFile),
WithLLMAPIKey(effectiveAPIKey),
WithLLMAPIKey(a.apiKey),
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", actionName,
"action", state.Action.Definition().Name,
"params", state.Params,
)
manager.Send(
sseLib.NewMessage(
sse.NewMessage(
fmt.Sprintf(`Thinking: %s`, utils.HTMLify(state.Reasoning)),
).WithEvent("status"),
)
@@ -449,10 +374,7 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
return true
}),
WithSystemPrompt(config.SystemPrompt),
WithInnerMonologueTemplate(config.InnerMonologueTemplate),
WithSchedulerTaskTemplate(config.SchedulerTaskTemplate),
WithMultimodalModel(multimodalModel),
WithLastMessageDuration(config.LastMessageDuration),
WithAgentResultCallback(func(state types.ActionState) {
a.Lock()
if _, ok := a.agentStatus[name]; !ok {
@@ -465,20 +387,16 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
"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,
actionName,
state.ActionCurrentState.Action.Definition().Name,
state.ActionCurrentState.Params,
state.Result)
manager.Send(
sseLib.NewMessage(
sse.NewMessage(
utils.HTMLify(
text,
),
@@ -489,7 +407,6 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
c.AgentResultCallback()(state)
}
}),
WithObserver(obs),
}
if config.HUD {
@@ -512,10 +429,6 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
opts = append(opts, EnableSummaryMemory)
}
if config.ConversationStorageMode != "" {
opts = append(opts, WithConversationStorageMode(ConversationStorageMode(config.ConversationStorageMode)))
}
if config.CanStopItself {
opts = append(opts, CanStopItself)
}
@@ -524,14 +437,6 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
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)
}
@@ -544,125 +449,22 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
}
}
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.EnableKnowledgeBase {
opts = append(opts, EnableKnowledgeBase)
}
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.ParallelJobs > 0 {
opts = append(opts, WithParallelJobs(config.ParallelJobs))
if config.LoopDetectionSteps > 0 {
opts = append(opts, WithLoopDetectionSteps(config.LoopDetectionSteps))
}
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.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...)
@@ -679,10 +481,6 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
}
}()
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 {
@@ -692,7 +490,7 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
go func() {
for {
time.Sleep(1 * time.Second) // Send a message every seconds
manager.Send(sseLib.NewMessage(
manager.Send(sse.NewMessage(
utils.HTMLify(agent.State().String()),
).WithEvent("hud"))
}
@@ -711,7 +509,7 @@ func (a *AgentPool) StartAll() error {
if a.agents[name] != nil { // Agent already started
continue
}
if err := a.startAgentWithConfig(name, a.pooldir, &config, nil); err != nil {
if err := a.startAgentWithConfig(name, &config); err != nil {
xlog.Error("Failed to start agent", "name", name, "error", err)
}
}
@@ -749,121 +547,12 @@ func (a *AgentPool) Start(name string) error {
return nil
}
if config, ok := a.pool[name]; ok {
return a.startAgentWithConfig(name, a.pooldir, &config, nil)
return a.startAgentWithConfig(name, &config)
}
return fmt.Errorf("agent %s not found", name)
}
// CreateOnly creates the agent instance without calling Run().
// This is used in distributed mode where the agent is executed statelessly
// via AskDirect() — the persistent Run() loop is not needed.
func (a *AgentPool) CreateOnly(name string) error {
a.Lock()
defer a.Unlock()
if _, ok := a.agents[name]; ok {
return nil // already created
}
if config, ok := a.pool[name]; ok {
return a.createAgentWithoutRun(name, a.pooldir, &config)
}
return fmt.Errorf("agent %s not found", name)
}
// createAgentWithoutRun is like startAgentWithConfig but skips Run(), connectors, and HUD.
func (a *AgentPool) createAgentWithoutRun(name, pooldir string, config *AgentConfig) error {
var manager sseLib.Manager
if m, ok := a.managers[name]; ok {
manager = m
} else {
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
}
effectiveAPIURL := a.apiURL
if config.APIURL != "" {
effectiveAPIURL = config.APIURL
} else {
config.APIURL = a.apiURL
}
effectiveAPIKey := a.apiKey
if config.APIKey != "" {
effectiveAPIKey = config.APIKey
} else {
config.APIKey = a.apiKey
}
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)
stateFile, characterFile := a.stateFiles(name)
obs := NewSSEObserver(name, manager)
opts := []Option{
WithSchedulerStorePath(filepath.Join(pooldir, fmt.Sprintf("scheduler-%s.json", name))),
WithModel(model),
WithLLMAPIURL(effectiveAPIURL),
WithContext(ctx),
WithTranscriptionModel(transcriptionModel),
WithTranscriptionLanguage(transcriptionLanguage),
WithTTSModel(ttsModel),
WithPrompts(promptBlocks...),
WithActions(actions...),
WithObserver(obs),
WithMultimodalModel(multimodalModel),
WithCharacterFile(characterFile),
WithStateFile(stateFile),
WithSystemPrompt(config.SystemPrompt),
}
if effectiveAPIKey != "" {
opts = append(opts, WithLLMAPIKey(effectiveAPIKey))
}
xlog.Info("Creating agent (no Run)", "name", name, "model", model, "api_url", effectiveAPIURL)
agent, err := New(opts...)
if err != nil {
return err
}
a.agents[name] = agent
a.managers[name] = manager
// Start the conversation consumer so ConversationAction doesn't deadlock.
// This is normally started by Run(), but we skip Run() in distributed mode.
agent.StartConversationConsumer()
xlog.Info("Agent created (no Run)", "name", name)
return nil
}
func (a *AgentPool) stateFiles(name string) (string, string) {
stateFile := filepath.Join(a.pooldir, fmt.Sprintf("%s.state.json", name))
characterFile := filepath.Join(a.pooldir, fmt.Sprintf("%s.character.json", name))
@@ -884,6 +573,9 @@ 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
}
@@ -901,21 +593,7 @@ func (a *AgentPool) save() error {
if err != nil {
return err
}
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
return os.WriteFile(a.file, data, 0644)
}
func (a *AgentPool) GetAgent(name string) *Agent {
@@ -944,9 +622,8 @@ func (a *AgentPool) GetConfig(name string) *AgentConfig {
return &agent
}
func (a *AgentPool) GetManager(name string) sseLib.Manager {
func (a *AgentPool) GetManager(name string) sse.Manager {
a.Lock()
defer a.Unlock()
return a.managers[name]
}
+5 -95
View File
@@ -3,9 +3,7 @@ package types
import (
"context"
"encoding/json"
"fmt"
"github.com/mudler/cogito"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
@@ -31,10 +29,9 @@ func NewActionContext(ctx context.Context, cancel context.CancelFunc) *ActionCon
type ActionParams map[string]interface{}
type ActionResult struct {
Job *Job
Result string
ImageBase64Result string
Metadata map[string]interface{}
Job *Job
Result string
Metadata map[string]interface{}
}
func (ap ActionParams) Read(s string) error {
@@ -89,90 +86,11 @@ 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)
Run(ctx context.Context, action ActionParams) (ActionResult, error)
Definition() ActionDefinition
}
// UserDefinedChecker interface to identify user-defined actions
type UserDefinedChecker interface {
IsUserDefined() bool
}
// BaseAction provides default implementation for Action interface
// Embed this in action implementations to get the default IsUserDefined behavior
type BaseAction struct{}
func (b *BaseAction) IsUserDefined() bool {
return false // Regular actions are not user-defined
}
// IsActionUserDefined checks if an action is user-defined
func IsActionUserDefined(action Action) bool {
if checker, ok := action.(UserDefinedChecker); ok {
return checker.IsUserDefined()
}
return false // Actions without UserDefinedChecker are not user-defined
}
// UserDefinedAction represents a user-defined function tool
type UserDefinedAction struct {
ActionDef *ActionDefinition
}
func (u *UserDefinedAction) Run(ctx context.Context, sharedState *AgentSharedState, action ActionParams) (ActionResult, error) {
// User-defined actions should not be executed directly
return ActionResult{}, fmt.Errorf("user-defined action '%s' cannot be executed by agent", u.ActionDef.Name)
}
func (u *UserDefinedAction) Definition() ActionDefinition {
return *u.ActionDef
}
func (u *UserDefinedAction) Plannable() bool {
return true // User-defined actions are plannable
}
func (u *UserDefinedAction) IsUserDefined() bool {
return true
}
// CreateUserDefinedActions converts user tools to UserDefinedAction instances
func CreateUserDefinedActions(userTools []ActionDefinition) []Action {
var actions []Action
for _, tool := range userTools {
actions = append(actions, &UserDefinedAction{
ActionDef: &tool,
})
}
return actions
Plannable() bool
}
type Actions []Action
@@ -188,14 +106,6 @@ 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) {
-27
View File
@@ -1,27 +0,0 @@
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
}
-15
View File
@@ -1,15 +0,0 @@
package types
type JobFilter interface {
Name() string
Apply(job *Job) (bool, error)
IsTrigger() bool
}
type JobFilters []JobFilter
type FilterResult struct {
HasTriggers bool `json:"has_triggers"`
TriggeredBy string `json:"triggered_by,omitempty"`
FailedBy string `json:"failed_by,omitempty"`
}
+50 -91
View File
@@ -5,15 +5,9 @@ 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
@@ -25,18 +19,14 @@ type Job struct {
ConversationHistory []openai.ChatCompletionMessage
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
fragment *cogito.Fragment
cancel context.CancelFunc
Obs *Observable
context context.Context
cancel context.CancelFunc
}
type ActionRequest struct {
@@ -52,24 +42,6 @@ func WithConversationHistory(history []openai.ChatCompletionMessage) JobOption {
}
}
func WithBuiltinTools(tools []ActionDefinition) JobOption {
return func(j *Job) {
j.BuiltinTools = tools
}
}
func WithUserTools(tools []ActionDefinition) JobOption {
return func(j *Job) {
j.UserTools = tools
}
}
func WithToolChoice(choice string) JobOption {
return func(j *Job) {
j.ToolChoice = choice
}
}
func WithReasoningCallback(f func(ActionCurrentState) bool) JobOption {
return func(r *Job) {
r.ReasoningCallback = f
@@ -82,7 +54,7 @@ func WithResultCallback(f func(ActionState)) JobOption {
}
}
func WithMetadata(metadata map[string]any) JobOption {
func WithMetadata(metadata map[string]interface{}) JobOption {
return func(j *Job) {
j.Metadata = metadata
}
@@ -110,6 +82,37 @@ 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{
@@ -156,23 +159,23 @@ func newUUID() string {
// To wait for a Job result, use JobResult.WaitResult()
func NewJob(opts ...JobOption) *Job {
j := &Job{
Result: NewJobResult(),
UUID: uuid.New().String(),
Metadata: make(map[string]interface{}),
context: context.Background(),
ConversationHistory: []openai.ChatCompletionMessage{},
Result: NewJobResult(),
UUID: newUUID(),
}
for _, o := range opts {
o(j)
}
for _, opt := range opts {
opt(j)
var ctx context.Context
if j.context == nil {
ctx = context.Background()
} else {
ctx = j.context
}
// Store the original request if it exists in the conversation history
ctx, cancel := context.WithCancel(j.context)
j.context = ctx
context, cancel := context.WithCancel(ctx)
j.context = context
j.cancel = cancel
return j
}
@@ -195,47 +198,3 @@ func (j *Job) Cancel() {
func (j *Job) GetContext() context.Context {
return j.context
}
func WithObservable(obs *Observable) JobOption {
return func(j *Job) {
j.Obs = obs
}
}
// GetEvaluationLoop returns the current evaluation loop count
func (j *Job) GetEvaluationLoop() int {
if j.Metadata == nil {
j.Metadata = make(map[string]interface{})
}
if loop, ok := j.Metadata["evaluation_loop"].(int); ok {
return loop
}
return 0
}
// IncrementEvaluationLoop increments the evaluation loop count
func (j *Job) IncrementEvaluationLoop() {
if j.Metadata == nil {
j.Metadata = make(map[string]interface{})
}
currentLoop := j.GetEvaluationLoop()
j.Metadata["evaluation_loop"] = currentLoop + 1
}
// GetBuiltinTools returns the builtin tools for this job
func (j *Job) GetBuiltinTools() []ActionDefinition {
return j.BuiltinTools
}
// GetUserTools returns the user tools for this job
func (j *Job) GetUserTools() []ActionDefinition {
return j.UserTools
}
// GetAllTools returns all tools (builtin + user) for this job
func (j *Job) GetAllTools() []ActionDefinition {
allTools := make([]ActionDefinition, 0, len(j.BuiltinTools)+len(j.UserTools))
allTools = append(allTools, j.BuiltinTools...)
allTools = append(allTools, j.UserTools...)
return allTools
}
-63
View File
@@ -1,63 +0,0 @@
package types
import (
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
)
type Creation struct {
ChatCompletionMessage *openai.ChatCompletionMessage `json:"chat_completion_message,omitempty"`
ChatCompletionRequest *openai.ChatCompletionRequest `json:"chat_completion_request,omitempty"`
FunctionDefinition *openai.FunctionDefinition `json:"function_definition,omitempty"`
FunctionParams ActionParams `json:"function_params,omitempty"`
}
type Progress struct {
Error string `json:"error,omitempty"`
ChatCompletionResponse *openai.ChatCompletionResponse `json:"chat_completion_response,omitempty"`
ActionResult string `json:"action_result,omitempty"`
AgentState *AgentInternalState `json:"agent_state"`
}
type Completion struct {
Error string `json:"error,omitempty"`
ChatCompletionResponse *openai.ChatCompletionResponse `json:"chat_completion_response,omitempty"`
Conversation []openai.ChatCompletionMessage `json:"conversation,omitempty"`
ActionResult string `json:"action_result,omitempty"`
AgentState *AgentInternalState `json:"agent_state,omitempty"`
FilterResult *FilterResult `json:"filter_result,omitempty"`
}
type Observable struct {
ID int32 `json:"id"`
ParentID int32 `json:"parent_id,omitempty"`
Agent string `json:"agent"`
Name string `json:"name"`
Icon string `json:"icon"`
Creation *Creation `json:"creation,omitempty"`
Progress []Progress `json:"progress,omitempty"`
Completion *Completion `json:"completion,omitempty"`
}
func (o *Observable) AddProgress(p Progress) {
if o.Progress == nil {
o.Progress = make([]Progress, 0)
}
o.Progress = append(o.Progress, p)
}
func (o *Observable) MakeLastProgressCompletion() {
if len(o.Progress) == 0 {
xlog.Error("Observable completed without any progress", "id", o.ID, "name", o.Name)
return
}
p := o.Progress[len(o.Progress)-1]
o.Progress = o.Progress[:len(o.Progress)-1]
o.Completion = &Completion{
Error: p.Error,
ChatCompletionResponse: p.ChatCompletionResponse,
ActionResult: p.ActionResult,
AgentState: p.AgentState,
}
}
-6
View File
@@ -1,6 +0,0 @@
package types
type PromptResult struct {
Content string
ImageBase64 string
}
+4 -11
View File
@@ -1,10 +1,8 @@
package types
import (
"context"
"sync"
"github.com/mudler/cogito"
"github.com/sashabaranov/go-openai"
)
@@ -13,7 +11,6 @@ type JobResult struct {
sync.Mutex
// The result of a job
State []ActionState
Plans []cogito.PlanStatus
Conversation []openai.ChatCompletionMessage
Finalizers []func([]openai.ChatCompletionMessage)
@@ -31,7 +28,7 @@ func (j *JobResult) SetResult(text ActionState) {
j.State = append(j.State, text)
}
// Finish marks the job as done and closes the ready channel.
// SetResult sets the result of a job
func (j *JobResult) Finish(e error) {
j.Lock()
j.Error = e
@@ -62,13 +59,9 @@ func (j *JobResult) SetResponse(response string) {
}
// WaitResult waits for the result of a job
func (j *JobResult) WaitResult(ctx context.Context) (*JobResult, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-j.ready:
}
func (j *JobResult) WaitResult() *JobResult {
<-j.ready
j.Lock()
defer j.Unlock()
return j, nil
return j
}
-92
View File
@@ -1,92 +0,0 @@
package types
import (
"fmt"
"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
// Besides a long term memory that is accessible by the agent (With vector database),
// And a context memory (that is always powered by a vector database),
// this memory is the shorter one that the LLM keeps across conversation and across its
// reasoning process's and life time.
// TODO: A special action is then used to let the LLM itself update its memory
// periodically during self-processing, and the same action is ALSO exposed
// during the conversation to let the user put for example, a new goal to the agent.
type AgentInternalState struct {
NowDoing string `json:"doing_now"`
DoingNext string `json:"doing_next"`
DoneHistory []string `json:"done_history"`
Memories []string `json:"memories"`
Goal string `json:"goal"`
}
const (
DefaultLastMessageDuration = 5 * time.Minute
)
// 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"`
Scheduler TaskScheduler `json:"-"` // Not serialized, set at runtime
AgentName string `json:"agent_name"`
}
func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
if lastMessageDuration == 0 {
lastMessageDuration = DefaultLastMessageDuration
}
return &AgentSharedState{
ConversationTracker: conversations.NewConversationTracker[string](lastMessageDuration),
}
}
const fmtT = `=====================
NowDoing: %s
DoingNext: %s
Your current goal is: %s
You have done: %+v
You have a short memory with: %+v
=====================
`
func (c AgentInternalState) String() string {
return fmt.Sprintf(
fmtT,
c.NowDoing,
c.DoingNext,
c.Goal,
c.DoneHistory,
c.Memories,
)
}
-27
View File
@@ -1,27 +0,0 @@
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
+5 -5
View File
@@ -6,21 +6,21 @@ services:
environment:
- LOCALAI_SINGLE_ACTIVE_BACKEND=true
- DEBUG=true
image: localai/localai:master-gpu-intel
image: localai/localai:master-sycl-f32-ffmpeg-core
devices:
# On a system with integrated GPU and an Arc 770, this is the Arc 770
- /dev/dri/card1
- /dev/dri/renderD129
postgres:
localrecall:
extends:
file: docker-compose.yaml
service: postgres
service: localrecall
dind:
localrecall-healthcheck:
extends:
file: docker-compose.yaml
service: dind
service: localrecall-healthcheck
localagi:
extends:
+6 -6
View File
@@ -6,7 +6,7 @@ services:
environment:
- LOCALAI_SINGLE_ACTIVE_BACKEND=true
- DEBUG=true
image: localai/localai:master-gpu-nvidia-cuda-12
image: localai/localai:master-cublas-cuda12-ffmpeg-core
# For images with python backends, use:
# image: localai/localai:master-cublas-cuda12-ffmpeg
deploy:
@@ -17,17 +17,17 @@ services:
count: 1
capabilities: [gpu]
postgres:
localrecall:
extends:
file: docker-compose.yaml
service: postgres
service: localrecall
dind:
localrecall-healthcheck:
extends:
file: docker-compose.yaml
service: dind
service: localrecall-healthcheck
localagi:
extends:
file: docker-compose.yaml
service: localagi
service: localagi
+29 -67
View File
@@ -5,10 +5,11 @@ services:
# Available images with CUDA, ROCm, SYCL, Vulkan
# Image list (quay.io): https://quay.io/repository/go-skynet/local-ai?tab=tags
# Image list (dockerhub): https://hub.docker.com/r/localai/localai
image: localai/localai:master
image: localai/localai:master-ffmpeg-core
command:
- ${MODEL_NAME:-gemma-3-4b-it-qat}
- ${MULTIMODAL_MODEL:-gemma-3-4b-it-qat}
- ${MODEL_NAME:-arcee-agent}
- ${MULTIMODAL_MODEL:-minicpm-v-2_6}
- ${IMAGE_MODEL:-sd-1.5-ggml}
- granite-embedding-107m-multilingual
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/readyz"]
@@ -21,62 +22,36 @@ services:
- DEBUG=true
#- LOCALAI_API_KEY=sk-1234567890
volumes:
- models:/models
- backends:/backends
- images:/tmp/generated/images
- ./volumes/models:/build/models:cached
- ./volumes/images:/tmp/generated/images
postgres:
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.2}-postgresql
environment:
- POSTGRES_DB=localrecall
- POSTGRES_USER=localrecall
- POSTGRES_PASSWORD=localrecall
localrecall:
image: quay.io/mudler/localrecall:main
ports:
- 5432:5432
- 8080
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
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U localrecall"]
interval: 10s
timeout: 5s
retries: 5
- ./volumes/localrag/db:/db
- ./volumes/localrag/assets/:/assets
sshbox:
build:
context: .
dockerfile: Dockerfile.sshbox
ports:
- "22"
environment:
- SSH_USER=root
- SSH_PASSWORD=root
- DOCKER_HOST=tcp://dind:2375
localrecall-healthcheck:
depends_on:
dind:
condition: service_healthy
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
timeout: 5s
retries: 3
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!'"]
localagi:
depends_on:
localai:
condition: service_healthy
postgres:
condition: service_healthy
dind:
condition: service_healthy
localrecall-healthcheck:
condition: service_completed_successfully
build:
context: .
dockerfile: Dockerfile.webui
@@ -84,29 +59,16 @@ services:
- 8080:3000
#image: quay.io/mudler/localagi:master
environment:
- LOCALAGI_MODEL=${MODEL_NAME:-gemma-3-4b-it-qat}
- LOCALAGI_MULTIMODAL_MODEL=${MULTIMODAL_MODEL:-moondream2-20250414}
- LOCALAGI_MODEL=${MODEL_NAME:-arcee-agent}
- LOCALAGI_MULTIMODAL_MODEL=${MULTIMODAL_MODEL:-minicpm-v-2_6}
- 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_SSHBOX_URL=root:root@sshbox:22
- DOCKER_HOST=tcp://dind:2375
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- 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:
- ./volumes/localagi/:/pool
-56
View File
@@ -1,56 +0,0 @@
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
}
+12
View File
@@ -0,0 +1,12 @@
#!/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)
+59 -125
View File
@@ -1,147 +1,79 @@
module github.com/mudler/LocalAGI
go 1.26.0
go 1.24
toolchain go1.24.2
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/bwmarrin/discordgo v0.28.1
github.com/chasefleming/elem-go v0.30.0
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/donseba/go-htmx v1.12.0
github.com/eritikass/githubmarkdownconvertergo v0.1.10
github.com/go-telegram/bot v1.17.0
github.com/gofiber/fiber/v2 v2.52.11
github.com/go-telegram/bot v1.14.2
github.com/gofiber/fiber/v2 v2.52.6
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/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.6.1-0.20260507074622-a7724fef6f81
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49
github.com/mudler/xlog v0.0.5
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/metoro-io/mcp-golang v0.9.0
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.37.0
github.com/philippgille/chromem-go v0.7.0
github.com/robfig/cron/v3 v3.0.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/sashabaranov/go-openai v1.38.1
github.com/slack-go/slack v0.16.0
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64
github.com/tmc/langchaingo v0.1.14
github.com/tmc/langchaingo v0.1.13
github.com/traefik/yaegi v0.16.1
github.com/valyala/fasthttp v1.68.0
golang.org/x/crypto v0.50.0
github.com/valyala/fasthttp v1.60.0
golang.org/x/crypto v0.37.0
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056
maunium.net/go/mautrix v0.17.0
mvdan.cc/xurls/v2 v2.6.0
)
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/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/jolestar/go-commons-pool/v2 v2.1.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klippa-app/go-pdfium v1.19.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/tetratelabs/wazero v1.11.0 // 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.34.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.20.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.4.0
github.com/PuerkitoBio/goquery v1.10.3 // 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/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.24.0
github.com/go-logr/logr v1.4.3 // indirect
github.com/PuerkitoBio/goquery v1.8.1 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/antchfx/htmlquery v1.3.0 // indirect
github.com/antchfx/xmlquery v1.3.17 // indirect
github.com/antchfx/xpath v1.2.4 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.8.1 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/go-playground/validator/v10 v10.10.0 // 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.9.7 // indirect
github.com/gocolly/colly v1.2.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/utils v1.1.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // 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-20260115054156-294ebfa9ad83 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/invopop/jsonschema v0.12.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.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // 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/pkoukk/tiktoken-go v0.1.7 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.31.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.9 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkoukk/tiktoken-go v0.1.6 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/temoto/robotstxt v1.1.2 // indirect
@@ -149,15 +81,17 @@ 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/ugorji/go/codec v1.2.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
go.mau.fi/util v0.3.0 // indirect
go.starlark.net v0.0.0-20250417143717-f57e51f710eb // indirect
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
go.starlark.net v0.0.0-20230302034142-4b1e35fe2254 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/tools v0.31.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.10 // indirect
maunium.net/go/maulogger/v2 v2.4.1 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+197 -415
View File
@@ -1,205 +1,105 @@
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.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/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=
github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc=
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/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/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=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E=
github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8=
github.com/antchfx/xmlquery v1.3.17 h1:d0qWjPp/D+vtRw7ivCwT5ApH/3CkQU8JOeo3245PpTk=
github.com/antchfx/xmlquery v1.3.17/go.mod h1:Afkq4JIeXut75taLSuI31ISJ/zeq+3jG7TunF7noreA=
github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.2.4 h1:dW1HB/JxKvGtJ9WyVGJ0sIoEcqftV3SqIstujI+B9XY=
github.com/antchfx/xpath v1.2.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
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/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/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/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.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/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/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/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/eritikass/githubmarkdownconvertergo v0.1.10 h1:mL93ADvYMOeT15DcGtK9AaFFc+RcWcy6kQBC6yS/5f4=
github.com/eritikass/githubmarkdownconvertergo v0.1.10/go.mod h1:BdpHs6imOtzE5KorbUtKa6bZ0ZBh1yFcrTTAL8FwDKY=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
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/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
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.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
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.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/go-telegram/bot v1.14.2 h1:j9hXerxTuvkw7yFi3sF5jjRVGozNVKkMQSKjMeBJ5FY=
github.com/go-telegram/bot v1.14.2/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/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-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
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.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI=
github.com/gofiber/fiber/v2 v2.52.6/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/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
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=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
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.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE=
@@ -207,190 +107,98 @@ 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-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/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/jolestar/go-commons-pool/v2 v2.1.2 h1:E+XGo58F23t7HtZiC/W6jzO2Ux2IccSH/yx4nD+J1CM=
github.com/jolestar/go-commons-pool/v2 v2.1.2/go.mod h1:r4NYccrkS5UqP1YQI1COyTZ9UjPJAAGTUxzcsK1kqhY=
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/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
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/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/klippa-app/go-pdfium v1.19.2 h1:Gc/OT7wVO7xStNlDR5o/Qz0T/tsVtODsh7I1vOJXIKU=
github.com/klippa-app/go-pdfium v1.19.2/go.mod h1:X+AMQDw/TXTsgiY2vEGA7oYlQTmjyqmlt6pm6aoIDa0=
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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
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/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/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
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=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/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/metoro-io/mcp-golang v0.9.0 h1:GpFENjieZ/KosTu7CE7tyGI/a2FhiG0nandR0d8B3rE=
github.com/metoro-io/mcp-golang v0.9.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
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.6.1-0.20260507074622-a7724fef6f81 h1:8D9NJ/ikhsJCxUwbdzIzadw6RqDrW+L0FPqpQQSeux8=
github.com/mudler/localrecall v0.6.1-0.20260507074622-a7724fef6f81/go.mod h1:28k5n19raUrkuwXkacdNsBlj8yuSnGhpT16tu+2+4dU=
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.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
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/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.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
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/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw=
github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
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.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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
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/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.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/sashabaranov/go-openai v1.38.1 h1:TtZabbFQZa1nEni/IhVtDF/WQjVqDgd+cWR5OeddzF8=
github.com/sashabaranov/go-openai v1.38.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/slack-go/slack v0.16.0 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8=
github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
github.com/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.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
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/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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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/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/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
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=
@@ -403,169 +211,143 @@ 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/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/tmc/langchaingo v0.1.13 h1:rcpMWBIi2y3B90XxfE4Ao8dhCQPVDMaNPnN5cGB1CaA=
github.com/tmc/langchaingo v0.1.13/go.mod h1:vpQ5NOIhpzxDfTZK9B6tf2GM/MoaHewPWM5KXXGh7hg=
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
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.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
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/valyala/fasthttp v1.60.0 h1:kBRYS0lOhVJ6V+bYN8PqAHELKHtXqwq9zNMLKx1MBsw=
github.com/valyala/fasthttp v1.60.0/go.mod h1:iY4kDgV3Gc6EqhRZ8icqcmlG6bqhcDXfuHgTO4FXCvc=
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/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.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.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.starlark.net v0.0.0-20230302034142-4b1e35fe2254 h1:Ss6D3hLXTM0KobyBYEAygXzFfGcjnmfEJOBgSbemCtg=
go.starlark.net v0.0.0-20230302034142-4b1e35fe2254/go.mod h1:jxU+3+j+71eXOW14274+SmmuW82qJzl6iZSeqEtTGds=
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/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-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
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/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
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-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
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=
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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
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/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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=
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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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-20210806184541-e5e7981a1069/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.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
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.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
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=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
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=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
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.0-20210107192922-496545a6307b/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=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 h1:6YFJoB+0fUH6X3xU/G2tQqCYg+PkGtnZ5nMR5rpw72g=
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:OxvTsCwKosqQ1q7B+8FwXqg4rKZ/UG9dUW+g/VL2xH4=
maunium.net/go/maulogger/v2 v2.4.1 h1:N7zSdd0mZkB2m2JtFUsiGTQQAdP0YeFWT7YMc80yAL8=
maunium.net/go/maulogger/v2 v2.4.1/go.mod h1:omPuYwYBILeVQobz8uO3XC8DIRuEb5rXYlQSuqrbCho=
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=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
+86 -3
View File
@@ -1,9 +1,92 @@
package main
import (
"github.com/mudler/LocalAGI/cmd"
"log"
"os"
"path/filepath"
"strings"
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/services"
"github.com/mudler/LocalAGI/webui"
)
func main() {
cmd.Execute()
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")
func init() {
if baseModel == "" {
panic("LOCALAGI_MODEL not set")
}
if apiURL == "" {
panic("LOCALAGI_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,
localRAG,
services.Actions,
services.Connectors,
services.DynamicPrompts,
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"))
}
+7 -7
View File
@@ -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"`
EnableReasoning bool `json:"enable_reasoning,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"`
Config map[string]interface{} `json:"config,omitempty"`
}
// AgentStatus represents the status of an agent
+6 -94
View File
@@ -4,58 +4,18 @@ import (
"encoding/json"
"fmt"
"net/http"
"github.com/sashabaranov/go-openai/jsonschema"
)
// UserLocation represents the user's location for web search
type UserLocation struct {
Type string `json:"type"`
City *string `json:"city,omitempty"`
Country *string `json:"country,omitempty"`
Region *string `json:"region,omitempty"`
Timezone *string `json:"timezone,omitempty"`
}
type Tool struct {
Type string `json:"type"`
// Function tool fields (used when type == "function")
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Parameters *jsonschema.Definition `json:"parameters,omitempty"`
// Web search tool fields (used when type == "web_search_preview" etc.)
SearchContextSize *string `json:"search_context_size,omitempty"`
UserLocation *UserLocation `json:"user_location,omitempty"`
}
type ToolChoice struct {
Name string `json:"name"`
Type string `json:"type"`
}
// RequestBody represents the message request to the AI model
type RequestBody struct {
Model string `json:"model"`
Input any `json:"input"`
Temperature *float64 `json:"temperature,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice *ToolChoice `json:"tool_choice"`
MaxTokens *int `json:"max_output_tokens,omitempty"`
}
type InputFunctionToolCallOutput struct {
CallID string `json:"call_id"`
Output string `json:"output"`
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status"`
}
// InputMessage represents a user input message
type InputMessage struct {
Type string `json:"type"`
Role string `json:"role"`
Content any `json:"content"`
}
@@ -69,49 +29,10 @@ type ContentItem struct {
// ResponseBody represents the response from the AI model
type ResponseBody struct {
CreatedAt int64 `json:"created_at"`
Status string `json:"status"`
Error any `json:"error,omitempty"`
Output []ResponseBase `json:"output"`
Tools []Tool `json:"tools"`
}
type ResponseType string
const (
ResponseTypeFunctionToolCall ResponseType = "function_call"
ResponseTypeMessage ResponseType = "message"
)
type ResponseBase json.RawMessage
func (r *ResponseBase) UnmarshalJSON(data []byte) error {
return (*json.RawMessage)(r).UnmarshalJSON(data)
}
func (r *ResponseBase) ToMessage() (msg ResponseMessage, err error) {
err = json.Unmarshal(*r, &msg)
if msg.Type != string(ResponseTypeMessage) {
return ResponseMessage{}, fmt.Errorf("Expected %s, not %s", ResponseTypeMessage, msg.Type)
}
return
}
func (r *ResponseBase) ToFunctionToolCall() (msg ResponseFunctionToolCall, err error) {
err = json.Unmarshal(*r, &msg)
if msg.Type != string(ResponseTypeFunctionToolCall) {
return ResponseFunctionToolCall{}, fmt.Errorf("Expected %s, not %s", ResponseTypeFunctionToolCall, msg.Type)
}
return
}
type ResponseFunctionToolCall struct {
Arguments string `json:"arguments"`
CallID string `json:"call_id"`
Name string `json:"name"`
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
Status string `json:"status"`
Error any `json:"error,omitempty"`
Output []ResponseMessage `json:"output"`
}
// ResponseMessage represents a message in the response
@@ -164,11 +85,7 @@ func (c *Client) SimpleAIResponse(agentName, input string) (string, error) {
}
// Extract the text response from the output
for _, out := range response.Output {
msg, err := out.ToMessage()
if err != nil {
return "", fmt.Errorf("out.ToMessage: %w", err)
}
for _, msg := range response.Output {
if msg.Role == "assistant" {
for _, content := range msg.Content {
if content.Type == "output_text" {
@@ -196,12 +113,7 @@ func (c *Client) ChatAIResponse(agentName string, messages []InputMessage) (stri
}
// Extract the text response from the output
for _, out := range response.Output {
msg, err := out.ToMessage()
if err != nil {
return "", fmt.Errorf("out.ToMessage: %w", err)
}
for _, msg := range response.Output {
if msg.Role == "assistant" {
for _, content := range msg.Content {
if content.Type == "output_text" {
+9 -13
View File
@@ -5,25 +5,21 @@ import (
"encoding/json"
"fmt"
"github.com/mudler/xlog"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
func GenerateTypedJSONWithGuidance(ctx context.Context, client *openai.Client, guidance, model string, i jsonschema.Definition, dst any) error {
return GenerateTypedJSONWithConversation(ctx, client, []openai.ChatCompletionMessage{
{
Role: "user",
Content: guidance,
},
}, model, i, dst)
}
func GenerateTypedJSONWithConversation(ctx context.Context, client *openai.Client, conv []openai.ChatCompletionMessage, model string, i jsonschema.Definition, dst any) error {
func GenerateTypedJSON(ctx context.Context, client *openai.Client, guidance, model string, i jsonschema.Definition, dst any) error {
toolName := "json"
decision := openai.ChatCompletionRequest{
Model: model,
Messages: conv,
Model: model,
Messages: []openai.ChatCompletionMessage{
{
Role: "user",
Content: guidance,
},
},
Tools: []openai.Tool{
{
+59 -303
View File
@@ -11,14 +11,12 @@ import (
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/xlog"
"github.com/mudler/LocalAGI/pkg/xlog"
)
var _ agent.RAGDB = &WrappedClient{}
@@ -28,8 +26,7 @@ type WrappedClient struct {
collection string
}
func NewWrappedClient(baseURL, apiKey, c string) *WrappedClient {
collection := strings.TrimSpace(strings.ToLower(c))
func NewWrappedClient(baseURL, apiKey, collection string) *WrappedClient {
wc := &WrappedClient{
Client: NewClient(baseURL, apiKey),
collection: collection,
@@ -40,11 +37,6 @@ func NewWrappedClient(baseURL, apiKey, c 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 {
@@ -93,39 +85,7 @@ func (c *WrappedClient) Store(s string) error {
}
defer os.Remove(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))
return c.Client.Store(c.collection, f)
}
// Result represents a single result from a query.
@@ -141,13 +101,6 @@ 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
@@ -198,8 +151,7 @@ func (c *Client) CreateCollection(name string) error {
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return parseAPIError(resp, body, "failed to create collection")
return errors.New("failed to create collection")
}
return nil
@@ -222,30 +174,17 @@ func (c *Client) ListCollections() ([]string, error) {
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, errors.New("failed to list collections")
}
var collections []string
err = json.NewDecoder(resp.Body).Decode(&collections)
if err != nil {
return nil, err
}
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
return collections, nil
}
// ListEntries lists all entries in a collection
@@ -265,79 +204,17 @@ func (c *Client) ListEntries(collection string) ([]string, error) {
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, errors.New("failed to list entries")
}
var entries []string
err = json.NewDecoder(resp.Body).Decode(&entries)
if err != nil {
return nil, err
}
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
return entries, nil
}
// DeleteEntry deletes an entry in a collection
@@ -367,30 +244,19 @@ func (c *Client) DeleteEntry(collection, entry string) ([]string, error) {
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
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)
if err != nil {
return nil, err
}
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
return results, nil
}
// Search searches a collection
@@ -421,30 +287,17 @@ func (c *Client) Search(collection, query string, maxResults int) ([]Result, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, errors.New("failed to search collection")
}
var results []Result
err = json.NewDecoder(resp.Body).Decode(&results)
if err != nil {
return nil, err
}
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
return results, nil
}
// Reset resets a collection
@@ -465,20 +318,21 @@ func (c *Client) Reset(collection string) error {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return parseAPIError(resp, body, "failed to reset collection")
b := new(bytes.Buffer)
b.ReadFrom(resp.Body)
return errors.New("failed to reset collection: " + b.String())
}
return nil
}
// Store uploads a file to a collection and returns the assigned entry key.
func (c *Client) Store(collection, filePath string) (string, error) {
// Store uploads a file to a collection
func (c *Client) Store(collection, filePath 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()
@@ -487,22 +341,22 @@ func (c *Client) Store(collection, filePath string) (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)
@@ -510,124 +364,26 @@ func (c *Client) Store(collection, filePath string) (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 {
body, _ := io.ReadAll(resp.Body)
return "", parseAPIError(resp, body, "failed to upload file")
}
b := new(bytes.Buffer)
b.ReadFrom(resp.Body)
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)
type response struct {
Error string `json:"error"`
}
return nil, fmt.Errorf("invalid response: %w", err)
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")
}
var data struct {
Sources []SourceInfo `json:"sources"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return nil, err
}
return data.Sources, nil
return nil
}
-5
View File
@@ -1,5 +0,0 @@
package ptr
func To[T any](v T) *T {
return &v
}
+71
View File
@@ -0,0 +1,71 @@
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...)
}
+148 -401
View File
@@ -4,16 +4,12 @@ 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/xlog"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/services/actions"
)
@@ -24,7 +20,6 @@ const (
ActionCustom = "custom"
ActionGithubIssueLabeler = "github-issue-labeler"
ActionGithubIssueOpener = "github-issue-opener"
ActionGithubIssueEditor = "github-issue-editor"
ActionGithubIssueCloser = "github-issue-closer"
ActionGithubIssueSearcher = "github-issue-searcher"
ActionGithubRepositoryGet = "github-repository-get-content"
@@ -37,37 +32,15 @@ const (
ActionGithubPRCreator = "github-pr-creator"
ActionGithubGetAllContent = "github-get-all-repository-content"
ActionGithubREADME = "github-readme"
ActionGithubRepositorySearchFiles = "github-repository-search-files"
ActionGithubRepositoryListFiles = "github-repository-list-files"
ActionScraper = "scraper"
ActionWikipedia = "wikipedia"
ActionBrowse = "browse"
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{
@@ -75,13 +48,10 @@ var AvailableActions = []string{
ActionCustom,
ActionGithubIssueLabeler,
ActionGithubIssueOpener,
ActionGithubIssueEditor,
ActionGithubIssueCloser,
ActionGithubIssueSearcher,
ActionGithubRepositoryGet,
ActionGithubGetAllContent,
ActionGithubRepositorySearchFiles,
ActionGithubRepositoryListFiles,
ActionGithubRepositoryCreateOrUpdate,
ActionGithubIssueReader,
ActionGithubIssueCommenter,
@@ -95,342 +65,51 @@ var AvailableActions = []string{
ActionWikipedia,
ActionSendMail,
ActionGenerateImage,
ActionGenerateSong,
ActionGeneratePDF,
ActionTwitterPost,
ActionCounter,
ActionCallAgents,
ActionShellcommand,
ActionSendTelegramMessage,
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(),
},
}
func Actions(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action {
return func(ctx context.Context, pool *state.AgentPool) []types.Action {
allActions := []types.Action{}
const (
ActionConfigSSHBoxURL = "sshbox-url"
ConfigStateDir = "state-dir"
CustomActionsDir = "custom-actions-dir"
)
agentName := a.Name
for _, a := range a.Actions {
var config map[string]string
if err := json.Unmarshal([]byte(a.Config), &config); err != nil {
xlog.Error("Error unmarshalling action config", "error", err)
continue
}
a, err := Action(a.Name, agentName, config, pool)
if err != nil {
continue
}
allActions = append(allActions, a)
}
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 {
allActions := []types.Action{}
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 {
xlog.Error("Error unmarshalling action config", "error", err)
continue
}
existingActionConfigs[a.Name] = config
a, err := Action(a.Name, agentName, config, pool, actionsConfigs)
if err != nil {
continue
}
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
}
}
}
func Action(name, agentName string, config map[string]string, pool *state.AgentPool, actionsConfigs map[string]string) (types.Action, error) {
func Action(name, agentName string, config map[string]string, pool *state.AgentPool) (types.Action, error) {
var a types.Action
var err error
if config == nil {
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:
a = actions.NewGithubIssueLabeler(config)
case ActionGithubIssueOpener:
a = actions.NewGithubIssueOpener(config)
case ActionGithubIssueEditor:
a = actions.NewGithubIssueEditor(config)
case ActionGithubIssueCloser:
a = actions.NewGithubIssueCloser(config)
case ActionGithubIssueSearcher:
@@ -447,10 +126,6 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
a = actions.NewGithubPRCreator(config)
case ActionGithubGetAllContent:
a = actions.NewGithubRepositoryGetAllContent(config)
case ActionGithubRepositorySearchFiles:
a = actions.NewGithubRepositorySearchFiles(config)
case ActionGithubRepositoryListFiles:
a = actions.NewGithubRepositoryListFiles(config)
case ActionGithubIssueCommenter:
a = actions.NewGithubIssueCommenter(config)
case ActionGithubRepositoryGet:
@@ -467,8 +142,6 @@ 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:
@@ -476,27 +149,7 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
case ActionCallAgents:
a = actions.NewCallAgent(config, agentName, pool.InternalAPI())
case ActionShellcommand:
a = actions.NewShell(config, actionsConfigs[ActionConfigSSHBoxURL])
case ActionSendTelegramMessage:
a = actions.NewSendTelegramMessageRunner(config)
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)
a = actions.NewShell(config)
default:
xlog.Error("Action not found", "name", name)
return nil, fmt.Errorf("Action not found")
@@ -509,38 +162,132 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
return a, nil
}
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",
},
},
})
}
func ActionsConfigMeta() []config.FieldGroup {
return []config.FieldGroup{
{
Name: "search",
Label: "Search",
Fields: actions.SearchConfigMeta(),
},
{
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-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-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: []config.Field{},
},
}
return all
}
+1 -1
View File
@@ -18,7 +18,7 @@ func NewBrowse(config map[string]string) *BrowseAction {
type BrowseAction struct{}
func (a *BrowseAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (a *BrowseAction) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
URL string `json:"url"`
}{}
+6 -73
View File
@@ -3,56 +3,26 @@ package actions
import (
"context"
"fmt"
"slices"
"strings"
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
func trimList(list []string) []string {
for i, v := range list {
list[i] = strings.TrimSpace(v)
}
return list
}
func NewCallAgent(config map[string]string, agentName string, pool *state.AgentPoolInternalAPI) *CallAgentAction {
whitelist := []string{}
blacklist := []string{}
if v, ok := config["whitelist"]; ok {
if strings.Contains(v, ",") {
whitelist = trimList(strings.Split(v, ","))
} else {
whitelist = []string{v}
}
}
if v, ok := config["blacklist"]; ok {
if strings.Contains(v, ",") {
blacklist = trimList(strings.Split(v, ","))
} else {
blacklist = []string{v}
}
}
return &CallAgentAction{
pool: pool,
myName: agentName,
whitelist: whitelist,
blacklist: blacklist,
pool: pool,
myName: agentName,
}
}
type CallAgentAction struct {
pool *state.AgentPoolInternalAPI
myName string
whitelist []string
blacklist []string
pool *state.AgentPoolInternalAPI
myName string
}
func (a *CallAgentAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (a *CallAgentAction) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
AgentName string `json:"agent_name"`
Message string `json:"message"`
@@ -113,32 +83,13 @@ func (a *CallAgentAction) Run(ctx context.Context, sharedState *types.AgentShare
return types.ActionResult{Result: resp.Response, Metadata: metadata}, nil
}
func (a *CallAgentAction) isAllowedToBeCalled(agentName string) bool {
if agentName == a.myName {
return false
}
if len(a.whitelist) > 0 && len(a.blacklist) > 0 {
return slices.Contains(a.whitelist, agentName) && !slices.Contains(a.blacklist, agentName)
}
if len(a.whitelist) > 0 {
return slices.Contains(a.whitelist, agentName)
}
if len(a.blacklist) > 0 {
return !slices.Contains(a.blacklist, agentName)
}
return true
}
func (a *CallAgentAction) Definition() types.ActionDefinition {
allAgents := a.pool.AllAgents()
agents := []string{}
for _, ag := range allAgents {
if a.isAllowedToBeCalled(ag) {
if ag != a.myName {
agents = append(agents, ag)
}
}
@@ -174,21 +125,3 @@ func (a *CallAgentAction) Definition() types.ActionDefinition {
func (a *CallAgentAction) Plannable() bool {
return true
}
func CallAgentConfigMeta() []config.Field {
return []config.Field{
{
Name: "whitelist",
Label: "Whitelist",
Type: config.FieldTypeText,
Required: false,
HelpText: "Comma-separated list of agent names to call. If not specified, all agents are allowed.",
},
{
Name: "blacklist",
Label: "Blacklist",
Type: config.FieldTypeText,
HelpText: "Comma-separated list of agent names to exclude from the call. If not specified, all agents are allowed.",
},
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ func NewCounter(config map[string]string) *CounterAction {
}
// Run executes the counter action
func (a *CounterAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (a *CounterAction) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
// Parse parameters
request := struct {
Name string `json:"name"`
+1 -1
View File
@@ -29,7 +29,7 @@ type GenImageAction struct {
imageModel string
}
func (a *GenImageAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (a *GenImageAction) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Prompt string `json:"prompt"`
Size string `json:"size"`
+2 -2
View File
@@ -42,7 +42,7 @@ var _ = Describe("GenImageAction", func() {
"size": "256x256",
}
url, err := action.Run(ctx, nil, params)
url, err := action.Run(ctx, params)
Expect(err).ToNot(HaveOccurred())
Expect(url).ToNot(BeEmpty())
})
@@ -52,7 +52,7 @@ var _ = Describe("GenImageAction", func() {
"size": "256x256",
}
_, err := action.Run(ctx, nil, params)
_, err := action.Run(ctx, params)
Expect(err).To(HaveOccurred())
})
})
-165
View File
@@ -1,165 +0,0 @@
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",
},
}
}
-412
View File
@@ -1,412 +0,0 @@
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, "")
}
}
}
-212
View File
@@ -1,212 +0,0 @@
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\", 23"
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))
})
})
-302
View File
@@ -1,302 +0,0 @@
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",
},
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ func NewGithubIssueCloser(config map[string]string) *GithubIssuesCloser {
}
}
func (g *GithubIssuesCloser) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubIssuesCloser) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
+1 -1
View File
@@ -27,7 +27,7 @@ func NewGithubIssueCommenter(config map[string]string) *GithubIssuesCommenter {
}
}
func (g *GithubIssuesCommenter) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubIssuesCommenter) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
-151
View File
@@ -1,151 +0,0 @@
package actions
import (
"context"
"fmt"
"github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/sashabaranov/go-openai/jsonschema"
)
type GithubIssueEditor struct {
token, repository, owner, customActionName string
client *github.Client
}
func NewGithubIssueEditor(config map[string]string) *GithubIssueEditor {
client := github.NewClient(nil).WithAuthToken(config["token"])
return &GithubIssueEditor{
client: client,
token: config["token"],
customActionName: config["customActionName"],
repository: config["repository"],
owner: config["owner"],
}
}
func (g *GithubIssueEditor) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
Description string `json:"description"`
Title string `json:"title"`
IssueNumber int `json:"issue_number"`
}{}
err := params.Unmarshal(&result)
if err != nil {
return types.ActionResult{}, err
}
if g.repository != "" && g.owner != "" {
result.Repository = g.repository
result.Owner = g.owner
}
_, _, err = g.client.Issues.Edit(ctx, result.Owner, result.Repository, result.IssueNumber,
&github.IssueRequest{
Body: &result.Description,
Title: &result.Title,
})
resultString := fmt.Sprintf("Updated issue %d in repository %s/%s", result.IssueNumber, result.Owner, result.Repository)
if err != nil {
resultString = fmt.Sprintf("Error updating issue %d in repository %s/%s: %v", result.IssueNumber, result.Owner, result.Repository, err)
}
return types.ActionResult{Result: resultString}, err
}
func (g *GithubIssueEditor) Definition() types.ActionDefinition {
actionName := "edit_github_issue"
if g.customActionName != "" {
actionName = g.customActionName
}
description := "Edit the title and description of a Github issue in a repository. Use this action after reading the issue"
if g.repository != "" && g.owner != "" {
return types.ActionDefinition{
Name: types.ActionDefinitionName(actionName),
Description: description,
Properties: map[string]jsonschema.Definition{
"issue_number": {
Type: jsonschema.Number,
Description: "The number of the issue to edit.",
},
"title": {
Type: jsonschema.String,
Description: "The new title for the issue.",
},
"description": {
Type: jsonschema.String,
Description: "The new description for the issue.",
},
},
Required: []string{"issue_number", "title", "description"},
}
}
return types.ActionDefinition{
Name: types.ActionDefinitionName(actionName),
Description: description,
Properties: map[string]jsonschema.Definition{
"issue_number": {
Type: jsonschema.Number,
Description: "The number of the issue to edit.",
},
"repository": {
Type: jsonschema.String,
Description: "The repository containing the issue.",
},
"owner": {
Type: jsonschema.String,
Description: "The owner of the repository.",
},
"title": {
Type: jsonschema.String,
Description: "The new title for the issue.",
},
"description": {
Type: jsonschema.String,
Description: "The new description for the issue.",
},
},
Required: []string{"issue_number", "repository", "owner", "title", "description"},
}
}
func (a *GithubIssueEditor) Plannable() bool {
return true
}
// GithubIssueEditorConfigMeta returns the metadata for GitHub Issue Editor action configuration fields
func GithubIssueEditorConfigMeta() []config.Field {
return []config.Field{
{
Name: "token",
Label: "GitHub Token",
Type: config.FieldTypeText,
Required: true,
HelpText: "GitHub API token with repository access",
},
{
Name: "repository",
Label: "Repository",
Type: config.FieldTypeText,
Required: false,
HelpText: "GitHub repository name",
},
{
Name: "owner",
Label: "Owner",
Type: config.FieldTypeText,
Required: false,
HelpText: "GitHub repository owner",
},
{
Name: "customActionName",
Label: "Custom Action Name",
Type: config.FieldTypeText,
HelpText: "Custom name for this action",
},
}
}
+2 -2
View File
@@ -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/xlog"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
@@ -38,7 +38,7 @@ func NewGithubIssueLabeler(config map[string]string) *GithubIssuesLabeler {
}
}
func (g *GithubIssuesLabeler) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubIssuesLabeler) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
+1 -1
View File
@@ -27,7 +27,7 @@ func NewGithubIssueOpener(config map[string]string) *GithubIssuesOpener {
}
}
func (g *GithubIssuesOpener) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubIssuesOpener) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Title string `json:"title"`
Body string `json:"text"`
+1 -1
View File
@@ -27,7 +27,7 @@ func NewGithubIssueReader(config map[string]string) *GithubIssuesReader {
}
}
func (g *GithubIssuesReader) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubIssuesReader) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
+2 -2
View File
@@ -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/xlog"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
@@ -28,7 +28,7 @@ func NewGithubIssueSearch(config map[string]string) *GithubIssueSearch {
}
}
func (g *GithubIssueSearch) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubIssueSearch) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Query string `json:"query"`
Repository string `json:"repository"`
+93 -1
View File
@@ -3,6 +3,8 @@ package actions
import (
"context"
"fmt"
"regexp"
"strconv"
"github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types"
@@ -15,6 +17,96 @@ type GithubPRCommenter struct {
client *github.Client
}
var (
patchRegex = regexp.MustCompile(`^@@.*\d [\+\-](\d+),?(\d+)?.+?@@`)
)
type commitFileInfo struct {
FileName string
hunkInfos []*hunkInfo
sha string
}
type hunkInfo struct {
hunkStart int
hunkEnd int
}
func (hi hunkInfo) isLineInHunk(line int) bool {
return line >= hi.hunkStart && line <= hi.hunkEnd
}
func (cfi *commitFileInfo) getHunkInfo(line int) *hunkInfo {
for _, hunkInfo := range cfi.hunkInfos {
if hunkInfo.isLineInHunk(line) {
return hunkInfo
}
}
return nil
}
func (cfi *commitFileInfo) isLineInChange(line int) bool {
return cfi.getHunkInfo(line) != nil
}
func (cfi commitFileInfo) calculatePosition(line int) *int {
hi := cfi.getHunkInfo(line)
if hi == nil {
return nil
}
position := line - hi.hunkStart
return &position
}
func parseHunkPositions(patch, filename string) ([]*hunkInfo, error) {
hunkInfos := make([]*hunkInfo, 0)
if patch != "" {
groups := patchRegex.FindAllStringSubmatch(patch, -1)
if len(groups) < 1 {
return hunkInfos, fmt.Errorf("the patch details for [%s] could not be resolved", filename)
}
for _, patchGroup := range groups {
endPos := 2
if len(patchGroup) > 2 && patchGroup[2] == "" {
endPos = 1
}
hunkStart, err := strconv.Atoi(patchGroup[1])
if err != nil {
hunkStart = -1
}
hunkEnd, err := strconv.Atoi(patchGroup[endPos])
if err != nil {
hunkEnd = -1
}
hunkInfos = append(hunkInfos, &hunkInfo{
hunkStart: hunkStart,
hunkEnd: hunkEnd,
})
}
}
return hunkInfos, nil
}
func getCommitInfo(file *github.CommitFile) (*commitFileInfo, error) {
patch := file.GetPatch()
hunkInfos, err := parseHunkPositions(patch, *file.Filename)
if err != nil {
return nil, err
}
sha := file.GetSHA()
if sha == "" {
return nil, fmt.Errorf("the sha details for [%s] could not be resolved", *file.Filename)
}
return &commitFileInfo{
FileName: *file.Filename,
hunkInfos: hunkInfos,
sha: sha,
}, nil
}
func NewGithubPRCommenter(config map[string]string) *GithubPRCommenter {
client := github.NewClient(nil).WithAuthToken(config["token"])
@@ -27,7 +119,7 @@ func NewGithubPRCommenter(config map[string]string) *GithubPRCommenter {
}
}
func (g *GithubPRCommenter) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubPRCommenter) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
+4 -78
View File
@@ -3,7 +3,6 @@ package actions
import (
"context"
"fmt"
"time"
"github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types"
@@ -11,16 +10,8 @@ import (
"github.com/sashabaranov/go-openai/jsonschema"
)
const (
// forkCreationRetries is the number of times to retry checking if a fork is ready
forkCreationRetries = 30
// forkCreationRetryDelay is the duration to wait between fork creation checks
forkCreationRetryDelay = 5 * time.Second
)
type GithubPRCreator struct {
token, repository, owner, customActionName, defaultBranch string
useFork bool
client *github.Client
}
@@ -32,8 +23,6 @@ func NewGithubPRCreator(config map[string]string) *GithubPRCreator {
defaultBranch = "main" // Default to "main" if not specified
}
useFork := config["useFork"] == "true"
return &GithubPRCreator{
client: client,
token: config["token"],
@@ -41,45 +30,9 @@ func NewGithubPRCreator(config map[string]string) *GithubPRCreator {
owner: config["owner"],
customActionName: config["customActionName"],
defaultBranch: defaultBranch,
useFork: useFork,
}
}
// ensureFork ensures that a fork exists for the given repository
func (g *GithubPRCreator) ensureFork(ctx context.Context, owner, repo string) (string, error) {
// First check if we already have a fork
user, _, err := g.client.Users.Get(ctx, "")
if err != nil {
return "", fmt.Errorf("failed to get current user: %w", err)
}
forkOwner := user.GetLogin()
// Check if fork already exists
_, _, err = g.client.Repositories.Get(ctx, forkOwner, repo)
if err == nil {
// Fork already exists
return forkOwner, nil
}
// Create fork
_, _, err = g.client.Repositories.CreateFork(ctx, owner, repo, &github.RepositoryCreateForkOptions{})
if err != nil {
return "", fmt.Errorf("failed to create fork: %w", err)
}
// Wait for fork to be ready
for i := 0; i < forkCreationRetries; i++ {
_, _, err = g.client.Repositories.Get(ctx, forkOwner, repo)
if err == nil {
return forkOwner, nil
}
// Sleep for a bit before retrying
time.Sleep(forkCreationRetryDelay)
}
return "", fmt.Errorf("fork creation timed out")
}
func (g *GithubPRCreator) createOrUpdateBranch(ctx context.Context, branchName string, owner string, repository string) error {
// Get the latest commit SHA from the default branch
ref, _, err := g.client.Git.GetRef(ctx, owner, repository, "refs/heads/"+g.defaultBranch)
@@ -148,7 +101,7 @@ func (g *GithubPRCreator) createOrUpdateFile(ctx context.Context, branch string,
return nil
}
func (g *GithubPRCreator) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubPRCreator) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
@@ -175,29 +128,15 @@ func (g *GithubPRCreator) Run(ctx context.Context, sharedState *types.AgentShare
result.BaseBranch = g.defaultBranch
}
var targetOwner, targetRepo string
if g.useFork {
// Ensure we have a fork and get the fork owner
forkOwner, err := g.ensureFork(ctx, result.Owner, result.Repository)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to ensure fork: %w", err)
}
targetOwner = forkOwner
targetRepo = result.Repository
} else {
targetOwner = result.Owner
targetRepo = result.Repository
}
// Create or update branch
err = g.createOrUpdateBranch(ctx, result.Branch, targetOwner, targetRepo)
err = g.createOrUpdateBranch(ctx, result.Branch, result.Owner, result.Repository)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to create/update branch: %w", err)
}
// Create or update files
for _, file := range result.Files {
err = g.createOrUpdateFile(ctx, result.Branch, file.Path, file.Content, fmt.Sprintf("Update %s", file.Path), targetOwner, targetRepo)
err = g.createOrUpdateFile(ctx, result.Branch, file.Path, file.Content, fmt.Sprintf("Update %s", file.Path), result.Owner, result.Repository)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to update file %s: %w", file.Path, err)
}
@@ -206,7 +145,7 @@ func (g *GithubPRCreator) Run(ctx context.Context, sharedState *types.AgentShare
// Check if PR already exists for this branch
prs, _, err := g.client.PullRequests.List(ctx, result.Owner, result.Repository, &github.PullRequestListOptions{
State: "open",
Head: fmt.Sprintf("%s:%s", targetOwner, result.Branch),
Head: fmt.Sprintf("%s:%s", result.Owner, result.Branch),
})
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to list pull requests: %w", err)
@@ -236,12 +175,6 @@ func (g *GithubPRCreator) Run(ctx context.Context, sharedState *types.AgentShare
Base: &result.BaseBranch,
}
// If using a fork, we need to specify the full head reference
if g.useFork {
head := fmt.Sprintf("%s:%s", targetOwner, result.Branch)
newPR.Head = &head
}
createdPR, _, err := g.client.PullRequests.Create(ctx, result.Owner, result.Repository, newPR)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to create pull request: %w", err)
@@ -389,12 +322,5 @@ func GithubPRCreatorConfigMeta() []config.Field {
Required: false,
HelpText: "Default branch to create PRs against (defaults to main)",
},
{
Name: "useFork",
Label: "Use Fork",
Type: config.FieldTypeCheckbox,
Required: false,
HelpText: "Whether to create PRs from a fork (useful when you don't have write access to the repository). Set to 'true' to enable.",
},
}
}
+2 -2
View File
@@ -54,7 +54,7 @@ var _ = Describe("GithubPRCreator", func() {
},
}
result, err := action.Run(ctx, nil, params)
result, err := action.Run(ctx, params)
Expect(err).NotTo(HaveOccurred())
Expect(result.Result).To(ContainSubstring("pull request #"))
})
@@ -65,7 +65,7 @@ var _ = Describe("GithubPRCreator", func() {
"body": "This is a test pull request",
}
_, err := action.Run(ctx, nil, params)
_, err := action.Run(ctx, params)
Expect(err).To(HaveOccurred())
})
})
+1 -1
View File
@@ -34,7 +34,7 @@ func NewGithubPRReader(config map[string]string) *GithubPRReader {
}
}
func (g *GithubPRReader) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubPRReader) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
+2 -2
View File
@@ -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/xlog"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
@@ -30,7 +30,7 @@ func NewGithubPRReviewer(config map[string]string) *GithubPRReviewer {
}
}
func (g *GithubPRReviewer) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubPRReviewer) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
+3 -3
View File
@@ -58,7 +58,7 @@ var _ = Describe("GithubPRReviewer", func() {
},
}
result, err := reviewer.Run(ctx, nil, params)
result, err := reviewer.Run(ctx, params)
Expect(err).NotTo(HaveOccurred())
Expect(result.Result).To(ContainSubstring("reviewed successfully"))
})
@@ -70,7 +70,7 @@ var _ = Describe("GithubPRReviewer", func() {
"review_action": "COMMENT",
}
result, err := reviewer.Run(ctx, nil, params)
result, err := reviewer.Run(ctx, params)
Expect(err).To(HaveOccurred())
Expect(result.Result).To(ContainSubstring("not found"))
})
@@ -85,7 +85,7 @@ var _ = Describe("GithubPRReviewer", func() {
"review_action": "INVALID_ACTION",
}
_, err := reviewer.Run(ctx, nil, params)
_, err := reviewer.Run(ctx, params)
Expect(err).To(HaveOccurred())
})
})
@@ -30,7 +30,7 @@ func NewGithubRepositoryCreateOrUpdateContent(config map[string]string) *GithubR
}
}
func (g *GithubRepositoryCreateOrUpdateContent) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubRepositoryCreateOrUpdateContent) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Path string `json:"path"`
Repository string `json:"repository"`
@@ -3,13 +3,11 @@ package actions
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
@@ -30,30 +28,6 @@ func NewGithubRepositoryGetAllContent(config map[string]string) *GithubRepositor
}
}
// isTextFile checks if a file is likely to be a text file based on its extension
func isTextFile(path string) bool {
// List of common text/code file extensions
textExtensions := map[string]bool{
".txt": true, ".md": true, ".go": true, ".py": true, ".js": true,
".ts": true, ".jsx": true, ".tsx": true, ".html": true, ".css": true,
".json": true, ".yaml": true, ".yml": true, ".xml": true, ".sql": true,
".sh": true, ".bash": true, ".zsh": true, ".rb": true, ".php": true,
".java": true, ".c": true, ".cpp": true, ".h": true, ".hpp": true,
".rs": true, ".swift": true, ".kt": true, ".scala": true, ".lua": true,
".pl": true, ".r": true, ".m": true, ".mm": true, ".f": true,
".f90": true, ".f95": true, ".f03": true, ".f08": true, ".f15": true,
".hs": true, ".lhs": true, ".erl": true, ".hrl": true, ".ex": true,
".exs": true, ".eex": true, ".leex": true, ".heex": true, ".config": true,
".toml": true, ".ini": true, ".conf": true, ".env": true, ".gitignore": true,
".dockerignore": true, ".editorconfig": true, ".prettierrc": true, ".eslintrc": true,
".babelrc": true, ".npmrc": true, ".yarnrc": true, ".lock": true,
}
// Get the file extension
ext := strings.ToLower(filepath.Ext(path))
return textExtensions[ext]
}
func (g *GithubRepositoryGetAllContent) getContentRecursively(ctx context.Context, path string, owner string, repository string) (string, error) {
var result strings.Builder
@@ -73,13 +47,6 @@ func (g *GithubRepositoryGetAllContent) getContentRecursively(ctx context.Contex
}
result.WriteString(subContent)
} else if item.GetType() == "file" {
// Skip binary/image files
if !isTextFile(item.GetPath()) {
xlog.Warn("Skipping non-text file: ", "file", item.GetPath())
result.WriteString(fmt.Sprintf("Skipping non-text file: %s\n", item.GetPath()))
continue
}
// Get file content
fileContent, _, _, err := g.client.Repositories.GetContents(ctx, owner, repository, item.GetPath(), nil)
if err != nil {
@@ -101,7 +68,7 @@ func (g *GithubRepositoryGetAllContent) getContentRecursively(ctx context.Contex
return result.String(), nil
}
func (g *GithubRepositoryGetAllContent) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubRepositoryGetAllContent) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Repository string `json:"repository"`
Owner string `json:"owner"`
@@ -45,7 +45,7 @@ var _ = Describe("GithubRepositoryGetAllContent", func() {
"path": ".",
}
result, err := action.Run(ctx, nil, params)
result, err := action.Run(ctx, params)
Expect(err).NotTo(HaveOccurred())
Expect(result.Result).NotTo(BeEmpty())
@@ -64,7 +64,7 @@ var _ = Describe("GithubRepositoryGetAllContent", func() {
"path": "non-existent-path",
}
_, err := action.Run(ctx, nil, params)
_, err := action.Run(ctx, params)
Expect(err).To(HaveOccurred())
})
})
@@ -27,7 +27,7 @@ func NewGithubRepositoryGetContent(config map[string]string) *GithubRepositoryGe
}
}
func (g *GithubRepositoryGetContent) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
func (g *GithubRepositoryGetContent) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Path string `json:"path"`
Repository string `json:"repository"`

Some files were not shown because too many files have changed in this diff Show More