Compare commits
19 Commits
status-obj
...
chore/mcpb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f555132fa | ||
|
|
15efd2d527 | ||
|
|
5e3bc0f89b | ||
|
|
12209ab926 | ||
|
|
547e9cd0c4 | ||
|
|
6a1e536ca7 | ||
|
|
eb8663ada1 | ||
|
|
ce997d2425 | ||
|
|
56cd0e05ca | ||
|
|
25bb3fb123 | ||
|
|
9e52438877 | ||
|
|
c4618896cf | ||
|
|
ee1667d51a | ||
|
|
bafd26e92c | ||
|
|
8ecc18f76f | ||
|
|
985f07a529 | ||
|
|
8b2900c6d8 | ||
|
|
50e56fe22f | ||
|
|
b5a12a1da6 |
74
.github/workflows/image.yml
vendored
74
.github/workflows/image.yml
vendored
@@ -84,3 +84,77 @@ jobs:
|
|||||||
#tags: ${{ steps.prep.outputs.tags }}
|
#tags: ${{ steps.prep.outputs.tags }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
mcpbox-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
id: prep
|
||||||
|
run: |
|
||||||
|
DOCKER_IMAGE=quay.io/mudler/localagi-mcpbox
|
||||||
|
# Use branch name as default
|
||||||
|
VERSION=${GITHUB_REF#refs/heads/}
|
||||||
|
BINARY_VERSION=$(git describe --always --tags --dirty)
|
||||||
|
SHORTREF=${GITHUB_SHA::8}
|
||||||
|
# If this is git tag, use the tag name as a docker tag
|
||||||
|
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
|
fi
|
||||||
|
TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${SHORTREF}"
|
||||||
|
# If the VERSION looks like a version number, assume that
|
||||||
|
# this is the most recent version of the image and also
|
||||||
|
# tag it 'latest'.
|
||||||
|
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||||
|
TAGS="$TAGS,${DOCKER_IMAGE}:latest"
|
||||||
|
fi
|
||||||
|
# Set output parameters.
|
||||||
|
echo ::set-output name=binary_version::${BINARY_VERSION}
|
||||||
|
echo ::set-output name=tags::${TAGS}
|
||||||
|
echo ::set-output name=docker_image::${DOCKER_IMAGE}
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@master
|
||||||
|
with:
|
||||||
|
platforms: all
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@master
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: quay.io
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
|
||||||
|
with:
|
||||||
|
images: quay.io/mudler/localagi-mcpbox
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch,suffix=-{{date 'YYYYMMDDHHmmss'}}
|
||||||
|
type=semver,pattern={{raw}}
|
||||||
|
type=sha,suffix=-{{date 'YYYYMMDDHHmmss'}}
|
||||||
|
type=ref,event=branch
|
||||||
|
flavor: |
|
||||||
|
latest=auto
|
||||||
|
prefix=
|
||||||
|
suffix=
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
builder: ${{ steps.buildx.outputs.name }}
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ steps.prep.outputs.binary_version }}
|
||||||
|
context: ./
|
||||||
|
file: ./Dockerfile.mcpbox
|
||||||
|
#platforms: linux/amd64,linux/arm64
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
#tags: ${{ steps.prep.outputs.tags }}
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v4
|
||||||
- run: |
|
- run: |
|
||||||
# Add Docker's official GPG key:
|
# Add Docker's official GPG key:
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
|
|||||||
47
Dockerfile.mcpbox
Normal file
47
Dockerfile.mcpbox
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o mcpbox ./cmd/mcpbox
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata docker
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
#RUN adduser -D -g '' appuser
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /app/mcpbox .
|
||||||
|
|
||||||
|
# Use non-root user
|
||||||
|
#USER appuser
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Set entrypoint
|
||||||
|
ENTRYPOINT ["/app/mcpbox"]
|
||||||
|
|
||||||
|
# Default command
|
||||||
|
CMD ["-addr", ":8080"]
|
||||||
14
Makefile
14
Makefile
@@ -1,15 +1,17 @@
|
|||||||
GOCMD?=go
|
GOCMD?=go
|
||||||
IMAGE_NAME?=webui
|
IMAGE_NAME?=webui
|
||||||
|
MCPBOX_IMAGE_NAME?=mcpbox
|
||||||
ROOT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
|
ROOT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
|
||||||
|
|
||||||
prepare-tests:
|
prepare-tests: build-mcpbox
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
docker run -d -v /var/run/docker.sock:/var/run/docker.sock --privileged -p 9090:8080 --rm -ti $(MCPBOX_IMAGE_NAME)
|
||||||
|
|
||||||
cleanup-tests:
|
cleanup-tests:
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
tests: prepare-tests
|
tests: prepare-tests
|
||||||
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 ./...
|
LOCALAGI_MCPBOX_URL="http://localhost:9090" LOCALAGI_MODEL="gemma-3-12b-it-qat" LOCALAI_API_URL="http://localhost:8081" LOCALAGI_API_URL="http://localhost:8080" $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --fail-fast -v -r ./...
|
||||||
|
|
||||||
run-nokb:
|
run-nokb:
|
||||||
$(MAKE) run KBDISABLEINDEX=true
|
$(MAKE) run KBDISABLEINDEX=true
|
||||||
@@ -23,10 +25,16 @@ build: webui/react-ui/dist
|
|||||||
|
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
run: webui/react-ui/dist
|
run: webui/react-ui/dist
|
||||||
$(GOCMD) run ./
|
LOCALAGI_MCPBOX_URL="http://localhost:9090" $(GOCMD) run ./
|
||||||
|
|
||||||
build-image:
|
build-image:
|
||||||
docker build -t $(IMAGE_NAME) -f Dockerfile.webui .
|
docker build -t $(IMAGE_NAME) -f Dockerfile.webui .
|
||||||
|
|
||||||
image-push:
|
image-push:
|
||||||
docker push $(IMAGE_NAME)
|
docker push $(IMAGE_NAME)
|
||||||
|
|
||||||
|
build-mcpbox:
|
||||||
|
docker build -t $(MCPBOX_IMAGE_NAME) -f Dockerfile.mcpbox .
|
||||||
|
|
||||||
|
run-mcpbox:
|
||||||
|
docker run -v /var/run/docker.sock:/var/run/docker.sock --privileged -p 9090:8080 -ti mcpbox
|
||||||
15
README.md
15
README.md
@@ -2,7 +2,7 @@
|
|||||||
<img src="./webui/react-ui/public/logo_1.png" alt="LocalAGI Logo" width="220"/>
|
<img src="./webui/react-ui/public/logo_1.png" alt="LocalAGI Logo" width="220"/>
|
||||||
</p>
|
</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">
|
<div align="center">
|
||||||
|
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
We empower you building AI Agents that you can run locally, without coding.
|
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.
|
||||||
|
|
||||||
**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).
|
**LocalAGI** is a powerful, self-hostable AI Agent platform that allows you to design AI automations without writing code. A complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU).
|
||||||
|
|
||||||
## 🛡️ Take Back Your Privacy
|
## 🛡️ Take Back Your Privacy
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ LocalAGI ensures your data stays exactly where you want it—on your hardware. N
|
|||||||
- 🖼 **Multimodal Support**: Ready for vision, text, and more.
|
- 🖼 **Multimodal Support**: Ready for vision, text, and more.
|
||||||
- 🔧 **Extensible Custom Actions**: Easily script dynamic agent behaviors in Go (interpreted, no compilation!).
|
- 🔧 **Extensible Custom Actions**: Easily script dynamic agent behaviors in Go (interpreted, no compilation!).
|
||||||
- 🛠 **Fully Customizable Models**: Use your own models or integrate seamlessly with [LocalAI](https://github.com/mudler/LocalAI).
|
- 🛠 **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
|
## 🛠️ Quickstart
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ LocalAGI supports multiple hardware configurations through Docker Compose profil
|
|||||||
- Supports text, multimodal, and image generation models
|
- Supports text, multimodal, and image generation models
|
||||||
- Run with: `docker compose -f docker-compose.nvidia.yaml up`
|
- Run with: `docker compose -f docker-compose.nvidia.yaml up`
|
||||||
- Default models:
|
- Default models:
|
||||||
- Text: `arcee-agent`
|
- Text: `gemma-3-12b-it-qat`
|
||||||
- Multimodal: `minicpm-v-2_6`
|
- Multimodal: `minicpm-v-2_6`
|
||||||
- Image: `sd-1.5-ggml`
|
- Image: `sd-1.5-ggml`
|
||||||
- Environment variables:
|
- Environment variables:
|
||||||
@@ -130,7 +131,7 @@ LocalAGI supports multiple hardware configurations through Docker Compose profil
|
|||||||
- Supports text, multimodal, and image generation models
|
- Supports text, multimodal, and image generation models
|
||||||
- Run with: `docker compose -f docker-compose.intel.yaml up`
|
- Run with: `docker compose -f docker-compose.intel.yaml up`
|
||||||
- Default models:
|
- Default models:
|
||||||
- Text: `arcee-agent`
|
- Text: `gemma-3-12b-it-qat`
|
||||||
- Multimodal: `minicpm-v-2_6`
|
- Multimodal: `minicpm-v-2_6`
|
||||||
- Image: `sd-1.5-ggml`
|
- Image: `sd-1.5-ggml`
|
||||||
- Environment variables:
|
- Environment variables:
|
||||||
@@ -161,7 +162,7 @@ docker compose -f docker-compose.intel.yaml up
|
|||||||
```
|
```
|
||||||
|
|
||||||
If no models are specified, it will use the defaults:
|
If no models are specified, it will use the defaults:
|
||||||
- Text model: `arcee-agent`
|
- Text model: `gemma-3-12b-it-qat`
|
||||||
- Multimodal model: `minicpm-v-2_6`
|
- Multimodal model: `minicpm-v-2_6`
|
||||||
- Image model: `sd-1.5-ggml`
|
- Image model: `sd-1.5-ggml`
|
||||||
|
|
||||||
@@ -194,6 +195,8 @@ LocalAGI is part of the powerful Local family of privacy-focused AI tools:
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
### Connectors Ready-to-Go
|
### Connectors Ready-to-Go
|
||||||
|
|
||||||
|
|||||||
38
cmd/mcpbox/main.go
Normal file
38
cmd/mcpbox/main.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/mudler/LocalAGI/pkg/stdio"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Parse command line flags
|
||||||
|
addr := flag.String("addr", ":8080", "HTTP server address")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Create and start the server
|
||||||
|
server := stdio.NewServer()
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("Starting server on %s", *addr)
|
||||||
|
if err := server.Start(*addr); err != nil {
|
||||||
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for shutdown signal
|
||||||
|
<-sigChan
|
||||||
|
log.Println("Shutting down server...")
|
||||||
|
|
||||||
|
// TODO: Implement graceful shutdown if needed
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package action
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/mudler/LocalAGI/core/types"
|
"github.com/mudler/LocalAGI/core/types"
|
||||||
"github.com/sashabaranov/go-openai/jsonschema"
|
"github.com/sashabaranov/go-openai/jsonschema"
|
||||||
@@ -16,24 +15,6 @@ func NewState() *StateAction {
|
|||||||
|
|
||||||
type StateAction struct{}
|
type StateAction struct{}
|
||||||
|
|
||||||
// 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) {
|
func (a *StateAction) Run(context.Context, types.ActionParams) (types.ActionResult, error) {
|
||||||
return types.ActionResult{Result: "internal state has been updated"}, nil
|
return types.ActionResult{Result: "internal state has been updated"}, nil
|
||||||
}
|
}
|
||||||
@@ -76,23 +57,3 @@ 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type decisionResult struct {
|
|||||||
|
|
||||||
// decision forces the agent to take one of the available actions
|
// decision forces the agent to take one of the available actions
|
||||||
func (a *Agent) decision(
|
func (a *Agent) decision(
|
||||||
ctx context.Context,
|
job *types.Job,
|
||||||
conversation []openai.ChatCompletionMessage,
|
conversation []openai.ChatCompletionMessage,
|
||||||
tools []openai.Tool, toolchoice string, maxRetries int) (*decisionResult, error) {
|
tools []openai.Tool, toolchoice string, maxRetries int) (*decisionResult, error) {
|
||||||
|
|
||||||
@@ -35,31 +35,63 @@ func (a *Agent) decision(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
decision := openai.ChatCompletionRequest{
|
||||||
|
Model: a.options.LLMAPI.Model,
|
||||||
|
Messages: conversation,
|
||||||
|
Tools: tools,
|
||||||
|
}
|
||||||
|
|
||||||
|
if choice != nil {
|
||||||
|
decision.ToolChoice = *choice
|
||||||
|
}
|
||||||
|
|
||||||
|
var obs *types.Observable
|
||||||
|
if job.Obs != nil {
|
||||||
|
obs = a.observer.NewObservable()
|
||||||
|
obs.Name = "decision"
|
||||||
|
obs.ParentID = job.Obs.ID
|
||||||
|
obs.Icon = "brain"
|
||||||
|
obs.Creation = &types.Creation{
|
||||||
|
ChatCompletionRequest: &decision,
|
||||||
|
}
|
||||||
|
a.observer.Update(*obs)
|
||||||
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempts := 0; attempts < maxRetries; attempts++ {
|
for attempts := 0; attempts < maxRetries; attempts++ {
|
||||||
decision := openai.ChatCompletionRequest{
|
resp, err := a.client.CreateChatCompletion(job.GetContext(), decision)
|
||||||
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 {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
xlog.Warn("Attempt to make a decision failed", "attempt", attempts+1, "error", err)
|
xlog.Warn("Attempt to make a decision failed", "attempt", attempts+1, "error", err)
|
||||||
|
|
||||||
|
if obs != nil {
|
||||||
|
obs.Progress = append(obs.Progress, types.Progress{
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
a.observer.Update(*obs)
|
||||||
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonResp, _ := json.Marshal(resp)
|
jsonResp, _ := json.Marshal(resp)
|
||||||
xlog.Debug("Decision response", "response", string(jsonResp))
|
xlog.Debug("Decision response", "response", string(jsonResp))
|
||||||
|
|
||||||
|
if obs != nil {
|
||||||
|
obs.AddProgress(types.Progress{
|
||||||
|
ChatCompletionResponse: &resp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if len(resp.Choices) != 1 {
|
if len(resp.Choices) != 1 {
|
||||||
lastErr = fmt.Errorf("no choices: %d", len(resp.Choices))
|
lastErr = fmt.Errorf("no choices: %d", len(resp.Choices))
|
||||||
xlog.Warn("Attempt to make a decision failed", "attempt", attempts+1, "error", lastErr)
|
xlog.Warn("Attempt to make a decision failed", "attempt", attempts+1, "error", lastErr)
|
||||||
|
|
||||||
|
if obs != nil {
|
||||||
|
obs.Progress[len(obs.Progress)-1].Error = lastErr.Error()
|
||||||
|
a.observer.Update(*obs)
|
||||||
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +100,12 @@ func (a *Agent) decision(
|
|||||||
if err := a.saveConversation(append(conversation, msg), "decision"); err != nil {
|
if err := a.saveConversation(append(conversation, msg), "decision"); err != nil {
|
||||||
xlog.Error("Error saving conversation", "error", err)
|
xlog.Error("Error saving conversation", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if obs != nil {
|
||||||
|
obs.MakeLastProgressCompletion()
|
||||||
|
a.observer.Update(*obs)
|
||||||
|
}
|
||||||
|
|
||||||
return &decisionResult{message: msg.Content}, nil
|
return &decisionResult{message: msg.Content}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +113,12 @@ func (a *Agent) decision(
|
|||||||
if err := params.Read(msg.ToolCalls[0].Function.Arguments); err != nil {
|
if err := params.Read(msg.ToolCalls[0].Function.Arguments); err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
xlog.Warn("Attempt to parse action parameters failed", "attempt", attempts+1, "error", err)
|
xlog.Warn("Attempt to parse action parameters failed", "attempt", attempts+1, "error", err)
|
||||||
|
|
||||||
|
if obs != nil {
|
||||||
|
obs.Progress[len(obs.Progress)-1].Error = lastErr.Error()
|
||||||
|
a.observer.Update(*obs)
|
||||||
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +126,11 @@ func (a *Agent) decision(
|
|||||||
xlog.Error("Error saving conversation", "error", err)
|
xlog.Error("Error saving conversation", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if obs != nil {
|
||||||
|
obs.MakeLastProgressCompletion()
|
||||||
|
a.observer.Update(*obs)
|
||||||
|
}
|
||||||
|
|
||||||
return &decisionResult{actionParams: params, actioName: msg.ToolCalls[0].Function.Name, message: msg.Content}, nil
|
return &decisionResult{actionParams: params, actioName: msg.ToolCalls[0].Function.Name, message: msg.Content}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +222,7 @@ func (m Messages) IsLastMessageFromRole(role string) bool {
|
|||||||
return m[len(m)-1].Role == role
|
return m[len(m)-1].Role == role
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) generateParameters(ctx context.Context, pickTemplate string, act types.Action, c []openai.ChatCompletionMessage, reasoning string, maxAttempts int) (*decisionResult, error) {
|
func (a *Agent) generateParameters(job *types.Job, pickTemplate string, act types.Action, c []openai.ChatCompletionMessage, reasoning string, maxAttempts int) (*decisionResult, error) {
|
||||||
stateHUD, err := renderTemplate(pickTemplate, a.prepareHUD(), a.availableActions(), reasoning)
|
stateHUD, err := renderTemplate(pickTemplate, a.prepareHUD(), a.availableActions(), reasoning)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -201,7 +250,7 @@ func (a *Agent) generateParameters(ctx context.Context, pickTemplate string, act
|
|||||||
var attemptErr error
|
var attemptErr error
|
||||||
|
|
||||||
for attempts := 0; attempts < maxAttempts; attempts++ {
|
for attempts := 0; attempts < maxAttempts; attempts++ {
|
||||||
result, attemptErr = a.decision(ctx,
|
result, attemptErr = a.decision(job,
|
||||||
cc,
|
cc,
|
||||||
a.availableActions().ToTools(),
|
a.availableActions().ToTools(),
|
||||||
act.Definition().Name.String(),
|
act.Definition().Name.String(),
|
||||||
@@ -263,7 +312,7 @@ func (a *Agent) handlePlanning(ctx context.Context, job *types.Job, chosenAction
|
|||||||
subTaskAction := a.availableActions().Find(subtask.Action)
|
subTaskAction := a.availableActions().Find(subtask.Action)
|
||||||
subTaskReasoning := fmt.Sprintf("%s Overall goal is: %s", subtask.Reasoning, planResult.Goal)
|
subTaskReasoning := fmt.Sprintf("%s Overall goal is: %s", subtask.Reasoning, planResult.Goal)
|
||||||
|
|
||||||
params, err := a.generateParameters(ctx, pickTemplate, subTaskAction, conv, subTaskReasoning, maxRetries)
|
params, err := a.generateParameters(job, pickTemplate, subTaskAction, conv, subTaskReasoning, maxRetries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xlog.Error("error generating action's parameters", "error", err)
|
xlog.Error("error generating action's parameters", "error", err)
|
||||||
return conv, fmt.Errorf("error generating action's parameters: %w", err)
|
return conv, fmt.Errorf("error generating action's parameters: %w", err)
|
||||||
@@ -293,7 +342,7 @@ func (a *Agent) handlePlanning(ctx context.Context, job *types.Job, chosenAction
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := a.runAction(ctx, subTaskAction, actionParams)
|
result, err := a.runAction(job, subTaskAction, actionParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xlog.Error("error running action", "error", err)
|
xlog.Error("error running action", "error", err)
|
||||||
return conv, fmt.Errorf("error running action: %w", err)
|
return conv, fmt.Errorf("error running action: %w", err)
|
||||||
@@ -378,7 +427,7 @@ func (a *Agent) prepareHUD() (promptHUD *PromptHUD) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pickAction picks an action based on the conversation
|
// 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) {
|
func (a *Agent) pickAction(job *types.Job, templ string, messages []openai.ChatCompletionMessage, maxRetries int) (types.Action, types.ActionParams, string, error) {
|
||||||
c := messages
|
c := messages
|
||||||
|
|
||||||
xlog.Debug("[pickAction] picking action starts", "messages", messages)
|
xlog.Debug("[pickAction] picking action starts", "messages", messages)
|
||||||
@@ -389,7 +438,7 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.
|
|||||||
xlog.Debug("not forcing reasoning")
|
xlog.Debug("not forcing reasoning")
|
||||||
// We also could avoid to use functions here and get just a reply from the LLM
|
// 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
|
// and then use the reply to get the action
|
||||||
thought, err := a.decision(ctx,
|
thought, err := a.decision(job,
|
||||||
messages,
|
messages,
|
||||||
a.availableActions().ToTools(),
|
a.availableActions().ToTools(),
|
||||||
"",
|
"",
|
||||||
@@ -431,7 +480,7 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.
|
|||||||
}, c...)
|
}, c...)
|
||||||
}
|
}
|
||||||
|
|
||||||
thought, err := a.decision(ctx,
|
thought, err := a.decision(job,
|
||||||
c,
|
c,
|
||||||
types.Actions{action.NewReasoning()}.ToTools(),
|
types.Actions{action.NewReasoning()}.ToTools(),
|
||||||
action.NewReasoning().Definition().Name.String(), maxRetries)
|
action.NewReasoning().Definition().Name.String(), maxRetries)
|
||||||
@@ -467,7 +516,7 @@ func (a *Agent) pickAction(ctx context.Context, templ string, messages []openai.
|
|||||||
// to avoid hallucinations
|
// to avoid hallucinations
|
||||||
|
|
||||||
// Extract an action
|
// Extract an action
|
||||||
params, err := a.decision(ctx,
|
params, err := a.decision(job,
|
||||||
append(c, openai.ChatCompletionMessage{
|
append(c, openai.ChatCompletionMessage{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: "Pick the relevant action given the following reasoning: " + originalReasoning,
|
Content: "Pick the relevant action given the following reasoning: " + originalReasoning,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -30,7 +31,7 @@ type Agent struct {
|
|||||||
jobQueue chan *types.Job
|
jobQueue chan *types.Job
|
||||||
context *types.ActionContext
|
context *types.ActionContext
|
||||||
|
|
||||||
currentState *action.AgentInternalState
|
currentState *types.AgentInternalState
|
||||||
|
|
||||||
selfEvaluationInProgress bool
|
selfEvaluationInProgress bool
|
||||||
pause bool
|
pause bool
|
||||||
@@ -41,6 +42,8 @@ type Agent struct {
|
|||||||
|
|
||||||
subscriberMutex sync.Mutex
|
subscriberMutex sync.Mutex
|
||||||
newMessagesSubscribers []func(openai.ChatCompletionMessage)
|
newMessagesSubscribers []func(openai.ChatCompletionMessage)
|
||||||
|
|
||||||
|
observer Observer
|
||||||
}
|
}
|
||||||
|
|
||||||
type RAGDB interface {
|
type RAGDB interface {
|
||||||
@@ -69,12 +72,17 @@ func New(opts ...Option) (*Agent, error) {
|
|||||||
options: options,
|
options: options,
|
||||||
client: client,
|
client: client,
|
||||||
Character: options.character,
|
Character: options.character,
|
||||||
currentState: &action.AgentInternalState{},
|
currentState: &types.AgentInternalState{},
|
||||||
context: types.NewActionContext(ctx, cancel),
|
context: types.NewActionContext(ctx, cancel),
|
||||||
newConversations: make(chan openai.ChatCompletionMessage),
|
newConversations: make(chan openai.ChatCompletionMessage),
|
||||||
newMessagesSubscribers: options.newConversationsSubscribers,
|
newMessagesSubscribers: options.newConversationsSubscribers,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize observer if provided
|
||||||
|
if options.observer != nil {
|
||||||
|
a.observer = options.observer
|
||||||
|
}
|
||||||
|
|
||||||
if a.options.statefile != "" {
|
if a.options.statefile != "" {
|
||||||
if _, err := os.Stat(a.options.statefile); err == nil {
|
if _, err := os.Stat(a.options.statefile); err == nil {
|
||||||
if err = a.LoadState(a.options.statefile); err != nil {
|
if err = a.LoadState(a.options.statefile); err != nil {
|
||||||
@@ -146,6 +154,14 @@ func (a *Agent) Ask(opts ...types.JobOption) *types.JobResult {
|
|||||||
xlog.Debug("Agent has finished being asked", "agent", a.Character.Name)
|
xlog.Debug("Agent has finished being asked", "agent", a.Character.Name)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
if a.observer != nil {
|
||||||
|
obs := a.observer.NewObservable()
|
||||||
|
obs.Name = "job"
|
||||||
|
obs.Icon = "plug"
|
||||||
|
a.observer.Update(*obs)
|
||||||
|
opts = append(opts, types.WithObservable(obs))
|
||||||
|
}
|
||||||
|
|
||||||
return a.Execute(types.NewJob(
|
return a.Execute(types.NewJob(
|
||||||
append(
|
append(
|
||||||
opts,
|
opts,
|
||||||
@@ -163,6 +179,20 @@ func (a *Agent) Execute(j *types.Job) *types.JobResult {
|
|||||||
xlog.Debug("Agent has finished", "agent", a.Character.Name)
|
xlog.Debug("Agent has finished", "agent", a.Character.Name)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
if j.Obs != nil {
|
||||||
|
j.Result.AddFinalizer(func(ccm []openai.ChatCompletionMessage) {
|
||||||
|
j.Obs.Completion = &types.Completion{
|
||||||
|
Conversation: ccm,
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.Result.Error != nil {
|
||||||
|
j.Obs.Completion.Error = j.Result.Error.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
a.observer.Update(*j.Obs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
a.Enqueue(j)
|
a.Enqueue(j)
|
||||||
return j.Result.WaitResult()
|
return j.Result.WaitResult()
|
||||||
}
|
}
|
||||||
@@ -211,6 +241,7 @@ func (a *Agent) Stop() {
|
|||||||
a.Lock()
|
a.Lock()
|
||||||
defer a.Unlock()
|
defer a.Unlock()
|
||||||
xlog.Debug("Stopping agent", "agent", a.Character.Name)
|
xlog.Debug("Stopping agent", "agent", a.Character.Name)
|
||||||
|
a.closeMCPSTDIOServers()
|
||||||
a.context.Cancel()
|
a.context.Cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,41 +268,90 @@ func (a *Agent) Memory() RAGDB {
|
|||||||
return a.options.ragdb
|
return a.options.ragdb
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) runAction(ctx context.Context, chosenAction types.Action, params types.ActionParams) (result types.ActionResult, err error) {
|
func (a *Agent) runAction(job *types.Job, chosenAction types.Action, params types.ActionParams) (result types.ActionResult, err error) {
|
||||||
|
var obs *types.Observable
|
||||||
|
if job.Obs != nil {
|
||||||
|
obs = a.observer.NewObservable()
|
||||||
|
obs.Name = "action"
|
||||||
|
obs.Icon = "bolt"
|
||||||
|
obs.ParentID = job.Obs.ID
|
||||||
|
obs.Creation = &types.Creation{
|
||||||
|
FunctionDefinition: chosenAction.Definition().ToFunctionDefinition(),
|
||||||
|
FunctionParams: params,
|
||||||
|
}
|
||||||
|
a.observer.Update(*obs)
|
||||||
|
}
|
||||||
|
|
||||||
|
xlog.Info("[runAction] Running action", "action", chosenAction.Definition().Name, "agent", a.Character.Name, "params", params.String())
|
||||||
|
|
||||||
for _, act := range a.availableActions() {
|
for _, act := range a.availableActions() {
|
||||||
if act.Definition().Name == chosenAction.Definition().Name {
|
if act.Definition().Name == chosenAction.Definition().Name {
|
||||||
res, err := act.Run(ctx, params)
|
res, err := act.Run(job.GetContext(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if obs != nil {
|
||||||
|
obs.Completion = &types.Completion{
|
||||||
|
Error: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return types.ActionResult{}, fmt.Errorf("error running action: %w", err)
|
return types.ActionResult{}, fmt.Errorf("error running action: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if obs != nil {
|
||||||
|
obs.Progress = append(obs.Progress, types.Progress{
|
||||||
|
ActionResult: res.Result,
|
||||||
|
})
|
||||||
|
a.observer.Update(*obs)
|
||||||
|
}
|
||||||
|
|
||||||
result = res
|
result = res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
xlog.Info("[runAction] Running action", "action", chosenAction.Definition().Name, "agent", a.Character.Name, "params", params.String())
|
|
||||||
|
|
||||||
if chosenAction.Definition().Name.Is(action.StateActionName) {
|
if chosenAction.Definition().Name.Is(action.StateActionName) {
|
||||||
// We need to store the result in the state
|
// We need to store the result in the state
|
||||||
state := action.AgentInternalState{}
|
state := types.AgentInternalState{}
|
||||||
|
|
||||||
err = params.Unmarshal(&state)
|
err = params.Unmarshal(&state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.ActionResult{}, fmt.Errorf("error unmarshalling state of the agent: %w", err)
|
werr := fmt.Errorf("error unmarshalling state of the agent: %w", err)
|
||||||
|
if obs != nil {
|
||||||
|
obs.Completion = &types.Completion{
|
||||||
|
Error: werr.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return types.ActionResult{}, werr
|
||||||
}
|
}
|
||||||
// update the current state with the one we just got from the action
|
// update the current state with the one we just got from the action
|
||||||
a.currentState = &state
|
a.currentState = &state
|
||||||
|
if obs != nil {
|
||||||
|
obs.Progress = append(obs.Progress, types.Progress{
|
||||||
|
AgentState: &state,
|
||||||
|
})
|
||||||
|
a.observer.Update(*obs)
|
||||||
|
}
|
||||||
|
|
||||||
// update the state file
|
// update the state file
|
||||||
if a.options.statefile != "" {
|
if a.options.statefile != "" {
|
||||||
if err := a.SaveState(a.options.statefile); err != nil {
|
if err := a.SaveState(a.options.statefile); err != nil {
|
||||||
|
if obs != nil {
|
||||||
|
obs.Completion = &types.Completion{
|
||||||
|
Error: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return types.ActionResult{}, err
|
return types.ActionResult{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
xlog.Debug("[runAction] Action result", "action", chosenAction.Definition().Name, "params", params.String(), "result", result.Result)
|
xlog.Debug("[runAction] Action result", "action", chosenAction.Definition().Name, "params", params.String(), "result", result.Result)
|
||||||
|
|
||||||
|
if obs != nil {
|
||||||
|
obs.MakeLastProgressCompletion()
|
||||||
|
a.observer.Update(*obs)
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +548,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
|||||||
chosenAction = *action
|
chosenAction = *action
|
||||||
reasoning = reason
|
reasoning = reason
|
||||||
if params == nil {
|
if params == nil {
|
||||||
p, err := a.generateParameters(job.GetContext(), pickTemplate, chosenAction, conv, reasoning, maxRetries)
|
p, err := a.generateParameters(job, pickTemplate, chosenAction, conv, reasoning, maxRetries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xlog.Error("Error generating parameters, trying again", "error", err)
|
xlog.Error("Error generating parameters, trying again", "error", err)
|
||||||
// try again
|
// try again
|
||||||
@@ -483,7 +563,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
|||||||
job.ResetNextAction()
|
job.ResetNextAction()
|
||||||
} else {
|
} else {
|
||||||
var err error
|
var err error
|
||||||
chosenAction, actionParams, reasoning, err = a.pickAction(job.GetContext(), pickTemplate, conv, maxRetries)
|
chosenAction, actionParams, reasoning, err = a.pickAction(job, pickTemplate, conv, maxRetries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xlog.Error("Error picking action", "error", err)
|
xlog.Error("Error picking action", "error", err)
|
||||||
job.Result.Finish(err)
|
job.Result.Finish(err)
|
||||||
@@ -557,7 +637,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
|||||||
"reasoning", reasoning,
|
"reasoning", reasoning,
|
||||||
)
|
)
|
||||||
|
|
||||||
params, err := a.generateParameters(job.GetContext(), pickTemplate, chosenAction, conv, reasoning, maxRetries)
|
params, err := a.generateParameters(job, pickTemplate, chosenAction, conv, reasoning, maxRetries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xlog.Error("Error generating parameters, trying again", "error", err)
|
xlog.Error("Error generating parameters, trying again", "error", err)
|
||||||
// try again
|
// try again
|
||||||
@@ -652,7 +732,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !chosenAction.Definition().Name.Is(action.PlanActionName) {
|
if !chosenAction.Definition().Name.Is(action.PlanActionName) {
|
||||||
result, err := a.runAction(job.GetContext(), chosenAction, actionParams)
|
result, err := a.runAction(job, chosenAction, actionParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
//job.Result.Finish(fmt.Errorf("error running action: %w", err))
|
//job.Result.Finish(fmt.Errorf("error running action: %w", err))
|
||||||
//return
|
//return
|
||||||
@@ -677,7 +757,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// given the result, we can now re-evaluate the conversation
|
// given the result, we can now re-evaluate the conversation
|
||||||
followingAction, followingParams, reasoning, err := a.pickAction(job.GetContext(), reEvaluationTemplate, conv, maxRetries)
|
followingAction, followingParams, reasoning, err := a.pickAction(job, reEvaluationTemplate, conv, maxRetries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
job.Result.Conversation = conv
|
job.Result.Conversation = conv
|
||||||
job.Result.Finish(fmt.Errorf("error picking action: %w", err))
|
job.Result.Finish(fmt.Errorf("error picking action: %w", err))
|
||||||
@@ -911,7 +991,6 @@ func (a *Agent) periodicallyRun(timer *time.Timer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) Run() error {
|
func (a *Agent) Run() error {
|
||||||
|
|
||||||
a.startNewConversationsConsumer()
|
a.startNewConversationsConsumer()
|
||||||
xlog.Debug("Agent is now running", "agent", a.Character.Name)
|
xlog.Debug("Agent is now running", "agent", a.Character.Name)
|
||||||
// The agent run does two things:
|
// The agent run does two things:
|
||||||
@@ -926,32 +1005,68 @@ func (a *Agent) Run() error {
|
|||||||
|
|
||||||
// Expose a REST API to interact with the agent to ask it things
|
// Expose a REST API to interact with the agent to ask it things
|
||||||
|
|
||||||
//todoTimer := time.NewTicker(a.options.periodicRuns)
|
|
||||||
timer := time.NewTimer(a.options.periodicRuns)
|
timer := time.NewTimer(a.options.periodicRuns)
|
||||||
|
|
||||||
|
// we fire the periodicalRunner only once.
|
||||||
|
go a.periodicalRunRunner(timer)
|
||||||
|
var errs []error
|
||||||
|
var muErr sync.Mutex
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
parallelJobs := a.options.parallelJobs
|
||||||
|
if a.options.parallelJobs == 0 {
|
||||||
|
parallelJobs = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < parallelJobs; i++ {
|
||||||
|
xlog.Debug("Starting agent worker", "worker", i)
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
e := a.run(timer)
|
||||||
|
muErr.Lock()
|
||||||
|
errs = append(errs, e)
|
||||||
|
muErr.Unlock()
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) run(timer *time.Timer) error {
|
||||||
for {
|
for {
|
||||||
xlog.Debug("Agent is now waiting for a new job", "agent", a.Character.Name)
|
xlog.Debug("Agent is now waiting for a new job", "agent", a.Character.Name)
|
||||||
select {
|
select {
|
||||||
case job := <-a.jobQueue:
|
case job := <-a.jobQueue:
|
||||||
a.loop(timer, job)
|
if !timer.Stop() {
|
||||||
|
<-timer.C
|
||||||
|
}
|
||||||
|
xlog.Debug("Agent is consuming a job", "agent", a.Character.Name, "job", job)
|
||||||
|
a.consumeJob(job, UserRole)
|
||||||
|
timer.Reset(a.options.periodicRuns)
|
||||||
case <-a.context.Done():
|
case <-a.context.Done():
|
||||||
// Agent has been canceled, return error
|
// Agent has been canceled, return error
|
||||||
xlog.Warn("Agent has been canceled", "agent", a.Character.Name)
|
xlog.Warn("Agent has been canceled", "agent", a.Character.Name)
|
||||||
return ErrContextCanceled
|
return ErrContextCanceled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) periodicalRunRunner(timer *time.Timer) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.context.Done():
|
||||||
|
// Agent has been canceled, return error
|
||||||
|
xlog.Warn("periodicalRunner has been canceled", "agent", a.Character.Name)
|
||||||
|
return
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
a.periodicallyRun(timer)
|
a.periodicallyRun(timer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) loop(timer *time.Timer, job *types.Job) {
|
func (a *Agent) Observer() Observer {
|
||||||
// Remember always to reset the timer - if we don't the agent will stop..
|
return a.observer
|
||||||
defer timer.Reset(a.options.periodicRuns)
|
|
||||||
// Consume the job and generate a response
|
|
||||||
// TODO: Give a short-term memory to the agent
|
|
||||||
// stop and drain the timer
|
|
||||||
if !timer.Stop() {
|
|
||||||
<-timer.C
|
|
||||||
}
|
|
||||||
xlog.Debug("Agent is consuming a job", "agent", a.Character.Name, "job", job)
|
|
||||||
a.consumeJob(job, UserRole)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,7 +226,10 @@ var _ = Describe("Agent test", func() {
|
|||||||
WithLLMAPIKey(apiKeyURL),
|
WithLLMAPIKey(apiKeyURL),
|
||||||
WithTimeout("10m"),
|
WithTimeout("10m"),
|
||||||
WithActions(
|
WithActions(
|
||||||
actions.NewSearch(map[string]string{}),
|
&TestAction{response: map[string]string{
|
||||||
|
"boston": testActionResult,
|
||||||
|
"milan": testActionResult2,
|
||||||
|
}},
|
||||||
),
|
),
|
||||||
EnablePlanning,
|
EnablePlanning,
|
||||||
EnableForceReasoning,
|
EnableForceReasoning,
|
||||||
@@ -238,18 +241,21 @@ var _ = Describe("Agent test", func() {
|
|||||||
defer agent.Stop()
|
defer agent.Stop()
|
||||||
|
|
||||||
result := agent.Ask(
|
result := agent.Ask(
|
||||||
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."),
|
types.WithText("Use the plan tool to do two actions in sequence: search for the weather in boston and search for the weather in milan"),
|
||||||
)
|
)
|
||||||
Expect(len(result.State)).To(BeNumerically(">", 1))
|
Expect(len(result.State)).To(BeNumerically(">", 1))
|
||||||
|
|
||||||
actionsExecuted := []string{}
|
actionsExecuted := []string{}
|
||||||
|
actionResults := []string{}
|
||||||
for _, r := range result.State {
|
for _, r := range result.State {
|
||||||
xlog.Info(r.Result)
|
xlog.Info(r.Result)
|
||||||
actionsExecuted = append(actionsExecuted, r.Action.Definition().Name.String())
|
actionsExecuted = append(actionsExecuted, r.Action.Definition().Name.String())
|
||||||
|
actionResults = append(actionResults, r.ActionResult.Result)
|
||||||
}
|
}
|
||||||
Expect(actionsExecuted).To(ContainElement("search_internet"), fmt.Sprint(result))
|
Expect(actionsExecuted).To(ContainElement("get_weather"), fmt.Sprint(result))
|
||||||
Expect(actionsExecuted).To(ContainElement("plan"), fmt.Sprint(result))
|
Expect(actionsExecuted).To(ContainElement("plan"), fmt.Sprint(result))
|
||||||
|
Expect(actionResults).To(ContainElement(testActionResult), fmt.Sprint(result))
|
||||||
|
Expect(actionResults).To(ContainElement(testActionResult2), fmt.Sprint(result))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("Can initiate conversations", func() {
|
It("Can initiate conversations", func() {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
|
|
||||||
mcp "github.com/metoro-io/mcp-golang"
|
mcp "github.com/metoro-io/mcp-golang"
|
||||||
"github.com/metoro-io/mcp-golang/transport/http"
|
"github.com/metoro-io/mcp-golang/transport/http"
|
||||||
|
stdioTransport "github.com/metoro-io/mcp-golang/transport/stdio"
|
||||||
"github.com/mudler/LocalAGI/core/types"
|
"github.com/mudler/LocalAGI/core/types"
|
||||||
|
"github.com/mudler/LocalAGI/pkg/stdio"
|
||||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||||
|
|
||||||
"github.com/sashabaranov/go-openai/jsonschema"
|
"github.com/sashabaranov/go-openai/jsonschema"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +21,12 @@ type MCPServer struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MCPSTDIOServer struct {
|
||||||
|
Args []string `json:"args"`
|
||||||
|
Env []string `json:"env"`
|
||||||
|
Cmd string `json:"cmd"`
|
||||||
|
}
|
||||||
|
|
||||||
type mcpAction struct {
|
type mcpAction struct {
|
||||||
mcpClient *mcp.Client
|
mcpClient *mcp.Client
|
||||||
inputSchema ToolInputSchema
|
inputSchema ToolInputSchema
|
||||||
@@ -79,6 +87,68 @@ type ToolInputSchema struct {
|
|||||||
Required []string `json:"required,omitempty"`
|
Required []string `json:"required,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Agent) addTools(client *mcp.Client) (types.Actions, error) {
|
||||||
|
|
||||||
|
var generatedActions types.Actions
|
||||||
|
xlog.Debug("Initializing client")
|
||||||
|
// Initialize the client
|
||||||
|
response, e := client.Initialize(a.context)
|
||||||
|
if e != nil {
|
||||||
|
xlog.Error("Failed to initialize client", "error", e.Error())
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
|
||||||
|
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 nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range tools.Tools {
|
||||||
|
desc := ""
|
||||||
|
if t.Description != nil {
|
||||||
|
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, &mcpAction{
|
||||||
|
mcpClient: client,
|
||||||
|
toolName: t.Name,
|
||||||
|
inputSchema: inputSchema,
|
||||||
|
toolDescription: desc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if tools.NextCursor == nil {
|
||||||
|
break // No more pages
|
||||||
|
}
|
||||||
|
cursor = tools.NextCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
return generatedActions, nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Agent) initMCPActions() error {
|
func (a *Agent) initMCPActions() error {
|
||||||
|
|
||||||
a.mcpActions = nil
|
a.mcpActions = nil
|
||||||
@@ -86,6 +156,7 @@ func (a *Agent) initMCPActions() error {
|
|||||||
|
|
||||||
generatedActions := types.Actions{}
|
generatedActions := types.Actions{}
|
||||||
|
|
||||||
|
// MCP HTTP Servers
|
||||||
for _, mcpServer := range a.options.mcpServers {
|
for _, mcpServer := range a.options.mcpServers {
|
||||||
transport := http.NewHTTPClientTransport("/mcp")
|
transport := http.NewHTTPClientTransport("/mcp")
|
||||||
transport.WithBaseURL(mcpServer.URL)
|
transport.WithBaseURL(mcpServer.URL)
|
||||||
@@ -95,70 +166,60 @@ func (a *Agent) initMCPActions() error {
|
|||||||
|
|
||||||
// Create a new client
|
// Create a new client
|
||||||
client := mcp.NewClient(transport)
|
client := mcp.NewClient(transport)
|
||||||
|
xlog.Debug("Adding tools for MCP server", "server", mcpServer)
|
||||||
|
actions, err := a.addTools(client)
|
||||||
|
if err != nil {
|
||||||
|
xlog.Error("Failed to add tools for MCP server", "server", mcpServer, "error", err.Error())
|
||||||
|
}
|
||||||
|
generatedActions = append(generatedActions, actions...)
|
||||||
|
}
|
||||||
|
|
||||||
xlog.Debug("Initializing client", "server", mcpServer.URL)
|
// MCP STDIO Servers
|
||||||
// Initialize the client
|
|
||||||
response, e := client.Initialize(a.context)
|
a.closeMCPSTDIOServers() // Make sure we stop all previous servers if any is active
|
||||||
if e != nil {
|
|
||||||
xlog.Error("Failed to initialize client", "error", e.Error(), "server", mcpServer)
|
if a.options.mcpPrepareScript != "" {
|
||||||
if err == nil {
|
xlog.Debug("Preparing MCP box", "script", a.options.mcpPrepareScript)
|
||||||
err = e
|
client := stdio.NewClient(a.options.mcpBoxURL)
|
||||||
} else {
|
client.RunProcess(a.context, "/bin/bash", []string{"-c", a.options.mcpPrepareScript}, []string{})
|
||||||
err = errors.Join(err, e)
|
}
|
||||||
}
|
|
||||||
|
for _, mcpStdioServer := range a.options.mcpStdioServers {
|
||||||
|
client := stdio.NewClient(a.options.mcpBoxURL)
|
||||||
|
p, err := client.CreateProcess(a.context,
|
||||||
|
mcpStdioServer.Cmd,
|
||||||
|
mcpStdioServer.Args,
|
||||||
|
mcpStdioServer.Env,
|
||||||
|
a.Character.Name)
|
||||||
|
if err != nil {
|
||||||
|
xlog.Error("Failed to create process", "error", err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
read, writer, err := client.GetProcessIO(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
xlog.Error("Failed to get process IO", "error", err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
xlog.Debug("Client initialized: %v", response.Instructions)
|
transport := stdioTransport.NewStdioServerTransportWithIO(read, writer)
|
||||||
|
|
||||||
var cursor *string
|
// Create a new client
|
||||||
for {
|
mcpClient := mcp.NewClient(transport)
|
||||||
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 {
|
xlog.Debug("Adding tools for MCP server (stdio)", "server", mcpStdioServer)
|
||||||
desc := ""
|
actions, err := a.addTools(mcpClient)
|
||||||
if t.Description != nil {
|
if err != nil {
|
||||||
desc = *t.Description
|
xlog.Error("Failed to add tools for MCP server", "server", mcpStdioServer, "error", err.Error())
|
||||||
}
|
|
||||||
|
|
||||||
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...)
|
||||||
}
|
}
|
||||||
|
|
||||||
a.mcpActions = generatedActions
|
a.mcpActions = generatedActions
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Agent) closeMCPSTDIOServers() {
|
||||||
|
client := stdio.NewClient(a.options.mcpBoxURL)
|
||||||
|
client.StopGroup(a.Character.Name)
|
||||||
|
}
|
||||||
|
|||||||
88
core/agent/observer.go
Normal file
88
core/agent/observer.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/mudler/LocalAGI/core/sse"
|
||||||
|
"github.com/mudler/LocalAGI/core/types"
|
||||||
|
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Observer interface {
|
||||||
|
NewObservable() *types.Observable
|
||||||
|
Update(types.Observable)
|
||||||
|
History() []types.Observable
|
||||||
|
}
|
||||||
|
|
||||||
|
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, 100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -50,9 +50,14 @@ type options struct {
|
|||||||
|
|
||||||
conversationsPath string
|
conversationsPath string
|
||||||
|
|
||||||
mcpServers []MCPServer
|
mcpServers []MCPServer
|
||||||
|
mcpStdioServers []MCPSTDIOServer
|
||||||
|
mcpBoxURL string
|
||||||
|
mcpPrepareScript string
|
||||||
newConversationsSubscribers []func(openai.ChatCompletionMessage)
|
newConversationsSubscribers []func(openai.ChatCompletionMessage)
|
||||||
|
|
||||||
|
observer Observer
|
||||||
|
parallelJobs int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *options) SeparatedMultimodalModel() bool {
|
func (o *options) SeparatedMultimodalModel() bool {
|
||||||
@@ -61,6 +66,7 @@ func (o *options) SeparatedMultimodalModel() bool {
|
|||||||
|
|
||||||
func defaultOptions() *options {
|
func defaultOptions() *options {
|
||||||
return &options{
|
return &options{
|
||||||
|
parallelJobs: 1,
|
||||||
periodicRuns: 15 * time.Minute,
|
periodicRuns: 15 * time.Minute,
|
||||||
LLMAPI: llmOptions{
|
LLMAPI: llmOptions{
|
||||||
APIURL: "http://localhost:8080",
|
APIURL: "http://localhost:8080",
|
||||||
@@ -136,6 +142,13 @@ func EnableKnowledgeBaseWithResults(results int) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithParallelJobs(jobs int) Option {
|
||||||
|
return func(o *options) error {
|
||||||
|
o.parallelJobs = jobs
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func WithNewConversationSubscriber(sub func(openai.ChatCompletionMessage)) Option {
|
func WithNewConversationSubscriber(sub func(openai.ChatCompletionMessage)) Option {
|
||||||
return func(o *options) error {
|
return func(o *options) error {
|
||||||
o.newConversationsSubscribers = append(o.newConversationsSubscribers, sub)
|
o.newConversationsSubscribers = append(o.newConversationsSubscribers, sub)
|
||||||
@@ -196,6 +209,27 @@ func WithMCPServers(servers ...MCPServer) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithMCPSTDIOServers(servers ...MCPSTDIOServer) Option {
|
||||||
|
return func(o *options) error {
|
||||||
|
o.mcpStdioServers = servers
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithMCPBoxURL(url string) Option {
|
||||||
|
return func(o *options) error {
|
||||||
|
o.mcpBoxURL = url
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithMCPPrepareScript(script string) Option {
|
||||||
|
return func(o *options) error {
|
||||||
|
o.mcpPrepareScript = script
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func WithLLMAPIURL(url string) Option {
|
func WithLLMAPIURL(url string) Option {
|
||||||
return func(o *options) error {
|
return func(o *options) error {
|
||||||
o.LLMAPI.APIURL = url
|
o.LLMAPI.APIURL = url
|
||||||
@@ -336,3 +370,10 @@ func WithActions(actions ...types.Action) Option {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithObserver(observer Observer) Option {
|
||||||
|
return func(o *options) error {
|
||||||
|
o.observer = observer
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/mudler/LocalAGI/core/action"
|
"github.com/mudler/LocalAGI/core/types"
|
||||||
"github.com/sashabaranov/go-openai/jsonschema"
|
"github.com/sashabaranov/go-openai/jsonschema"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
// in the prompts
|
// in the prompts
|
||||||
type PromptHUD struct {
|
type PromptHUD struct {
|
||||||
Character Character `json:"character"`
|
Character Character `json:"character"`
|
||||||
CurrentState action.AgentInternalState `json:"current_state"`
|
CurrentState types.AgentInternalState `json:"current_state"`
|
||||||
PermanentGoal string `json:"permanent_goal"`
|
PermanentGoal string `json:"permanent_goal"`
|
||||||
ShowCharacter bool `json:"show_character"`
|
ShowCharacter bool `json:"show_character"`
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ func Load(path string) (*Character, error) {
|
|||||||
return &c, nil
|
return &c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) State() action.AgentInternalState {
|
func (a *Agent) State() types.AgentInternalState {
|
||||||
return *a.currentState
|
return *a.currentState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ var _ = Describe("Agent test", func() {
|
|||||||
agent, err = New(
|
agent, err = New(
|
||||||
WithLLMAPIURL(apiURL),
|
WithLLMAPIURL(apiURL),
|
||||||
WithModel(testModel),
|
WithModel(testModel),
|
||||||
|
WithTimeout("10m"),
|
||||||
WithRandomIdentity(),
|
WithRandomIdentity(),
|
||||||
)
|
)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package state
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/mudler/LocalAGI/core/agent"
|
"github.com/mudler/LocalAGI/core/agent"
|
||||||
"github.com/mudler/LocalAGI/core/types"
|
"github.com/mudler/LocalAGI/core/types"
|
||||||
@@ -30,10 +32,13 @@ func (d DynamicPromptsConfig) ToMap() map[string]string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
Connector []ConnectorConfig `json:"connectors" form:"connectors" `
|
Connector []ConnectorConfig `json:"connectors" form:"connectors" `
|
||||||
Actions []ActionsConfig `json:"actions" form:"actions"`
|
Actions []ActionsConfig `json:"actions" form:"actions"`
|
||||||
DynamicPrompts []DynamicPromptsConfig `json:"dynamic_prompts" form:"dynamic_prompts"`
|
DynamicPrompts []DynamicPromptsConfig `json:"dynamic_prompts" form:"dynamic_prompts"`
|
||||||
MCPServers []agent.MCPServer `json:"mcp_servers" form:"mcp_servers"`
|
MCPServers []agent.MCPServer `json:"mcp_servers" form:"mcp_servers"`
|
||||||
|
MCPSTDIOServers []agent.MCPSTDIOServer `json:"mcp_stdio_servers" form:"mcp_stdio_servers"`
|
||||||
|
MCPPrepareScript string `json:"mcp_prepare_script" form:"mcp_prepare_script"`
|
||||||
|
MCPBoxURL string `json:"mcp_box_url" form:"mcp_box_url"`
|
||||||
|
|
||||||
Description string `json:"description" form:"description"`
|
Description string `json:"description" form:"description"`
|
||||||
|
|
||||||
@@ -61,6 +66,7 @@ type AgentConfig struct {
|
|||||||
SystemPrompt string `json:"system_prompt" form:"system_prompt"`
|
SystemPrompt string `json:"system_prompt" form:"system_prompt"`
|
||||||
LongTermMemory bool `json:"long_term_memory" form:"long_term_memory"`
|
LongTermMemory bool `json:"long_term_memory" form:"long_term_memory"`
|
||||||
SummaryLongTermMemory bool `json:"summary_long_term_memory" form:"summary_long_term_memory"`
|
SummaryLongTermMemory bool `json:"summary_long_term_memory" form:"summary_long_term_memory"`
|
||||||
|
ParallelJobs int `json:"parallel_jobs" form:"parallel_jobs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfigMeta struct {
|
type AgentConfigMeta struct {
|
||||||
@@ -260,6 +266,32 @@ func NewAgentConfigMeta(
|
|||||||
Step: 1,
|
Step: 1,
|
||||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "parallel_jobs",
|
||||||
|
Label: "Parallel Jobs",
|
||||||
|
Type: "number",
|
||||||
|
DefaultValue: 5,
|
||||||
|
Min: 1,
|
||||||
|
Step: 1,
|
||||||
|
HelpText: "Number of concurrent tasks that can run in parallel",
|
||||||
|
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "mcp_stdio_servers",
|
||||||
|
Label: "MCP STDIO Servers",
|
||||||
|
Type: "textarea",
|
||||||
|
DefaultValue: "",
|
||||||
|
HelpText: "JSON configuration for MCP STDIO servers",
|
||||||
|
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "mcp_prepare_script",
|
||||||
|
Label: "MCP Prepare Script",
|
||||||
|
Type: "textarea",
|
||||||
|
DefaultValue: "",
|
||||||
|
HelpText: "Script to prepare the MCP box",
|
||||||
|
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
MCPServers: []config.Field{
|
MCPServers: []config.Field{
|
||||||
{
|
{
|
||||||
@@ -286,3 +318,148 @@ type Connector interface {
|
|||||||
AgentReasoningCallback() func(state types.ActionCurrentState) bool
|
AgentReasoningCallback() func(state types.ActionCurrentState) bool
|
||||||
Start(a *agent.Agent)
|
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"`
|
||||||
|
}{
|
||||||
|
Alias: (*Alias)(a),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &aux); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 _, 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{
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpConfig.MCPServers[fmt.Sprintf("server%d", i)] = 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type AgentPool struct {
|
|||||||
managers map[string]sse.Manager
|
managers map[string]sse.Manager
|
||||||
agentStatus map[string]*Status
|
agentStatus map[string]*Status
|
||||||
apiURL, defaultModel, defaultMultimodalModel string
|
apiURL, defaultModel, defaultMultimodalModel string
|
||||||
|
mcpBoxURL string
|
||||||
imageModel, localRAGAPI, localRAGKey, apiKey string
|
imageModel, localRAGAPI, localRAGKey, apiKey string
|
||||||
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action
|
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action
|
||||||
connectors func(*AgentConfig) []Connector
|
connectors func(*AgentConfig) []Connector
|
||||||
@@ -72,7 +73,7 @@ func loadPoolFromFile(path string) (*AgentPoolData, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewAgentPool(
|
func NewAgentPool(
|
||||||
defaultModel, defaultMultimodalModel, imageModel, apiURL, apiKey, directory string,
|
defaultModel, defaultMultimodalModel, imageModel, apiURL, apiKey, directory, mcpBoxURL string,
|
||||||
LocalRAGAPI string,
|
LocalRAGAPI string,
|
||||||
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action,
|
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action,
|
||||||
connectors func(*AgentConfig) []Connector,
|
connectors func(*AgentConfig) []Connector,
|
||||||
@@ -98,6 +99,7 @@ func NewAgentPool(
|
|||||||
apiURL: apiURL,
|
apiURL: apiURL,
|
||||||
defaultModel: defaultModel,
|
defaultModel: defaultModel,
|
||||||
defaultMultimodalModel: defaultMultimodalModel,
|
defaultMultimodalModel: defaultMultimodalModel,
|
||||||
|
mcpBoxURL: mcpBoxURL,
|
||||||
imageModel: imageModel,
|
imageModel: imageModel,
|
||||||
localRAGAPI: LocalRAGAPI,
|
localRAGAPI: LocalRAGAPI,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
@@ -123,6 +125,7 @@ func NewAgentPool(
|
|||||||
pooldir: directory,
|
pooldir: directory,
|
||||||
defaultModel: defaultModel,
|
defaultModel: defaultModel,
|
||||||
defaultMultimodalModel: defaultMultimodalModel,
|
defaultMultimodalModel: defaultMultimodalModel,
|
||||||
|
mcpBoxURL: mcpBoxURL,
|
||||||
imageModel: imageModel,
|
imageModel: imageModel,
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
agents: make(map[string]*Agent),
|
agents: make(map[string]*Agent),
|
||||||
@@ -166,7 +169,56 @@ func (a *AgentPool) CreateAgent(name string, agentConfig *AgentConfig) error {
|
|||||||
}
|
}
|
||||||
}(a.pool[name])
|
}(a.pool[name])
|
||||||
|
|
||||||
return a.startAgentWithConfig(name, agentConfig)
|
return a.startAgentWithConfig(name, agentConfig, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
|
||||||
|
a.Lock()
|
||||||
|
defer a.Unlock()
|
||||||
|
|
||||||
|
oldAgent := a.agents[name]
|
||||||
|
var o *types.Observable
|
||||||
|
obs := oldAgent.Observer()
|
||||||
|
if obs != nil {
|
||||||
|
o = obs.NewObservable()
|
||||||
|
o.Name = "Restarting Agent"
|
||||||
|
o.Icon = "sync"
|
||||||
|
o.Creation = &types.Creation{}
|
||||||
|
obs.Update(*o)
|
||||||
|
}
|
||||||
|
|
||||||
|
stateFile, characterFile := a.stateFiles(name)
|
||||||
|
|
||||||
|
os.Remove(stateFile)
|
||||||
|
os.Remove(characterFile)
|
||||||
|
|
||||||
|
oldAgent.Stop()
|
||||||
|
|
||||||
|
a.pool[name] = *agentConfig
|
||||||
|
delete(a.agents, name)
|
||||||
|
|
||||||
|
if err := a.save(); err != nil {
|
||||||
|
if obs != nil {
|
||||||
|
o.Completion = &types.Completion{Error: err.Error()}
|
||||||
|
obs.Update(*o)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.startAgentWithConfig(name, 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func createAgentAvatar(APIURL, APIKey, model, imageModel, avatarDir string, agent AgentConfig) error {
|
func createAgentAvatar(APIURL, APIKey, model, imageModel, avatarDir string, agent AgentConfig) error {
|
||||||
@@ -268,8 +320,13 @@ func (a *AgentPool) GetStatusHistory(name string) *Status {
|
|||||||
return a.agentStatus[name]
|
return a.agentStatus[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig) error {
|
func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs Observer) error {
|
||||||
manager := sse.NewManager(5)
|
var manager sse.Manager
|
||||||
|
if m, ok := a.managers[name]; ok {
|
||||||
|
manager = m
|
||||||
|
} else {
|
||||||
|
manager = sse.NewManager(5)
|
||||||
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
model := a.defaultModel
|
model := a.defaultModel
|
||||||
multimodalModel := a.defaultMultimodalModel
|
multimodalModel := a.defaultMultimodalModel
|
||||||
@@ -282,6 +339,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig) error
|
|||||||
model = config.Model
|
model = config.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.MCPBoxURL != "" {
|
||||||
|
a.mcpBoxURL = config.MCPBoxURL
|
||||||
|
}
|
||||||
|
|
||||||
if config.PeriodicRuns == "" {
|
if config.PeriodicRuns == "" {
|
||||||
config.PeriodicRuns = "10m"
|
config.PeriodicRuns = "10m"
|
||||||
}
|
}
|
||||||
@@ -331,6 +392,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig) error
|
|||||||
// dynamicPrompts = append(dynamicPrompts, p.ToMap())
|
// dynamicPrompts = append(dynamicPrompts, p.ToMap())
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
if obs == nil {
|
||||||
|
obs = NewSSEObserver(name, manager)
|
||||||
|
}
|
||||||
|
|
||||||
opts := []Option{
|
opts := []Option{
|
||||||
WithModel(model),
|
WithModel(model),
|
||||||
WithLLMAPIURL(a.apiURL),
|
WithLLMAPIURL(a.apiURL),
|
||||||
@@ -338,7 +403,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig) error
|
|||||||
WithMCPServers(config.MCPServers...),
|
WithMCPServers(config.MCPServers...),
|
||||||
WithPeriodicRuns(config.PeriodicRuns),
|
WithPeriodicRuns(config.PeriodicRuns),
|
||||||
WithPermanentGoal(config.PermanentGoal),
|
WithPermanentGoal(config.PermanentGoal),
|
||||||
|
WithMCPSTDIOServers(config.MCPSTDIOServers...),
|
||||||
|
WithMCPBoxURL(a.mcpBoxURL),
|
||||||
WithPrompts(promptBlocks...),
|
WithPrompts(promptBlocks...),
|
||||||
|
WithMCPPrepareScript(config.MCPPrepareScript),
|
||||||
// WithDynamicPrompts(dynamicPrompts...),
|
// WithDynamicPrompts(dynamicPrompts...),
|
||||||
WithCharacter(Character{
|
WithCharacter(Character{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -407,6 +475,7 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig) error
|
|||||||
c.AgentResultCallback()(state)
|
c.AgentResultCallback()(state)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
WithObserver(obs),
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.HUD {
|
if config.HUD {
|
||||||
@@ -465,6 +534,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig) error
|
|||||||
opts = append(opts, WithLoopDetectionSteps(config.LoopDetectionSteps))
|
opts = append(opts, WithLoopDetectionSteps(config.LoopDetectionSteps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.ParallelJobs > 0 {
|
||||||
|
opts = append(opts, WithParallelJobs(config.ParallelJobs))
|
||||||
|
}
|
||||||
|
|
||||||
xlog.Info("Starting agent", "name", name, "config", config)
|
xlog.Info("Starting agent", "name", name, "config", config)
|
||||||
|
|
||||||
agent, err := New(opts...)
|
agent, err := New(opts...)
|
||||||
@@ -509,7 +582,7 @@ func (a *AgentPool) StartAll() error {
|
|||||||
if a.agents[name] != nil { // Agent already started
|
if a.agents[name] != nil { // Agent already started
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := a.startAgentWithConfig(name, &config); err != nil {
|
if err := a.startAgentWithConfig(name, &config, nil); err != nil {
|
||||||
xlog.Error("Failed to start agent", "name", name, "error", err)
|
xlog.Error("Failed to start agent", "name", name, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,7 +620,7 @@ func (a *AgentPool) Start(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if config, ok := a.pool[name]; ok {
|
if config, ok := a.pool[name]; ok {
|
||||||
return a.startAgentWithConfig(name, &config)
|
return a.startAgentWithConfig(name, &config, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("agent %s not found", name)
|
return fmt.Errorf("agent %s not found", name)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type Job struct {
|
|||||||
|
|
||||||
context context.Context
|
context context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
Obs *Observable
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionRequest struct {
|
type ActionRequest struct {
|
||||||
@@ -198,3 +200,9 @@ func (j *Job) Cancel() {
|
|||||||
func (j *Job) GetContext() context.Context {
|
func (j *Job) GetContext() context.Context {
|
||||||
return j.context
|
return j.context
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithObservable(obs *Observable) JobOption {
|
||||||
|
return func(j *Job) {
|
||||||
|
j.Obs = obs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
61
core/types/observable.go
Normal file
61
core/types/observable.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||||
|
"github.com/sashabaranov/go-openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Creation struct {
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
41
core/types/state.go
Normal file
41
core/types/state.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// 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 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,16 @@ services:
|
|||||||
- /dev/dri/card1
|
- /dev/dri/card1
|
||||||
- /dev/dri/renderD129
|
- /dev/dri/renderD129
|
||||||
|
|
||||||
|
mcpbox:
|
||||||
|
extends:
|
||||||
|
file: docker-compose.yaml
|
||||||
|
service: mcpbox
|
||||||
|
|
||||||
|
dind:
|
||||||
|
extends:
|
||||||
|
file: docker-compose.yaml
|
||||||
|
service: dind
|
||||||
|
|
||||||
localrecall:
|
localrecall:
|
||||||
extends:
|
extends:
|
||||||
file: docker-compose.yaml
|
file: docker-compose.yaml
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ services:
|
|||||||
count: 1
|
count: 1
|
||||||
capabilities: [gpu]
|
capabilities: [gpu]
|
||||||
|
|
||||||
|
mcpbox:
|
||||||
|
extends:
|
||||||
|
file: docker-compose.yaml
|
||||||
|
service: mcpbox
|
||||||
|
|
||||||
|
dind:
|
||||||
|
extends:
|
||||||
|
file: docker-compose.yaml
|
||||||
|
service: dind
|
||||||
|
|
||||||
localrecall:
|
localrecall:
|
||||||
extends:
|
extends:
|
||||||
file: docker-compose.yaml
|
file: docker-compose.yaml
|
||||||
@@ -30,4 +40,4 @@ services:
|
|||||||
localagi:
|
localagi:
|
||||||
extends:
|
extends:
|
||||||
file: docker-compose.yaml
|
file: docker-compose.yaml
|
||||||
service: localagi
|
service: localagi
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ services:
|
|||||||
# Image list (dockerhub): https://hub.docker.com/r/localai/localai
|
# Image list (dockerhub): https://hub.docker.com/r/localai/localai
|
||||||
image: localai/localai:master-ffmpeg-core
|
image: localai/localai:master-ffmpeg-core
|
||||||
command:
|
command:
|
||||||
- ${MODEL_NAME:-arcee-agent}
|
- ${MODEL_NAME:-gemma-3-12b-it-qat}
|
||||||
- ${MULTIMODAL_MODEL:-minicpm-v-2_6}
|
- ${MULTIMODAL_MODEL:-minicpm-v-2_6}
|
||||||
- ${IMAGE_MODEL:-sd-1.5-ggml}
|
- ${IMAGE_MODEL:-sd-1.5-ggml}
|
||||||
- granite-embedding-107m-multilingual
|
- granite-embedding-107m-multilingual
|
||||||
@@ -46,12 +46,44 @@ services:
|
|||||||
image: busybox
|
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!'"]
|
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!'"]
|
||||||
|
|
||||||
|
mcpbox:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.mcpbox
|
||||||
|
ports:
|
||||||
|
- "8080"
|
||||||
|
volumes:
|
||||||
|
- ./volumes/mcpbox:/app/data
|
||||||
|
environment:
|
||||||
|
- DOCKER_HOST=tcp://dind:2375
|
||||||
|
depends_on:
|
||||||
|
dind:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/processes"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
dind:
|
||||||
|
image: docker:dind
|
||||||
|
privileged: true
|
||||||
|
environment:
|
||||||
|
- DOCKER_TLS_CERTDIR=""
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "docker", "info"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
localagi:
|
localagi:
|
||||||
depends_on:
|
depends_on:
|
||||||
localai:
|
localai:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
localrecall-healthcheck:
|
localrecall-healthcheck:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
mcpbox:
|
||||||
|
condition: service_healthy
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.webui
|
dockerfile: Dockerfile.webui
|
||||||
@@ -59,7 +91,7 @@ services:
|
|||||||
- 8080:3000
|
- 8080:3000
|
||||||
#image: quay.io/mudler/localagi:master
|
#image: quay.io/mudler/localagi:master
|
||||||
environment:
|
environment:
|
||||||
- LOCALAGI_MODEL=${MODEL_NAME:-arcee-agent}
|
- LOCALAGI_MODEL=${MODEL_NAME:-gemma-3-12b-it-qat}
|
||||||
- LOCALAGI_MULTIMODAL_MODEL=${MULTIMODAL_MODEL:-minicpm-v-2_6}
|
- LOCALAGI_MULTIMODAL_MODEL=${MULTIMODAL_MODEL:-minicpm-v-2_6}
|
||||||
- LOCALAGI_IMAGE_MODEL=${IMAGE_MODEL:-sd-1.5-ggml}
|
- LOCALAGI_IMAGE_MODEL=${IMAGE_MODEL:-sd-1.5-ggml}
|
||||||
- LOCALAGI_LLM_API_URL=http://localai:8080
|
- LOCALAGI_LLM_API_URL=http://localai:8080
|
||||||
@@ -68,6 +100,7 @@ services:
|
|||||||
- LOCALAGI_STATE_DIR=/pool
|
- LOCALAGI_STATE_DIR=/pool
|
||||||
- LOCALAGI_TIMEOUT=5m
|
- LOCALAGI_TIMEOUT=5m
|
||||||
- LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
|
- LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
|
||||||
|
- LOCALAGI_MCPBOX_URL=http://mcpbox:8080
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -15,11 +15,11 @@ require (
|
|||||||
github.com/gofiber/template/html/v2 v2.1.3
|
github.com/gofiber/template/html/v2 v2.1.3
|
||||||
github.com/google/go-github/v69 v69.2.0
|
github.com/google/go-github/v69 v69.2.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/metoro-io/mcp-golang v0.9.0
|
github.com/metoro-io/mcp-golang v0.11.0
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4
|
github.com/onsi/ginkgo/v2 v2.23.4
|
||||||
github.com/onsi/gomega v1.37.0
|
github.com/onsi/gomega v1.37.0
|
||||||
github.com/philippgille/chromem-go v0.7.0
|
github.com/philippgille/chromem-go v0.7.0
|
||||||
github.com/sashabaranov/go-openai v1.38.1
|
github.com/sashabaranov/go-openai v1.38.2
|
||||||
github.com/slack-go/slack v0.16.0
|
github.com/slack-go/slack v0.16.0
|
||||||
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64
|
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64
|
||||||
github.com/tmc/langchaingo v0.1.13
|
github.com/tmc/langchaingo v0.1.13
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -144,8 +144,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
|||||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
github.com/mattn/go-runewidth v0.0.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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/metoro-io/mcp-golang v0.9.0 h1:GpFENjieZ/KosTu7CE7tyGI/a2FhiG0nandR0d8B3rE=
|
github.com/metoro-io/mcp-golang v0.11.0 h1:1k+VSE9QaeMTLn0gJ3FgE/DcjsCBsLFnz5eSFbgXUiI=
|
||||||
github.com/metoro-io/mcp-golang v0.9.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
|
github.com/metoro-io/mcp-golang v0.11.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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -179,8 +179,8 @@ github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGK
|
|||||||
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
|
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 h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
|
||||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||||
github.com/sashabaranov/go-openai v1.38.1 h1:TtZabbFQZa1nEni/IhVtDF/WQjVqDgd+cWR5OeddzF8=
|
github.com/sashabaranov/go-openai v1.38.2 h1:akrssjj+6DY3lWuDwHv6cBvJ8Z+FZDM9XEaaYFt0Auo=
|
||||||
github.com/sashabaranov/go-openai v1.38.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
github.com/sashabaranov/go-openai v1.38.2/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 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8=
|
||||||
github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
|
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 h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||||
|
|||||||
7
main.go
7
main.go
@@ -22,6 +22,8 @@ var withLogs = os.Getenv("LOCALAGI_ENABLE_CONVERSATIONS_LOGGING") == "true"
|
|||||||
var apiKeysEnv = os.Getenv("LOCALAGI_API_KEYS")
|
var apiKeysEnv = os.Getenv("LOCALAGI_API_KEYS")
|
||||||
var imageModel = os.Getenv("LOCALAGI_IMAGE_MODEL")
|
var imageModel = os.Getenv("LOCALAGI_IMAGE_MODEL")
|
||||||
var conversationDuration = os.Getenv("LOCALAGI_CONVERSATION_DURATION")
|
var conversationDuration = os.Getenv("LOCALAGI_CONVERSATION_DURATION")
|
||||||
|
var localOperatorBaseURL = os.Getenv("LOCALOPERATOR_BASE_URL")
|
||||||
|
var mcpboxURL = os.Getenv("LOCALAGI_MCPBOX_URL")
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if baseModel == "" {
|
if baseModel == "" {
|
||||||
@@ -60,8 +62,11 @@ func main() {
|
|||||||
apiURL,
|
apiURL,
|
||||||
apiKey,
|
apiKey,
|
||||||
stateDir,
|
stateDir,
|
||||||
|
mcpboxURL,
|
||||||
localRAG,
|
localRAG,
|
||||||
services.Actions,
|
services.Actions(map[string]string{
|
||||||
|
"browser-agent-runner-base-url": localOperatorBaseURL,
|
||||||
|
}),
|
||||||
services.Connectors,
|
services.Connectors,
|
||||||
services.DynamicPrompts,
|
services.DynamicPrompts,
|
||||||
timeout,
|
timeout,
|
||||||
|
|||||||
72
pkg/localoperator/client.go
Normal file
72
pkg/localoperator/client.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package localoperator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client represents a client for interacting with the LocalOperator API
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new API client
|
||||||
|
func NewClient(baseURL string) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRequest represents the request body for running an agent
|
||||||
|
type AgentRequest struct {
|
||||||
|
Goal string `json:"goal"`
|
||||||
|
MaxAttempts int `json:"max_attempts,omitempty"`
|
||||||
|
MaxNoActionAttempts int `json:"max_no_action_attempts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateDescription represents a single state in the agent's history
|
||||||
|
type StateDescription struct {
|
||||||
|
CurrentURL string `json:"current_url"`
|
||||||
|
PageTitle string `json:"page_title"`
|
||||||
|
PageContentDescription string `json:"page_content_description"`
|
||||||
|
Screenshot string `json:"screenshot"`
|
||||||
|
ScreenshotMimeType string `json:"screenshot_mime_type"` // MIME type of the screenshot (e.g., "image/png")
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateHistory represents the complete history of states during agent execution
|
||||||
|
type StateHistory struct {
|
||||||
|
States []StateDescription `json:"states"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunAgent sends a request to run an agent with the given goal
|
||||||
|
func (c *Client) RunBrowserAgent(req AgentRequest) (*StateHistory, error) {
|
||||||
|
body, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Post(
|
||||||
|
fmt.Sprintf("%s/api/browser/run", c.baseURL),
|
||||||
|
"application/json",
|
||||||
|
bytes.NewBuffer(body),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var state StateHistory
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &state, nil
|
||||||
|
}
|
||||||
325
pkg/stdio/client.go
Normal file
325
pkg/stdio/client.go
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
package stdio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client implements the transport.Interface for stdio processes
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
processes map[string]*Process
|
||||||
|
groups map[string][]string
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new stdio transport client
|
||||||
|
func NewClient(baseURL string) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
processes: make(map[string]*Process),
|
||||||
|
groups: make(map[string][]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProcess starts a new process in a group
|
||||||
|
func (c *Client) CreateProcess(ctx context.Context, command string, args []string, env []string, groupID string) (*Process, error) {
|
||||||
|
log.Printf("Creating process: command=%s, args=%v, groupID=%s", command, args, groupID)
|
||||||
|
|
||||||
|
req := struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
Env []string `json:"env"`
|
||||||
|
GroupID string `json:"group_id"`
|
||||||
|
}{
|
||||||
|
Command: command,
|
||||||
|
Args: args,
|
||||||
|
Env: env,
|
||||||
|
GroupID: groupID,
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/processes", c.baseURL)
|
||||||
|
log.Printf("Sending POST request to %s", url)
|
||||||
|
|
||||||
|
resp, err := http.Post(url, "application/json", bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to start process: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
log.Printf("Received response with status: %d", resp.StatusCode)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode response: %w. body: %s", err, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Successfully created process with ID: %s", result.ID)
|
||||||
|
|
||||||
|
process := &Process{
|
||||||
|
ID: result.ID,
|
||||||
|
GroupID: groupID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
c.processes[process.ID] = process
|
||||||
|
if groupID != "" {
|
||||||
|
c.groups[groupID] = append(c.groups[groupID], process.ID)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
return process, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProcess returns a process by ID
|
||||||
|
func (c *Client) GetProcess(id string) (*Process, error) {
|
||||||
|
c.mu.RLock()
|
||||||
|
process, exists := c.processes[id]
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("process not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return process, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupProcesses returns all processes in a group
|
||||||
|
func (c *Client) GetGroupProcesses(groupID string) ([]*Process, error) {
|
||||||
|
c.mu.RLock()
|
||||||
|
processIDs, exists := c.groups[groupID]
|
||||||
|
if !exists {
|
||||||
|
c.mu.RUnlock()
|
||||||
|
return nil, fmt.Errorf("group not found: %s", groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
processes := make([]*Process, 0, len(processIDs))
|
||||||
|
for _, pid := range processIDs {
|
||||||
|
if process, exists := c.processes[pid]; exists {
|
||||||
|
processes = append(processes, process)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
return processes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopProcess stops a single process
|
||||||
|
func (c *Client) StopProcess(id string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
process, exists := c.processes[id]
|
||||||
|
if !exists {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return fmt.Errorf("process not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from group if it exists
|
||||||
|
if process.GroupID != "" {
|
||||||
|
groupProcesses := c.groups[process.GroupID]
|
||||||
|
for i, pid := range groupProcesses {
|
||||||
|
if pid == id {
|
||||||
|
c.groups[process.GroupID] = append(groupProcesses[:i], groupProcesses[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(c.groups[process.GroupID]) == 0 {
|
||||||
|
delete(c.groups, process.GroupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(c.processes, id)
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(
|
||||||
|
"DELETE",
|
||||||
|
fmt.Sprintf("%s/processes/%s", c.baseURL, id),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stop process: %w", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopGroup stops all processes in a group
|
||||||
|
func (c *Client) StopGroup(groupID string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
processIDs, exists := c.groups[groupID]
|
||||||
|
if !exists {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return fmt.Errorf("group not found: %s", groupID)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
for _, pid := range processIDs {
|
||||||
|
if err := c.StopProcess(pid); err != nil {
|
||||||
|
return fmt.Errorf("failed to stop process %s in group %s: %w", pid, groupID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGroups returns all group IDs
|
||||||
|
func (c *Client) ListGroups() []string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
groups := make([]string, 0, len(c.groups))
|
||||||
|
for groupID := range c.groups {
|
||||||
|
groups = append(groups, groupID)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProcessIO returns io.Reader and io.Writer for a process
|
||||||
|
func (c *Client) GetProcessIO(id string) (io.Reader, io.Writer, error) {
|
||||||
|
log.Printf("Getting IO for process: %s", id)
|
||||||
|
|
||||||
|
process, err := c.GetProcess(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the base URL to get the host
|
||||||
|
baseURL, err := url.Parse(c.baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse base URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to WebSocket
|
||||||
|
u := url.URL{
|
||||||
|
Scheme: "ws",
|
||||||
|
Host: baseURL.Host,
|
||||||
|
Path: fmt.Sprintf("/ws/%s", process.ID),
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Connecting to WebSocket at: %s", u.String())
|
||||||
|
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to connect to WebSocket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Successfully connected to WebSocket for process: %s", id)
|
||||||
|
|
||||||
|
// Create reader and writer
|
||||||
|
reader := &websocketReader{conn: conn}
|
||||||
|
writer := &websocketWriter{conn: conn}
|
||||||
|
|
||||||
|
return reader, writer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// websocketReader implements io.Reader for WebSocket
|
||||||
|
type websocketReader struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *websocketReader) Read(p []byte) (n int, err error) {
|
||||||
|
_, message, err := r.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n = copy(p, message)
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// websocketWriter implements io.Writer for WebSocket
|
||||||
|
type websocketWriter struct {
|
||||||
|
conn *websocket.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *websocketWriter) Write(p []byte) (n int, err error) {
|
||||||
|
// Use BinaryMessage type for better compatibility
|
||||||
|
err = w.conn.WriteMessage(websocket.BinaryMessage, p)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to write WebSocket message: %w", err)
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes all connections and stops all processes
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
// Stop all processes
|
||||||
|
for id := range c.processes {
|
||||||
|
if err := c.StopProcess(id); err != nil {
|
||||||
|
return fmt.Errorf("failed to stop process %s: %w", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunProcess executes a command and returns its output
|
||||||
|
func (c *Client) RunProcess(ctx context.Context, command string, args []string, env []string) (string, error) {
|
||||||
|
log.Printf("Running one-time process: command=%s, args=%v", command, args)
|
||||||
|
|
||||||
|
req := struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
Env []string `json:"env"`
|
||||||
|
}{
|
||||||
|
Command: command,
|
||||||
|
Args: args,
|
||||||
|
Env: env,
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/run", c.baseURL)
|
||||||
|
log.Printf("Sending POST request to %s", url)
|
||||||
|
|
||||||
|
resp, err := http.Post(url, "application/json", bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to execute process: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
log.Printf("Received response with status: %d", resp.StatusCode)
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("failed to decode response: %w. body: %s", err, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Successfully executed process with output length: %d", len(result.Output))
|
||||||
|
return result.Output, nil
|
||||||
|
}
|
||||||
28
pkg/stdio/client_suite_test.go
Normal file
28
pkg/stdio/client_suite_test.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package stdio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSTDIOTransport(t *testing.T) {
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "STDIOTransport test suite")
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseURL string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
baseURL = os.Getenv("LOCALAGI_MCPBOX_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "http://localhost:8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = AfterSuite(func() {
|
||||||
|
client := NewClient(baseURL)
|
||||||
|
client.StopGroup("test-group")
|
||||||
|
})
|
||||||
235
pkg/stdio/client_test.go
Normal file
235
pkg/stdio/client_test.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
package stdio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mcp "github.com/metoro-io/mcp-golang"
|
||||||
|
"github.com/metoro-io/mcp-golang/transport/stdio"
|
||||||
|
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Client", func() {
|
||||||
|
var (
|
||||||
|
client *Client
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
client = NewClient(baseURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
if client != nil {
|
||||||
|
Expect(client.Close()).To(Succeed())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("Process Management", func() {
|
||||||
|
It("should create and stop a process", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
// Use a command that doesn't exit immediately
|
||||||
|
process, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Hello, World!'; sleep 10"}, []string{}, "test-group")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(process).NotTo(BeNil())
|
||||||
|
Expect(process.ID).NotTo(BeEmpty())
|
||||||
|
|
||||||
|
// Get process IO
|
||||||
|
reader, writer, err := client.GetProcessIO(process.ID)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(reader).NotTo(BeNil())
|
||||||
|
Expect(writer).NotTo(BeNil())
|
||||||
|
|
||||||
|
// Write to process
|
||||||
|
_, err = writer.Write([]byte("test input\n"))
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Read from process with timeout
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
readDone := make(chan struct{})
|
||||||
|
var readErr error
|
||||||
|
var readN int
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
readN, readErr = reader.Read(buf)
|
||||||
|
close(readDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for read with timeout
|
||||||
|
select {
|
||||||
|
case <-readDone:
|
||||||
|
Expect(readErr).NotTo(HaveOccurred())
|
||||||
|
Expect(readN).To(BeNumerically(">", 0))
|
||||||
|
Expect(string(buf[:readN])).To(ContainSubstring("Hello, World!"))
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
Fail("Timeout waiting for process output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the process
|
||||||
|
err = client.StopProcess(process.ID)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should manage process groups", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
groupID := "test-group"
|
||||||
|
|
||||||
|
// Create multiple processes in the same group
|
||||||
|
process1, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Process 1'; sleep 1"}, []string{}, groupID)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(process1).NotTo(BeNil())
|
||||||
|
|
||||||
|
process2, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Process 2'; sleep 1"}, []string{}, groupID)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(process2).NotTo(BeNil())
|
||||||
|
|
||||||
|
// Get group processes
|
||||||
|
processes, err := client.GetGroupProcesses(groupID)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(processes).To(HaveLen(2))
|
||||||
|
|
||||||
|
// List groups
|
||||||
|
groups := client.ListGroups()
|
||||||
|
Expect(groups).To(ContainElement(groupID))
|
||||||
|
|
||||||
|
// Stop the group
|
||||||
|
err = client.StopGroup(groupID)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should run a one-time process", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
output, err := client.RunProcess(ctx, "echo", []string{"One-time process"}, []string{})
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(output).To(ContainSubstring("One-time process"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle process with environment variables", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
env := []string{"TEST_VAR=test_value"}
|
||||||
|
process, err := client.CreateProcess(ctx, "sh", []string{"-c", "env | grep TEST_VAR; sleep 1"}, env, "test-group")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(process).NotTo(BeNil())
|
||||||
|
|
||||||
|
// Get process IO
|
||||||
|
reader, _, err := client.GetProcessIO(process.ID)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Read environment variables with timeout
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
readDone := make(chan struct{})
|
||||||
|
var readErr error
|
||||||
|
var readN int
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
readN, readErr = reader.Read(buf)
|
||||||
|
close(readDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for read with timeout
|
||||||
|
select {
|
||||||
|
case <-readDone:
|
||||||
|
Expect(readErr).NotTo(HaveOccurred())
|
||||||
|
Expect(readN).To(BeNumerically(">", 0))
|
||||||
|
Expect(string(buf[:readN])).To(ContainSubstring("TEST_VAR=test_value"))
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
Fail("Timeout waiting for process output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the process
|
||||||
|
err = client.StopProcess(process.ID)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle long-running processes", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
process, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Starting long process'; sleep 5"}, []string{}, "test-group")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(process).NotTo(BeNil())
|
||||||
|
|
||||||
|
// Get process IO
|
||||||
|
reader, _, err := client.GetProcessIO(process.ID)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
// Read initial output
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
readDone := make(chan struct{})
|
||||||
|
var readErr error
|
||||||
|
var readN int
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
readN, readErr = reader.Read(buf)
|
||||||
|
close(readDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for read with timeout
|
||||||
|
select {
|
||||||
|
case <-readDone:
|
||||||
|
Expect(readErr).NotTo(HaveOccurred())
|
||||||
|
Expect(readN).To(BeNumerically(">", 0))
|
||||||
|
Expect(string(buf[:readN])).To(ContainSubstring("Starting long process"))
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
Fail("Timeout waiting for process output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a bit to ensure process is running
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
|
||||||
|
// Stop the process
|
||||||
|
err = client.StopProcess(process.ID)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("MCP", func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
process, err := client.CreateProcess(ctx,
|
||||||
|
"docker", []string{"run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"},
|
||||||
|
[]string{"GITHUB_PERSONAL_ACCESS_TOKEN=test"}, "test-group")
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(process).NotTo(BeNil())
|
||||||
|
Expect(process.ID).NotTo(BeEmpty())
|
||||||
|
|
||||||
|
defer client.StopProcess(process.ID)
|
||||||
|
|
||||||
|
// MCP client
|
||||||
|
|
||||||
|
read, writer, err := client.GetProcessIO(process.ID)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(read).NotTo(BeNil())
|
||||||
|
Expect(writer).NotTo(BeNil())
|
||||||
|
|
||||||
|
transport := stdio.NewStdioServerTransportWithIO(read, writer)
|
||||||
|
|
||||||
|
// Create a new client
|
||||||
|
mcpClient := mcp.NewClient(transport)
|
||||||
|
// Initialize the client
|
||||||
|
response, e := mcpClient.Initialize(ctx)
|
||||||
|
Expect(e).NotTo(HaveOccurred())
|
||||||
|
Expect(response).NotTo(BeNil())
|
||||||
|
|
||||||
|
Expect(mcpClient.Ping(ctx)).To(Succeed())
|
||||||
|
|
||||||
|
xlog.Debug("Client initialized: %v", response.Instructions)
|
||||||
|
|
||||||
|
alltools := []mcp.ToolRetType{}
|
||||||
|
var cursor *string
|
||||||
|
for {
|
||||||
|
tools, err := mcpClient.ListTools(ctx, cursor)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(tools).NotTo(BeNil())
|
||||||
|
Expect(tools.Tools).NotTo(BeEmpty())
|
||||||
|
alltools = append(alltools, tools.Tools...)
|
||||||
|
|
||||||
|
if tools.NextCursor == nil {
|
||||||
|
break // No more pages
|
||||||
|
}
|
||||||
|
cursor = tools.NextCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tool := range alltools {
|
||||||
|
xlog.Debug("Tool: %v", tool)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
473
pkg/stdio/server.go
Normal file
473
pkg/stdio/server.go
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
package stdio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Process represents a running process with its stdio streams
|
||||||
|
type Process struct {
|
||||||
|
ID string
|
||||||
|
GroupID string
|
||||||
|
Cmd *exec.Cmd
|
||||||
|
Stdin io.WriteCloser
|
||||||
|
Stdout io.ReadCloser
|
||||||
|
Stderr io.ReadCloser
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server handles process management and stdio streaming
|
||||||
|
type Server struct {
|
||||||
|
processes map[string]*Process
|
||||||
|
groups map[string][]string // maps group ID to process IDs
|
||||||
|
mu sync.RWMutex
|
||||||
|
upgrader websocket.Upgrader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new stdio server
|
||||||
|
func NewServer() *Server {
|
||||||
|
return &Server{
|
||||||
|
processes: make(map[string]*Process),
|
||||||
|
groups: make(map[string][]string),
|
||||||
|
upgrader: websocket.Upgrader{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartProcess starts a new process and returns its ID
|
||||||
|
func (s *Server) StartProcess(ctx context.Context, command string, args []string, env []string, groupID string) (string, error) {
|
||||||
|
xlog.Debug("Starting process", "command", command, "args", args, "groupID", groupID)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, command, args...)
|
||||||
|
|
||||||
|
if len(env) > 0 {
|
||||||
|
cmd.Env = append(os.Environ(), env...)
|
||||||
|
xlog.Debug("Process environment", "env", cmd.Env)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create stdin pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to start process: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
process := &Process{
|
||||||
|
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||||
|
GroupID: groupID,
|
||||||
|
Cmd: cmd,
|
||||||
|
Stdin: stdin,
|
||||||
|
Stdout: stdout,
|
||||||
|
Stderr: stderr,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.processes[process.ID] = process
|
||||||
|
if groupID != "" {
|
||||||
|
s.groups[groupID] = append(s.groups[groupID], process.ID)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
xlog.Debug("Successfully started process", "id", process.ID, "pid", cmd.Process.Pid)
|
||||||
|
return process.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopProcess stops a running process
|
||||||
|
func (s *Server) StopProcess(id string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
process, exists := s.processes[id]
|
||||||
|
if !exists {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return fmt.Errorf("process not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
xlog.Debug("Stopping process", "processID", id, "pid", process.Cmd.Process.Pid)
|
||||||
|
|
||||||
|
// Remove from group if it exists
|
||||||
|
if process.GroupID != "" {
|
||||||
|
groupProcesses := s.groups[process.GroupID]
|
||||||
|
for i, pid := range groupProcesses {
|
||||||
|
if pid == id {
|
||||||
|
s.groups[process.GroupID] = append(groupProcesses[:i], groupProcesses[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.groups[process.GroupID]) == 0 {
|
||||||
|
delete(s.groups, process.GroupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(s.processes, id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err := process.Cmd.Process.Kill(); err != nil {
|
||||||
|
xlog.Debug("Failed to kill process", "processID", id, "pid", process.Cmd.Process.Pid, "error", err)
|
||||||
|
return fmt.Errorf("failed to kill process: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
xlog.Debug("Successfully killed process", "processID", id, "pid", process.Cmd.Process.Pid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopGroup stops all processes in a group
|
||||||
|
func (s *Server) StopGroup(groupID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
processIDs, exists := s.groups[groupID]
|
||||||
|
if !exists {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return fmt.Errorf("group not found: %s", groupID)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
for _, pid := range processIDs {
|
||||||
|
if err := s.StopProcess(pid); err != nil {
|
||||||
|
return fmt.Errorf("failed to stop process %s in group %s: %w", pid, groupID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupProcesses returns all processes in a group
|
||||||
|
func (s *Server) GetGroupProcesses(groupID string) ([]*Process, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
processIDs, exists := s.groups[groupID]
|
||||||
|
if !exists {
|
||||||
|
s.mu.RUnlock()
|
||||||
|
return nil, fmt.Errorf("group not found: %s", groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
processes := make([]*Process, 0, len(processIDs))
|
||||||
|
for _, pid := range processIDs {
|
||||||
|
if process, exists := s.processes[pid]; exists {
|
||||||
|
processes = append(processes, process)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
return processes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGroups returns all group IDs
|
||||||
|
func (s *Server) ListGroups() []string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
groups := make([]string, 0, len(s.groups))
|
||||||
|
for groupID := range s.groups {
|
||||||
|
groups = append(groups, groupID)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProcess returns a process by ID
|
||||||
|
func (s *Server) GetProcess(id string) (*Process, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
process, exists := s.processes[id]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("process not found: %s", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return process, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProcesses returns all running processes
|
||||||
|
func (s *Server) ListProcesses() []*Process {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
processes := make([]*Process, 0, len(s.processes))
|
||||||
|
for _, p := range s.processes {
|
||||||
|
processes = append(processes, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return processes
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunProcess executes a command and returns its output
|
||||||
|
func (s *Server) RunProcess(ctx context.Context, command string, args []string, env []string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, command, args...)
|
||||||
|
|
||||||
|
if len(env) > 0 {
|
||||||
|
cmd.Env = append(os.Environ(), env...)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return string(output), fmt.Errorf("process failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the HTTP server
|
||||||
|
func (s *Server) Start(addr string) error {
|
||||||
|
http.HandleFunc("/processes", s.handleProcesses)
|
||||||
|
http.HandleFunc("/processes/", s.handleProcess)
|
||||||
|
http.HandleFunc("/ws/", s.handleWebSocket)
|
||||||
|
http.HandleFunc("/groups", s.handleGroups)
|
||||||
|
http.HandleFunc("/groups/", s.handleGroup)
|
||||||
|
http.HandleFunc("/run", s.handleRun)
|
||||||
|
|
||||||
|
return http.ListenAndServe(addr, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleProcesses(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Printf("Handling /processes request: method=%s", r.Method)
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
processes := s.ListProcesses()
|
||||||
|
json.NewEncoder(w).Encode(processes)
|
||||||
|
case http.MethodPost:
|
||||||
|
var req struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
Env []string `json:"env"`
|
||||||
|
GroupID string `json:"group_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.StartProcess(context.Background(), req.Command, req.Args, req.Env, req.GroupID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"id": id})
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleProcess(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Path[len("/processes/"):]
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
process, err := s.GetProcess(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(process)
|
||||||
|
case http.MethodDelete:
|
||||||
|
if err := s.StopProcess(id); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Path[len("/ws/"):]
|
||||||
|
xlog.Debug("Handling WebSocket connection", "processID", id)
|
||||||
|
|
||||||
|
process, err := s.GetProcess(id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if process.Cmd.ProcessState != nil && process.Cmd.ProcessState.Exited() {
|
||||||
|
xlog.Debug("Process already exited", "processID", id)
|
||||||
|
http.Error(w, "Process already exited", http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
xlog.Debug("Process is running", "processID", id, "pid", process.Cmd.Process.Pid)
|
||||||
|
|
||||||
|
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
xlog.Debug("WebSocket connection established", "processID", id)
|
||||||
|
|
||||||
|
// Create a done channel to signal process completion
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
// Handle stdin
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
xlog.Debug("Process stdin handler done", "processID", id)
|
||||||
|
default:
|
||||||
|
xlog.Debug("WebSocket stdin connection closed", "processID", id)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, message, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||||
|
xlog.Debug("WebSocket stdin unexpected error", "processID", id, "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
xlog.Debug("Received message", "processID", id, "message", string(message))
|
||||||
|
if _, err := process.Stdin.Write(message); err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
xlog.Debug("WebSocket stdin write error", "processID", id, "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
xlog.Debug("Message sent to process", "processID", id, "message", string(message))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Handle stdout and stderr
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
xlog.Debug("Process output handler done", "processID", id)
|
||||||
|
default:
|
||||||
|
xlog.Debug("WebSocket output connection closed", "processID", id)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create a buffer for reading
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
reader := io.MultiReader(process.Stdout, process.Stderr)
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, err := reader.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
xlog.Debug("Read error", "processID", id, "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if n > 0 {
|
||||||
|
xlog.Debug("Sending message", "processID", id, "size", n)
|
||||||
|
if err := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||||
|
xlog.Debug("WebSocket output write error", "processID", id, "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
xlog.Debug("Message sent to client", "processID", id, "size", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for process to exit
|
||||||
|
xlog.Debug("Waiting for process to exit", "processID", id)
|
||||||
|
err = process.Cmd.Wait()
|
||||||
|
close(done) // Signal that the process is done
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
xlog.Debug("Process exited with error",
|
||||||
|
"processID", id,
|
||||||
|
"pid", process.Cmd.Process.Pid,
|
||||||
|
"error", err)
|
||||||
|
} else {
|
||||||
|
xlog.Debug("Process exited successfully",
|
||||||
|
"processID", id,
|
||||||
|
"pid", process.Cmd.Process.Pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new handlers for group management
|
||||||
|
func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
groups := s.ListGroups()
|
||||||
|
json.NewEncoder(w).Encode(groups)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGroup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
groupID := r.URL.Path[len("/groups/"):]
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
processes, err := s.GetGroupProcesses(groupID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(processes)
|
||||||
|
case http.MethodDelete:
|
||||||
|
if err := s.StopGroup(groupID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Handling /run request")
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
Env []string `json:"env"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Executing one-time process: command=%s, args=%v", req.Command, req.Args)
|
||||||
|
|
||||||
|
output, err := s.RunProcess(r.Context(), req.Command, req.Args, req.Env)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("One-time process completed with output length: %d", len(output))
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"output": output,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ const (
|
|||||||
// Actions
|
// Actions
|
||||||
ActionSearch = "search"
|
ActionSearch = "search"
|
||||||
ActionCustom = "custom"
|
ActionCustom = "custom"
|
||||||
|
ActionBrowserAgentRunner = "browser-agent-runner"
|
||||||
ActionGithubIssueLabeler = "github-issue-labeler"
|
ActionGithubIssueLabeler = "github-issue-labeler"
|
||||||
ActionGithubIssueOpener = "github-issue-opener"
|
ActionGithubIssueOpener = "github-issue-opener"
|
||||||
ActionGithubIssueCloser = "github-issue-closer"
|
ActionGithubIssueCloser = "github-issue-closer"
|
||||||
@@ -52,6 +53,7 @@ var AvailableActions = []string{
|
|||||||
ActionGithubIssueSearcher,
|
ActionGithubIssueSearcher,
|
||||||
ActionGithubRepositoryGet,
|
ActionGithubRepositoryGet,
|
||||||
ActionGithubGetAllContent,
|
ActionGithubGetAllContent,
|
||||||
|
ActionBrowserAgentRunner,
|
||||||
ActionGithubRepositoryCreateOrUpdate,
|
ActionGithubRepositoryCreateOrUpdate,
|
||||||
ActionGithubIssueReader,
|
ActionGithubIssueReader,
|
||||||
ActionGithubIssueCommenter,
|
ActionGithubIssueCommenter,
|
||||||
@@ -71,31 +73,34 @@ var AvailableActions = []string{
|
|||||||
ActionShellcommand,
|
ActionShellcommand,
|
||||||
}
|
}
|
||||||
|
|
||||||
func Actions(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action {
|
func Actions(actionsConfigs map[string]string) func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action {
|
||||||
return func(ctx context.Context, pool *state.AgentPool) []types.Action {
|
return func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action {
|
||||||
allActions := []types.Action{}
|
return func(ctx context.Context, pool *state.AgentPool) []types.Action {
|
||||||
|
allActions := []types.Action{}
|
||||||
|
|
||||||
agentName := a.Name
|
agentName := a.Name
|
||||||
|
|
||||||
for _, a := range a.Actions {
|
for _, a := range a.Actions {
|
||||||
var config map[string]string
|
var config map[string]string
|
||||||
if err := json.Unmarshal([]byte(a.Config), &config); err != nil {
|
if err := json.Unmarshal([]byte(a.Config), &config); err != nil {
|
||||||
xlog.Error("Error unmarshalling action config", "error", err)
|
xlog.Error("Error unmarshalling action config", "error", err)
|
||||||
continue
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
a, err := Action(a.Name, agentName, config, pool, actionsConfigs)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allActions = append(allActions, a)
|
||||||
}
|
}
|
||||||
|
|
||||||
a, err := Action(a.Name, agentName, config, pool)
|
return allActions
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
allActions = append(allActions, a)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return allActions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Action(name, agentName string, config map[string]string, pool *state.AgentPool) (types.Action, error) {
|
func Action(name, agentName string, config map[string]string, pool *state.AgentPool, actionsConfigs map[string]string) (types.Action, error) {
|
||||||
var a types.Action
|
var a types.Action
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -114,6 +119,8 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
|
|||||||
a = actions.NewGithubIssueCloser(config)
|
a = actions.NewGithubIssueCloser(config)
|
||||||
case ActionGithubIssueSearcher:
|
case ActionGithubIssueSearcher:
|
||||||
a = actions.NewGithubIssueSearch(config)
|
a = actions.NewGithubIssueSearch(config)
|
||||||
|
case ActionBrowserAgentRunner:
|
||||||
|
a = actions.NewBrowserAgentRunner(config, actionsConfigs["browser-agent-runner-base-url"])
|
||||||
case ActionGithubIssueReader:
|
case ActionGithubIssueReader:
|
||||||
a = actions.NewGithubIssueReader(config)
|
a = actions.NewGithubIssueReader(config)
|
||||||
case ActionGithubPRReader:
|
case ActionGithubPRReader:
|
||||||
@@ -169,6 +176,11 @@ func ActionsConfigMeta() []config.FieldGroup {
|
|||||||
Label: "Search",
|
Label: "Search",
|
||||||
Fields: actions.SearchConfigMeta(),
|
Fields: actions.SearchConfigMeta(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "browser-agent-runner",
|
||||||
|
Label: "Browser Agent Runner",
|
||||||
|
Fields: actions.BrowserAgentRunnerConfigMeta(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "generate_image",
|
Name: "generate_image",
|
||||||
Label: "Generate Image",
|
Label: "Generate Image",
|
||||||
|
|||||||
121
services/actions/browseragentrunner.go
Normal file
121
services/actions/browseragentrunner.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mudler/LocalAGI/core/types"
|
||||||
|
"github.com/mudler/LocalAGI/pkg/config"
|
||||||
|
api "github.com/mudler/LocalAGI/pkg/localoperator"
|
||||||
|
"github.com/sashabaranov/go-openai/jsonschema"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MetadataBrowserAgentHistory = "browser_agent_history"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BrowserAgentRunner struct {
|
||||||
|
baseURL, customActionName string
|
||||||
|
client *api.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBrowserAgentRunner(config map[string]string, defaultURL string) *BrowserAgentRunner {
|
||||||
|
if config["baseURL"] == "" {
|
||||||
|
config["baseURL"] = defaultURL
|
||||||
|
}
|
||||||
|
|
||||||
|
client := api.NewClient(config["baseURL"])
|
||||||
|
|
||||||
|
return &BrowserAgentRunner{
|
||||||
|
client: client,
|
||||||
|
baseURL: config["baseURL"],
|
||||||
|
customActionName: config["customActionName"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrowserAgentRunner) Run(ctx context.Context, params types.ActionParams) (types.ActionResult, error) {
|
||||||
|
result := api.AgentRequest{}
|
||||||
|
err := params.Unmarshal(&result)
|
||||||
|
if err != nil {
|
||||||
|
return types.ActionResult{}, fmt.Errorf("failed to unmarshal params: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := api.AgentRequest{
|
||||||
|
Goal: result.Goal,
|
||||||
|
MaxAttempts: result.MaxAttempts,
|
||||||
|
MaxNoActionAttempts: result.MaxNoActionAttempts,
|
||||||
|
}
|
||||||
|
|
||||||
|
stateHistory, err := b.client.RunBrowserAgent(req)
|
||||||
|
if err != nil {
|
||||||
|
return types.ActionResult{}, fmt.Errorf("failed to run browser agent: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the state history into a readable string
|
||||||
|
var historyStr string
|
||||||
|
// for i, state := range stateHistory.States {
|
||||||
|
// historyStr += fmt.Sprintf("State %d:\n", i+1)
|
||||||
|
// historyStr += fmt.Sprintf(" URL: %s\n", state.CurrentURL)
|
||||||
|
// historyStr += fmt.Sprintf(" Title: %s\n", state.PageTitle)
|
||||||
|
// historyStr += fmt.Sprintf(" Description: %s\n\n", state.PageContentDescription)
|
||||||
|
// }
|
||||||
|
|
||||||
|
historyStr += fmt.Sprintf(" URL: %s\n", stateHistory.States[len(stateHistory.States)-1].CurrentURL)
|
||||||
|
historyStr += fmt.Sprintf(" Title: %s\n", stateHistory.States[len(stateHistory.States)-1].PageTitle)
|
||||||
|
historyStr += fmt.Sprintf(" Description: %s\n\n", stateHistory.States[len(stateHistory.States)-1].PageContentDescription)
|
||||||
|
|
||||||
|
return types.ActionResult{
|
||||||
|
Result: fmt.Sprintf("Browser agent completed successfully. History:\n%s", historyStr),
|
||||||
|
Metadata: map[string]interface{}{MetadataBrowserAgentHistory: stateHistory},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *BrowserAgentRunner) Definition() types.ActionDefinition {
|
||||||
|
actionName := "run_browser_agent"
|
||||||
|
if b.customActionName != "" {
|
||||||
|
actionName = b.customActionName
|
||||||
|
}
|
||||||
|
description := "Run a browser agent to achieve a specific goal, for example: 'Go to https://www.google.com and search for 'LocalAI', and tell me what's on the first page'"
|
||||||
|
return types.ActionDefinition{
|
||||||
|
Name: types.ActionDefinitionName(actionName),
|
||||||
|
Description: description,
|
||||||
|
Properties: map[string]jsonschema.Definition{
|
||||||
|
"goal": {
|
||||||
|
Type: jsonschema.String,
|
||||||
|
Description: "The goal for the browser agent to achieve",
|
||||||
|
},
|
||||||
|
"max_attempts": {
|
||||||
|
Type: jsonschema.Number,
|
||||||
|
Description: "Maximum number of attempts the agent can make (optional)",
|
||||||
|
},
|
||||||
|
"max_no_action_attempts": {
|
||||||
|
Type: jsonschema.Number,
|
||||||
|
Description: "Maximum number of attempts without taking an action (optional)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Required: []string{"goal"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *BrowserAgentRunner) Plannable() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrowserAgentRunnerConfigMeta returns the metadata for Browser Agent Runner action configuration fields
|
||||||
|
func BrowserAgentRunnerConfigMeta() []config.Field {
|
||||||
|
return []config.Field{
|
||||||
|
{
|
||||||
|
Name: "baseURL",
|
||||||
|
Label: "Base URL",
|
||||||
|
Type: config.FieldTypeText,
|
||||||
|
Required: false,
|
||||||
|
HelpText: "Base URL of the LocalOperator API",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "customActionName",
|
||||||
|
Label: "Custom Action Name",
|
||||||
|
Type: config.FieldTypeText,
|
||||||
|
HelpText: "Custom name for this action",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,9 +30,15 @@ func NewDiscord(config map[string]string) *Discord {
|
|||||||
duration = 5 * time.Minute
|
duration = 5 * time.Minute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
token := config["token"]
|
||||||
|
|
||||||
|
if !strings.HasPrefix(token, "Bot ") {
|
||||||
|
token = "Bot " + token
|
||||||
|
}
|
||||||
|
|
||||||
return &Discord{
|
return &Discord{
|
||||||
conversationTracker: NewConversationTracker[string](duration),
|
conversationTracker: NewConversationTracker[string](duration),
|
||||||
token: config["token"],
|
token: token,
|
||||||
defaultChannel: config["defaultChannel"],
|
defaultChannel: config["defaultChannel"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,8 +77,9 @@ func (i *IRC) Start(a *agent.Agent) {
|
|||||||
}
|
}
|
||||||
i.conn.UseTLS = false
|
i.conn.UseTLS = false
|
||||||
i.conn.AddCallback("001", func(e *irc.Event) {
|
i.conn.AddCallback("001", func(e *irc.Event) {
|
||||||
xlog.Info("Connected to IRC server", "server", i.server)
|
xlog.Info("Connected to IRC server", "server", i.server, "arguments", e.Arguments)
|
||||||
i.conn.Join(i.channel)
|
i.conn.Join(i.channel)
|
||||||
|
i.nickname = e.Arguments[0]
|
||||||
xlog.Info("Joined channel", "channel", i.channel)
|
xlog.Info("Joined channel", "channel", i.channel)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -207,6 +208,13 @@ func (i *IRC) Start(a *agent.Agent) {
|
|||||||
|
|
||||||
// Start the IRC client in a goroutine
|
// Start the IRC client in a goroutine
|
||||||
go i.conn.Loop()
|
go i.conn.Loop()
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-a.Context().Done():
|
||||||
|
i.conn.Quit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// IRCConfigMeta returns the metadata for IRC connector configuration fields
|
// IRCConfigMeta returns the metadata for IRC connector configuration fields
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mudler/LocalAGI/pkg/config"
|
"github.com/mudler/LocalAGI/pkg/config"
|
||||||
|
"github.com/mudler/LocalAGI/pkg/localoperator"
|
||||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||||
"github.com/mudler/LocalAGI/pkg/xstrings"
|
"github.com/mudler/LocalAGI/pkg/xstrings"
|
||||||
"github.com/mudler/LocalAGI/services/actions"
|
"github.com/mudler/LocalAGI/services/actions"
|
||||||
@@ -167,8 +168,38 @@ func replaceUserIDsWithNamesInMessage(api *slack.Client, message string) string
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateAttachmentsFromJobResponse(j *types.JobResult) (attachments []slack.Attachment) {
|
func generateAttachmentsFromJobResponse(j *types.JobResult, api *slack.Client, channelID, ts string) (attachments []slack.Attachment) {
|
||||||
for _, state := range j.State {
|
for _, state := range j.State {
|
||||||
|
// coming from the browser agent
|
||||||
|
if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
|
||||||
|
if historyStruct, ok := history.(*localoperator.StateHistory); ok {
|
||||||
|
state := historyStruct.States[len(historyStruct.States)-1]
|
||||||
|
// Decode base64 screenshot and upload to Slack
|
||||||
|
if state.Screenshot != "" {
|
||||||
|
screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
|
||||||
|
if err != nil {
|
||||||
|
xlog.Error(fmt.Sprintf("Error decoding screenshot: %v", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data := string(screenshotData)
|
||||||
|
// Upload the file to Slack
|
||||||
|
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||||
|
Reader: bytes.NewReader(screenshotData),
|
||||||
|
FileSize: len(data),
|
||||||
|
ThreadTimestamp: ts,
|
||||||
|
Channel: channelID,
|
||||||
|
Filename: "screenshot.png",
|
||||||
|
InitialComment: "Browser Agent Screenshot",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
xlog.Error(fmt.Sprintf("Error uploading screenshot: %v", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// coming from the search action
|
// coming from the search action
|
||||||
if urls, exists := state.Metadata[actions.MetadataUrls]; exists {
|
if urls, exists := state.Metadata[actions.MetadataUrls]; exists {
|
||||||
for _, url := range xstrings.UniqueSlice(urls.([]string)) {
|
for _, url := range xstrings.UniqueSlice(urls.([]string)) {
|
||||||
@@ -375,7 +406,7 @@ func replyWithPostMessage(finalResponse string, api *slack.Client, ev *slackeven
|
|||||||
slack.MsgOptionEnableLinkUnfurl(),
|
slack.MsgOptionEnableLinkUnfurl(),
|
||||||
slack.MsgOptionText(message, true),
|
slack.MsgOptionText(message, true),
|
||||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res)...),
|
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, "")...),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
|
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
|
||||||
@@ -387,7 +418,7 @@ func replyWithPostMessage(finalResponse string, api *slack.Client, ev *slackeven
|
|||||||
slack.MsgOptionEnableLinkUnfurl(),
|
slack.MsgOptionEnableLinkUnfurl(),
|
||||||
slack.MsgOptionText(res.Response, true),
|
slack.MsgOptionText(res.Response, true),
|
||||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res)...),
|
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, "")...),
|
||||||
// slack.MsgOptionTS(ts),
|
// slack.MsgOptionTS(ts),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -408,7 +439,7 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
|
|||||||
slack.MsgOptionLinkNames(true),
|
slack.MsgOptionLinkNames(true),
|
||||||
slack.MsgOptionEnableLinkUnfurl(),
|
slack.MsgOptionEnableLinkUnfurl(),
|
||||||
slack.MsgOptionText(messages[0], true),
|
slack.MsgOptionText(messages[0], true),
|
||||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res)...),
|
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, msgTs)...),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
||||||
@@ -435,7 +466,7 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
|
|||||||
slack.MsgOptionLinkNames(true),
|
slack.MsgOptionLinkNames(true),
|
||||||
slack.MsgOptionEnableLinkUnfurl(),
|
slack.MsgOptionEnableLinkUnfurl(),
|
||||||
slack.MsgOptionText(finalResponse, true),
|
slack.MsgOptionText(finalResponse, true),
|
||||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res)...),
|
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, msgTs)...),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ oauth_config:
|
|||||||
- commands
|
- commands
|
||||||
- groups:history
|
- groups:history
|
||||||
- files:read
|
- files:read
|
||||||
|
- files:write
|
||||||
- im:history
|
- im:history
|
||||||
- im:read
|
- im:read
|
||||||
- im:write
|
- im:write
|
||||||
|
|||||||
18
webui/app.go
18
webui/app.go
@@ -176,17 +176,7 @@ func (a *App) UpdateAgentConfig(pool *state.AgentPool) func(c *fiber.Ctx) error
|
|||||||
return errorJSONMessage(c, err.Error())
|
return errorJSONMessage(c, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the agent first
|
if err := pool.RecreateAgent(agentName, &newConfig); err != nil {
|
||||||
if err := pool.Remove(agentName); err != nil {
|
|
||||||
return errorJSONMessage(c, "Error removing agent: "+err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create agent with new config
|
|
||||||
if err := pool.CreateAgent(agentName, &newConfig); err != nil {
|
|
||||||
// Try to restore the old configuration if update fails
|
|
||||||
if restoreErr := pool.CreateAgent(agentName, oldConfig); restoreErr != nil {
|
|
||||||
return errorJSONMessage(c, fmt.Sprintf("Failed to update agent and restore failed: %v, %v", err, restoreErr))
|
|
||||||
}
|
|
||||||
return errorJSONMessage(c, "Error updating agent: "+err.Error())
|
return errorJSONMessage(c, "Error updating agent: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +360,7 @@ func (a *App) Chat(pool *state.AgentPool) func(c *fiber.Ctx) error {
|
|||||||
xlog.Error("Error marshaling status message", "error", err)
|
xlog.Error("Error marshaling status message", "error", err)
|
||||||
} else {
|
} else {
|
||||||
manager.Send(
|
manager.Send(
|
||||||
sse.NewMessage(string(statusData)).WithEvent("json_status"))
|
sse.NewMessage(string(statusData)).WithEvent("json_message_status"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the message asynchronously
|
// Process the message asynchronously
|
||||||
@@ -417,7 +407,7 @@ func (a *App) Chat(pool *state.AgentPool) func(c *fiber.Ctx) error {
|
|||||||
xlog.Error("Error marshaling completed status", "error", err)
|
xlog.Error("Error marshaling completed status", "error", err)
|
||||||
} else {
|
} else {
|
||||||
manager.Send(
|
manager.Send(
|
||||||
sse.NewMessage(string(completedData)).WithEvent("json_status"))
|
sse.NewMessage(string(completedData)).WithEvent("json_message_status"))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -444,7 +434,7 @@ func (a *App) ExecuteAction(pool *state.AgentPool) func(c *fiber.Ctx) error {
|
|||||||
actionName := c.Params("name")
|
actionName := c.Params("name")
|
||||||
|
|
||||||
xlog.Debug("Executing action", "action", actionName, "config", payload.Config, "params", payload.Params)
|
xlog.Debug("Executing action", "action", actionName, "config", payload.Config, "params", payload.Params)
|
||||||
a, err := services.Action(actionName, "", payload.Config, pool)
|
a, err := services.Action(actionName, "", payload.Config, pool, map[string]string{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xlog.Error("Error creating action", "error", err)
|
xlog.Error("Error creating action", "error", err)
|
||||||
return errorJSONMessage(c, err.Error())
|
return errorJSONMessage(c, err.Error())
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "react-ui",
|
"name": "react-ui",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
},
|
},
|
||||||
@@ -13,11 +14,11 @@
|
|||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.4.0",
|
"@vitejs/plugin-react": "^4.4.0",
|
||||||
"eslint": "^9.24.0",
|
"eslint": "^9.24.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^6.0.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"react-router-dom": "^7.5.0",
|
"react-router-dom": "^7.5.1",
|
||||||
"vite": "^6.3.1",
|
"vite": "^6.3.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -32,14 +33,26 @@
|
|||||||
|
|
||||||
"@babel/generator": ["@babel/generator@7.27.0", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/types": "^7.27.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw=="],
|
"@babel/generator": ["@babel/generator@7.27.0", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/types": "^7.27.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw=="],
|
||||||
|
|
||||||
|
"@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.25.9", "", { "dependencies": { "@babel/types": "^7.25.9" } }, "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g=="],
|
||||||
|
|
||||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.0", "", { "dependencies": { "@babel/compat-data": "^7.26.8", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA=="],
|
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.0", "", { "dependencies": { "@babel/compat-data": "^7.26.8", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.27.0", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.25.9", "@babel/helper-member-expression-to-functions": "^7.25.9", "@babel/helper-optimise-call-expression": "^7.25.9", "@babel/helper-replace-supers": "^7.26.5", "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", "@babel/traverse": "^7.27.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg=="],
|
||||||
|
|
||||||
|
"@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ=="],
|
||||||
|
|
||||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw=="],
|
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw=="],
|
||||||
|
|
||||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.26.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw=="],
|
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.26.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw=="],
|
||||||
|
|
||||||
|
"@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.25.9", "", { "dependencies": { "@babel/types": "^7.25.9" } }, "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ=="],
|
||||||
|
|
||||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.26.5", "", {}, "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg=="],
|
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.26.5", "", {}, "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg=="],
|
||||||
|
|
||||||
|
"@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.26.5", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.25.9", "@babel/helper-optimise-call-expression": "^7.25.9", "@babel/traverse": "^7.26.5" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg=="],
|
||||||
|
|
||||||
|
"@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA=="],
|
||||||
|
|
||||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="],
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="],
|
||||||
|
|
||||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="],
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="],
|
||||||
@@ -50,6 +63,8 @@
|
|||||||
|
|
||||||
"@babel/parser": ["@babel/parser@7.27.0", "", { "dependencies": { "@babel/types": "^7.27.0" }, "bin": "./bin/babel-parser.js" }, "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg=="],
|
"@babel/parser": ["@babel/parser@7.27.0", "", { "dependencies": { "@babel/types": "^7.27.0" }, "bin": "./bin/babel-parser.js" }, "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.25.9", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg=="],
|
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg=="],
|
||||||
|
|
||||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg=="],
|
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg=="],
|
||||||
@@ -194,8 +209,6 @@
|
|||||||
|
|
||||||
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
|
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
|
||||||
|
|
||||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
@@ -256,7 +269,7 @@
|
|||||||
|
|
||||||
"eslint": ["eslint@9.24.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", "@eslint/config-helpers": "^0.2.0", "@eslint/core": "^0.12.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.24.0", "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ=="],
|
"eslint": ["eslint@9.24.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", "@eslint/config-helpers": "^0.2.0", "@eslint/core": "^0.12.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.24.0", "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ=="],
|
||||||
|
|
||||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
|
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@6.0.0", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "@babel/plugin-transform-private-methods": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.22.4", "zod-validation-error": "^3.0.3" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-NyC3yIC9fazLitYiN8eHykV5wLp/SMuUZMh+sdPSHIeN4ReXIc7if40jtGjDplAgVL/4OkN1d9gneWe9lFZgag=="],
|
||||||
|
|
||||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.19", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-eyy8pcr/YxSYjBoqIFSrlbn9i/xvxUFa8CjzAYo9cFjgGXqq1hyjihcpZvxRLalpaWmueWR81xn7vuKmAFijDQ=="],
|
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.19", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-eyy8pcr/YxSYjBoqIFSrlbn9i/xvxUFa8CjzAYo9cFjgGXqq1hyjihcpZvxRLalpaWmueWR81xn7vuKmAFijDQ=="],
|
||||||
|
|
||||||
@@ -300,6 +313,12 @@
|
|||||||
|
|
||||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
|
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||||
|
|
||||||
|
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||||
|
|
||||||
|
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||||
@@ -374,9 +393,9 @@
|
|||||||
|
|
||||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||||
|
|
||||||
"react-router": ["react-router@7.5.0", "", { "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g=="],
|
"react-router": ["react-router@7.5.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0", "turbo-stream": "2.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-/jjU3fcYNd2bwz9Q0xt5TwyiyoO8XjSEFXJY4O/lMAlkGTHWuHRAbR9Etik+lSDqMC7A7mz3UlXzgYT6Vl58sA=="],
|
||||||
|
|
||||||
"react-router-dom": ["react-router-dom@7.5.0", "", { "dependencies": { "react-router": "7.5.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-fFhGFCULy4vIseTtH5PNcY/VvDJK5gvOWcwJVHQp8JQcWVr85ENhJ3UpuF/zP1tQOIFYNRJHzXtyhU1Bdgw0RA=="],
|
"react-router-dom": ["react-router-dom@7.5.1", "", { "dependencies": { "react-router": "7.5.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5DPSPc7ENrt2tlKPq0FtpG80ZbqA9aIKEyqX6hSNJDlol/tr6iqCK4crqdsusmOSSotq6zDsn0y3urX9TuTNmA=="],
|
||||||
|
|
||||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
||||||
@@ -408,7 +427,7 @@
|
|||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
"vite": ["vite@6.3.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.3", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.12" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-kkzzkqtMESYklo96HKKPE5KKLkC1amlsqt+RjFMlX2AvbRB/0wghap19NdBxxwGZ+h/C6DLCrcEphPIItlGrRQ=="],
|
"vite": ["vite@6.3.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.3", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.12" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
@@ -418,6 +437,10 @@
|
|||||||
|
|
||||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
|
"zod": ["zod@3.24.3", "", {}, "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg=="],
|
||||||
|
|
||||||
|
"zod-validation-error": ["zod-validation-error@3.4.0", "", { "peerDependencies": { "zod": "^3.18.0" } }, "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ=="],
|
||||||
|
|
||||||
"@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
|
"@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
|
||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0",
|
||||||
|
"highlight.js": "^11.11.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.24.0",
|
"@eslint/js": "^9.24.0",
|
||||||
@@ -19,10 +20,10 @@
|
|||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.4.0",
|
"@vitejs/plugin-react": "^4.4.0",
|
||||||
"eslint": "^9.24.0",
|
"eslint": "^9.24.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^6.0.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"react-router-dom": "^7.5.0",
|
"react-router-dom": "^7.5.1",
|
||||||
"vite": "^6.3.1"
|
"vite": "^6.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
/* Base styles */
|
/* Base styles */
|
||||||
|
pre.hljs {
|
||||||
|
background-color: var(--medium-bg);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
code.json {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary: #00ff95;
|
--primary: #00ff95;
|
||||||
--secondary: #ff00b1;
|
--secondary: #ff00b1;
|
||||||
@@ -1994,16 +2007,62 @@ select.form-control {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-button:hover {
|
|
||||||
background: rgba(0, 255, 149, 0.8);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-button i {
|
.file-button i {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--medium-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
background: var(--light-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: transparent;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2em;
|
||||||
|
padding: 5px;
|
||||||
|
margin-left: 10px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-button:hover {
|
||||||
|
color: var(--success);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-button:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.selected-file-info {
|
.selected-file-info {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|||||||
4
webui/react-ui/src/hooks/useSSE.js
vendored
4
webui/react-ui/src/hooks/useSSE.js
vendored
@@ -63,8 +63,8 @@ export function useSSE(agentName) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle 'json_status' event
|
// Handle 'json_message_status' event
|
||||||
eventSource.addEventListener('json_status', (event) => {
|
eventSource.addEventListener('json_message_status', (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
const timestamp = data.timestamp || new Date().toISOString();
|
const timestamp = data.timestamp || new Date().toISOString();
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import hljs from 'highlight.js/lib/core';
|
||||||
|
import json from 'highlight.js/lib/languages/json';
|
||||||
|
import 'highlight.js/styles/monokai.css';
|
||||||
|
|
||||||
|
hljs.registerLanguage('json', json);
|
||||||
|
|
||||||
function AgentStatus() {
|
function AgentStatus() {
|
||||||
|
const [showStatus, setShowStatus] = useState(true);
|
||||||
const { name } = useParams();
|
const { name } = useParams();
|
||||||
const [statusData, setStatusData] = useState(null);
|
const [statusData, setStatusData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [_eventSource, setEventSource] = useState(null);
|
const [_eventSource, setEventSource] = useState(null);
|
||||||
const [liveUpdates, setLiveUpdates] = useState([]);
|
// Store all observables by id
|
||||||
|
const [observableMap, setObservableMap] = useState({});
|
||||||
|
const [observableTree, setObservableTree] = useState([]);
|
||||||
|
const [expandedCards, setExpandedCards] = useState(new Map());
|
||||||
|
|
||||||
// Update document title
|
// Update document title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,17 +48,89 @@ function AgentStatus() {
|
|||||||
|
|
||||||
fetchStatusData();
|
fetchStatusData();
|
||||||
|
|
||||||
|
// Helper to build observable tree from map
|
||||||
|
function buildObservableTree(map) {
|
||||||
|
const nodes = Object.values(map);
|
||||||
|
const nodeMap = {};
|
||||||
|
nodes.forEach(node => { nodeMap[node.id] = { ...node, children: [] }; });
|
||||||
|
const roots = [];
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (!node.parent_id) {
|
||||||
|
roots.push(nodeMap[node.id]);
|
||||||
|
} else if (nodeMap[node.parent_id]) {
|
||||||
|
nodeMap[node.parent_id].children.push(nodeMap[node.id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch initial observable history
|
||||||
|
const fetchObservables = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/agent/${name}/observables`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json();
|
||||||
|
if (Array.isArray(data.History)) {
|
||||||
|
const map = {};
|
||||||
|
data.History.forEach(obs => {
|
||||||
|
map[obs.id] = obs;
|
||||||
|
});
|
||||||
|
setObservableMap(map);
|
||||||
|
setObservableTree(buildObservableTree(map));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore errors for now
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchObservables();
|
||||||
|
|
||||||
// Setup SSE connection for live updates
|
// Setup SSE connection for live updates
|
||||||
const sse = new EventSource(`/sse/${name}`);
|
const sse = new EventSource(`/sse/${name}`);
|
||||||
setEventSource(sse);
|
setEventSource(sse);
|
||||||
|
|
||||||
|
sse.addEventListener('observable_update', (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log(data);
|
||||||
|
setObservableMap(prevMap => {
|
||||||
|
const prev = prevMap[data.id] || {};
|
||||||
|
const updated = {
|
||||||
|
...prev,
|
||||||
|
...data,
|
||||||
|
creation: data.creation,
|
||||||
|
progress: data.progress,
|
||||||
|
completion: data.completion,
|
||||||
|
};
|
||||||
|
// Events can be received out of order
|
||||||
|
if (data.creation)
|
||||||
|
updated.creation = data.creation;
|
||||||
|
if (data.completion)
|
||||||
|
updated.completion = data.completion;
|
||||||
|
if (data.parent_id && !prevMap[data.parent_id])
|
||||||
|
prevMap[data.parent_id] = {
|
||||||
|
id: data.parent_id,
|
||||||
|
name: "unknown",
|
||||||
|
};
|
||||||
|
const newMap = { ...prevMap, [data.id]: updated };
|
||||||
|
setObservableTree(buildObservableTree(newMap));
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for status events and append to statusData.History
|
||||||
sse.addEventListener('status', (event) => {
|
sse.addEventListener('status', (event) => {
|
||||||
try {
|
const status = event.data;
|
||||||
const data = JSON.parse(event.data);
|
setStatusData(prev => {
|
||||||
setLiveUpdates(prev => [data, ...prev.slice(0, 19)]); // Keep last 20 updates
|
// If prev is null, start a new object
|
||||||
} catch (err) {
|
if (!prev || typeof prev !== 'object') {
|
||||||
setLiveUpdates(prev => [event.data, ...prev.slice(0, 19)]);
|
return { History: [status] };
|
||||||
}
|
}
|
||||||
|
// If History not present, add it
|
||||||
|
if (!Array.isArray(prev.History)) {
|
||||||
|
return { ...prev, History: [status] };
|
||||||
|
}
|
||||||
|
// Otherwise, append
|
||||||
|
return { ...prev, History: [...prev.History, status] };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
sse.onerror = (err) => {
|
sse.onerror = (err) => {
|
||||||
@@ -69,7 +150,7 @@ function AgentStatus() {
|
|||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
@@ -77,14 +158,14 @@ function AgentStatus() {
|
|||||||
return '[Complex Object]';
|
return '[Complex Object]';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(value);
|
return String(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="loading-container">
|
<div>
|
||||||
<div className="loader"></div>
|
<div></div>
|
||||||
<p>Loading agent status...</p>
|
<p>Loading agent status...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -92,56 +173,199 @@ function AgentStatus() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="error-container">
|
<div>
|
||||||
<h2>Error</h2>
|
<h2>Error</h2>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<Link to="/agents" className="back-btn">
|
<Link to="/agents">
|
||||||
<i className="fas fa-arrow-left"></i> Back to Agents
|
<i className="fas fa-arrow-left"></i> Back to Agents
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine live updates with history
|
|
||||||
const allUpdates = [...liveUpdates, ...(statusData?.History || [])];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="agent-status-container">
|
<div>
|
||||||
<header className="page-header">
|
<h1>Agent Status: {name}</h1>
|
||||||
<div className="header-content">
|
<div style={{ color: '#aaa', fontSize: 16, marginBottom: 18 }}>
|
||||||
<h1>
|
See what the agent is doing and thinking
|
||||||
<Link to="/agents" className="back-link">
|
</div>
|
||||||
<i className="fas fa-arrow-left"></i>
|
{error && (
|
||||||
</Link>
|
<div>
|
||||||
Agent Status: {name}
|
{error}
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
)}
|
||||||
|
{loading && <div>Loading...</div>}
|
||||||
|
{statusData && (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
onClick={() => setShowStatus(prev => !prev)}>
|
||||||
|
<h2 style={{ margin: 0 }}>Current Status</h2>
|
||||||
|
<i
|
||||||
|
className={`fas fa-chevron-${showStatus ? 'up' : 'down'}`}
|
||||||
|
style={{ color: 'var(--primary)', marginLeft: 12 }}
|
||||||
|
title={showStatus ? 'Collapse' : 'Expand'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#aaa', fontSize: 14, margin: '5px 0 10px 2px' }}>
|
||||||
|
Summary of the agent's thoughts and actions
|
||||||
|
</div>
|
||||||
|
{showStatus && (
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
{(Array.isArray(statusData?.History) && statusData.History.length === 0) && (
|
||||||
|
<div style={{ color: '#aaa' }}>No status history available.</div>
|
||||||
|
)}
|
||||||
|
{Array.isArray(statusData?.History) && statusData.History.map((item, idx) => (
|
||||||
|
<div key={idx} style={{
|
||||||
|
background: '#222',
|
||||||
|
border: '1px solid #444',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '12px 16px',
|
||||||
|
marginBottom: 10,
|
||||||
|
whiteSpace: 'pre-line',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#eee',
|
||||||
|
}}>
|
||||||
|
{/* Replace <br> tags with newlines, then render as pre-line */}
|
||||||
|
{typeof item === 'string'
|
||||||
|
? item.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
: JSON.stringify(item)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{observableTree.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2>Observable Updates</h2>
|
||||||
|
<div style={{ color: '#aaa', fontSize: 14, margin: '5px 0 10px 2px' }}>
|
||||||
|
Drill down into what the agent is doing and thinking when activated by a connector
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{observableTree.map((container, idx) => (
|
||||||
|
<div key={container.id || idx} className='card' style={{ marginBottom: '1em' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}
|
||||||
|
onClick={() => {
|
||||||
|
const newExpanded = !expandedCards.get(container.id);
|
||||||
|
setExpandedCards(new Map(expandedCards).set(container.id, newExpanded));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||||
|
<i className={`fas fa-${container.icon || 'robot'}`} style={{ verticalAlign: '-0.125em' }}></i>
|
||||||
|
<span>
|
||||||
|
<span className='stat-label'>{container.name}</span>#<span className='stat-label'>{container.id}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<i
|
||||||
|
className={`fas fa-chevron-${expandedCards.get(container.id) ? 'up' : 'down'}`}
|
||||||
|
style={{ color: 'var(--primary)' }}
|
||||||
|
title='Toggle details'
|
||||||
|
/>
|
||||||
|
{!container.completion && (
|
||||||
|
<div className='spinner' />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: expandedCards.get(container.id) ? 'block' : 'none' }}>
|
||||||
|
{container.children && container.children.length > 0 && (
|
||||||
|
|
||||||
<div className="chat-container bg-gray-800 shadow-lg rounded-lg">
|
<div style={{ marginLeft: '2em', marginTop: '1em' }}>
|
||||||
{/* Chat Messages */}
|
<h4>Nested Observables</h4>
|
||||||
<div className="chat-messages p-4">
|
{container.children.map(child => {
|
||||||
{allUpdates.length > 0 ? (
|
const childKey = `child-${child.id}`;
|
||||||
allUpdates.map((item, index) => (
|
const isExpanded = expandedCards.get(childKey);
|
||||||
<div key={index} className="status-item mb-4">
|
return (
|
||||||
<div className="bg-gray-700 p-4 rounded-lg">
|
<div key={`${container.id}-child-${child.id}`} className='card' style={{ background: '#222', marginBottom: '0.5em' }}>
|
||||||
<h2 className="text-sm font-semibold mb-2">Agent Action:</h2>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}
|
||||||
<div className="status-details">
|
onClick={() => {
|
||||||
<div className="status-row">
|
const newExpanded = !expandedCards.get(childKey);
|
||||||
<span className="status-label">{index}</span>
|
setExpandedCards(new Map(expandedCards).set(childKey, newExpanded));
|
||||||
<span className="status-value">{formatValue(item)}</span>
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||||
|
<i className={`fas fa-${child.icon || 'robot'}`} style={{ verticalAlign: '-0.125em' }}></i>
|
||||||
|
<span>
|
||||||
|
<span className='stat-label'>{child.name}</span>#<span className='stat-label'>{child.id}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<i
|
||||||
|
className={`fas fa-chevron-${isExpanded ? 'up' : 'down'}`}
|
||||||
|
style={{ color: 'var(--primary)' }}
|
||||||
|
title='Toggle details'
|
||||||
|
/>
|
||||||
|
{!child.completion && (
|
||||||
|
<div className='spinner' />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: isExpanded ? 'block' : 'none' }}>
|
||||||
|
{child.creation && (
|
||||||
|
<div>
|
||||||
|
<h5>Creation:</h5>
|
||||||
|
<pre className="hljs"><code>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(child.creation || {}, null, 2), { language: 'json' }).value }}></div>
|
||||||
|
</code></pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{child.progress && child.progress.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h5>Progress:</h5>
|
||||||
|
<pre className="hljs"><code>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(child.progress || {}, null, 2), { language: 'json' }).value }}></div>
|
||||||
|
</code></pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{child.completion && (
|
||||||
|
<div>
|
||||||
|
<h5>Completion:</h5>
|
||||||
|
<pre className="hljs"><code>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(child.completion || {}, null, 2), { language: 'json' }).value }}></div>
|
||||||
|
</code></pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{container.creation && (
|
||||||
|
<div>
|
||||||
|
<h4>Creation:</h4>
|
||||||
|
<pre className="hljs"><code>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(container.creation || {}, null, 2), { language: 'json' }).value }}></div>
|
||||||
|
</code></pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{container.progress && container.progress.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4>Progress:</h4>
|
||||||
|
<pre className="hljs"><code>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(container.progress || {}, null, 2), { language: 'json' }).value }}></div>
|
||||||
|
</code></pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{container.completion && (
|
||||||
|
<div>
|
||||||
|
<h4>Completion:</h4>
|
||||||
|
<pre className="hljs"><code>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: hljs.highlight(JSON.stringify(container.completion || {}, null, 2), { language: 'json' }).value }}></div>
|
||||||
|
</code></pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="no-status-data">
|
|
||||||
<p>No status data available for this agent.</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,13 +241,14 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
|
|||||||
|
|
||||||
entries := []string{}
|
entries := []string{}
|
||||||
for _, h := range Reverse(history.Results()) {
|
for _, h := range Reverse(history.Results()) {
|
||||||
entries = append(entries, fmt.Sprintf(
|
entries = append(entries, fmt.Sprintf(`Reasoning: %s
|
||||||
"Result: %v Action: %v Params: %v Reasoning: %v",
|
Action taken: %+v
|
||||||
h.Result,
|
Parameters: %+v
|
||||||
h.Action.Definition().Name,
|
Result: %s`,
|
||||||
h.Params,
|
|
||||||
h.Reasoning,
|
h.Reasoning,
|
||||||
))
|
h.ActionCurrentState.Action.Definition().Name,
|
||||||
|
h.ActionCurrentState.Params,
|
||||||
|
h.Result))
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
@@ -256,6 +257,21 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
webapp.Get("/api/agent/:name/observables", func(c *fiber.Ctx) error {
|
||||||
|
name := c.Params("name")
|
||||||
|
agent := pool.GetAgent(name)
|
||||||
|
if agent == nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||||
|
"error": "Agent not found",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"Name": name,
|
||||||
|
"History": agent.Observer().History(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
webapp.Post("/settings/import", app.ImportAgent(pool))
|
webapp.Post("/settings/import", app.ImportAgent(pool))
|
||||||
webapp.Get("/settings/export/:name", app.ExportAgent(pool))
|
webapp.Get("/settings/export/:name", app.ExportAgent(pool))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user