Compare commits

...

199 Commits

Author SHA1 Message Date
Ettore Di Giacinto 9a6b32f9bc chore: bump localrecall to include PostgreSQL table-name sanitization… (#478)
chore: bump localrecall to include PostgreSQL table-name sanitization fix

Pulls mudler/LocalRecall#48, which makes sanitizeTableName allowlist valid
identifier characters so collection names containing ':' (e.g. the per-user
"legacy-api-key:<agent>" namespace used by LocalAI) no longer break
PostgreSQL CREATE TABLE with "syntax error at or near ':'".

Ref: mudler/LocalAI#10375

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-18 16:48:34 +02:00
Ettore Di Giacinto 14aed1ae43 chore: bump localrecall to index-backed hybrid search (#477)
chore: bump localrecall to index-backed RRF hybrid search

Pulls in mudler/LocalRecall#46 (merged), which rewrites the PostgreSQL
hybrid search using the canonical Reciprocal Rank Fusion pattern
(index-backed candidate retrieval + FULL OUTER JOIN + weighted RRF). Fixes
the full sequential scan that blew past the statement timeout on large
collections (mudler/LocalAI#10186).

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-06 09:12:51 +02:00
Bas Hulsken 22280d4c88 allow configuring LOCALAGI to use a custom base url (#469)
allow configuring LOCALAGI to use a custom base url other than the default :3000
2026-06-02 16:12:18 +02:00
Ettore Di Giacinto 37810d918a fix(collections): rehydrate in-process collections lazily after init failure (#468)
When NewInProcessBackend boots, it iterates every on-disk collection and
calls newVectorEngine to construct its engine wrapper. For the postgres
engine that constructor performs a "test embedding" probe that requires
the embedding model to be reachable. If the embedding service is briefly
unavailable at boot — e.g. the node hosting the embedding model has
temporary NATS connectivity issues — the construction fails and the
collection is silently dropped from the in-memory map. Subsequent
operations against that collection (Upload / Search / ListEntries / …)
all return "collection not found" indefinitely, even after the embedding
service comes back, because nothing ever retries.

Worse, the collection still exists on disk (its JSON sidecar) and its
data still exists in the vector DB (e.g. the per-collection
documents_col_<uuid> table in PostgreSQL). From the user's perspective
their collection has silently disappeared.

Two changes:

1. NewInProcessBackend always registers every on-disk collection in
   state.Collections — even when newVectorEngine returns nil. A nil
   entry is a placeholder meaning "known on disk, not yet loaded".

2. backendInProcess.lookup centralises the cache read for every
   operation. If the cache holds a placeholder, it retries
   newVectorEngine now, under the write lock. So as soon as the
   embedding service is reachable again, the next request to the
   collection will rehydrate it transparently. If init still fails or
   the collection isn't on disk at all, lookup returns (nil, false)
   and the operation surfaces "collection not found" as before.

The state.EnsureCollection callback used by the internal RAG provider
already handled the placeholder case (it re-inits whenever the cache
entry is missing or nil), so it needs no change.

This does not address the underlying probe-and-cache pattern in
LocalRecall's NewPersistentPostgresCollection — read-only operations
on existing collections still require an embedding probe at engine
construction. That is a separate, deeper fix worth pursuing in
LocalRecall directly.
2026-05-08 14:52:35 +02:00
Ettore Di Giacinto c1a1231793 chore(deps): bump localrecall — go-fitz cgo → go-pdfium WASM
Pulls LocalRecall@a7724fe which switches PDF extraction from
gen2brain/go-fitz to klippa-app/go-pdfium with the WebAssembly backend.
Fixes aarch64 link failures (`__isoc23_strtol` undefined reference) in
binaries that pull LocalRecall through cgo.

Pure dep bump — no LocalAGI source changes. Indirect graph picks up
go-pdfium + tetratelabs/wazero; drops gen2brain/go-fitz +
ebitengine/purego + jupiterrider/ffi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 07:47:08 +00:00
Ettore Di Giacinto facd8881b1 chore(deps): bump localrecall to v0.6.0 for go-fitz PDF extraction
LocalRecall v0.6.0 swaps the PDF text extractor from dslipak/pdf to
gen2brain/go-fitz (libmupdf bindings) and wraps the call in a 60s
goroutine timeout. Fixes upload hangs that affected adversarial PDFs
(broken xref tables, encrypted, image-only without OCR) where the
previous parser would block indefinitely.

Pure dep bump — no LocalAGI source code changes. Indirect graph picks
up gen2brain/go-fitz, ebitengine/purego, jupiterrider/ffi; drops
dslipak/pdf.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:07:19 +00:00
Ettore Di Giacinto e83bf515d0 fix(collections): handle errors from LocalRecall constructors
LocalRecall's NewPersistent{Chrome,LocalAI,Postgres}Collection now
return errors instead of os.Exit'ing on init failure. Bump the dep and
update newVectorEngine to consume the new signatures, surfacing failures
as a nil collection (the existing "engine misconfigured" code path)
instead of letting the upstream os.Exit kill the embedding process.

Without this, a transient embedding-service or PostgreSQL outage during
lazy RAG init would crash any long-running host (e.g. the LocalAI
distributed orchestrator) instead of degrading to "no RAG available".

RAGProviderFromState already handles a nil collection from
EnsureCollection by returning (nil, nil, false) to the agent pool, which
is the same path agents take when no RAG is configured — so callers
upstream need no further change.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-05-04 16:51:00 +00:00
Ettore Di Giacinto 3369136c73 chore(deps): bump localrecall for vector-dim migration fix
Picks up the postgres backend fix where switching the embedding model
on an existing collection now resizes the VECTOR column instead of
failing every subsequent insert with SQLSTATE 22000.
2026-04-15 16:51:42 +00:00
Richard Palethorpe c65d62e762 chore: Update fiber to v2.52.11 (#461)
Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-03-31 19:38:55 +02:00
Ettore Di Giacinto 736ccbb95e forward reasoning
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-25 17:28:45 +00:00
Ettore Di Giacinto 640d97b6db small fixups
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-25 15:38:18 +00:00
Ettore Di Giacinto 23344560f0 small fixups
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-25 14:25:29 +00:00
Ettore Di Giacinto 3d0af0088e split run from start
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-25 13:48:23 +00:00
Ettore Di Giacinto 1b87514b2f allow to ask directly
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-25 08:33:17 +00:00
Ettore Di Giacinto b485b77037 Adapt client too
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-21 00:47:23 +00:00
Ettore Di Giacinto 07caa0b95d Adapt client too
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-21 00:46:41 +00:00
Ettore Di Giacinto 43c65ec7e8 feat: update localrecall to support files with same names in the collections
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-19 17:45:13 +00:00
dependabot[bot] cc2d2838ca chore(deps-dev): bump eslint-plugin-react-refresh from 0.5.0 to 0.5.2 in /webui/react-ui (#430)
chore(deps-dev): bump eslint-plugin-react-refresh in /webui/react-ui

Bumps [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) from 0.5.0 to 0.5.2.
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.0...v0.5.2)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 08:43:54 +01:00
Ettore Di Giacinto da286065e1 feat: support streaming mode for tool calls
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-15 22:34:07 +00:00
Ettore Di Giacinto 9438d39f70 feat: support streaming mode for tool calls
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-15 22:33:51 +00:00
Ettore Di Giacinto e38f13ab8c feat: add handler to serve raw files
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-14 22:28:28 +00:00
LocalAI [bot] 2d2da7df95 feat: add --prompt flag for foreground agent mode (#454)
* feat: add --prompt flag for foreground agent mode

- Add --prompt/-p flag to 'agent run' command
- When --prompt is provided, runs agent in foreground mode
- Creates agent, executes Ask() with the prompt, prints response, and exits
- Supports both agent name and --config file input modes
- Follows existing code patterns in the repository

* fix: correct JobResult field access for error and response

---------

Co-authored-by: localai-bot <localai-bot@noreply.github.com>
2026-03-11 17:53:47 +01:00
LocalAI [bot] dc21ee83bc feat: implement 'agent run' CLI command (#448) (#449)
* feat: implement 'agent run' CLI command (#448)

- Add 'local-agi agent run' command supporting agent name or config file
- Support 'local-agi agent run <name>' to run agent from registry (pool.json)
- Support 'local-agi agent run --config <file.json>' to run from JSON config
- Extract web server into 'local-agi serve' subcommand
- Implement agent name lookup from registry
- Add JSON config file parsing and validation
- Create standalone agent execution logic
- Add proper error handling for invalid inputs
- Reuse existing service factories (actions, connectors, filters, skills)
- Environment variable fallback for config values
- No web server - agent runs in foreground with clean SIGINT/SIGTERM handling

References: https://github.com/mudler/LocalAGI/issues/448

* refactor(cmd): use pool to start agent instead of duplicating logic

Replace ~220 lines of duplicated agent initialization code in
startStandaloneAgent() with a call to pool.StartAgentStandalone().
The new pool method delegates to the existing startAgentWithConfig(),
eliminating code duplication between the CLI and the pool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add default help command to root

* fix: default to serve command when no subcommand provided

The e2e tests fail because when the container starts with no arguments,
the root command shows help instead of starting the web server.

Changed the root command to call serveCmd.RunE() directly when no
subcommand is provided, ensuring the web server starts by default.

This fixes the CI e2e test failure where the web server wasn't starting.

* fix: add serve subcommand to Dockerfile ENTRYPOINT

* refactor: remove unused GetRAGProvider function

* refactor: centralize env var setup in cmd/env.go

---------

Co-authored-by: localai-bot <localai-bot@noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:09:02 +01:00
dependabot[bot] 9b744d2bac chore(deps-dev): bump eslint from 10.0.0 to 10.0.3 in /webui/react-ui (#451)
Bumps [eslint](https://github.com/eslint/eslint) from 10.0.0 to 10.0.3.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.0.0...v10.0.3)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 23:05:19 +01:00
LocalAI [bot] 4177479f82 chore(deps): bump cogito from v0.9.2 to v0.9.3 (#452)
Co-authored-by: LocalAI Bot <localai-bot@example.com>
2026-03-09 22:37:10 +01:00
LocalAI [bot] 34db83dc32 fix(slack): correct thread timestamp and improve file upload handling (#447)
Root cause: uploadJobResultFiles was passing msgTs (placeholder reply timestamp)
as thread_ts for file uploads. Slack's API rejects this - it requires the
parent thread timestamp, not a reply's timestamp.

Changes:
1. Use 'ts' (thread root timestamp) instead of 'msgTs' in replyToUpdateMessage
2. Fix type handling in attachmentsFromMetadataOnly to handle []interface{}
3. Download and upload generated images (e.g., DALL-E) as files instead of
   just adding link attachments, to preserve temporary URLs
4. Remove dead code: generateAttachmentsFromJobResponse was never called

This matches Telegram connector behavior where files are properly uploaded
rather than just referenced by URL.

Co-authored-by: localai-bot <localai-bot@noreply.github.com>
2026-03-08 15:59:37 +01:00
Ettore Di Giacinto 5a27c471ca chore: refactoring to make it importable
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-06 16:49:48 +01:00
Ettore Di Giacinto 3ecbbf3eac chore: minor enhancement to be fully importable
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-06 16:39:30 +01:00
Ettore Di Giacinto d9bf193457 feat(ui): allow to edit agent before importing (#444)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-04 18:39:11 +01:00
Ettore Di Giacinto 2acdc6688c fix(conv): merge system messages
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-27 11:08:16 +01:00
LocalAI [bot] fb20d12ae1 feat: add automatic compaction settings to agent config (#437)
* Merge origin/main and resolve conflicts: keep all features including auto-compaction and evaluation

* fix: resolve merge conflict syntax errors in config.go

* feat: wire auto-compaction options to cogito

Wire enableAutoCompaction and autoCompactionThreshold options to
cogito.WithCompactionThreshold() when starting the agent.

---------

Co-authored-by: Team Coding Agent 1 <team-coding-agent-1@local>
2026-02-26 21:32:27 +01:00
Ettore Di Giacinto 7bbec7b6dd fix(templates): add agentname in prompt templates (#438)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-26 15:43:13 +01:00
LocalAI [bot] 905ffcb185 chore: update cogito go dependency to latest main (#436)
Updated github.com/mudler/cogito from
v0.9.2-0.20260223101954-070948df04f7 to
v0.9.2-0.20260225234859-b76691637703

Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-02-26 01:12:12 +01:00
LocalAI [bot] cb82322df2 feat: add inner monologue template support for scheduler and template-based skills prompt (#435)
* feat: add scheduler task template and template-based skills prompt

- Add WithSchedulerTaskTemplate option for recurring tasks run by scheduler
- Expose scheduler_task_template in core/state/config.go for user configuration
- Add WithSkillPromptTemplate option for custom skill prompt templates
- Use {{.Skills}} slice in templates to iterate over Skill.Name, Skill.Description
- Default template mimics current XML behavior with <available_skills> format
- Rename customIntro to customTemplate in services/skills for consistency

* cleanups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Update core/state/config.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 18:44:49 +01:00
LocalAI [bot] 56ecd1921e chore: disable reasoning by default (#433)
Set EnableReasoning DefaultValue to false in agent config form metadata.

Signed-off-by: mudler <mudler@localai.io>
2026-02-24 21:50:55 +01:00
Ettore Di Giacinto a0faa14ffe fix(slack/files): trying to debug files upload
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 22:39:23 +00:00
Ettore Di Giacinto 7494a1559e chore(observables): add missing observables
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 21:09:01 +00:00
Ettore Di Giacinto 3dad5acf1f chore(pdf): refactor and try to always send pdf
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 20:53:02 +00:00
Ettore Di Giacinto 99f7e3ab60 fix: finish the job once
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 19:10:29 +01:00
Ettore Di Giacinto 5b90d4c3e2 chore: stop tool call when model calls send_message
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 19:06:31 +01:00
Ettore Di Giacinto 9406d699cc chore: allow to configure inner monologue template
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 18:43:25 +01:00
Ettore Di Giacinto 78defbad32 fix: avoid panics
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 15:31:04 +01:00
Ettore Di Giacinto 2efadbf6c4 bump go mod
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 11:55:42 +01:00
Ettore Di Giacinto b46c300307 chore: enable more behavioral settings
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 11:42:30 +01:00
Ettore Di Giacinto d2407105a4 fix: remove the duplicated unlock call
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 23:24:30 +00:00
Ettore Di Giacinto 32a1a59389 feat: add accumulator to print tool status/results during execution (#427)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 22:44:19 +01:00
Ettore Di Giacinto 61a89aaf58 feat(pdf): improve pdf markdown rendering (#426)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 22:42:36 +01:00
Ettore Di Giacinto c5e3df41ee fix: atomic writes to the pool, create backups
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 19:11:46 +01:00
Ettore Di Giacinto cb37f80724 fix(slack): process all metadata
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 10:12:13 +01:00
Ettore Di Giacinto bc256fbbe2 chore: avoid panics from Slack connector
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 09:49:03 +01:00
Ettore Di Giacinto 8014d22588 fix: unify api keys for collection endpoint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-21 23:39:19 +00:00
Ettore Di Giacinto 410ba7a467 feat: allow to manage external localrecall instances (#425)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 00:02:12 +01:00
Ettore Di Giacinto c56cc43552 feat(memory): add knowledgebase and memory management from LocalRecall (#424)
* feat: integrate knowledge base management

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactorings

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-21 23:17:33 +01:00
Ettore Di Giacinto bc567ef7dd feat(skills): add skills management (#423)
* feat(skills): add skills management

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* improve ui

* Update webui/skills_handlers.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update webui/skills_handlers.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update webui/skills_handlers.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update webui/skills_handlers.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Dockerfile.webui

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update go.mod

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update webui/skills_handlers.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Address feedback from review

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* allow to customize skill prompt

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-21 22:21:33 +01:00
Ettore Di Giacinto f29e98bccb fix(reasoning): do not update if no new content
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-21 15:15:57 +01:00
Ettore Di Giacinto 73d8304e2e fix: do not imply there is an action
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-20 22:44:45 +00:00
Ettore Di Giacinto 52e8df6599 fix(slack): another attempt to fix markdown result display (#422)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-20 23:15:02 +01:00
Ettore Di Giacinto 3828dd7ccc chore(deps): bump cogito
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-20 22:07:33 +00:00
Ettore Di Giacinto cea2d9d618 chore: hook reasoning callback to status updates
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-20 21:51:00 +00:00
Ettore Di Giacinto 5daf4bda8e fix: do not let agents override each other configuration
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-20 10:08:16 +01:00
Ettore Di Giacinto 508a307e8b chore: update cogito
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-20 10:02:21 +01:00
Ettore Di Giacinto 8a16ad5407 feat: bump cogito, use LocalAILLM
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-19 21:25:05 +00:00
Ettore Di Giacinto 27cc58a25f feat(ui): left navbar, dark/light theme (#421)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-17 23:21:34 +01:00
Ettore Di Giacinto a8decbcc85 chore: fix login
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-17 17:31:12 +01:00
Ettore Di Giacinto 1c083abc60 Add back old webui (login)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-17 16:18:36 +01:00
Ettore Di Giacinto 8f64d246d8 chore: bump cogito
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-17 16:05:16 +01:00
Ettore Di Giacinto 700047c6b9 chore: allow to set force reasoning tool
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-17 13:13:41 +01:00
dependabot[bot] 419a4363c3 chore(deps-dev): bump @types/react from 19.2.13 to 19.2.14 in /webui/react-ui (#420)
chore(deps-dev): bump @types/react in /webui/react-ui

Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 19.2.13 to 19.2.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 22:35:14 +01:00
Ettore Di Giacinto 292d0c9c19 chore: improvements to sink state handling
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-16 19:29:03 +01:00
Ettore Di Giacinto 1147e02844 chore: bump cogito
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-16 17:31:49 +01:00
Ettore Di Giacinto c8e83dc4b9 chore: bump cogito
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-16 15:45:19 +01:00
Ettore Di Giacinto 8b5188c35b chore(cogito): bump
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-15 23:08:10 +00:00
Ettore Di Giacinto 55b631bc51 fix: append user actions
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-15 22:56:44 +01:00
Ettore Di Giacinto c1e2eeb8af chore(cogito): bump
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-15 21:34:59 +00:00
Ettore Di Giacinto 50f168f322 drop unused code, run compaction immediately at start
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-14 23:08:02 +01:00
Ettore Di Giacinto b5dacb0e4f chore: allow to choose how we store things in the vector database (#417)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-14 23:06:49 +01:00
Ettore Di Giacinto 0762d6fabb bump cogito (#416)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-14 21:25:43 +01:00
Ettore Di Giacinto 6dc515bae5 feat: expose further cogito settings
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-14 19:15:55 +01:00
Ettore Di Giacinto 9110e9cfdb fix: set sink state tool with a custom name
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-10 21:50:15 +00:00
Ettore Di Giacinto 9965e3df36 Comment out DisableSinkState in agent configuration
Comment out the DisableSinkState option in agent.go.
2026-02-10 22:43:26 +01:00
Ettore Di Giacinto da9e8d7c20 feat: add support to medatata to messages sent from the agents (#415)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-10 18:18:53 +01:00
dependabot[bot] 7f2e52c0c8 chore(deps-dev): bump @vitejs/plugin-react from 5.1.2 to 5.1.3 in /webui/react-ui (#402)
chore(deps-dev): bump @vitejs/plugin-react in /webui/react-ui

Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.1.2 to 5.1.3.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.3/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 16:45:09 +01:00
dependabot[bot] 6a33484fce chore(deps-dev): bump @eslint/js from 9.39.2 to 10.0.1 in /webui/react-ui (#414)
chore(deps-dev): bump @eslint/js in /webui/react-ui

Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.39.2 to 10.0.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/HEAD/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 15:32:36 +01:00
dependabot[bot] bdf12e4c3d chore(deps-dev): bump @types/react from 19.2.10 to 19.2.13 in /webui/react-ui (#413)
chore(deps-dev): bump @types/react in /webui/react-ui

Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 19.2.10 to 19.2.13.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 13:03:02 +01:00
dependabot[bot] 5743d6f757 chore(deps-dev): bump eslint from 9.39.2 to 10.0.0 in /webui/react-ui (#412)
Bumps [eslint](https://github.com/eslint/eslint) from 9.39.2 to 10.0.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.2...v10.0.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 13:02:50 +01:00
dependabot[bot] 0845e9dc81 chore(deps-dev): bump eslint-plugin-react-refresh from 0.4.26 to 0.5.0 in /webui/react-ui (#401)
chore(deps-dev): bump eslint-plugin-react-refresh in /webui/react-ui

Bumps [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) from 0.4.26 to 0.5.0.
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.26...v0.5.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 09:07:40 +01:00
Ettore Di Giacinto ef2a6e2296 chore: bump LocalRecall
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-09 21:26:57 +00:00
Ettore Di Giacinto 272c54f851 fix: int type passing from react
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-09 20:49:37 +00:00
Ettore Di Giacinto 775b1a9ab8 chore: drop agents avatars (#411)
This functionality makes LocalAGI harder to starts without no real gain.
While having agents avatars is nice, on generating a new agent is
required a new image to be created, which, on small HW could become a
huge computational strain.

It moves the agent list view to tabular, and cleanups the index page to
improve readability.

We can go back at this, but the motivations are mainly:

- Do not waste computation and be more lightweight
- Less models to download unless the user wants to download image models
- Let's not make look the application "weird" as images could be
  generated which are not good looking at all

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-09 18:59:52 +01:00
Ettore Di Giacinto 673909918a feat: improve theme to follow LocalAI (#410)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-09 15:46:01 +01:00
Ettore Di Giacinto 807b7e756b Small fixups
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-09 11:22:43 +01:00
Ettore Di Giacinto c75da2235d Rename actions from knowledge base to memory 2026-02-09 00:41:27 +01:00
Ettore Di Giacinto 80a12f6662 Rename reminder actions to task actions 2026-02-09 00:33:40 +01:00
Ettore Di Giacinto 1128615140 Rename ConversationActionName to 'send_message' 2026-02-09 00:32:28 +01:00
Ettore Di Giacinto 8847bfe0fc chore: allow to set scheduler poll interval
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-08 23:26:27 +01:00
Ettore Di Giacinto 25ce5475de chore: drop deprecated set reminders
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-08 23:03:03 +01:00
Ettore Di Giacinto 2a4bc0eb0a chore: mark scheduled jobs so we inject tools to initiate new conversations
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-08 22:56:25 +01:00
Ettore Di Giacinto d0bfa01494 Change runner to arc-runner-localagent 2026-02-08 22:34:31 +01:00
Copilot 57023d6386 Integrate task scheduler into agent reminder system (#408)
* Initial plan

* Add task scheduler with cron/interval/once support and comprehensive tests

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Add comprehensive documentation for scheduler package

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Add working example demonstrating scheduler usage

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Update .gitignore to exclude example binaries and task files

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Integrate task scheduler into existing reminder system

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Fix scheduler integration and compilation errors

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Remove standalone scheduler example - integrated into agent system

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Update scheduler README to reflect integration with agent system

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* replace old reminders logic

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* A task scheduler for each agent

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* tests

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* change to duration

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-08 22:19:22 +01:00
Copilot 1c3e9b36e7 Add generate_pdf action with connector support (#409)
* Initial plan

* Add PDF generation action with tests and connector support

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Address code review feedback: Add error logging and clarify filename description

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Add path traversal protection for filename parameter

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-02-08 21:09:04 +01:00
Ettore Di Giacinto 4a3ee02081 chore: cleanup
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-07 09:00:12 +01:00
Ettore Di Giacinto d804ef66e5 fix: register actions
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-06 23:21:38 +01:00
Ettore Di Giacinto cea3b7b111 chore: drop LocalOperator actions, we will use MCP for this (#407)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-06 21:51:54 +01:00
Ettore Di Giacinto 1d574069f2 feat: allow to adjust knowledgebase usage (#406)
This allows to configure specifically how the Knowledge base should be
accessible to the agent.

- Auto search: When enabled, every message will trigger a search to the
  knowledge base. This is useful mostly if you mean to give immediate
  context to the agent. Particularly suited for search agents.
- As tools: it will inject search and add tools to the knowledge base so
  the agent can handle these autonomously.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-06 21:36:44 +01:00
dependabot[bot] b09da13111 chore(deps-dev): bump globals from 17.1.0 to 17.3.0 in /webui/react-ui (#403)
Bumps [globals](https://github.com/sindresorhus/globals) from 17.1.0 to 17.3.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.1.0...v17.3.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 21:05:16 +01:00
Ettore Di Giacinto ba2eafe4b2 fix: pass-by state result
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-06 16:50:33 +01:00
Ettore Di Giacinto cbe4cd62ca fix: correct API url
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-05 22:49:18 +01:00
Ettore Di Giacinto 4f4ad5069d feat(gen-song): add song generation action (#404)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-05 19:00:50 +01:00
Ettore Di Giacinto 0950791be6 fix: skip KB if not enabled
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-05 10:46:35 +01:00
Ettore Di Giacinto f47d5d0e01 fix(ui): checkbox event propagation
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-03 12:25:57 +01:00
Ettore Di Giacinto e3d4ac3d0b Enhance LocalAGI description in README
Expanded description of LocalAGI to include agent creation and skillserver integration.
2026-02-01 18:30:48 +01:00
Ettore Di Giacinto c98178d41d chore(settings): move mcp config to the MCP section and rework form (#400)
* chore: move MCP configuration to the MCP section

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* add MCP stdio form, visually split the two

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-01 18:23:41 +01:00
Ettore Di Giacinto 4e89c8aac0 chore: attach observables for standalone jobs (#399)
* chore: add observable for standalone jobs

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore: update docker compose with latest models

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-01 09:36:24 +01:00
Ettore Di Giacinto c3bd2bee42 feat: bump cogito, allow to enable guidelines
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-31 09:41:14 +01:00
Ettore Di Giacinto 9f358abb54 chore(memory): open index only once
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-31 09:25:17 +01:00
Ettore Di Giacinto 456c32c284 use volumes
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-31 09:19:54 +01:00
Ettore Di Giacinto b527cbc332 Revert "chore: do not wait for agents to start, add timeout on MCP connections"
This reverts commit 7cc0c3e85b.
2026-01-31 09:19:26 +01:00
Ettore Di Giacinto 7cc0c3e85b chore: do not wait for agents to start, add timeout on MCP connections
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-30 20:20:46 +00:00
Ettore Di Giacinto 73ce451c13 feat: add automatic compaction of knowledge base (#398)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-30 18:00:06 +01:00
vugenti 72a7807a81 HTTP MCP Fixes (#395)
Add support for Streamable MCP over HTTP - fallback on legacy SSE transport. Fix: Prevent HTTP MCP servers from being closed immediately after initialization.
2026-01-30 15:43:34 +01:00
Ettore Di Giacinto 16775f356a Use bleve for indexing short-term memory (#397)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-30 15:41:23 +01:00
dependabot[bot] a363682b39 chore(deps-dev): bump vite from 7.3.0 to 7.3.1 in /webui/react-ui (#387)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.0 to 7.3.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.1/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-29 10:33:37 +01:00
Ettore Di Giacinto 2288e7f08d feat: update localrecall and use postgresql as default (#394)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-29 10:26:25 +01:00
dependabot[bot] c703495e8c chore(deps-dev): bump react-router-dom from 7.11.0 to 7.13.0 in /webui/react-ui (#392)
chore(deps-dev): bump react-router-dom in /webui/react-ui

Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.11.0 to 7.13.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.13.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-29 09:40:41 +01:00
dependabot[bot] 73c1158aa2 chore(deps): bump react and @types/react in /webui/react-ui (#393)
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react). These dependencies needed to be updated together.

Updates `react` from 19.2.3 to 19.2.4
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react)

Updates `@types/react` from 19.2.7 to 19.2.9
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/react"
  dependency-version: 19.2.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-29 09:40:30 +01:00
dependabot[bot] a1036a166b chore(deps): bump react-dom from 19.2.3 to 19.2.4 in /webui/react-ui (#391)
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.3 to 19.2.4.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-28 11:40:05 +01:00
dependabot[bot] 9a669e2552 chore(deps-dev): bump globals from 16.5.0 to 17.1.0 in /webui/react-ui (#390)
Bumps [globals](https://github.com/sindresorhus/globals) from 16.5.0 to 17.1.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v16.5.0...v17.1.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-28 11:39:52 +01:00
Ettore Di Giacinto cfdefcd075 chore(knowledgebase): clean collection names (no spaces,no uppercase) (#385)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-12 23:15:19 +01:00
dependabot[bot] ec3f302cdf chore(deps-dev): bump react-router-dom from 7.10.1 to 7.11.0 in /webui/react-ui (#383)
chore(deps-dev): bump react-router-dom in /webui/react-ui

Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.10.1 to 7.11.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.11.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-23 09:07:57 +01:00
dependabot[bot] 345dd672d2 chore(deps-dev): bump eslint-plugin-react-refresh from 0.4.25 to 0.4.26 in /webui/react-ui (#382)
chore(deps-dev): bump eslint-plugin-react-refresh in /webui/react-ui

Bumps [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) from 0.4.25 to 0.4.26.
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.25...v0.4.26)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.4.26
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-23 09:07:42 +01:00
dependabot[bot] 7e50afde73 chore(deps-dev): bump eslint from 9.39.1 to 9.39.2 in /webui/react-ui (#381)
Bumps [eslint](https://github.com/eslint/eslint) from 9.39.1 to 9.39.2.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.1...v9.39.2)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.39.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-23 09:07:29 +01:00
dependabot[bot] e33046db03 chore(deps): bump react-dom from 19.2.1 to 19.2.3 in /webui/react-ui (#375)
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.1 to 19.2.3.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.3/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-21 21:27:10 +01:00
Ettore Di Giacinto 9a33537ae0 chore: move logging to its own package so can be shared across repos (#380)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-12-19 23:05:04 +01:00
dependabot[bot] c7040585d3 chore(deps-dev): bump eslint-plugin-react-refresh from 0.4.24 to 0.4.25 in /webui/react-ui (#378)
chore(deps-dev): bump eslint-plugin-react-refresh in /webui/react-ui

Bumps [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) from 0.4.24 to 0.4.25.
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.24...v0.4.25)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.4.25
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 22:26:22 +01:00
dependabot[bot] 85823961f3 chore(deps-dev): bump @eslint/js from 9.39.1 to 9.39.2 in /webui/react-ui (#377)
chore(deps-dev): bump @eslint/js in /webui/react-ui

Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.39.1 to 9.39.2.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v9.39.2/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.39.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 22:26:04 +01:00
dependabot[bot] ef0e6d2025 chore(deps): bump react from 19.2.1 to 19.2.3 in /webui/react-ui (#376)
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.1 to 19.2.3.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.3/packages/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 22:25:49 +01:00
dependabot[bot] 4687efb502 chore(deps-dev): bump vite from 7.2.7 to 7.3.0 in /webui/react-ui (#374)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.2.7 to 7.3.0.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.0/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.0/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 22:25:33 +01:00
dependabot[bot] 9829930451 chore(deps-dev): bump vite from 7.2.4 to 7.2.7 in /webui/react-ui (#373)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.2.4 to 7.2.7.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.2.7/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.2.7/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 12:45:16 +01:00
dependabot[bot] eaf81438fa chore(deps): bump react-dom from 19.2.0 to 19.2.1 in /webui/react-ui (#372)
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.0 to 19.2.1.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.1/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 12:45:07 +01:00
dependabot[bot] 8141defbaf chore(deps-dev): bump react-router-dom from 7.9.6 to 7.10.1 in /webui/react-ui (#371)
chore(deps-dev): bump react-router-dom in /webui/react-ui

Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.9.6 to 7.10.1.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.10.1/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.10.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 21:48:50 +01:00
dependabot[bot] a1667b2227 chore(deps): bump react from 19.2.0 to 19.2.1 in /webui/react-ui (#370)
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.0 to 19.2.1.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.1/packages/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 21:48:33 +01:00
dependabot[bot] 05f21c6c80 chore(deps-dev): bump @vitejs/plugin-react from 5.1.1 to 5.1.2 in /webui/react-ui (#369)
chore(deps-dev): bump @vitejs/plugin-react in /webui/react-ui

Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.1.1 to 5.1.2.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.2/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 21:48:22 +01:00
dependabot[bot] f6ab437796 chore(deps): bump docker/metadata-action from 5.7.0 to 5.10.0 (#364)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.7.0 to 5.10.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/902fa8ec7d6ecbf8d84d538b9b233a880e428804...c299e40c65443455700f0fdfc63efafe5b349051)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 5.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 14:23:14 +01:00
dependabot[bot] 9405fe3825 chore(deps): bump actions/checkout from 4 to 6 (#363)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 14:23:01 +01:00
dependabot[bot] bef63357be chore(deps-dev): bump @types/react from 19.2.2 to 19.2.7 in /webui/react-ui (#360)
chore(deps-dev): bump @types/react in /webui/react-ui

Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 19.2.2 to 19.2.7.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 14:22:37 +01:00
dependabot[bot] 0fec0d39b7 chore(deps-dev): bump vite from 7.1.12 to 7.2.4 in /webui/react-ui (#361)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.12 to 7.2.4.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.2.4/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.2.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-26 21:45:52 +01:00
dependabot[bot] 8709422e05 chore(deps-dev): bump @types/react-dom from 19.2.2 to 19.2.3 in /webui/react-ui (#359)
chore(deps-dev): bump @types/react-dom in /webui/react-ui

Bumps [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) from 19.2.2 to 19.2.3.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-26 21:45:37 +01:00
dependabot[bot] 229aab41f8 chore(deps-dev): bump eslint from 9.39.0 to 9.39.1 in /webui/react-ui (#352)
Bumps [eslint](https://github.com/eslint/eslint) from 9.39.0 to 9.39.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.0...v9.39.1)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.39.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-26 21:45:25 +01:00
dependabot[bot] 853a168773 chore(deps-dev): bump react-router-dom from 7.9.5 to 7.9.6 in /webui/react-ui (#358)
chore(deps-dev): bump react-router-dom in /webui/react-ui

Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.9.5 to 7.9.6.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.9.6/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.9.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-23 20:55:56 +01:00
dependabot[bot] fa963660e0 chore(deps-dev): bump @vitejs/plugin-react from 5.1.0 to 5.1.1 in /webui/react-ui (#357)
chore(deps-dev): bump @vitejs/plugin-react in /webui/react-ui

Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.1.0 to 5.1.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.1/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-23 20:55:43 +01:00
dependabot[bot] fbb098e996 chore(deps-dev): bump @eslint/js from 9.38.0 to 9.39.0 in /webui/react-ui (#342)
chore(deps-dev): bump @eslint/js in /webui/react-ui

Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.38.0 to 9.39.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v9.39.0/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.39.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 09:31:29 +01:00
dependabot[bot] b124244b0c chore(deps-dev): bump react-router-dom from 7.9.4 to 7.9.5 in /webui/react-ui (#341)
chore(deps-dev): bump react-router-dom in /webui/react-ui

Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.9.4 to 7.9.5.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.9.5/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.9.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 09:31:19 +01:00
dependabot[bot] c7d1b58340 chore(deps-dev): bump eslint from 9.38.0 to 9.39.0 in /webui/react-ui (#343)
Bumps [eslint](https://github.com/eslint/eslint) from 9.38.0 to 9.39.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.38.0...v9.39.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.39.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 22:11:37 +01:00
dependabot[bot] 9971819350 chore(deps-dev): bump globals from 16.3.0 to 16.5.0 in /webui/react-ui (#344)
Bumps [globals](https://github.com/sindresorhus/globals) from 16.3.0 to 16.5.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v16.3.0...v16.5.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 16.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 22:11:22 +01:00
dependabot[bot] e3769d3e1f chore(deps): bump github.com/modelcontextprotocol/go-sdk from 1.0.0 to 1.1.0 (#346)
chore(deps): bump github.com/modelcontextprotocol/go-sdk

Bumps [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) from 1.0.0 to 1.1.0.
- [Release notes](https://github.com/modelcontextprotocol/go-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/go-sdk/compare/v1.0.0...v1.1.0)

---
updated-dependencies:
- dependency-name: github.com/modelcontextprotocol/go-sdk
  dependency-version: 1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 22:10:56 +01:00
dependabot[bot] f777ce2992 chore(deps): bump python from 3.13-slim to 3.14-slim (#338)
Bumps python from 3.13-slim to 3.14-slim.

---
updated-dependencies:
- dependency-name: python
  dependency-version: 3.14-slim
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-01 20:57:44 +01:00
Ettore Di Giacinto 02eda4efb3 feat(prompts): support Sprig in templates (#337)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-10-31 22:44:56 +01:00
Ettore Di Giacinto 1b3420c857 chore(deps): bump cogito
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-10-28 18:48:20 +01:00
dependabot[bot] 2f537e72bb chore(deps-dev): bump eslint-plugin-react-hooks from 6.0.0 to 7.0.1 in /webui/react-ui (#330)
chore(deps-dev): bump eslint-plugin-react-hooks in /webui/react-ui

Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 6.0.0 to 7.0.1.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/eslint-plugin-react-hooks)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-hooks
  dependency-version: 7.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-28 11:03:56 +01:00
dependabot[bot] 4afdb6f813 chore(deps): bump github.com/tmc/langchaingo from 0.1.13 to 0.1.14 (#335)
Bumps [github.com/tmc/langchaingo](https://github.com/tmc/langchaingo) from 0.1.13 to 0.1.14.
- [Release notes](https://github.com/tmc/langchaingo/releases)
- [Commits](https://github.com/tmc/langchaingo/compare/v0.1.13...v0.1.14)

---
updated-dependencies:
- dependency-name: github.com/tmc/langchaingo
  dependency-version: 0.1.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-28 11:03:46 +01:00
dependabot[bot] c610c2c6ca chore(deps): bump github.com/gofiber/fiber/v2 from 2.52.8 to 2.52.9 (#306)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.52.8 to 2.52.9.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.52.8...v2.52.9)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  dependency-version: 2.52.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-28 09:17:07 +01:00
dependabot[bot] 047a324e0e chore(deps-dev): bump @eslint/js from 9.37.0 to 9.38.0 in /webui/react-ui (#333)
chore(deps-dev): bump @eslint/js in /webui/react-ui

Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.37.0 to 9.38.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v9.38.0/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.38.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-28 09:16:45 +01:00
dependabot[bot] e37283cb11 chore(deps): bump github.com/valyala/fasthttp from 1.62.0 to 1.68.0 (#334)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.62.0 to 1.68.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.62.0...v1.68.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.68.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 21:34:18 +01:00
dependabot[bot] d6f10b28c1 chore(deps-dev): bump vite from 7.1.4 to 7.1.12 in /webui/react-ui (#331)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.4 to 7.1.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.1.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 21:33:35 +01:00
dependabot[bot] c082b42395 chore(deps-dev): bump @vitejs/plugin-react from 5.0.4 to 5.1.0 in /webui/react-ui (#332)
chore(deps-dev): bump @vitejs/plugin-react in /webui/react-ui

Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.0.4 to 5.1.0.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.0/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 21:33:23 +01:00
dependabot[bot] 252ee2aff6 chore(deps-dev): bump react-router-dom from 7.7.0 to 7.9.4 in /webui/react-ui (#323)
chore(deps-dev): bump react-router-dom in /webui/react-ui

Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.7.0 to 7.9.4.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.9.4/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.9.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 19:05:44 +01:00
dependabot[bot] fffc1f8647 chore(deps): bump react and @types/react in /webui/react-ui (#322)
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react). These dependencies needed to be updated together.

Updates `react` from 19.1.0 to 19.2.0
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.0/packages/react)

Updates `@types/react` from 19.1.8 to 19.2.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: "@types/react"
  dependency-version: 19.2.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 19:05:35 +01:00
dependabot[bot] dc1653294d chore(deps): bump actions/setup-go from 5 to 6 (#308)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 19:01:46 +01:00
dependabot[bot] 3862c3c0eb chore(deps-dev): bump eslint from 9.35.0 to 9.38.0 in /webui/react-ui (#326)
Bumps [eslint](https://github.com/eslint/eslint) from 9.35.0 to 9.38.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.35.0...v9.38.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.38.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 19:01:32 +01:00
dependabot[bot] 756c46b526 chore(deps-dev): bump eslint-plugin-react-refresh from 0.4.20 to 0.4.24 in /webui/react-ui (#327)
chore(deps-dev): bump eslint-plugin-react-refresh in /webui/react-ui

Bumps [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) from 0.4.20 to 0.4.24.
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.20...v0.4.24)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.4.24
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 19:01:14 +01:00
Ettore Di Giacinto 73a6be8264 feat: consume cogito for agent reasoning (#320)
* WIP

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Drop old webui

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Almost there

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* It builds

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Make it build

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fixups, still doesn't work

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* unused now

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Send result before closing

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Fix observability

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Drop MCP code and wire-up in cogito

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Drop some templates

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Keep reporting into conv

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* tests fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* tests fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Do not complete observable during thought process

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Update cogito

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Drop unneded option now

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Better handling of user tools

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* TEST

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Add flake attempts

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Revert "TEST"

This reverts commit 8b12a9fd03.

* tKeep indexing MCP actions

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Split CI jobs to improve speed

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* CI optimizations

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Bump timeout

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Bump cogito

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix: always commit last progress

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* chore: better management of observables

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-10-27 18:41:28 +01:00
dependabot[bot] 84f21c6ab4 chore(deps): bump react-dom and @types/react-dom in /webui/react-ui (#324)
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom). These dependencies needed to be updated together.

Updates `react-dom` from 19.1.0 to 19.2.0
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.0/packages/react-dom)

Updates `@types/react-dom` from 19.1.6 to 19.2.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 18:52:24 +02:00
dependabot[bot] a0ab067bf7 chore(deps-dev): bump @vitejs/plugin-react from 5.0.2 to 5.0.4 in /webui/react-ui (#325)
chore(deps-dev): bump @vitejs/plugin-react in /webui/react-ui

Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.0.2 to 5.0.4.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.0.4/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 19:06:25 +02:00
Ettore Di Giacinto c719cbd544 Remove mcpbox service from docker-compose.nvidia.yaml (#319)
Removed mcpbox service extension from NVIDIA compose file.


Leftover of #318
2025-10-09 09:23:15 +02:00
Ettore Di Giacinto 6fa73262fc feat: switch to official MCP SDK (#318)
* feat: switch to official MCP SDK

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Drop MCP Box

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-10-08 17:14:03 +02:00
Ettore Di Giacinto 558fa396a8 feat: add audio support (#316)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-10-07 12:00:33 +02:00
dependabot[bot] 33ffe06510 chore(deps-dev): bump @eslint/js from 9.35.0 to 9.37.0 in /webui/react-ui (#314)
chore(deps-dev): bump @eslint/js in /webui/react-ui

Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.35.0 to 9.37.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v9.37.0/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.37.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 09:23:46 +02:00
Michel Weimerskirch a5a602c5b0 Add "Play" button to action configuration for testing in Playground (#315) 2025-10-07 09:23:26 +02:00
Michel Weimerskirch 500862b768 Add "clear history" option to the agent observables (#312) 2025-10-03 23:06:21 +02:00
Michel Weimerskirch b06522d1ec Add custom name, description, and payload description to WebhookAction (#311) 2025-10-03 23:05:14 +02:00
Michel Weimerskirch 0c312c9caa Update README with detailed instructions for development (#310) 2025-10-03 23:04:23 +02:00
Michel Weimerskirch d456315f19 Add webhook action for HTTP request integration (#309)
* Add Webhook action handling

Integrates a new "webhook" action, enabling configurable HTTP requests with customizable methods, content types, and payloads through runtime parameters.

* Enhance Webhook action documentation and runtime handling

Improves comments and function-level documentation for the Webhook action, clarifying its configuration, runtime parameter handling, and operational behavior. Adds detailed explanations for methods, payload templates, and HTTP request construction.
2025-10-03 09:14:08 +02:00
dependabot[bot] 983f5599d5 chore(deps): bump github.com/onsi/ginkgo/v2 from 2.23.4 to 2.25.3 (#303)
Bumps [github.com/onsi/ginkgo/v2](https://github.com/onsi/ginkgo) from 2.23.4 to 2.25.3.
- [Release notes](https://github.com/onsi/ginkgo/releases)
- [Changelog](https://github.com/onsi/ginkgo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/onsi/ginkgo/compare/v2.23.4...v2.25.3)

---
updated-dependencies:
- dependency-name: github.com/onsi/ginkgo/v2
  dependency-version: 2.25.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-24 10:52:02 +02:00
dependabot[bot] 471c937d57 chore(deps): bump github.com/JohannesKaufmann/html-to-markdown/v2 from 2.3.3 to 2.4.0 (#286)
chore(deps): bump github.com/JohannesKaufmann/html-to-markdown/v2

Bumps [github.com/JohannesKaufmann/html-to-markdown/v2](https://github.com/JohannesKaufmann/html-to-markdown) from 2.3.3 to 2.4.0.
- [Release notes](https://github.com/JohannesKaufmann/html-to-markdown/releases)
- [Changelog](https://github.com/JohannesKaufmann/html-to-markdown/blob/main/.goreleaser.yaml)
- [Commits](https://github.com/JohannesKaufmann/html-to-markdown/compare/v2.3.3...v2.4.0)

---
updated-dependencies:
- dependency-name: github.com/JohannesKaufmann/html-to-markdown/v2
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-19 18:14:52 +02:00
dependabot[bot] ad61551db5 chore(deps-dev): bump @vitejs/plugin-react from 4.7.0 to 5.0.2 in /webui/react-ui (#288)
chore(deps-dev): bump @vitejs/plugin-react in /webui/react-ui

Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.7.0 to 5.0.2.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.0.2/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-19 18:14:36 +02:00
dependabot[bot] 2bad9c399f chore(deps-dev): bump @eslint/js from 9.33.0 to 9.35.0 in /webui/react-ui (#298)
chore(deps-dev): bump @eslint/js in /webui/react-ui

Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.33.0 to 9.35.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.35.0/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-16 15:43:21 +02:00
dependabot[bot] 580ad7d46f chore(deps): bump github.com/sashabaranov/go-openai from 1.40.3 to 1.41.2 (#296)
chore(deps): bump github.com/sashabaranov/go-openai

Bumps [github.com/sashabaranov/go-openai](https://github.com/sashabaranov/go-openai) from 1.40.3 to 1.41.2.
- [Release notes](https://github.com/sashabaranov/go-openai/releases)
- [Commits](https://github.com/sashabaranov/go-openai/compare/v1.40.3...v1.41.2)

---
updated-dependencies:
- dependency-name: github.com/sashabaranov/go-openai
  dependency-version: 1.41.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-16 15:42:42 +02:00
dependabot[bot] 47f013f3e1 chore(deps-dev): bump eslint from 9.34.0 to 9.35.0 in /webui/react-ui (#299)
Bumps [eslint](https://github.com/eslint/eslint) from 9.34.0 to 9.35.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.34.0...v9.35.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-16 15:42:18 +02:00
mudler d5219a9379 Update example 2025-09-13 16:42:25 +02:00
mudler 00460b1538 fix: do not change name field
Signed-off-by: mudler <mudler@localai.io>
2025-09-13 16:33:08 +02:00
Ettore Di Giacinto 98e62edf12 feat(actions): add action to control pikvm (#294)
Signed-off-by: mudler <mudler@localai.io>
2025-09-13 16:28:42 +02:00
Ettore Di Giacinto 592969f976 feat(actions/dynamicprompt): allow to pass-by a configuration (#293)
This is useful mainly to pass-by from the web interface a generic string
that can be later re-used in the custom actions or dynamic prompt during
Init().

The Signature of the custom actions and prompts around Init has changed,
and now is Init(string), this is on the custom action duty to decide if
the string is e.g. a JSON, or a comma separated list, etc.

Signed-off-by: mudler <mudler@localai.io>
2025-09-13 15:50:25 +02:00
Ettore Di Giacinto 6be849e409 Fix image tag for localai with CUDA 12 2025-09-12 12:26:01 +02:00
Ettore Di Giacinto d42c2a5749 Update Docker image tag for localai service 2025-09-12 12:25:44 +02:00
Ettore Di Giacinto 8d2eafbc35 chore: propagate env (#292)
Signed-off-by: mudler <mudler@localai.io>
2025-09-11 18:16:51 +02:00
Ettore Di Giacinto 848f688ae9 Scan for dynamic prompts in custom action folder (#291)
Signed-off-by: mudler <mudler@localai.io>
2025-09-10 23:45:46 +02:00
dependabot[bot] b813842f85 chore(deps-dev): bump eslint from 9.31.0 to 9.34.0 in /webui/react-ui (#282)
Bumps [eslint](https://github.com/eslint/eslint) from 9.31.0 to 9.34.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.31.0...v9.34.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.34.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-09 23:26:36 +02:00
Ettore Di Giacinto 684ce43eb8 feat: preload custom actions from dir (#290)
* feat: preload custom actions from dir

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(custom dir): allow to override name and description, strip package from code

Signed-off-by: mudler <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Signed-off-by: mudler <mudler@localai.io>
2025-09-09 18:05:15 +02:00
dependabot[bot] 39d13d155f chore(deps-dev): bump vite from 7.0.6 to 7.1.4 in /webui/react-ui (#287)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.0.6 to 7.1.4.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.4/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 21:02:16 +02:00
178 changed files with 18359 additions and 12406 deletions
+2 -2
View File
@@ -16,11 +16,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: 1.24
- name: Run GoReleaser
+7 -78
View File
@@ -11,10 +11,11 @@ concurrency:
cancel-in-progress: true
jobs:
containerImages:
runs-on: ubuntu-latest
#runs-on: ubuntu-latest
runs-on: arc-runner-localagent
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Prepare
id: prep
@@ -57,7 +58,7 @@ jobs:
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
with:
images: quay.io/mudler/localagi
tags: |
@@ -83,84 +84,12 @@ jobs:
#tags: ${{ steps.prep.outputs.tags }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
mcpbox-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare
id: prep
run: |
DOCKER_IMAGE=quay.io/mudler/localagi-mcpbox
# Use branch name as default
VERSION=${GITHUB_REF#refs/heads/}
BINARY_VERSION=$(git describe --always --tags --dirty)
SHORTREF=${GITHUB_SHA::8}
# If this is git tag, use the tag name as a docker tag
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/}
fi
TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${SHORTREF}"
# If the VERSION looks like a version number, assume that
# this is the most recent version of the image and also
# tag it 'latest'.
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
TAGS="$TAGS,${DOCKER_IMAGE}:latest"
fi
# Set output parameters.
echo ::set-output name=binary_version::${BINARY_VERSION}
echo ::set-output name=tags::${TAGS}
echo ::set-output name=docker_image::${DOCKER_IMAGE}
- name: Set up QEMU
uses: docker/setup-qemu-action@master
with:
platforms: all
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@master
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: quay.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
with:
images: quay.io/mudler/localagi-mcpbox
tags: |
type=ref,event=branch,suffix=-{{date 'YYYYMMDDHHmmss'}}
type=semver,pattern={{raw}}
type=sha,suffix=-{{date 'YYYYMMDDHHmmss'}}
type=ref,event=branch
flavor: |
latest=auto
prefix=
suffix=
- name: Build
uses: docker/build-push-action@v6
with:
builder: ${{ steps.buildx.outputs.name }}
build-args: |
VERSION=${{ steps.prep.outputs.binary_version }}
context: ./
file: ./Dockerfile.mcpbox
platforms: linux/amd64,linux/arm64
push: true
#tags: ${{ steps.prep.outputs.tags }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
sshbox-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Prepare
id: prep
@@ -203,7 +132,7 @@ jobs:
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
with:
images: quay.io/mudler/localagi-sshbox
tags: |
+14 -38
View File
@@ -1,4 +1,4 @@
name: Run Go Tests
name: Run Tests
on:
push:
@@ -15,44 +15,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- run: |
# Add Docker's official GPG key:
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add the repository to Apt sources:
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin make
docker version
docker run --rm hello-world
- uses: actions/setup-go@v5
uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '>=1.17.0'
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo apt-get clean
docker system prune -af || true
df -h
go-version: '>=1.26.0'
- name: Run tests
run: |
make tests
#sudo mv coverage/coverage.txt coverage.txt
#sudo chmod 777 coverage.txt
# - name: Upload coverage to Codecov
# uses: codecov/codecov-action@v4
# with:
# token: ${{ secrets.CODECOV_TOKEN }}
test-e2e:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '>=1.26.0'
- run: |
make tests-e2e
+2
View File
@@ -8,3 +8,5 @@ LocalAGI
**/.env
.vscode
volumes/
example/scheduler/scheduler
example/scheduler/example_tasks.json
-49
View File
@@ -1,49 +0,0 @@
# Build stage
FROM golang:1.24-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git
# Set working directory
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -o mcpbox ./cmd/mcpbox
# Final stage
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Install runtime dependencies
RUN apt-get update && apt-get install -y ca-certificates tzdata docker.io bash wget curl
# Create non-root user
#RUN adduser -D -g '' appuser
# Set working directory
WORKDIR /app
# Copy binary from builder
COPY --from=builder /app/mcpbox .
# Use non-root user
#USER appuser
# Expose port
EXPOSE 8080
# Set entrypoint
ENTRYPOINT ["/app/mcpbox"]
# Default command
CMD ["-addr", ":8080"]
+1 -1
View File
@@ -1,5 +1,5 @@
# python
FROM python:3.13-slim
FROM python:3.14-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y python3-dev portaudio19-dev ffmpeg build-essential
+15 -6
View File
@@ -16,8 +16,8 @@ COPY webui/react-ui/ ./
# Build the React UI
RUN bun run build
# Use a temporary build image based on Golang 1.24-alpine
FROM golang:1.24-alpine AS builder
# Use a temporary build image based on Golang 1.26-alpine
FROM golang:1.26-alpine AS builder
# Define argument for linker flags
ARG LDFLAGS="-s -w"
@@ -44,12 +44,21 @@ COPY --from=ui-builder /app/dist /work/webui/react-ui/dist
# Build the application
RUN CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o localagi ./
FROM scratch
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
tzdata \
docker.io \
bash \
wget \
curl
# Copy the webui binary from the builder stage to the final image
COPY --from=builder /work/localagi /localagi
COPY --from=builder /etc/ssl/ /etc/ssl/
COPY --from=builder /tmp /tmp
# Define the command that will be run when the container is started
ENTRYPOINT ["/localagi"]
ENTRYPOINT ["/localagi", "serve"]
+6 -11
View File
@@ -1,23 +1,21 @@
GOCMD?=go
IMAGE_NAME?=webui
MCPBOX_IMAGE_NAME?=mcpbox
ROOT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
prepare-tests: build-mcpbox
prepare-tests:
docker compose up -d --build
docker run -d -v /var/run/docker.sock:/var/run/docker.sock --privileged -p 9090:8080 --rm -ti $(MCPBOX_IMAGE_NAME)
cleanup-tests:
docker compose down
tests: prepare-tests
LOCALAGI_MCPBOX_URL="http://localhost:9090" LOCALAGI_MODEL="gemma-3-4b-it-qat" LOCALAI_API_URL="http://localhost:8081" LOCALAGI_API_URL="http://localhost:8080" $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --fail-fast -v -r ./...
LOCALAGI_MODEL="gemma-3-4b-it-qat" LOCALAI_API_URL="http://localhost:8081" LOCALAGI_API_URL="http://localhost:8080" $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter="!E2E" --flake-attempts=5 --fail-fast -v -r ./...
run-nokb:
$(MAKE) run KBDISABLEINDEX=true
webui/react-ui/dist:
docker run --entrypoint /bin/bash -v $(ROOT_DIR):/app oven/bun:1 -c "cd /app/webui/react-ui && bun install && bun run build"
docker run --entrypoint /bin/bash -v $(ROOT_DIR):/app:z oven/bun:1 -c "cd /app/webui/react-ui && bun install && bun run build"
.PHONY: build
build: webui/react-ui/dist
@@ -25,7 +23,7 @@ build: webui/react-ui/dist
.PHONY: run
run: webui/react-ui/dist
LOCALAGI_MCPBOX_URL="http://localhost:9090" $(GOCMD) run ./
$(GOCMD) run ./
build-image:
docker build -t $(IMAGE_NAME) -f Dockerfile.webui .
@@ -33,8 +31,5 @@ build-image:
image-push:
docker push $(IMAGE_NAME)
build-mcpbox:
docker build -t $(MCPBOX_IMAGE_NAME) -f Dockerfile.mcpbox .
run-mcpbox:
docker run -v /var/run/docker.sock:/var/run/docker.sock --privileged -p 9090:8080 -ti mcpbox
tests-e2e: prepare-tests
LOCALAGI_MODEL="gemma-3-4b-it-qat" LOCALAI_API_URL="http://localhost:8081" LOCALAGI_API_URL="http://localhost:8080" $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --label-filter="E2E" --flake-attempts=5 --fail-fast -v -r ./tests/e2e/...
+60 -18
View File
@@ -18,7 +18,7 @@ Try on [![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-b
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 that allows you to design AI automations without writing code. A complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU).
**LocalAGI** is a powerful, self-hostable AI Agent platform that allows you to design AI automations without writing code. Create Agents with a couple of clicks, connect via MCP, and use built-in **Skills** (manage skills in the Web UI and enable them per agent). Every agent exposes a complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU). Skills follow the [skillserver](https://github.com/mudler/skillserver) format and can be created, imported, or synced from git.
## 🛡️ Take Back Your Privacy
@@ -33,12 +33,13 @@ LocalAGI ensures your data stays exactly where you want it—on your hardware. N
- 🤖 **Advanced Agent Teaming**: Instantly create cooperative agent teams from a single prompt.
- 📡 **Connectors**: Built-in integrations with Discord, Slack, Telegram, GitHub Issues, and IRC.
- 🛠 **Comprehensive REST API**: Seamless integration into your workflows. Every agent created will support OpenAI Responses API out of the box.
- 📚 **Short & Long-Term Memory**: Powered by [LocalRecall](https://github.com/mudler/LocalRecall).
- 📚 **Short & Long-Term Memory**: Built-in knowledge base (RAG) for collections, file uploads, and semantic search. Manage collections in the Web UI under **Knowledge base**; agents with "Knowledge base" enabled use it automatically (implementation uses [LocalRecall](https://github.com/mudler/LocalRecall) libraries).
- 🧠 **Planning & Reasoning**: Agents intelligently plan, reason, and adapt.
- 🔄 **Periodic Tasks**: Schedule tasks with cron-like syntax.
- 💾 **Memory Management**: Control memory usage with options for long-term and summary memory.
- 🖼 **Multimodal Support**: Ready for vision, text, and more.
- 🔧 **Extensible Custom Actions**: Easily script dynamic agent behaviors in Go (interpreted, no compilation!).
- 📚 **Built-in Skills**: Manage reusable agent skills in the Web UI (create, edit, import/export, git sync). Enable "Skills" per agent to inject skill tools and the skill list into the agent.
- 🛠 **Fully Customizable Models**: Use your own models or integrate seamlessly with [LocalAI](https://github.com/mudler/LocalAI).
- 📊 **Observability**: Monitor agent status and view detailed observable updates in real-time.
@@ -107,7 +108,7 @@ Still having issues? see this Youtube video: https://youtu.be/HtVwIxW3ePg
</td>
<td width="50%" valign="top">
<h3><a href="https://github.com/mudler/LocalRecall">LocalRecall</a></h3>
<p>A REST-ful API and knowledge base management system that provides persistent memory and storage capabilities for AI agents.</p>
<p>A REST-ful API and knowledge base management system. LocalAGI embeds this functionality: the Web UI includes a <strong>Knowledge base</strong> section and the same collections API, so you no longer need to run LocalRecall separately.</p>
</td>
</tr>
</table>
@@ -195,7 +196,7 @@ Good (relatively small) models that have been tested are:
- **✓ Flexible Model Integration**: Supports GGUF, GGML, and more thanks to [LocalAI](https://github.com/mudler/LocalAI).
- **✓ Developer-Friendly**: Rich APIs and intuitive interfaces.
- **✓ Effortless Setup**: Simple Docker compose setups and pre-built binaries.
- **✓ Feature-Rich**: From planning to multimodal capabilities, connectors for Slack, MCP support, LocalAGI has it all.
- **✓ Feature-Rich**: From planning to multimodal capabilities, connectors for Slack, MCP support, built-in Skills, LocalAGI has it all.
## 🌟 Screenshots
@@ -224,6 +225,7 @@ Explore detailed documentation including:
- [REST API Documentation](#rest-api)
- [Connector Configuration](#connectors)
- [Agent Configuration](#agent-configuration-reference)
- [Skills](#3-skills)
### Environment Configuration
@@ -237,11 +239,16 @@ LocalAGI supports environment configurations. Note that these environment variab
| `LOCALAGI_LLM_API_KEY` | API authentication |
| `LOCALAGI_TIMEOUT` | Request timeout settings |
| `LOCALAGI_STATE_DIR` | Where state gets stored |
| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection |
| `LOCALAGI_LOCALRAG_URL` | Optional URL when using an external LocalRAG URL; not used for built-in knowledge base |
| `LOCALAGI_BASE_URL` | Optional base URL for the app (defaults to ":3000") |
| `LOCALAGI_ENABLE_CONVERSATIONS_LOGGING` | Toggle conversation logs |
| `LOCALAGI_API_KEYS` | A comma separated list of api keys used for authentication |
| `LOCALAGI_CUSTOM_ACTIONS_DIR` | Directory containing custom Go action files to be automatically loaded |
For the built-in knowledge base, optional env (defaults use `LOCALAGI_STATE_DIR`): `COLLECTION_DB_PATH`, `FILE_ASSETS`, `VECTOR_ENGINE` (e.g. `chromem`, `postgres`), `EMBEDDING_MODEL`, `DATABASE_URL` (when `VECTOR_ENGINE=postgres`).
Skills are stored in a fixed `skills` subdirectory under `LOCALAGI_STATE_DIR` (e.g. `/pool/skills` in Docker). Git repo config for skills lives in that directory. No extra environment variables are required.
## Installation Options
### Pre-Built Binaries
@@ -335,16 +342,16 @@ import (
"github.com/mudler/LocalAGI/core/types"
)
// Create a new agent pool
// Create a new agent pool (call pool.SetRAGProvider(...) for knowledge base; see main.go)
pool, err := state.NewAgentPool(
"default-model", // default model name
"default-multimodal-model", // default multimodal model
"image-model", // image generation model
"transcription-model", // default transcription model
"en", // default transcription language
"tts-model", // default TTS model
"http://localhost:8080", // API URL
"your-api-key", // API key
"./state", // state directory
"", // MCP box URL (optional)
"http://localhost:8081", // LocalRAG API URL
"your-api-key", // API key
"./state", // state directory
func(config *AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action {
// Define available actions for agents
return func(ctx context.Context, pool *AgentPool) []types.Action {
@@ -371,8 +378,9 @@ pool, err := state.NewAgentPool(
// Add your custom filters here
}
},
"10m", // timeout
true, // enable conversation logs
"10m", // timeout
true, // enable conversation logs
nil, // skills service (optional)
)
// Create a new agent in the pool
@@ -694,6 +702,16 @@ You can create MCP servers in any language that supports the MCP protocol and ad
- **Testing**: Test your MCP servers independently before integrating with LocalAGI
- **Resource Management**: Ensure your MCP servers properly clean up resources
### 3. Skills
LocalAGI includes built-in **Skills** management. Skills are reusable instructions and resources (scripts, references, assets) that agents can use when "Enable Skills" is turned on for that agent.
- **Skills section (Web UI)**: Open **Skills** in the sidebar. Skills are stored under the state directory (`STATE_DIR/skills`). Create, edit, search, import, and export skills. You can also add git repositories to sync skills from.
- **Per-agent**: In agent creation or settings, enable **Enable Skills** in Advanced Settings. The agent will receive a list of available skills in its context and have access to skill tools (list, read, search, resources) via the built-in skills MCP.
- Skills use the same format as [skillserver](https://github.com/mudler/skillserver) (e.g. `SKILL.md` in a directory). You can export skills from LocalAGI and use them with the standalone skillserver, or import skills created elsewhere.
In Docker, the state directory is persisted (`/pool`), so skills are stored in `/pool/skills`. To use a host folder for skills, mount it over that path in your compose file (e.g. `- ./my-skills:/pool/skills`).
### Development
The development workflow is similar to the source build, but with additional steps for hot reloading of the frontend:
@@ -703,15 +721,39 @@ The development workflow is similar to the source build, but with additional ste
git clone https://github.com/mudler/LocalAGI.git
cd LocalAGI
# Install dependencies and start frontend development server
cd webui/react-ui && bun i && bun run dev
cd webui/react-ui
# Install dependencies
bun i
# Compile frontend (the build directory needs to exist for the backend to start)
bun run build
# Start frontend development server
bun run dev
```
Then in separate terminal:
```bash
cd LocalAGI
# Create a "pool" directory for agent state
mkdir pool
# Set required environment variables
export LOCALAGI_MODEL=gemma-3-4b-it-qat
export LOCALAGI_MULTIMODAL_MODEL=moondream2-20250414
export LOCALAGI_IMAGE_MODEL=sd-1.5-ggml
export LOCALAGI_LLM_API_URL=http://localai:8080
# Knowledge base is built-in; no separate LocalRecall service needed
export LOCALAGI_STATE_DIR=./pool
export LOCALAGI_TIMEOUT=5m
export LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
export LOCALAGI_SSHBOX_URL=root:root@sshbox:22
# Start development server
cd ../.. && go run main.go
go run main.go
```
> Note: see webui/react-ui/.vite.config.js for env vars that can be used to configure the backend URL
@@ -1008,9 +1050,9 @@ LocalAGI supports environment configurations. Note that these environment variab
| `LOCALAGI_LLM_API_KEY` | API authentication |
| `LOCALAGI_TIMEOUT` | Request timeout settings |
| `LOCALAGI_STATE_DIR` | Where state gets stored |
| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection |
| `LOCALAGI_LOCALRAG_URL` | Optional URL when using an external LocalRAG URL; not used for built-in knowledge base |
| `LOCALAGI_BASE_URL` | Optional base URL for the app (defaults to ":3000") |
| `LOCALAGI_SSHBOX_URL` | LocalAGI SSHBox URL, e.g. user:pass@ip:port |
| `LOCALAGI_MCPBOX_URL` | LocalAGI MCPBox URL, e.g. http://mcpbox:8080 |
| `LOCALAGI_ENABLE_CONVERSATIONS_LOGGING` | Toggle conversation logs |
| `LOCALAGI_API_KEYS` | A comma separated list of api keys used for authentication |
| `LOCALAGI_CUSTOM_ACTIONS_DIR` | Directory containing custom Go action files to be automatically loaded |
+15
View File
@@ -0,0 +1,15 @@
package cmd
import (
"github.com/spf13/cobra"
)
var agentCmd = &cobra.Command{
Use: "agent",
Short: "Manage agents",
Long: "Commands for managing and running LocalAGI agents.",
}
func init() {
agentCmd.AddCommand(agentRunCmd)
}
+355
View File
@@ -0,0 +1,355 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/services"
"github.com/mudler/LocalAGI/services/skills"
"github.com/spf13/cobra"
)
var (
configFile string
prompt string
)
var agentRunCmd = &cobra.Command{
Use: "run [agent_name]",
Short: "Run an agent standalone",
Long: `Run an agent without starting the web server.
Two modes are supported:
1. Run an agent by name from the registry (pool.json):
local-agi agent run my-agent
2. Run an agent from a JSON config file:
local-agi agent run --config agent.json
3. Run an agent in foreground mode with a prompt:
local-agi agent run my-agent --prompt "Your question here"
The agent runs in the foreground until interrupted (Ctrl+C).`,
Args: cobra.MaximumNArgs(1),
RunE: runAgent,
}
func init() {
agentRunCmd.Flags().StringVarP(&configFile, "config", "c", "", "path to agent JSON config file")
agentRunCmd.Flags().StringVarP(&prompt, "prompt", "p", "", "run in foreground mode with the given prompt and exit after response")
}
func runAgent(cmd *cobra.Command, args []string) error {
agentName, agentConfig, err := resolveAgentConfig(args)
if err != nil {
return err
}
// If --prompt is provided, run in foreground mode
if prompt != "" {
return runAgentForeground(agentName, agentConfig, prompt)
}
return startStandaloneAgent(agentName, agentConfig)
}
// runAgentForeground runs an agent in foreground mode with a single prompt,
// prints the response, and exits.
func runAgentForeground(agentName string, agentConfig *state.AgentConfig, promptText string) error {
// Load all environment variables
env := LoadEnv()
if env.Model == "" {
env.Model = agentConfig.Model
}
if env.LLMAPIURL == "" {
env.LLMAPIURL = agentConfig.APIURL
}
if env.LLMAPIKey == "" {
env.LLMAPIKey = agentConfig.APIKey
}
if env.Model == "" {
return fmt.Errorf("model not set: provide 'model' in config or set LOCALAGI_MODEL")
}
if env.LLMAPIURL == "" {
return fmt.Errorf("API URL not set: provide 'api_url' in config or set LOCALAGI_LLM_API_URL")
}
if env.StateDir == "" {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
env.StateDir = filepath.Join(cwd, "pool")
}
os.MkdirAll(env.StateDir, 0755)
// Override config with resolved values
agentConfig.Model = env.Model
agentConfig.APIURL = env.LLMAPIURL
agentConfig.APIKey = env.LLMAPIKey
agentConfig.MultimodalModel = env.MultimodalModel
agentConfig.TranscriptionModel = env.TranscriptionModel
agentConfig.TranscriptionLanguage = env.TranscriptionLanguage
agentConfig.TTSModel = env.TTSModel
// Initialize skills service
skillsService, err := skills.NewService(env.StateDir)
if err != nil {
return fmt.Errorf("failed to initialize skills service: %w", err)
}
// Build service factories
actionsFactory := services.Actions(map[string]string{
services.ActionConfigSSHBoxURL: env.SSHBoxURL,
services.ConfigStateDir: env.StateDir,
services.CustomActionsDir: env.CustomActionsDir,
})
dynamicPromptsFactory := services.DynamicPrompts(map[string]string{
services.ConfigStateDir: env.StateDir,
services.CustomActionsDir: env.CustomActionsDir,
})
// Create the pool
pool, err := state.NewAgentPool(
env.Model, env.MultimodalModel, env.TranscriptionModel, env.TranscriptionLanguage, env.TTSModel,
env.LLMAPIURL, env.LLMAPIKey, env.StateDir,
actionsFactory, services.Connectors, dynamicPromptsFactory, services.Filters,
env.Timeout, false, skillsService,
)
if err != nil {
return fmt.Errorf("failed to create agent pool: %w", err)
}
if env.LocalRAGURL != "" {
pool.SetRAGProvider(state.NewHTTPRAGProvider(env.LocalRAGURL, env.LLMAPIKey))
}
// Start the agent
if err := pool.StartAgentStandalone(agentName, agentConfig); err != nil {
return fmt.Errorf("failed to start agent: %w", err)
}
a := pool.GetAgent(agentName)
if a == nil {
return fmt.Errorf("agent %q was not found after starting", agentName)
}
fmt.Fprintf(os.Stderr, "Running agent %q in foreground mode with prompt...\n", agentName)
// Execute Ask with the prompt using WithText option
result := a.Ask(types.WithText(promptText))
// Print the result
if result.Error != nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", result.Error.Error())
pool.Stop(agentName)
return fmt.Errorf("agent error: %s", result.Error.Error())
}
// Print the response
fmt.Println(result.Response)
// Clean up
pool.Stop(agentName)
return nil
}
// resolveAgentConfig determines the agent name and config from either
// a registry lookup or a JSON config file.
func resolveAgentConfig(args []string) (string, *state.AgentConfig, error) {
if configFile != "" && len(args) > 0 {
return "", nil, fmt.Errorf("cannot specify both --config and agent name; use one or the other")
}
if configFile == "" && len(args) == 0 {
return "", nil, fmt.Errorf("either an agent name or --config <file> is required")
}
if configFile != "" {
return loadConfigFromFile(configFile)
}
return loadConfigFromRegistry(args[0])
}
// loadConfigFromFile reads and validates an agent config from a JSON file.
func loadConfigFromFile(path string) (string, *state.AgentConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", nil, fmt.Errorf("failed to read config file %q: %w", path, err)
}
var config state.AgentConfig
if err := json.Unmarshal(data, &config); err != nil {
return "", nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
}
if err := validateConfig(&config); err != nil {
return "", nil, fmt.Errorf("invalid config in %q: %w", path, err)
}
name := config.Name
if name == "" {
// Derive name from filename
base := filepath.Base(path)
name = base[:len(base)-len(filepath.Ext(base))]
config.Name = name
}
return name, &config, nil
}
// loadConfigFromRegistry loads an agent config from the pool.json registry.
func loadConfigFromRegistry(name string) (string, *state.AgentConfig, error) {
stateDir := os.Getenv("LOCALAGI_STATE_DIR")
if stateDir == "" {
cwd, err := os.Getwd()
if err != nil {
return "", nil, fmt.Errorf("failed to get working directory: %w", err)
}
stateDir = filepath.Join(cwd, "pool")
}
poolFile := filepath.Join(stateDir, "pool.json")
data, err := os.ReadFile(poolFile)
if err != nil {
return "", nil, fmt.Errorf("failed to read pool file %q: %w\nEnsure LOCALAGI_STATE_DIR is set or a pool/ directory exists", poolFile, err)
}
var pool map[string]state.AgentConfig
if err := json.Unmarshal(data, &pool); err != nil {
return "", nil, fmt.Errorf("failed to parse pool file %q: %w", poolFile, err)
}
config, exists := pool[name]
if !exists {
available := make([]string, 0, len(pool))
for k := range pool {
available = append(available, k)
}
return "", nil, fmt.Errorf("agent %q not found in registry\nAvailable agents: %v", name, available)
}
return name, &config, nil
}
// validateConfig checks that required fields are present in the config.
func validateConfig(config *state.AgentConfig) error {
// Model and API URL can come from env vars, so they're not strictly required in config.
// But we validate that the config is at least parseable (already done by JSON unmarshal).
return nil
}
// startStandaloneAgent creates and runs a single agent using the pool,
// without starting the web server.
func startStandaloneAgent(name string, config *state.AgentConfig) error {
// Load all environment variables
env := LoadEnv()
if env.Model == "" {
env.Model = config.Model
}
if env.LLMAPIURL == "" {
env.LLMAPIURL = config.APIURL
}
if env.LLMAPIKey == "" {
env.LLMAPIKey = config.APIKey
}
if env.Model == "" {
return fmt.Errorf("model not set: provide 'model' in config or set LOCALAGI_MODEL")
}
if env.LLMAPIURL == "" {
return fmt.Errorf("API URL not set: provide 'api_url' in config or set LOCALAGI_LLM_API_URL")
}
if env.StateDir == "" {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
env.StateDir = filepath.Join(cwd, "pool")
}
os.MkdirAll(env.StateDir, 0755)
// Override config with resolved values
config.Model = env.Model
config.APIURL = env.LLMAPIURL
config.APIKey = env.LLMAPIKey
config.MultimodalModel = env.MultimodalModel
config.TranscriptionModel = env.TranscriptionModel
config.TranscriptionLanguage = env.TranscriptionLanguage
config.TTSModel = env.TTSModel
if config.PeriodicRuns == "" {
config.PeriodicRuns = "10m"
}
if config.SchedulerPollInterval == "" {
config.SchedulerPollInterval = "30s"
}
// Initialize skills service
skillsService, err := skills.NewService(env.StateDir)
if err != nil {
return fmt.Errorf("failed to initialize skills service: %w", err)
}
// Build service factories
actionsFactory := services.Actions(map[string]string{
services.ActionConfigSSHBoxURL: env.SSHBoxURL,
services.ConfigStateDir: env.StateDir,
services.CustomActionsDir: env.CustomActionsDir,
})
dynamicPromptsFactory := services.DynamicPrompts(map[string]string{
services.ConfigStateDir: env.StateDir,
services.CustomActionsDir: env.CustomActionsDir,
})
// Create the pool and use it to start the agent
pool, err := state.NewAgentPool(
env.Model, env.MultimodalModel, env.TranscriptionModel, env.TranscriptionLanguage, env.TTSModel,
env.LLMAPIURL, env.LLMAPIKey, env.StateDir,
actionsFactory, services.Connectors, dynamicPromptsFactory, services.Filters,
env.Timeout, false, skillsService,
)
if err != nil {
return fmt.Errorf("failed to create agent pool: %w", err)
}
if env.LocalRAGURL != "" {
pool.SetRAGProvider(state.NewHTTPRAGProvider(env.LocalRAGURL, env.LLMAPIKey))
}
// Start the agent via the pool (handles all option building, connectors, etc.)
if err := pool.StartAgentStandalone(name, config); err != nil {
return fmt.Errorf("failed to start agent: %w", err)
}
a := pool.GetAgent(name)
if a == nil {
return fmt.Errorf("agent %q was not found after starting", name)
}
fmt.Fprintf(os.Stderr, "Starting agent %q (model: %s, api: %s)\n", name, env.Model, env.LLMAPIURL)
fmt.Fprintf(os.Stderr, "Press Ctrl+C to stop\n")
// Wait for interrupt
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
fmt.Fprintf(os.Stderr, "\nReceived %v, stopping agent...\n", sig)
pool.Stop(name)
// Give agent a moment to clean up
time.Sleep(2 * time.Second)
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package cmd
import (
"os"
"strconv"
"strings"
)
// Env contains all environment variables used by LocalAGI
type Env struct {
// Model and API configuration
Model string
LLMAPIURL string
LLMAPIKey string
MultimodalModel string
TranscriptionModel string
TranscriptionLanguage string
TTSModel string
Timeout string
// Directories and paths
StateDir string
LocalAGIURL string
LocalRAGURL string
CustomActionsDir string
SSHBoxURL string
CollectionDBPath string
FileAssets string
// Conversation settings
EnableConversationsLogging bool
APIKeys []string
ConversationDuration string
// RAG/Vector settings
VectorEngine string
EmbeddingModel string
MaxChunkingSize int
ChunkOverlap int
DatabaseURL string
}
// LoadEnv reads all environment variables and returns an Env struct
func LoadEnv() Env {
env := Env{
Model: envOrDefault("LOCALAGI_MODEL", ""),
LLMAPIURL: envOrDefault("LOCALAGI_LLM_API_URL", ""),
LLMAPIKey: envOrDefault("LOCALAGI_LLM_API_KEY", ""),
MultimodalModel: envOrDefault("LOCALAGI_MULTIMODAL_MODEL", ""),
TranscriptionModel: envOrDefault("LOCALAGI_TRANSCRIPTION_MODEL", ""),
TranscriptionLanguage: envOrDefault("LOCALAGI_TRANSCRIPTION_LANGUAGE", ""),
TTSModel: envOrDefault("LOCALAGI_TTS_MODEL", ""),
Timeout: envOrDefault("LOCALAGI_TIMEOUT", "5m"),
StateDir: envOrDefault("LOCALAGI_STATE_DIR", ""),
LocalAGIURL: envOrDefault("LOCALAGI_BASE_URL", ":3000"),
LocalRAGURL: os.Getenv("LOCALAGI_LOCALRAG_URL"),
CustomActionsDir: os.Getenv("LOCALAGI_CUSTOM_ACTIONS_DIR"),
SSHBoxURL: os.Getenv("LOCALAGI_SSHBOX_URL"),
EnableConversationsLogging: os.Getenv("LOCALAGI_ENABLE_CONVERSATIONS_LOGGING") == "true",
ConversationDuration: os.Getenv("LOCALAGI_CONVERSATION_DURATION"),
CollectionDBPath: os.Getenv("COLLECTION_DB_PATH"),
FileAssets: os.Getenv("FILE_ASSETS"),
VectorEngine: os.Getenv("VECTOR_ENGINE"),
EmbeddingModel: os.Getenv("EMBEDDING_MODEL"),
DatabaseURL: os.Getenv("DATABASE_URL"),
}
// Parse APIKeys from comma-separated string
if apiKeysEnv := os.Getenv("LOCALAGI_API_KEYS"); apiKeysEnv != "" {
env.APIKeys = strings.Split(apiKeysEnv, ",")
}
// Parse numeric values
if maxChunkingSizeEnv := os.Getenv("MAX_CHUNKING_SIZE"); maxChunkingSizeEnv != "" {
if n, err := strconv.Atoi(maxChunkingSizeEnv); err == nil {
env.MaxChunkingSize = n
}
}
if chunkOverlapEnv := os.Getenv("CHUNK_OVERLAP"); chunkOverlapEnv != "" {
if n, err := strconv.Atoi(chunkOverlapEnv); err == nil {
env.ChunkOverlap = n
}
}
// Set defaults for empty values
if env.VectorEngine == "" {
env.VectorEngine = "chromem"
}
if env.EmbeddingModel == "" {
env.EmbeddingModel = "granite-embedding-107m-multilingual"
}
if env.MaxChunkingSize == 0 {
env.MaxChunkingSize = 400
}
return env
}
// envOrDefault returns the environment variable value if set, otherwise the fallback.
func envOrDefault(envKey, fallback string) string {
if v := os.Getenv(envKey); v != "" {
return v
}
return fallback
}
-38
View File
@@ -1,38 +0,0 @@
package main
import (
"flag"
"log"
"os"
"os/signal"
"syscall"
"github.com/mudler/LocalAGI/pkg/stdio"
)
func main() {
// Parse command line flags
addr := flag.String("addr", ":8080", "HTTP server address")
flag.Parse()
// Create and start the server
server := stdio.NewServer()
// Handle graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Printf("Starting server on %s", *addr)
if err := server.Start(*addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}()
// Wait for shutdown signal
<-sigChan
log.Println("Shutting down server...")
// TODO: Implement graceful shutdown if needed
os.Exit(0)
}
+32
View File
@@ -0,0 +1,32 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "local-agi",
Short: "LocalAGI - Self-hosted AI Agent platform",
Long: "LocalAGI is a self-hosted AI Agent platform that allows running autonomous agents with various connectors, actions, and tools.",
RunE: func(cmd *cobra.Command, args []string) error {
// If no subcommand is provided, default to serving the web server
// This ensures the container starts the web server by default
return serveCmd.RunE(cmd, args)
},
}
// Execute runs the root command.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
rootCmd.AddCommand(serveCmd)
rootCmd.AddCommand(agentCmd)
}
+128
View File
@@ -0,0 +1,128 @@
package cmd
import (
"log"
"os"
"path/filepath"
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/services"
"github.com/mudler/LocalAGI/services/skills"
"github.com/mudler/LocalAGI/webui"
"github.com/spf13/cobra"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start the LocalAGI web server",
Long: "Start the LocalAGI web server with the agent pool and web UI.",
RunE: runServe,
}
func init() {
rootCmd.AddCommand(serveCmd)
}
func runServe(cmd *cobra.Command, args []string) error {
// Load all environment variables
env := LoadEnv()
if env.Model == "" {
return cmd.Help()
}
if env.LLMAPIURL == "" {
return cmd.Help()
}
if env.StateDir == "" {
cwd, err := os.Getwd()
if err != nil {
return err
}
env.StateDir = filepath.Join(cwd, "pool")
}
os.MkdirAll(env.StateDir, 0755)
if env.CollectionDBPath == "" {
env.CollectionDBPath = filepath.Join(env.StateDir, "collections")
}
if env.FileAssets == "" {
env.FileAssets = filepath.Join(env.StateDir, "assets")
}
apiKeys := env.APIKeys
if len(apiKeys) == 0 {
apiKeys = []string{}
}
skillsService, err := skills.NewService(env.StateDir)
if err != nil {
return err
}
pool, err := state.NewAgentPool(
env.Model,
env.MultimodalModel,
env.TranscriptionModel,
env.TranscriptionLanguage,
env.TTSModel,
env.LLMAPIURL,
env.LLMAPIKey,
env.StateDir,
services.Actions(map[string]string{
services.ActionConfigSSHBoxURL: env.SSHBoxURL,
services.ConfigStateDir: env.StateDir,
services.CustomActionsDir: env.CustomActionsDir,
}),
services.Connectors,
services.DynamicPrompts(map[string]string{
services.ConfigStateDir: env.StateDir,
services.CustomActionsDir: env.CustomActionsDir,
}),
services.Filters,
env.Timeout,
env.EnableConversationsLogging,
skillsService,
)
if err != nil {
return err
}
app := webui.NewApp(
webui.WithPool(pool),
webui.WithSkillsService(skillsService),
webui.WithConversationStoreduration(env.ConversationDuration),
webui.WithApiKeys(apiKeys...),
webui.WithLLMAPIUrl(env.LLMAPIURL),
webui.WithLLMAPIKey(env.LLMAPIKey),
webui.WithLLMModel(env.Model),
webui.WithCustomActionsDir(env.CustomActionsDir),
webui.WithStateDir(env.StateDir),
webui.WithCollectionDBPath(env.CollectionDBPath),
webui.WithFileAssets(env.FileAssets),
webui.WithVectorEngine(env.VectorEngine),
webui.WithEmbeddingModel(env.EmbeddingModel),
webui.WithMaxChunkingSize(env.MaxChunkingSize),
webui.WithChunkOverlap(env.ChunkOverlap),
webui.WithDatabaseURL(env.DatabaseURL),
webui.WithLocalRAGURL(env.LocalRAGURL),
)
if env.LocalRAGURL != "" {
pool.SetRAGProvider(state.NewHTTPRAGProvider(env.LocalRAGURL, env.LLMAPIKey))
} else {
embedded := app.CollectionsRAGProvider()
pool.SetRAGProvider(func(collectionName, _, _ string) (agent.RAGDB, state.KBCompactionClient, bool) {
return embedded(collectionName)
})
}
if err := pool.StartAll(); err != nil {
return err
}
log.Fatal(app.Listen(env.LocalAGIURL))
return nil
}
+43 -6
View File
@@ -3,11 +3,13 @@ package action
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
"github.com/traefik/yaegi/interp"
"github.com/traefik/yaegi/stdlib"
@@ -24,7 +26,7 @@ func NewCustom(config map[string]string, goPkgPath string) (*CustomAction, error
}
if err := a.callInit(); err != nil {
xlog.Error("Error calling custom action init", "error", err)
xlog.Warn("No init function found for custom action", "error", err, "action", a.config["name"])
}
return a, nil
@@ -43,18 +45,23 @@ func (a *CustomAction) callInit() error {
v, err := a.i.Eval(fmt.Sprintf("%s.Init", a.config["name"]))
if err != nil {
return err
xlog.Warn("No init function found for custom action", "error", err, "action", a.config["name"])
return nil
}
run := v.Interface().(func() error)
run, ok := v.Interface().(func(string) error)
if !ok {
return nil
}
return run()
return run(a.config["configuration"])
}
func (a *CustomAction) initializeInterpreter() error {
if _, exists := a.config["code"]; exists && a.i == nil {
unsafe := strings.ToLower(a.config["unsafe"]) == "true"
i := interp.New(interp.Options{
Env: os.Environ(),
GoPath: a.goPkgPath,
Unrestricted: unsafe,
})
@@ -66,6 +73,15 @@ func (a *CustomAction) initializeInterpreter() error {
a.config["name"] = "custom"
}
// let's find first if there is already a package declarated in the code
// the user might want to specify it to not break syntax with IDEs
re := regexp.MustCompile("package (\\w+)")
packageName := re.FindStringSubmatch(a.config["code"])
if len(packageName) > 1 {
// remove it from the code, normalize to `name`
a.config["code"] = re.ReplaceAllString(a.config["code"], "")
}
_, err := i.Eval(fmt.Sprintf("package %s\n%s", a.config["name"], a.config["code"]))
if err != nil {
return err
@@ -106,6 +122,21 @@ func (a *CustomAction) Definition() types.ActionDefinition {
return types.ActionDefinition{}
}
description := ""
desc, err := a.i.Eval(fmt.Sprintf("%s.Description", a.config["name"]))
if err != nil {
xlog.Warn("No description found for custom action", "error", err, "action", a.config["name"])
} else {
d, ok := desc.Interface().(func() string)
if ok {
description = d()
}
}
if a.config["description"] != "" {
description = a.config["description"]
}
properties := v.Interface().(func() map[string][]string)
v, err = a.i.Eval(fmt.Sprintf("%s.RequiredFields", a.config["name"]))
@@ -130,7 +161,7 @@ func (a *CustomAction) Definition() types.ActionDefinition {
}
return types.ActionDefinition{
Name: types.ActionDefinitionName(a.config["name"]),
Description: a.config["description"],
Description: description,
Properties: prop,
Required: requiredFields(),
}
@@ -164,5 +195,11 @@ func CustomConfigMeta() []config.Field {
Type: config.FieldTypeCheckbox,
HelpText: "Allow unsafe code execution",
},
{
Name: "configuration",
Label: "Configuration",
Type: config.FieldTypeTextarea,
HelpText: "Configuration for the custom action",
},
}
}
-48
View File
@@ -1,48 +0,0 @@
package action
import (
"context"
"github.com/mudler/LocalAGI/core/types"
"github.com/sashabaranov/go-openai/jsonschema"
)
// NewGoal creates a new intention action
// The inention action is special as it tries to identify
// a tool to use and a reasoning over to use it
func NewGoal() *GoalAction {
return &GoalAction{}
}
type GoalAction struct {
}
type GoalResponse struct {
Goal string `json:"goal"`
Achieved bool `json:"achieved"`
}
func (a *GoalAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
return types.ActionResult{}, nil
}
func (a *GoalAction) Plannable() bool {
return false
}
func (a *GoalAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: "goal",
Description: "Check if the goal is achieved",
Properties: map[string]jsonschema.Definition{
"goal": {
Type: jsonschema.String,
Description: "The goal to check if it is achieved.",
},
"achieved": {
Type: jsonschema.Boolean,
Description: "Whether the goal is achieved",
},
},
Required: []string{"goal", "achieved"},
}
}
-50
View File
@@ -1,50 +0,0 @@
package action
import (
"context"
"github.com/mudler/LocalAGI/core/types"
"github.com/sashabaranov/go-openai/jsonschema"
)
// NewIntention creates a new intention action
// The inention action is special as it tries to identify
// a tool to use and a reasoning over to use it
func NewIntention(s ...string) *IntentAction {
return &IntentAction{tools: s}
}
type IntentAction struct {
tools []string
}
type IntentResponse struct {
Tool string `json:"tool"`
Reasoning string `json:"reasoning"`
}
func (a *IntentAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
return types.ActionResult{}, nil
}
func (a *IntentAction) Plannable() bool {
return false
}
func (a *IntentAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: "pick_tool",
Description: "Pick a tool",
Properties: map[string]jsonschema.Definition{
"reasoning": {
Type: jsonschema.String,
Description: "A detailed reasoning on why you want to call this tool.",
},
"tool": {
Type: jsonschema.String,
Description: "The tool you want to use",
Enum: a.tools,
},
},
Required: []string{"tool", "reasoning"},
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/sashabaranov/go-openai/jsonschema"
)
const ConversationActionName = "new_conversation"
const ConversationActionName = "send_message"
func NewConversation() *ConversationAction {
return &ConversationAction{}
-71
View File
@@ -1,71 +0,0 @@
package action
import (
"context"
"github.com/mudler/LocalAGI/core/types"
"github.com/sashabaranov/go-openai/jsonschema"
)
// PlanActionName is the name of the plan action
// used by the LLM to schedule more actions
const PlanActionName = "plan"
func NewPlan(plannableActions []string) *PlanAction {
return &PlanAction{
plannables: plannableActions,
}
}
type PlanAction struct {
plannables []string
}
type PlanResult struct {
Subtasks []PlanSubtask `json:"subtasks"`
Goal string `json:"goal"`
}
type PlanSubtask struct {
Action string `json:"action"`
Reasoning string `json:"reasoning"`
}
func (a *PlanAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
return types.ActionResult{}, nil
}
func (a *PlanAction) Plannable() bool {
return false
}
func (a *PlanAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: PlanActionName,
Description: "Use it for situations that involves doing more actions in sequence.",
Properties: map[string]jsonschema.Definition{
"subtasks": {
Type: jsonschema.Array,
Description: "The subtasks to be executed",
Items: &jsonschema.Definition{
Type: jsonschema.Object,
Properties: map[string]jsonschema.Definition{
"action": {
Type: jsonschema.String,
Description: "The action to call",
Enum: a.plannables,
},
"reasoning": {
Type: jsonschema.String,
Description: "The reasoning for calling this action",
},
},
},
},
"goal": {
Type: jsonschema.String,
Description: "The goal of this plan",
},
},
Required: []string{"subtasks", "goal"},
}
}
-43
View File
@@ -1,43 +0,0 @@
package action
import (
"context"
"github.com/mudler/LocalAGI/core/types"
"github.com/sashabaranov/go-openai/jsonschema"
)
// NewReasoning creates a new reasoning action
// The reasoning action is special as it tries to force the LLM
// to think about what to do next
func NewReasoning() *ReasoningAction {
return &ReasoningAction{}
}
type ReasoningAction struct{}
type ReasoningResponse struct {
Reasoning string `json:"reasoning"`
}
func (a *ReasoningAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
return types.ActionResult{}, nil
}
func (a *ReasoningAction) Plannable() bool {
return false
}
func (a *ReasoningAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: "pick_action",
Description: "try to understand what's the best thing to do and pick an action with a reasoning",
Properties: map[string]jsonschema.Definition{
"reasoning": {
Type: jsonschema.String,
Description: "A detailed reasoning on what would you do in this situation.",
},
},
Required: []string{"reasoning"},
}
}
+127 -52
View File
@@ -6,19 +6,24 @@ import (
"strings"
"time"
"github.com/mudler/LocalAGI/core/scheduler"
"github.com/mudler/LocalAGI/core/types"
"github.com/robfig/cron/v3"
"github.com/sashabaranov/go-openai/jsonschema"
)
const (
ReminderActionName = "set_reminder"
ListRemindersName = "list_reminders"
RemoveReminderName = "remove_reminder"
RecurringReminderActionName = "set_recurring_task"
OneTimeReminderActionName = "set_onetime_task"
ListRemindersName = "list_tasks"
RemoveReminderName = "remove_task"
)
func NewReminder() *ReminderAction {
return &ReminderAction{}
func NewRecurringReminder() *RecurringReminderAction {
return &RecurringReminderAction{}
}
func NewOneTimeReminder() *OneTimeReminderAction {
return &OneTimeReminderAction{}
}
func NewListReminders() *ListRemindersAction {
@@ -29,7 +34,8 @@ func NewRemoveReminder() *RemoveReminderAction {
return &RemoveReminderAction{}
}
type ReminderAction struct{}
type RecurringReminderAction struct{}
type OneTimeReminderAction struct{}
type ListRemindersAction struct{}
type RemoveReminderAction struct{}
@@ -37,46 +43,87 @@ type RemoveReminderParams struct {
Index int `json:"index"`
}
func (a *ReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := types.ReminderActionResponse{}
func (a *RecurringReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := types.RecurringReminderParams{}
err := params.Unmarshal(&result)
if err != nil {
return types.ActionResult{}, err
}
// Validate the cron expression
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
_, err = parser.Parse(result.CronExpr)
task, err := scheduler.NewTask(
sharedState.AgentName,
result.Message,
scheduler.ScheduleTypeCron,
result.CronExpr,
)
if err != nil {
return types.ActionResult{}, err
}
// Calculate next run time
now := time.Now()
schedule, _ := parser.Parse(result.CronExpr) // We can ignore the error since we validated above
nextRun := schedule.Next(now)
task.Metadata["reminder_type"] = "user_created"
// Set the reminder details
result.LastRun = now
result.NextRun = nextRun
// IsRecurring is set by the user through the action parameters
// Store the reminder in the shared state
if sharedState.Reminders == nil {
sharedState.Reminders = make([]types.ReminderActionResponse, 0)
err = sharedState.Scheduler.CreateTask(task)
if err != nil {
return types.ActionResult{}, err
}
sharedState.Reminders = append(sharedState.Reminders, result)
return types.ActionResult{
Result: "Reminder set successfully",
Result: fmt.Sprintf("Recurring reminder set successfully (ID: %s). Next run: %s", task.ID, task.NextRun.Format(time.RFC3339)),
Metadata: map[string]interface{}{
"reminder": result,
"task_id": task.ID,
"message": result.Message,
"next_run": task.NextRun,
},
}, nil
}
func (a *OneTimeReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := types.OneTimeReminderParams{}
err := params.Unmarshal(&result)
if err != nil {
return types.ActionResult{}, err
}
// Validate the delay parses correctly before creating the task
_, err = scheduler.ParseDuration(result.Delay)
if err != nil {
return types.ActionResult{}, fmt.Errorf("invalid delay format, expected a duration like '30m', '2h', '1d', '1d12h': %w", err)
}
task, err := scheduler.NewTask(
sharedState.AgentName,
result.Message,
scheduler.ScheduleTypeOnce,
result.Delay,
)
if err != nil {
return types.ActionResult{}, err
}
task.Metadata["reminder_type"] = "user_created"
err = sharedState.Scheduler.CreateTask(task)
if err != nil {
return types.ActionResult{}, err
}
return types.ActionResult{
Result: fmt.Sprintf("One-time reminder set in %s (at %s, ID: %s)", result.Delay, task.NextRun.Format(time.RFC3339), task.ID),
Metadata: map[string]interface{}{
"task_id": task.ID,
"message": result.Message,
"next_run": task.NextRun,
},
}, nil
}
func (a *ListRemindersAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if sharedState.Reminders == nil || len(sharedState.Reminders) == 0 {
tasks, err := sharedState.Scheduler.GetAllTasks()
if err != nil {
return types.ActionResult{}, err
}
if len(tasks) == 0 {
return types.ActionResult{
Result: "No reminders set",
}, nil
@@ -84,22 +131,25 @@ func (a *ListRemindersAction) Run(ctx context.Context, sharedState *types.AgentS
var result strings.Builder
result.WriteString("Current reminders:\n")
for i, reminder := range sharedState.Reminders {
for i, task := range tasks {
status := "one-time"
if reminder.IsRecurring {
if task.ScheduleType == scheduler.ScheduleTypeCron || task.ScheduleType == scheduler.ScheduleTypeInterval {
status = "recurring"
}
result.WriteString(fmt.Sprintf("%d. %s (Next run: %s, Status: %s)\n",
result.WriteString(fmt.Sprintf("%d. %s (Next run: %s, Status: %s, ID: %s)\n",
i+1,
reminder.Message,
reminder.NextRun.Format(time.RFC3339),
status))
task.Prompt,
task.NextRun.Format(time.RFC3339),
status,
task.ID))
}
return types.ActionResult{
Result: result.String(),
Metadata: map[string]interface{}{
"reminders": sharedState.Reminders,
"tasks": tasks,
},
}, nil
}
@@ -111,7 +161,12 @@ func (a *RemoveReminderAction) Run(ctx context.Context, sharedState *types.Agent
return types.ActionResult{}, err
}
if sharedState.Reminders == nil || len(sharedState.Reminders) == 0 {
tasks, err := sharedState.Scheduler.GetAllTasks()
if err != nil {
return types.ActionResult{}, err
}
if len(tasks) == 0 {
return types.ActionResult{
Result: "No reminders to remove",
}, nil
@@ -119,23 +174,29 @@ func (a *RemoveReminderAction) Run(ctx context.Context, sharedState *types.Agent
// Convert from 1-based index to 0-based
index := removeParams.Index - 1
if index < 0 || index >= len(sharedState.Reminders) {
if index < 0 || index >= len(tasks) {
return types.ActionResult{}, fmt.Errorf("invalid reminder index: %d", removeParams.Index)
}
// Remove the reminder
removed := sharedState.Reminders[index]
sharedState.Reminders = append(sharedState.Reminders[:index], sharedState.Reminders[index+1:]...)
task := tasks[index]
err = sharedState.Scheduler.DeleteTask(task.ID)
if err != nil {
return types.ActionResult{}, err
}
return types.ActionResult{
Result: fmt.Sprintf("Removed reminder: %s", removed.Message),
Result: fmt.Sprintf("Removed reminder: %s", task.Prompt),
Metadata: map[string]interface{}{
"removed_reminder": removed,
"removed_task_id": task.ID,
},
}, nil
}
func (a *ReminderAction) Plannable() bool {
func (a *RecurringReminderAction) Plannable() bool {
return true
}
func (a *OneTimeReminderAction) Plannable() bool {
return true
}
@@ -147,10 +208,10 @@ func (a *RemoveReminderAction) Plannable() bool {
return true
}
func (a *ReminderAction) Definition() types.ActionDefinition {
func (a *RecurringReminderAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: ReminderActionName,
Description: "Set a reminder for the agent to wake up and perform a task based on a cron schedule. Examples: '0 0 * * *' (daily at midnight), '0 */2 * * *' (every 2 hours), '0 0 * * 1' (every Monday at midnight)",
Name: RecurringReminderActionName,
Description: "Set a recurring reminder for the agent to wake up and perform a task on a cron schedule. The reminder will keep repeating. Examples: '0 0 * * *' (daily at midnight), '0 */2 * * *' (every 2 hours), '0 0 * * 1' (every Monday at midnight)",
Properties: map[string]jsonschema.Definition{
"message": {
Type: jsonschema.String,
@@ -158,14 +219,28 @@ func (a *ReminderAction) Definition() types.ActionDefinition {
},
"cron_expr": {
Type: jsonschema.String,
Description: "Cron expression for scheduling (e.g. '0 0 * * *' for daily at midnight). Format: 'second minute hour day month weekday'",
},
"is_recurring": {
Type: jsonschema.Boolean,
Description: "Whether this reminder should repeat according to the cron schedule (true) or trigger only once (false)",
Description: "Cron expression for scheduling (e.g. '0 0 * * *' for daily at midnight). Format: 'minute hour day month weekday'",
},
},
Required: []string{"message", "cron_expr", "is_recurring"},
Required: []string{"message", "cron_expr"},
}
}
func (a *OneTimeReminderAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: OneTimeReminderActionName,
Description: "Set a one-time reminder for the agent to wake up and perform a task after a delay. The reminder triggers only once and is then automatically removed. Use this when asked to do something 'in X minutes/hours/days'. Examples: '30m' (30 minutes), '2h' (2 hours), '1d' (1 day), '1d12h' (1.5 days), '2h30m' (2.5 hours)",
Properties: map[string]jsonschema.Definition{
"message": {
Type: jsonschema.String,
Description: "The message or task to be reminded about",
},
"delay": {
Type: jsonschema.String,
Description: "How long to wait before triggering. Use Go duration format: '30m' (30 minutes), '2h' (2 hours), '1d' (1 day), '1d12h' (1.5 days), '2h30m' (2.5 hours)",
},
},
Required: []string{"message", "delay"},
}
}
-45
View File
@@ -1,45 +0,0 @@
package action
import (
"context"
"github.com/mudler/LocalAGI/core/types"
"github.com/sashabaranov/go-openai/jsonschema"
)
// ReplyActionName is the name of the reply action
// used by the LLM to reply to the user without
// any additional processing
const ReplyActionName = "reply"
func NewReply() *ReplyAction {
return &ReplyAction{}
}
type ReplyAction struct{}
type ReplyResponse struct {
Message string `json:"message"`
}
func (a *ReplyAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (string, error) {
return "no-op", nil
}
func (a *ReplyAction) Plannable() bool {
return false
}
func (a *ReplyAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: ReplyActionName,
Description: "Use this tool to reply to the user once we have all the informations we need.",
Properties: map[string]jsonschema.Definition{
"message": {
Type: jsonschema.String,
Description: "The message to reply with",
},
},
Required: []string{"message"},
}
}
+39 -500
View File
@@ -1,155 +1,19 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/mudler/LocalAGI/core/action"
"github.com/mudler/LocalAGI/core/types"
"golang.org/x/exp/slices"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
const parameterReasoningPrompt = `You are tasked with generating the optimal parameters for the action "%s". The action requires the following parameters:
%s
Your task is to:
1. Generate the best possible values for each required parameter
2. If the parameter requires code, provide complete, working code
3. If the parameter requires text or documentation, provide comprehensive, well-structured content
4. Ensure all parameters are complete and ready to be used
Focus on quality and completeness. Do not explain your reasoning or analyze the action's purpose - just provide the best possible parameter values.`
type decisionResult struct {
actionParams types.ActionParams
message string
actionName string
}
// decision forces the agent to take one of the available actions
func (a *Agent) decision(
job *types.Job,
conversation []openai.ChatCompletionMessage,
tools []openai.Tool, toolchoice string, maxRetries int) (*decisionResult, error) {
var choice *openai.ToolChoice
if toolchoice != "" {
choice = &openai.ToolChoice{
Type: openai.ToolTypeFunction,
Function: openai.ToolFunction{Name: toolchoice},
}
}
decision := openai.ChatCompletionRequest{
Model: a.options.LLMAPI.Model,
Messages: conversation,
Tools: tools,
}
if choice != nil {
decision.ToolChoice = *choice
}
var obs *types.Observable
if job.Obs != nil {
obs = a.observer.NewObservable()
obs.Name = "decision"
obs.ParentID = job.Obs.ID
obs.Icon = "brain"
obs.Creation = &types.Creation{
ChatCompletionRequest: &decision,
}
a.observer.Update(*obs)
}
var lastErr error
for attempts := 0; attempts < maxRetries; attempts++ {
resp, err := a.client.CreateChatCompletion(job.GetContext(), decision)
if err != nil {
lastErr = err
xlog.Warn("Attempt to make a decision failed", "attempt", attempts+1, "error", err)
if obs != nil {
obs.Progress = append(obs.Progress, types.Progress{
Error: err.Error(),
})
a.observer.Update(*obs)
}
continue
}
jsonResp, _ := json.Marshal(resp)
xlog.Debug("Decision response", "response", string(jsonResp))
if obs != nil {
obs.AddProgress(types.Progress{
ChatCompletionResponse: &resp,
})
}
if len(resp.Choices) != 1 {
lastErr = fmt.Errorf("no choices: %d", len(resp.Choices))
xlog.Warn("Attempt to make a decision failed", "attempt", attempts+1, "error", lastErr)
if obs != nil {
obs.Progress[len(obs.Progress)-1].Error = lastErr.Error()
a.observer.Update(*obs)
}
continue
}
msg := resp.Choices[0].Message
if len(msg.ToolCalls) != 1 {
if err := a.saveConversation(append(conversation, msg), "decision"); err != nil {
xlog.Error("Error saving conversation", "error", err)
}
if obs != nil {
obs.MakeLastProgressCompletion()
a.observer.Update(*obs)
}
return &decisionResult{message: msg.Content}, nil
}
params := types.ActionParams{}
if err := params.Read(msg.ToolCalls[0].Function.Arguments); err != nil {
lastErr = err
xlog.Warn("Attempt to parse action parameters failed", "attempt", attempts+1, "error", err)
if obs != nil {
obs.Progress[len(obs.Progress)-1].Error = lastErr.Error()
a.observer.Update(*obs)
}
continue
}
if err := a.saveConversation(append(conversation, msg), "decision"); err != nil {
xlog.Error("Error saving conversation", "error", err)
}
if obs != nil {
obs.MakeLastProgressCompletion()
a.observer.Update(*obs)
}
return &decisionResult{actionParams: params, actionName: msg.ToolCalls[0].Function.Name, message: msg.Content}, nil
}
return nil, fmt.Errorf("failed to make a decision after %d attempts: %w", maxRetries, lastErr)
}
type Messages []openai.ChatCompletionMessage
func (m Messages) ToOpenAI() []openai.ChatCompletionMessage {
@@ -228,209 +92,47 @@ func (m Messages) GetLatestUserMessage() *openai.ChatCompletionMessage {
return nil
}
func (m Messages) IsLastMessageFromRole(role string) bool {
if len(m) == 0 {
return false
}
return m[len(m)-1].Role == role
}
func (a *Agent) generateParameters(job *types.Job, pickTemplate string, act types.Action, c []openai.ChatCompletionMessage, reasoning string, maxAttempts int) (*decisionResult, error) {
if act == nil {
return nil, fmt.Errorf("action is nil")
}
if len(act.Definition().Properties) > 0 {
xlog.Debug("Action has properties", "action", act.Definition().Name, "properties", act.Definition().Properties)
} else {
xlog.Debug("Action has no properties", "action", act.Definition().Name)
return &decisionResult{actionParams: types.ActionParams{}}, nil
}
stateHUD, err := renderTemplate(pickTemplate, a.prepareHUD(), a.availableActions(), reasoning)
if err != nil {
return nil, err
}
conversation := c
if !Messages(c).Exist(stateHUD) && a.options.enableHUD {
conversation = append([]openai.ChatCompletionMessage{
{
Role: "system",
Content: stateHUD,
},
}, conversation...)
}
cc := conversation
if a.options.forceReasoning {
// First, get the LLM to reason about optimal parameter usage
parameterReasoningPrompt := fmt.Sprintf(parameterReasoningPrompt,
act.Definition().Name,
formatProperties(act.Definition().Properties))
// Get initial reasoning about parameters using askLLM
paramReasoningMsg, err := a.askLLM(job.GetContext(),
append(conversation, openai.ChatCompletionMessage{
Role: "system",
Content: parameterReasoningPrompt,
}),
maxAttempts,
)
if err != nil {
xlog.Warn("Failed to get parameter reasoning", "error", err)
// mergeLeadingSystemMessages replaces all leading system messages with a single
// system message. prefixBlocks are prepended in order (e.g. self-eval, then HUD).
// Only non-empty prefixBlocks are joined. Mid-conversation system messages are unchanged.
func (conv Messages) mergeLeadingSystemMessages(prefixBlocks ...string) Messages {
var leading []string
for _, s := range prefixBlocks {
if s != "" {
leading = append(leading, s)
}
// Combine original reasoning with parameter-specific reasoning
enhancedReasoning := reasoning
if paramReasoningMsg.Content != "" {
enhancedReasoning = fmt.Sprintf("%s\n\nParameter Analysis:\n%s", reasoning, paramReasoningMsg.Content)
}
i := 0
for i < len(conv) && conv[i].Role == SystemRole {
content := conv[i].Content
if content == "" && conv[i].MultiContent != nil {
for _, part := range conv[i].MultiContent {
if part.Type == openai.ChatMessagePartTypeText && part.Text != "" {
content = part.Text
break
}
}
}
cc = append(conversation, openai.ChatCompletionMessage{
Role: "system",
Content: fmt.Sprintf("The agent decided to use the tool %s with the following reasoning: %s", act.Definition().Name, enhancedReasoning),
})
}
var result *decisionResult
var attemptErr error
for attempts := 0; attempts < maxAttempts; attempts++ {
result, attemptErr = a.decision(job,
cc,
a.availableActions().ToTools(),
act.Definition().Name.String(),
maxAttempts,
)
if attemptErr == nil && result.actionParams != nil {
return result, nil
if content != "" {
leading = append(leading, content)
}
xlog.Warn("Attempt to generate parameters failed", "attempt", attempts+1, "error", attemptErr)
i++
}
return nil, fmt.Errorf("failed to generate parameters after %d attempts: %w", maxAttempts, attemptErr)
}
// Helper function to format properties for the prompt
func formatProperties(props map[string]jsonschema.Definition) string {
var result strings.Builder
for name, prop := range props {
result.WriteString(fmt.Sprintf("- %s: %s\n", name, prop.Description))
if len(leading) == 0 {
return conv
}
return result.String()
}
func (a *Agent) handlePlanning(ctx context.Context, job *types.Job, chosenAction types.Action, actionParams types.ActionParams, reasoning string, pickTemplate string, conv Messages) (Messages, error) {
// Planning: run all the actions in sequence
if !chosenAction.Definition().Name.Is(action.PlanActionName) {
xlog.Debug("no plan action")
return conv, nil
combined := strings.Join(leading, "\n\n")
single := openai.ChatCompletionMessage{
Role: SystemRole,
Content: combined,
}
xlog.Debug("[planning]...")
planResult := action.PlanResult{}
if err := actionParams.Unmarshal(&planResult); err != nil {
return conv, fmt.Errorf("error unmarshalling plan result: %w", err)
}
stateResult := types.ActionState{
ActionCurrentState: types.ActionCurrentState{
Job: job,
Action: chosenAction,
Params: actionParams,
Reasoning: reasoning,
},
ActionResult: types.ActionResult{
Result: fmt.Sprintf("planning %s, subtasks: %+v", planResult.Goal, planResult.Subtasks),
},
}
job.Result.SetResult(stateResult)
job.CallbackWithResult(stateResult)
xlog.Info("[Planning] starts", "agent", a.Character.Name, "goal", planResult.Goal)
for _, s := range planResult.Subtasks {
xlog.Info("[Planning] subtask", "agent", a.Character.Name, "action", s.Action, "reasoning", s.Reasoning)
}
if len(planResult.Subtasks) == 0 {
return conv, fmt.Errorf("no subtasks")
}
// Execute all subtasks in sequence
for _, subtask := range planResult.Subtasks {
xlog.Info("[subtask] Generating parameters",
"agent", a.Character.Name,
"action", subtask.Action,
"reasoning", reasoning,
)
subTaskAction := a.availableActions().Find(subtask.Action)
if subTaskAction == nil {
xlog.Error("Action not found: %s", subtask.Action)
return conv, fmt.Errorf("action %s not found", subtask.Action)
}
subTaskReasoning := fmt.Sprintf("%s Overall goal is: %s", subtask.Reasoning, planResult.Goal)
params, err := a.generateParameters(job, pickTemplate, subTaskAction, conv, subTaskReasoning, maxRetries)
if err != nil {
xlog.Error("error generating action's parameters", "error", err)
return conv, fmt.Errorf("error generating action's parameters: %w", err)
}
actionParams = params.actionParams
if !job.Callback(types.ActionCurrentState{
Job: job,
Action: subTaskAction,
Params: actionParams,
Reasoning: subTaskReasoning,
}) {
job.Result.SetResult(types.ActionState{
ActionCurrentState: types.ActionCurrentState{
Job: job,
Action: chosenAction,
Params: actionParams,
Reasoning: subTaskReasoning,
},
ActionResult: types.ActionResult{
Result: "stopped by callback",
},
})
job.Result.Conversation = conv
job.Result.Finish(nil)
break
}
result, err := a.runAction(job, subTaskAction, actionParams)
if err != nil {
xlog.Error("error running action", "error", err)
return conv, fmt.Errorf("error running action: %w", err)
}
stateResult := types.ActionState{
ActionCurrentState: types.ActionCurrentState{
Job: job,
Action: subTaskAction,
Params: actionParams,
Reasoning: subTaskReasoning,
},
ActionResult: result,
}
job.Result.SetResult(stateResult)
job.CallbackWithResult(stateResult)
xlog.Debug("[subtask] Action executed", "agent", a.Character.Name, "action", subTaskAction.Definition().Name, "result", result)
conv = a.addFunctionResultToConversation(job.GetContext(), subTaskAction, actionParams, result, conv)
}
return conv, nil
return append([]openai.ChatCompletionMessage{single}, conv[i:]...)
}
// getAvailableActionsForJob returns available actions including user-defined ones for a specific job
func (a *Agent) getAvailableActionsForJob(job *types.Job) types.Actions {
// Start with regular available actions
baseActions := a.availableActions()
baseActions := a.availableActions(job)
// Add user-defined actions from the job
userTools := job.GetUserTools()
@@ -443,27 +145,11 @@ func (a *Agent) getAvailableActionsForJob(job *types.Job) types.Actions {
return baseActions
}
func (a *Agent) availableActions() types.Actions {
func (a *Agent) availableActions(j *types.Job) types.Actions {
// defaultActions := append(a.options.userActions, action.NewReply())
addPlanAction := func(actions types.Actions) types.Actions {
if !a.options.canPlan {
return actions
}
plannablesActions := []string{}
for _, a := range actions {
if a.Plannable() {
plannablesActions = append(plannablesActions, a.Definition().Name.String())
}
}
planAction := action.NewPlan(plannablesActions)
actions = append(actions, planAction)
return actions
}
defaultActions := append(a.mcpActions, a.options.userActions...)
if a.options.initiateConversations && a.selfEvaluationInProgress { // && self-evaluation..
defaultActions := slices.Clone(a.options.userActions)
if j.Metadata["type"] == "scheduled" || (a.options.initiateConversations && a.selfEvaluationInProgress) { // && self-evaluation..
acts := append(defaultActions, action.NewConversation())
if a.options.enableHUD {
acts = append(acts, action.NewState())
@@ -472,7 +158,7 @@ func (a *Agent) availableActions() types.Actions {
// acts = append(acts, action.NewStop())
// }
return addPlanAction(acts)
return acts
}
if a.options.canStopItself {
@@ -480,14 +166,14 @@ func (a *Agent) availableActions() types.Actions {
if a.options.enableHUD {
acts = append(acts, action.NewState())
}
return addPlanAction(acts)
return acts
}
if a.options.enableHUD {
return addPlanAction(append(defaultActions, action.NewState()))
return append(defaultActions, action.NewState())
}
return addPlanAction(defaultActions)
return defaultActions
}
func (a *Agent) prepareHUD() (promptHUD *PromptHUD) {
@@ -502,150 +188,3 @@ func (a *Agent) prepareHUD() (promptHUD *PromptHUD) {
ShowCharacter: a.options.showCharacter,
}
}
// pickAction picks an action based on the conversation
func (a *Agent) pickAction(job *types.Job, templ string, messages []openai.ChatCompletionMessage, maxRetries int) (types.Action, types.ActionParams, string, error) {
c := messages
xlog.Debug("[pickAction] picking action starts", "messages", messages)
// Get available actions including user-defined ones
availableActions := a.getAvailableActionsForJob(job)
// Identify the goal of this conversation
if !a.options.forceReasoning || job.ToolChoice != "" {
xlog.Debug("not forcing reasoning", "forceReasoning", a.options.forceReasoning, "ToolChoice", job.ToolChoice)
// We also could avoid to use functions here and get just a reply from the LLM
// and then use the reply to get the action
thought, err := a.decision(job,
messages,
availableActions.ToTools(),
job.ToolChoice,
maxRetries)
if err != nil {
return nil, nil, "", err
}
xlog.Debug("thought action Name", "actionName", thought.actionName)
xlog.Debug("thought message", "message", thought.message)
// Find the action
chosenAction := availableActions.Find(thought.actionName)
if chosenAction == nil || thought.actionName == "" {
xlog.Debug("no answer")
// LLM replied with an answer?
//fmt.Errorf("no action found for intent:" + thought.actioName)
return nil, nil, thought.message, nil
}
xlog.Debug(fmt.Sprintf("chosenAction: %v", chosenAction.Definition().Name))
return chosenAction, thought.actionParams, thought.message, nil
}
// Force the LLM to think and we extract a "reasoning" to pick a specific action and with which parameters
xlog.Debug("[pickAction] forcing reasoning")
prompt, err := renderTemplate(templ, a.prepareHUD(), a.availableActions(), "")
if err != nil {
return nil, nil, "", err
}
// Get the LLM to think on what to do
// and have a thought
if !Messages(c).Exist(prompt) {
c = append([]openai.ChatCompletionMessage{
{
Role: "system",
Content: prompt,
},
}, c...)
}
// Create a detailed prompt for reasoning that includes available actions and their properties
reasoningPrompt := "Analyze the current situation and determine the best course of action. Consider the following:\n\n"
reasoningPrompt += "Available Actions:\n"
for _, act := range a.availableActions() {
reasoningPrompt += fmt.Sprintf("- %s: %s\n", act.Definition().Name, act.Definition().Description)
if len(act.Definition().Properties) > 0 {
reasoningPrompt += " Properties:\n"
for name, prop := range act.Definition().Properties {
reasoningPrompt += fmt.Sprintf(" - %s: %s\n", name, prop.Description)
}
}
reasoningPrompt += "\n"
}
reasoningPrompt += "\nProvide a detailed reasoning about what action would be most appropriate in this situation and why. You can also just reply with a simple message by choosing the 'reply' or 'answer' action."
// Get reasoning using askLLM
reasoningMsg, err := a.askLLM(job.GetContext(),
append(c, openai.ChatCompletionMessage{
Role: "system",
Content: reasoningPrompt,
}),
maxRetries)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to get reasoning: %w", err)
}
originalReasoning := reasoningMsg.Content
xlog.Debug("[pickAction] picking action", "messages", c)
actionsID := []string{"reply"}
for _, m := range a.availableActions() {
actionsID = append(actionsID, m.Definition().Name.String())
}
xlog.Debug("[pickAction] actionsID", "actionsID", actionsID)
intentionsTools := action.NewIntention(actionsID...)
// TODO: FORCE to select ana ction here
// NOTE: we do not give the full conversation here to pick the action
// to avoid hallucinations
// Extract an action
params, err := a.decision(job,
append(c, openai.ChatCompletionMessage{
Role: "system",
Content: "Pick the relevant action given the following reasoning: " + originalReasoning,
}),
types.Actions{intentionsTools}.ToTools(),
intentionsTools.Definition().Name.String(), maxRetries)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to get the action tool parameters: %v", err)
}
if params.actionParams == nil {
xlog.Debug("[pickAction] no action params found")
return nil, nil, params.message, nil
}
actionChoice := action.IntentResponse{}
err = params.actionParams.Unmarshal(&actionChoice)
if err != nil {
return nil, nil, "", err
}
if actionChoice.Tool == "" || actionChoice.Tool == "reply" {
xlog.Debug("[pickAction] no action found, replying")
return nil, nil, "", nil
}
chosenAction := a.availableActions().Find(actionChoice.Tool)
xlog.Debug("[pickAction] chosenAction", "chosenAction", chosenAction, "actionName", actionChoice.Tool)
// // Let's double check if the action is correct by asking the LLM to judge it
// if chosenAction!= nil {
// promptString:= "Given the following goal and thoughts, is the action correct? \n\n"
// promptString+= fmt.Sprintf("Goal: %s\n", goalResponse.Goal)
// promptString+= fmt.Sprintf("Thoughts: %s\n", originalReasoning)
// promptString+= fmt.Sprintf("Action: %s\n", chosenAction.Definition().Name.String())
// promptString+= fmt.Sprintf("Action description: %s\n", chosenAction.Definition().Description)
// promptString+= fmt.Sprintf("Action parameters: %s\n", params.actionParams)
// }
return chosenAction, nil, originalReasoning, nil
}
+864 -702
View File
File diff suppressed because it is too large Load Diff
+47 -18
View File
@@ -7,8 +7,8 @@ import (
"strings"
"sync"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/services/actions"
"github.com/mudler/xlog"
. "github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
@@ -37,7 +37,8 @@ var debugOptions = []types.JobOption{
}
type TestAction struct {
response map[string]string
response map[string]string
definition *types.ActionDefinition
}
func (a *TestAction) Plannable() bool {
@@ -55,7 +56,7 @@ func (a *TestAction) Run(c context.Context, sharedState *types.AgentSharedState,
}
func (a *TestAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
def := types.ActionDefinition{
Name: "get_weather",
Description: "get current weather",
Properties: map[string]jsonschema.Definition{
@@ -71,6 +72,11 @@ func (a *TestAction) Definition() types.ActionDefinition {
Required: []string{"location"},
}
if a.definition != nil {
def = *a.definition
}
return def
}
type FakeStoreResultAction struct {
@@ -128,7 +134,6 @@ var _ = Describe("Agent test", func() {
WithModel(testModel),
EnableForceReasoning,
WithTimeout("10m"),
WithLoopDetectionSteps(3),
// WithRandomIdentity(),
WithActions(&TestAction{response: map[string]string{
"boston": testActionResult,
@@ -153,6 +158,9 @@ var _ = Describe("Agent test", func() {
}
Expect(reasons).To(ContainElement(testActionResult), fmt.Sprint(res))
Expect(reasons).To(ContainElement(testActionResult2), fmt.Sprint(res))
Expect(len(res.Conversation)).To(BeNumerically(">", 1), fmt.Sprint(res.Conversation))
reasons = []string{}
res = agent.Ask(
@@ -225,11 +233,33 @@ var _ = Describe("Agent test", func() {
WithModel(testModel),
WithLLMAPIKey(apiKeyURL),
WithTimeout("10m"),
WithMaxEvaluationLoops(1),
WithActions(
&TestAction{response: map[string]string{
"boston": testActionResult,
"milan": testActionResult2,
}},
&TestAction{
response: map[string]string{
"boston": testActionResult,
"milan": testActionResult2,
},
},
&TestAction{
response: map[string]string{
"flight": "Flight options from Boston to Milan (April 22-26, 2025):\n• Outbound: Boston Logan (BOS) → Milan Malpensa (MXP), April 22, 2025\n - Economy: $450-650 (Alitalia, Delta, Lufthansa)\n - Business: $1,200-1,800\n - Flight time: 8h 15m (1 stop) or 9h 45m (direct)\n• Return: Milan Malpensa (MXP) → Boston Logan (BOS), April 26, 2025\n - Economy: $420-580\n - Business: $1,100-1,600\n• Total estimated cost: $870-1,230 per person\n• Best booking window: 2-3 months in advance for optimal prices",
"hotel": "Hotel recommendations in Milan for April 22-26, 2025:\n• Luxury (4-5 stars): $200-400/night\n - Hotel Principe di Savoia: $380/night (central location)\n - Mandarin Oriental: $420/night (luxury amenities)\n• Mid-range (3-4 stars): $120-200/night\n - Hotel Spadari al Duomo: $160/night (near cathedral)\n - Hotel Milano Scala: $140/night (theater district)\n• Budget (2-3 stars): $80-120/night\n - Hotel Bernina: $95/night (near train station)\n• Total 4-night stay: $320-1,680 depending on category\n• Booking tip: Reserve early for spring season discounts",
"car": "Car rental options in Milan for April 22-26, 2025:\n• Economy cars: $35-50/day (Fiat 500, VW Polo)\n• Compact cars: $45-65/day (Ford Focus, Opel Astra)\n• Mid-size cars: $60-85/day (BMW 3 Series, Audi A4)\n• SUV/Luxury: $90-150/day (BMW X3, Mercedes E-Class)\n• Total 4-day rental: $140-600\n• Pickup locations: Milan Malpensa Airport, Milan Central Station, city center\n• Insurance: $15-25/day additional\n• Fuel: ~$60-80 for 4 days of city driving\n• Parking: $20-40/day in city center hotels",
"food": "Dining budget and recommendations for Milan (April 22-26, 2025):\n• Fine dining: $80-150/person (Michelin-starred restaurants)\n - Cracco: $120/person (2 Michelin stars)\n - Trussardi alla Scala: $100/person\n• Mid-range restaurants: $40-80/person\n - Luini: $15/person (famous panzerotti)\n - Piz: $25/person (authentic pizza)\n• Casual dining: $20-40/person\n - Aperitivo bars: $15-25/person\n - Street food: $8-15/person\n• Daily food budget: $60-120/person\n• Total 4-day food cost: $240-480/person\n• Must-try: Risotto alla Milanese, Osso Buco, Panettone",
},
definition: &types.ActionDefinition{
Name: "search_internet",
Description: "search the internet for information",
Properties: map[string]jsonschema.Definition{
"query": {
Type: jsonschema.String,
Description: "The query to search for",
},
},
Required: []string{"query"},
},
},
),
EnablePlanning,
EnableForceReasoning,
@@ -241,21 +271,20 @@ var _ = Describe("Agent test", func() {
defer agent.Stop()
result := agent.Ask(
types.WithText("Use the plan tool to do two actions in sequence: search for the weather in boston and search for the weather in milan"),
types.WithText("Create a plan for my 4-day trip from Boston to milan in April of this year (2025). I'm not sure about the dates yet, I want you to find out the best dates also according to what you find."),
)
Expect(len(result.State)).To(BeNumerically(">", 1))
Expect(len(result.Conversation)).To(BeNumerically(">", 1), fmt.Sprint(result.Conversation))
Expect(len(result.Plans)).To(BeNumerically(">=", 1), fmt.Sprintf("%+v", result))
Expect(len(result.State)).To(BeNumerically(">=", 1))
actionsExecuted := []string{}
actionResults := []string{}
for _, r := range result.State {
xlog.Info(r.Result)
actionsExecuted = append(actionsExecuted, r.Action.Definition().Name.String())
actionResults = append(actionResults, r.ActionResult.Result)
}
Expect(actionsExecuted).To(ContainElement("get_weather"), fmt.Sprint(result))
Expect(actionsExecuted).To(ContainElement("plan"), fmt.Sprint(result))
Expect(actionResults).To(ContainElement(testActionResult), fmt.Sprint(result))
Expect(actionResults).To(ContainElement(testActionResult2), fmt.Sprint(result))
Expect(actionsExecuted).To(Or(ContainElement("search_internet"), ContainElement("get_weather")), fmt.Sprint(result))
})
It("Can initiate conversations", func() {
@@ -267,9 +296,9 @@ var _ = Describe("Agent test", func() {
WithModel(testModel),
WithLLMAPIKey(apiKeyURL),
WithTimeout("10m"),
WithNewConversationSubscriber(func(m openai.ChatCompletionMessage) {
WithNewConversationSubscriber(func(m *types.ConversationMessage) {
mu.Lock()
message = m
message = m.Message
mu.Unlock()
}),
WithActions(
-162
View File
@@ -1,162 +0,0 @@
package agent
import (
"fmt"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/llm"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
type EvaluationResult struct {
Satisfied bool `json:"satisfied"`
Gaps []string `json:"gaps"`
Reasoning string `json:"reasoning"`
}
type GoalExtraction struct {
Goal string `json:"goal"`
Constraints []string `json:"constraints"`
Context string `json:"context"`
}
func (a *Agent) extractGoal(job *types.Job, conv []openai.ChatCompletionMessage) (*GoalExtraction, error) {
// Create the goal extraction schema
schema := jsonschema.Definition{
Type: jsonschema.Object,
Properties: map[string]jsonschema.Definition{
"goal": {
Type: jsonschema.String,
Description: "The main goal or request from the user",
},
"constraints": {
Type: jsonschema.Array,
Items: &jsonschema.Definition{
Type: jsonschema.String,
},
Description: "Any constraints or requirements specified by the user",
},
"context": {
Type: jsonschema.String,
Description: "Additional context that might be relevant for understanding the goal",
},
},
Required: []string{"goal", "constraints", "context"},
}
// Create the goal extraction prompt
prompt := `Analyze the conversation and extract the user's main goal, any constraints, and relevant context.
Consider the entire conversation history to understand the complete context and requirements.
Focus on identifying the primary objective and any specific requirements or limitations mentioned.`
var result GoalExtraction
err := llm.GenerateTypedJSONWithConversation(job.GetContext(), a.client,
append(
[]openai.ChatCompletionMessage{
{
Role: "system",
Content: prompt,
},
},
conv...), a.options.LLMAPI.Model, schema, &result)
if err != nil {
return nil, fmt.Errorf("error extracting goal: %w", err)
}
return &result, nil
}
func (a *Agent) evaluateJob(job *types.Job, conv []openai.ChatCompletionMessage) (*EvaluationResult, error) {
if !a.options.enableEvaluation {
return &EvaluationResult{Satisfied: true}, nil
}
// Extract the goal first
goal, err := a.extractGoal(job, conv)
if err != nil {
return nil, fmt.Errorf("error extracting goal: %w", err)
}
// Create the evaluation schema
schema := jsonschema.Definition{
Type: jsonschema.Object,
Properties: map[string]jsonschema.Definition{
"satisfied": {
Type: jsonschema.Boolean,
},
"gaps": {
Type: jsonschema.Array,
Items: &jsonschema.Definition{
Type: jsonschema.String,
},
},
"reasoning": {
Type: jsonschema.String,
},
},
Required: []string{"satisfied", "gaps", "reasoning"},
}
// Create the evaluation prompt
prompt := fmt.Sprintf(`Evaluate if the assistant has satisfied the user's request. Consider:
1. The identified goal: %s
2. Constraints and requirements: %v
3. Context: %s
4. The conversation history
5. Any gaps or missing information
6. Whether the response fully addresses the user's needs
Provide a detailed evaluation with specific gaps if any are found.`,
goal.Goal,
goal.Constraints,
goal.Context)
var result EvaluationResult
err = llm.GenerateTypedJSONWithConversation(job.GetContext(), a.client,
append(
[]openai.ChatCompletionMessage{
{
Role: "system",
Content: prompt,
},
},
conv...),
a.options.LLMAPI.Model, schema, &result)
if err != nil {
return nil, fmt.Errorf("error generating evaluation: %w", err)
}
return &result, nil
}
func (a *Agent) handleEvaluation(job *types.Job, conv []openai.ChatCompletionMessage, currentLoop int) (bool, []openai.ChatCompletionMessage, error) {
if !a.options.enableEvaluation || currentLoop >= a.options.maxEvaluationLoops {
return true, conv, nil
}
result, err := a.evaluateJob(job, conv)
if err != nil {
return false, conv, err
}
if result.Satisfied {
return true, conv, nil
}
// If there are gaps, we need to address them
if len(result.Gaps) > 0 {
// Add the evaluation result to the conversation
conv = append(conv, openai.ChatCompletionMessage{
Role: "system",
Content: fmt.Sprintf("Evaluation found gaps that need to be addressed:\n%s\nReasoning: %s",
result.Gaps, result.Reasoning),
})
xlog.Debug("Evaluation found gaps, incrementing loop count", "loop", currentLoop+1)
return false, conv, nil
}
return true, conv, nil
}
+169 -13
View File
@@ -1,23 +1,35 @@
package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/cogito"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
func (a *Agent) knowledgeBaseLookup(job *types.Job, conv Messages) Messages {
if (!a.options.enableKB && !a.options.enableLongTermMemory && !a.options.enableSummaryMemory) ||
len(conv) <= 0 {
// Only run KB recall/lookup when KB is explicitly enabled; long-term/summary memory
// only affect saving in saveConversation, not this lookup.
if !a.options.enableKB || len(conv) <= 0 {
xlog.Debug("[Knowledge Base Lookup] Disabled, skipping", "agent", a.Character.Name)
return conv
}
if !a.options.kbAutoSearch {
xlog.Debug("[Knowledge Base Lookup] Auto search disabled, skipping", "agent", a.Character.Name)
return conv
}
if a.options.ragdb == nil {
xlog.Debug("[Knowledge Base Lookup] No RAG DB configured, skipping", "agent", a.Character.Name)
return conv
}
var obs *types.Observable
if job != nil && job.Obs != nil && a.observer != nil {
obs = a.observer.NewObservable()
@@ -90,7 +102,7 @@ func (a *Agent) knowledgeBaseLookup(job *types.Job, conv Messages) Messages {
if obs != nil {
obs.Completion = &types.Completion{
Conversation: []openai.ChatCompletionMessage{systemMessage},
Conversation: []openai.ChatCompletionMessage{systemMessage},
}
a.observer.Update(*obs)
}
@@ -125,24 +137,168 @@ func (a *Agent) saveCurrentConversation(conv Messages) {
xlog.Info("Saving conversation", "agent", a.Character.Name, "conversation size", len(conv))
if a.options.enableSummaryMemory && len(conv) > 0 {
msg, err := a.askLLM(a.context.Context, []openai.ChatCompletionMessage{{
Role: "user",
Content: "Summarize the conversation below, keep the highlights as a bullet list:\n" + Messages(conv).String(),
}}, maxRetries)
fragment := cogito.NewEmptyFragment().AddStartMessage("user", "Summarize the conversation below, keep the highlights as a bullet list:\n"+Messages(conv).String())
fragment, err := a.llm.Ask(a.context.Context, fragment)
if err != nil {
xlog.Error("Error summarizing conversation", "error", err)
}
msg := fragment.LastMessage()
if err := a.options.ragdb.Store(msg.Content); err != nil {
xlog.Error("Error storing into memory", "error", err)
}
} else {
for _, message := range conv {
if message.Role == "user" {
if err := a.options.ragdb.Store(message.Content); err != nil {
xlog.Error("Error storing into memory", "error", err)
// Use the conversation storage mode to determine what to store
switch a.options.conversationStorageMode {
case StoreWholeConversation:
// Store the entire conversation as a single block
if len(conv) > 0 {
convStr := Messages(conv).String()
if err := a.options.ragdb.Store(convStr); err != nil {
xlog.Error("Error storing whole conversation into memory", "error", err)
}
}
case StoreUserAndAssistant:
// Store user and assistant messages separately
for _, message := range conv {
if message.Role == "user" || message.Role == "assistant" {
if err := a.options.ragdb.Store(message.Content); err != nil {
xlog.Error("Error storing message into memory", "error", err, "role", message.Role)
}
}
}
case StoreUserOnly:
fallthrough
default:
// Store only user messages (default behavior)
for _, message := range conv {
if message.Role == "user" {
if err := a.options.ragdb.Store(message.Content); err != nil {
xlog.Error("Error storing into memory", "error", err)
}
}
}
}
}
}
// KBWrapperActions wraps RAGDB functionality as actions
type KBWrapperActions struct {
ragdb RAGDB
kbResults int
}
type SearchKnowledgeBaseAction struct {
*KBWrapperActions
}
type AddToKnowledgeBaseAction struct {
*KBWrapperActions
}
// NewKBWrapperActions creates factory functions for KB wrapper actions
func NewKBWrapperActions(ragdb RAGDB, kbResults int) (*SearchKnowledgeBaseAction, *AddToKnowledgeBaseAction) {
wrapper := &KBWrapperActions{
ragdb: ragdb,
kbResults: kbResults,
}
return &SearchKnowledgeBaseAction{wrapper}, &AddToKnowledgeBaseAction{wrapper}
}
func (a *SearchKnowledgeBaseAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if a.ragdb == nil {
return types.ActionResult{}, fmt.Errorf("knowledge base is not configured")
}
var req struct {
Query string `json:"query"`
}
if err := params.Unmarshal(&req); err != nil {
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
}
if req.Query == "" {
return types.ActionResult{}, fmt.Errorf("query cannot be empty")
}
results, err := a.ragdb.Search(req.Query, a.kbResults)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to search knowledge base: %w", err)
}
if len(results) == 0 {
return types.ActionResult{
Result: fmt.Sprintf("No results found for query: %q", req.Query),
}, nil
}
formatResults := ""
for i, r := range results {
formatResults += fmt.Sprintf("%d. %s\n", i+1, r)
}
return types.ActionResult{
Result: fmt.Sprintf("Found %d result(s) for query %q:\n%s", len(results), req.Query, formatResults),
Metadata: map[string]interface{}{
"query": req.Query,
"results": results,
"count": len(results),
},
}, nil
}
func (a *SearchKnowledgeBaseAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: types.ActionDefinitionName("search_memory"),
Description: "Search your memory for relevant information using a query string",
Properties: map[string]jsonschema.Definition{
"query": {
Type: jsonschema.String,
Description: "The search query to find relevant information in the knowledge base",
},
},
Required: []string{"query"},
}
}
func (a *AddToKnowledgeBaseAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if a.ragdb == nil {
return types.ActionResult{}, fmt.Errorf("knowledge base is not configured")
}
var req struct {
Content string `json:"content"`
}
if err := params.Unmarshal(&req); err != nil {
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
}
if req.Content == "" {
return types.ActionResult{}, fmt.Errorf("content cannot be empty")
}
if err := a.ragdb.Store(req.Content); err != nil {
return types.ActionResult{}, fmt.Errorf("failed to store content in knowledge base: %w", err)
}
return types.ActionResult{
Result: "Successfully added content to knowledge base",
Metadata: map[string]interface{}{
"content": req.Content,
},
}, nil
}
func (a *AddToKnowledgeBaseAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: types.ActionDefinitionName("add_memory"),
Description: "Add new content to your memory for future retrieval",
Properties: map[string]jsonschema.Definition{
"content": {
Type: jsonschema.String,
Description: "The content to store in the knowledge base",
},
},
Required: []string{"content"},
}
}
+139 -120
View File
@@ -3,18 +3,21 @@ package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"time"
mcp "github.com/metoro-io/mcp-golang"
"github.com/metoro-io/mcp-golang/transport/http"
stdioTransport "github.com/metoro-io/mcp-golang/transport/stdio"
"net/http"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/stdio"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
var _ types.Action = &mcpAction{}
var _ types.Action = &mcpWrapperAction{}
type MCPServer struct {
URL string `json:"url"`
@@ -22,49 +25,26 @@ type MCPServer struct {
}
type MCPSTDIOServer struct {
Name string `json:"name,omitempty"`
Args []string `json:"args"`
Env []string `json:"env"`
Cmd string `json:"cmd"`
}
type mcpAction struct {
mcpClient *mcp.Client
type mcpWrapperAction struct {
mcpClient *mcp.ClientSession
inputSchema ToolInputSchema
toolName string
toolDescription string
}
func (a *mcpAction) Plannable() bool {
return true
func (m *mcpWrapperAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
// We don't call the method here, it is used by cogito.
// We will just use these to have a list of actions that MCP server provides for resolving internal states
return types.ActionResult{Result: "MCP action called"}, fmt.Errorf("not implemented")
}
func (m *mcpAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
resp, err := m.mcpClient.CallTool(ctx, m.toolName, params)
if err != nil {
xlog.Error("Failed to call tool", "error", err.Error())
return types.ActionResult{}, err
}
xlog.Debug("MCP response", "response", resp)
textResult := ""
for _, c := range resp.Content {
switch c.Type {
case mcp.ContentTypeText:
textResult += c.TextContent.Text + "\n"
case mcp.ContentTypeImage:
xlog.Error("Image content not supported yet")
case mcp.ContentTypeEmbeddedResource:
xlog.Error("Embedded resource content not supported yet")
}
}
return types.ActionResult{
Result: textResult,
}, nil
}
func (m *mcpAction) Definition() types.ActionDefinition {
func (m *mcpWrapperAction) Definition() types.ActionDefinition {
props := map[string]jsonschema.Definition{}
dat, err := json.Marshal(m.inputSchema.Properties)
if err != nil {
@@ -87,87 +67,110 @@ type ToolInputSchema struct {
Required []string `json:"required,omitempty"`
}
func (a *Agent) addTools(client *mcp.Client) (types.Actions, error) {
func (a *Agent) addTools(client *mcp.ClientSession) (types.Actions, error) {
var generatedActions types.Actions
xlog.Debug("Initializing client")
// Initialize the client
response, e := client.Initialize(a.context)
if e != nil {
xlog.Error("Failed to initialize client", "error", e.Error())
return nil, e
tools, err := client.ListTools(a.context, nil)
if err != nil {
xlog.Error("Failed to list tools", "error", err.Error())
return nil, err
}
xlog.Debug("Client initialized: %v", response.Instructions)
for _, t := range tools.Tools {
desc := ""
if t.Description != "" {
desc = t.Description
}
var cursor *string
for {
tools, err := client.ListTools(a.context, cursor)
xlog.Debug("Tool", "name", t.Name, "description", desc)
dat, err := json.Marshal(t.InputSchema)
if err != nil {
xlog.Error("Failed to list tools", "error", err.Error())
return nil, err
xlog.Error("Failed to marshal input schema", "error", err.Error())
}
for _, t := range tools.Tools {
desc := ""
if t.Description != nil {
desc = *t.Description
}
xlog.Debug("Input schema", "tool", t.Name, "schema", string(dat))
xlog.Debug("Tool", "name", t.Name, "description", desc)
dat, err := json.Marshal(t.InputSchema)
if err != nil {
xlog.Error("Failed to marshal input schema", "error", err.Error())
}
xlog.Debug("Input schema", "tool", t.Name, "schema", string(dat))
// XXX: This is a wild guess, to verify (data types might be incompatible)
var inputSchema ToolInputSchema
err = json.Unmarshal(dat, &inputSchema)
if err != nil {
xlog.Error("Failed to unmarshal input schema", "error", err.Error())
}
// Create a new action with Client + tool
generatedActions = append(generatedActions, &mcpAction{
mcpClient: client,
toolName: t.Name,
inputSchema: inputSchema,
toolDescription: desc,
})
// XXX: This is a wild guess, to verify (data types might be incompatible)
var inputSchema ToolInputSchema
err = json.Unmarshal(dat, &inputSchema)
if err != nil {
xlog.Error("Failed to unmarshal input schema", "error", err.Error())
}
if tools.NextCursor == nil {
break // No more pages
}
cursor = tools.NextCursor
// Create a new action with Client + tool
generatedActions = append(generatedActions, &mcpWrapperAction{
mcpClient: client,
toolName: t.Name,
inputSchema: inputSchema,
toolDescription: desc,
})
}
return generatedActions, nil
}
// bearerTokenRoundTripper is a custom roundtripper that injects a bearer token
// into HTTP requests
type bearerTokenRoundTripper struct {
token string
base http.RoundTripper
}
// RoundTrip implements the http.RoundTripper interface
func (rt *bearerTokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if rt.token != "" {
req.Header.Set("Authorization", "Bearer "+rt.token)
}
return rt.base.RoundTrip(req)
}
// newBearerTokenRoundTripper creates a new roundtripper that injects the given token
func newBearerTokenRoundTripper(token string, base http.RoundTripper) http.RoundTripper {
if base == nil {
base = http.DefaultTransport
}
return &bearerTokenRoundTripper{
token: token,
base: base,
}
}
func (a *Agent) initMCPActions() error {
a.closeMCPServers() // Make sure we stop all previous servers if any is active
a.mcpActions = nil
a.mcpActionDefinitions = nil
var err error
generatedActions := types.Actions{}
client := mcp.NewClient(&mcp.Implementation{Name: "LocalAI", Version: "v1.0.0"}, nil)
// Connect to a server over stdin/stdout.
// MCP HTTP Servers
for _, mcpServer := range a.options.mcpServers {
transport := http.NewHTTPClientTransport("/mcp")
transport.WithBaseURL(mcpServer.URL)
if mcpServer.Token != "" {
transport.WithHeader("Authorization", "Bearer "+mcpServer.Token)
// Create HTTP client with custom roundtripper for bearer token injection
httpclient := &http.Client{
Timeout: 360 * time.Second,
Transport: newBearerTokenRoundTripper(mcpServer.Token, http.DefaultTransport),
}
// Create a new client
client := mcp.NewClient(transport)
streamableTransport := &mcp.StreamableClientTransport{HTTPClient: httpclient, Endpoint: mcpServer.URL}
session, err := client.Connect(a.context, streamableTransport, nil)
if err != nil {
xlog.Error("Failed to connect to MCP server via StreamableClientTransport", "server", mcpServer, "error", err.Error())
sseTransport := &mcp.SSEClientTransport{HTTPClient: httpclient, Endpoint: mcpServer.URL}
session, err = client.Connect(a.context, sseTransport, nil)
if err != nil {
xlog.Error("Failed to connect to MCP server via SSEClientTransport", "server", mcpServer, "error", err.Error())
continue
}
}
a.mcpSessions = append(a.mcpSessions, session)
xlog.Debug("Adding tools for MCP server", "server", mcpServer)
actions, err := a.addTools(client)
actions, err := a.addTools(session)
if err != nil {
xlog.Error("Failed to add tools for MCP server", "server", mcpServer, "error", err.Error())
}
@@ -175,51 +178,67 @@ func (a *Agent) initMCPActions() error {
}
// MCP STDIO Servers
a.closeMCPSTDIOServers() // Make sure we stop all previous servers if any is active
if a.options.mcpPrepareScript != "" {
xlog.Debug("Preparing MCP box", "script", a.options.mcpPrepareScript)
client := stdio.NewClient(a.options.mcpBoxURL)
client.RunProcess(a.context, "/bin/bash", []string{"-c", a.options.mcpPrepareScript}, []string{})
xlog.Debug("Preparing MCP", "script", a.options.mcpPrepareScript)
prepareCmd := exec.Command("/bin/bash", "-c", a.options.mcpPrepareScript)
output, err := prepareCmd.CombinedOutput()
if err != nil {
xlog.Error("Failed with error: '%s' - %s", err.Error(), output)
}
xlog.Debug("Prepared MCP: \n%s", output)
}
for _, mcpStdioServer := range a.options.mcpStdioServers {
client := stdio.NewClient(a.options.mcpBoxURL)
p, err := client.CreateProcess(a.context,
mcpStdioServer.Cmd,
mcpStdioServer.Args,
mcpStdioServer.Env,
a.Character.Name)
if err != nil {
xlog.Error("Failed to create process", "error", err.Error())
continue
}
read, writer, err := client.GetProcessIO(p.ID)
if err != nil {
xlog.Error("Failed to get process IO", "error", err.Error())
continue
}
transport := stdioTransport.NewStdioServerTransportWithIO(read, writer)
command := exec.Command(mcpStdioServer.Cmd, mcpStdioServer.Args...)
command.Env = os.Environ()
command.Env = append(command.Env, mcpStdioServer.Env...)
// Create a new client
mcpClient := mcp.NewClient(transport)
session, err := client.Connect(a.context, &mcp.CommandTransport{
Command: command}, nil)
if err != nil {
xlog.Error("Failed to connect to MCP server", "server", mcpStdioServer, "error", err.Error())
continue
}
a.mcpSessions = append(a.mcpSessions, session)
xlog.Debug("Adding tools for MCP server (stdio)", "server", mcpStdioServer)
actions, err := a.addTools(mcpClient)
actions, err := a.addTools(session)
if err != nil {
xlog.Error("Failed to add tools for MCP server", "server", mcpStdioServer, "error", err.Error())
}
generatedActions = append(generatedActions, actions...)
}
a.mcpActions = generatedActions
// Pre-connected MCP sessions (e.g. in-process skills server); already in a.mcpSessions after closeMCPServers()
for _, session := range a.options.extraMCPSessions {
actions, err := a.addTools(session)
if err != nil {
xlog.Error("Failed to add tools for extra MCP session", "error", err.Error())
continue
}
a.mcpSessions = append(a.mcpSessions, session)
generatedActions = append(generatedActions, actions...)
}
a.mcpActionDefinitions = generatedActions
return err
}
func (a *Agent) closeMCPSTDIOServers() {
client := stdio.NewClient(a.options.mcpBoxURL)
client.StopGroup(a.Character.Name)
func (a *Agent) closeMCPServers() {
extraSet := make(map[*mcp.ClientSession]bool)
for _, e := range a.options.extraMCPSessions {
extraSet[e] = true
}
var keep []*mcp.ClientSession
for _, s := range a.mcpSessions {
if extraSet[s] {
keep = append(keep, s)
} else {
s.Close()
}
}
a.mcpSessions = keep
}
+101
View File
@@ -0,0 +1,101 @@
package agent
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("mergeLeadingSystemMessages", func() {
It("merges multiple leading system messages into one", func() {
conv := Messages{
{Role: SystemRole, Content: "You are a helper."},
{Role: SystemRole, Content: "Given the user input you have the following in memory:\n- fact1"},
{Role: "user", Content: "hello"},
}
out := conv.mergeLeadingSystemMessages()
Expect(out).To(HaveLen(2))
Expect(out[0].Role).To(Equal(SystemRole))
Expect(out[0].Content).To(Equal("You are a helper.\n\nGiven the user input you have the following in memory:\n- fact1"))
Expect(out[1].Role).To(Equal("user"))
Expect(out[1].Content).To(Equal("hello"))
})
It("prepends prefix blocks in order (self-eval then HUD)", func() {
conv := Messages{
{Role: SystemRole, Content: "Main system prompt."},
{Role: "user", Content: "hi"},
}
out := conv.mergeLeadingSystemMessages("Self-eval block.", "HUD block.")
Expect(out).To(HaveLen(2))
Expect(out[0].Role).To(Equal(SystemRole))
Expect(out[0].Content).To(Equal("Self-eval block.\n\nHUD block.\n\nMain system prompt."))
Expect(out[1].Role).To(Equal("user"))
})
It("skips empty prefix blocks", func() {
conv := Messages{
{Role: SystemRole, Content: "Only this."},
{Role: "user", Content: "hi"},
}
out := conv.mergeLeadingSystemMessages("", "HUD.", "")
Expect(out[0].Content).To(Equal("HUD.\n\nOnly this."))
})
It("leaves mid-conversation system messages unchanged", func() {
conv := Messages{
{Role: SystemRole, Content: "Leading system."},
{Role: "user", Content: "message with images"},
{Role: SystemRole, Content: "Image explainer (would be rectified elsewhere)."},
{Role: "assistant", Content: "reply"},
}
out := conv.mergeLeadingSystemMessages()
Expect(out).To(HaveLen(4))
Expect(out[0].Role).To(Equal(SystemRole))
Expect(out[0].Content).To(Equal("Leading system."))
Expect(out[1].Role).To(Equal("user"))
Expect(out[2].Role).To(Equal(SystemRole))
Expect(out[2].Content).To(Equal("Image explainer (would be rectified elsewhere)."))
Expect(out[3].Role).To(Equal("assistant"))
})
It("returns conv unchanged when there are no leading system messages and no prefix blocks", func() {
conv := Messages{
{Role: "user", Content: "hi"},
}
out := conv.mergeLeadingSystemMessages()
Expect(out).To(Equal(conv))
})
It("returns only prefix blocks as single system message when conv has no leading system messages", func() {
conv := Messages{
{Role: "user", Content: "hi"},
}
out := conv.mergeLeadingSystemMessages("Self-eval.", "HUD.")
Expect(out).To(HaveLen(2))
Expect(out[0].Role).To(Equal(SystemRole))
Expect(out[0].Content).To(Equal("Self-eval.\n\nHUD."))
Expect(out[1].Role).To(Equal("user"))
})
It("produces exactly one leading system message with config + RAG + HUD content", func() {
conv := Messages{
{Role: SystemRole, Content: "RAG: memory context"},
{Role: SystemRole, Content: "Config system prompt."},
{Role: "user", Content: "hello"},
}
out := conv.mergeLeadingSystemMessages("Self-eval.", "HUD.")
Expect(out).To(HaveLen(2))
Expect(out[0].Role).To(Equal(SystemRole))
Expect(out[0].Content).To(ContainSubstring("Self-eval."))
Expect(out[0].Content).To(ContainSubstring("HUD."))
Expect(out[0].Content).To(ContainSubstring("RAG: memory context"))
Expect(out[0].Content).To(ContainSubstring("Config system prompt."))
systemCount := 0
for _, m := range out {
if m.Role == SystemRole {
systemCount++
}
}
Expect(systemCount).To(Equal(1))
})
})
+20 -6
View File
@@ -7,22 +7,28 @@ import (
"github.com/mudler/LocalAGI/core/sse"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
)
type Observer interface {
NewObservable() *types.Observable
Update(types.Observable)
History() []types.Observable
ClearHistory()
}
// historyRingSize is the number of observables kept in the ring buffer. When full,
// the oldest entry is overwritten. The UI builds a tree from parent_id; if a parent
// is evicted before its children, those children will appear as roots or be omitted.
const historyRingSize = 500
type SSEObserver struct {
agent string
maxID int32
manager sse.Manager
mutex sync.Mutex
history []types.Observable
mutex sync.Mutex
history []types.Observable
historyLast int
}
@@ -31,13 +37,13 @@ func NewSSEObserver(agent string, manager sse.Manager) *SSEObserver {
agent: agent,
maxID: 1,
manager: manager,
history: make([]types.Observable, 100),
history: make([]types.Observable, historyRingSize),
}
}
func (s *SSEObserver) NewObservable() *types.Observable {
id := atomic.AddInt32(&s.maxID, 1)
id := atomic.AddInt32(&s.maxID, 1)
return &types.Observable{
ID: id - 1,
Agent: s.agent,
@@ -86,3 +92,11 @@ func (s *SSEObserver) History() []types.Observable {
return h
}
func (s *SSEObserver) ClearHistory() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.history = make([]types.Observable, historyRingSize)
s.historyLast = 0
}
+223 -33
View File
@@ -5,17 +5,34 @@ import (
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mudler/LocalAGI/core/types"
"github.com/sashabaranov/go-openai"
"github.com/mudler/cogito"
)
type Option func(*options) error
// ConversationStorageMode defines how conversations are stored in the knowledge base
type ConversationStorageMode string
const (
// StoreUserOnly stores only user messages (default)
StoreUserOnly ConversationStorageMode = "user_only"
// StoreUserAndAssistant stores both user and assistant messages separately
StoreUserAndAssistant ConversationStorageMode = "user_and_assistant"
// StoreWholeConversation stores the entire conversation as a single block
StoreWholeConversation ConversationStorageMode = "whole_conversation"
)
type llmOptions struct {
APIURL string
APIKey string
Model string
MultimodalModel string
APIURL string
APIKey string
Model string
MultimodalModel string
ReviewerModel string
TranscriptionModel string
TranscriptionLanguage string
TTSModel string
}
type options struct {
@@ -27,28 +44,38 @@ type options struct {
jobFilters types.JobFilters
enableHUD, standaloneJob, showCharacter, enableKB, enableSummaryMemory, enableLongTermMemory bool
stripThinkingTags bool
kbAutoSearch bool
conversationStorageMode ConversationStorageMode
canStopItself bool
initiateConversations bool
loopDetectionSteps int
forceReasoning bool
forceReasoningTool bool
enableGuidedTools bool
canPlan bool
disableSinkState bool
characterfile string
statefile string
schedulerStorePath string // Path to scheduler JSON storage file
context context.Context
permanentGoal string
timeout string
periodicRuns time.Duration
schedulerPollInterval time.Duration
kbResults int
ragdb RAGDB
// Evaluation settings
maxEvaluationLoops int
loopDetection int
enableEvaluation bool
prompts []DynamicPrompt
systemPrompt string
systemPrompt string
innerMonologueTemplate string
skillPromptTemplate string
schedulerTaskTemplate string
// callbacks
reasoningCallback func(types.ActionCurrentState) bool
@@ -58,14 +85,25 @@ type options struct {
mcpServers []MCPServer
mcpStdioServers []MCPSTDIOServer
mcpBoxURL string
mcpPrepareScript string
newConversationsSubscribers []func(openai.ChatCompletionMessage)
extraMCPSessions []*mcp.ClientSession
newConversationsSubscribers []func(*types.ConversationMessage)
observer Observer
observer Observer
enableAutoCompaction bool
autoCompactionThreshold int
parallelJobs int
lastMessageDuration time.Duration
// cancelPreviousOnNewMessage: when true (or nil), Enqueue cancels the running job for the same conversation_id. When false, jobs are queued.
cancelPreviousOnNewMessage *bool
// maxAttempts: on ExecuteTools failure, retry up to this many times before surfacing the error to the user (1 = no retries).
maxAttempts int
// streamCallback receives streaming events from cogito during final answer generation.
streamCallback func(cogito.StreamEvent)
}
func (o *options) SeparatedMultimodalModel() bool {
@@ -74,14 +112,20 @@ func (o *options) SeparatedMultimodalModel() bool {
func defaultOptions() *options {
return &options{
parallelJobs: 1,
periodicRuns: 15 * time.Minute,
loopDetectionSteps: 10,
maxEvaluationLoops: 2,
enableEvaluation: false,
parallelJobs: 1,
maxAttempts: 1,
periodicRuns: 15 * time.Minute,
schedulerPollInterval: 30 * time.Second,
maxEvaluationLoops: 2,
enableEvaluation: false,
kbAutoSearch: true, // Default to true to maintain backward compatibility
conversationStorageMode: StoreUserOnly, // Default to user-only for backward compatibility
LLMAPI: llmOptions{
APIURL: "http://localhost:8080",
Model: "gpt-4",
APIURL: "http://localhost:8080",
Model: "gpt-4",
TranscriptionModel: "whisper-1",
TranscriptionLanguage: "",
TTSModel: "tts-1",
},
character: Character{
Name: "",
@@ -113,6 +157,16 @@ var EnableForceReasoning = func(o *options) error {
return nil
}
var EnableGuidedTools = func(o *options) error {
o.enableGuidedTools = true
return nil
}
var EnableForceReasoningTool = func(o *options) error {
o.forceReasoningTool = true
return nil
}
var EnableKnowledgeBase = func(o *options) error {
o.enableKB = true
o.kbResults = 5
@@ -131,13 +185,6 @@ func WithTimeout(timeout string) Option {
}
}
func WithLoopDetectionSteps(steps int) Option {
return func(o *options) error {
o.loopDetectionSteps = steps
return nil
}
}
func WithConversationsPath(path string) Option {
return func(o *options) error {
o.conversationsPath = path
@@ -171,7 +218,30 @@ func WithParallelJobs(jobs int) Option {
}
}
func WithNewConversationSubscriber(sub func(openai.ChatCompletionMessage)) Option {
// WithCancelPreviousOnNewMessage sets whether a new job with the same conversation_id cancels the currently running job (true) or is queued (false). Nil/default means true.
func WithCancelPreviousOnNewMessage(cancel bool) Option {
return func(o *options) error {
o.cancelPreviousOnNewMessage = &cancel
return nil
}
}
// WithMaxAttempts sets how many times to attempt execution on failure before surfacing the error to the user (1 = no retries).
func WithMaxAttempts(attempts int) Option {
return func(o *options) error {
o.maxAttempts = attempts
return nil
}
}
func WithLoopDetection(loops int) Option {
return func(o *options) error {
o.loopDetection = loops
return nil
}
}
func WithNewConversationSubscriber(sub func(*types.ConversationMessage)) Option {
return func(o *options) error {
o.newConversationsSubscribers = append(o.newConversationsSubscribers, sub)
return nil
@@ -188,6 +258,18 @@ var EnablePlanning = func(o *options) error {
return nil
}
var DisableSinkState = func(o *options) error {
o.disableSinkState = true
return nil
}
var WithPlanReviewerLLM = func(model string) Option {
return func(o *options) error {
o.LLMAPI.ReviewerModel = model
return nil
}
}
// EnableStandaloneJob is an option to enable the agent
// to run jobs in the background automatically
var EnableStandaloneJob = func(o *options) error {
@@ -217,6 +299,19 @@ func WithRAGDB(db RAGDB) Option {
}
}
// WithConversationStorageMode sets how conversations are stored in the knowledge base
func WithConversationStorageMode(mode ConversationStorageMode) Option {
return func(o *options) error {
switch mode {
case StoreUserOnly, StoreUserAndAssistant, StoreWholeConversation:
o.conversationStorageMode = mode
default:
o.conversationStorageMode = StoreUserOnly
}
return nil
}
}
func WithSystemPrompt(prompt string) Option {
return func(o *options) error {
o.systemPrompt = prompt
@@ -224,6 +319,22 @@ func WithSystemPrompt(prompt string) Option {
}
}
// WithInnerMonologueTemplate sets the prompt used for periodic/standalone runs. If empty, the default template is used.
func WithInnerMonologueTemplate(template string) Option {
return func(o *options) error {
o.innerMonologueTemplate = template
return nil
}
}
// WithSkillPromptTemplate sets the template for rendering skills in the prompt. If empty, the default template is used.
func WithSkillPromptTemplate(template string) Option {
return func(o *options) error {
o.skillPromptTemplate = template
return nil
}
}
func WithMCPServers(servers ...MCPServer) Option {
return func(o *options) error {
o.mcpServers = servers
@@ -238,13 +349,6 @@ func WithMCPSTDIOServers(servers ...MCPSTDIOServer) Option {
}
}
func WithMCPBoxURL(url string) Option {
return func(o *options) error {
o.mcpBoxURL = url
return nil
}
}
func WithMCPPrepareScript(script string) Option {
return func(o *options) error {
o.mcpPrepareScript = script
@@ -283,6 +387,14 @@ func WithPrompts(prompts ...DynamicPrompt) Option {
}
}
// WithMCPSession adds a pre-connected MCP client session (e.g. in-process skills MCP) to the agent.
func WithMCPSession(session *mcp.ClientSession) Option {
return func(o *options) error {
o.extraMCPSessions = append(o.extraMCPSessions, session)
return nil
}
}
// WithDynamicPrompts is a helper function to create dynamic prompts
// Dynamic prompts contains golang code which is executed dynamically
// // to render a prompt to the LLM
@@ -331,6 +443,18 @@ func WithPeriodicRuns(duration string) Option {
}
}
func WithSchedulerPollInterval(duration string) Option {
return func(o *options) error {
t, err := time.ParseDuration(duration)
if err != nil {
o.schedulerPollInterval = 30 * time.Second
return nil
}
o.schedulerPollInterval = t
return nil
}
}
func WithContext(ctx context.Context) Option {
return func(o *options) error {
o.context = ctx
@@ -388,7 +512,7 @@ func WithRandomIdentity(guidance ...string) Option {
func WithActions(actions ...types.Action) Option {
return func(o *options) error {
o.userActions = actions
o.userActions = append(o.userActions, actions...)
return nil
}
}
@@ -425,3 +549,69 @@ func EnableEvaluation() Option {
return nil
}
}
func WithTranscriptionModel(model string) Option {
return func(o *options) error {
o.LLMAPI.TranscriptionModel = model
return nil
}
}
func WithTranscriptionLanguage(language string) Option {
return func(o *options) error {
o.LLMAPI.TranscriptionLanguage = language
return nil
}
}
func WithTTSModel(model string) Option {
return func(o *options) error {
o.LLMAPI.TTSModel = model
return nil
}
}
func WithKBAutoSearch(enabled bool) Option {
return func(o *options) error {
o.kbAutoSearch = enabled
return nil
}
}
// WithSchedulerStorePath sets the path for the scheduler's JSON storage file
func WithSchedulerStorePath(path string) Option {
return func(o *options) error {
o.schedulerStorePath = path
return nil
}
}
// WithSchedulerTaskTemplate sets the prompt used for scheduled/recurring tasks run by the scheduler.
// If empty, the default inner monologue template is used with the task injected.
func WithSchedulerTaskTemplate(template string) Option {
return func(o *options) error {
o.schedulerTaskTemplate = template
return nil
}
}
var EnableAutoCompaction = func(o *options) error {
o.enableAutoCompaction = true
return nil
}
func WithAutoCompactionThreshold(threshold int) Option {
return func(o *options) error {
o.autoCompactionThreshold = threshold
return nil
}
}
// WithStreamCallback sets a callback to receive streaming events from cogito
// during final answer generation. This enables live token-by-token delivery.
func WithStreamCallback(fn func(cogito.StreamEvent)) Option {
return func(o *options) error {
o.streamCallback = fn
return nil
}
}
+84
View File
@@ -0,0 +1,84 @@
package agent
import (
"context"
"fmt"
"github.com/mudler/LocalAGI/core/scheduler"
"github.com/mudler/LocalAGI/core/types"
)
// agentSchedulerExecutor implements scheduler.AgentExecutor for executing scheduled tasks through the agent
type agentSchedulerExecutor struct {
agent *Agent
}
// Execute processes a scheduled task by creating a job for the agent
func (e *agentSchedulerExecutor) Execute(ctx context.Context, agentName string, prompt string) (*scheduler.JobResult, error) {
// Render the scheduler task template - if custom template is set, it will include {{.Task}}
// If no custom scheduler template is set, fall back to default inner monologue template
innerMonologue := fmt.Sprintf("You need to execute the following task, by using the tools available to you. When the task is completed, you need to send a message to the user with send_message tool to inform them that the task is completed: %s", prompt)
if e.agent.options.schedulerTaskTemplate != "" {
tmpl, err := templateBase("taskTemplate", e.agent.options.schedulerTaskTemplate)
if err != nil {
return nil, fmt.Errorf("failed to render scheduler task template: %w", err)
}
innerMonologue, err = templateExecute(tmpl, &InnerMonologueTemplateData{
CommonTemplateData: CommonTemplateData{
AgentName: agentName,
},
Task: prompt,
})
if err != nil {
return nil, fmt.Errorf("failed to render scheduler task template: %w", err)
}
}
// Create a job for the reminder with the rendered inner monologue
reminderJob := types.NewJob(
types.WithText(innerMonologue),
types.WithReasoningCallback(e.agent.options.reasoningCallback),
types.WithResultCallback(e.agent.options.resultCallback),
types.WithContext(ctx),
types.WithMetadata(map[string]any{
"message": prompt,
"is_reminder": true,
"type": "scheduled",
}),
)
// Attach observable so UI can show reminder processing state
if e.agent.observer != nil {
obs := e.agent.observer.NewObservable()
obs.Name = "reminder"
obs.Icon = "bell"
e.agent.observer.Update(*obs)
reminderJob.Obs = obs
}
// Send the job to be processed
e.agent.jobQueue <- reminderJob
// Wait for the job to complete or context to be cancelled
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
result, err := reminderJob.Result.WaitResult(ctx)
if err != nil {
return nil, err
}
if result.Error != nil {
return &scheduler.JobResult{
Response: "",
Error: result.Error,
}, result.Error
}
return &scheduler.JobResult{
Response: result.Response,
Error: nil,
}, nil
}
}
+25 -42
View File
@@ -2,18 +2,41 @@ package agent
import (
"bytes"
"html/template"
"text/template"
"time"
"github.com/Masterminds/sprig/v3"
"github.com/mudler/LocalAGI/core/types"
"github.com/sashabaranov/go-openai"
)
type CommonTemplateData struct {
AgentName string
}
type InnerMonologueTemplateData struct {
CommonTemplateData
Task string
}
func templateBase(templateName, templatetext string) (*template.Template, error) {
return template.New(templateName).Funcs(sprig.FuncMap()).Parse(templatetext)
}
func templateExecute(template *template.Template, data interface{}) (string, error) {
prompt := bytes.NewBuffer([]byte{})
err := template.Execute(prompt, data)
if err != nil {
return "", err
}
return prompt.String(), nil
}
func renderTemplate(templ string, hud *PromptHUD, actions types.Actions, reasoning string) (string, error) {
// prepare the prompt
prompt := bytes.NewBuffer([]byte{})
promptTemplate, err := template.New("pickAction").Parse(templ)
promptTemplate, err := templateBase("pickAction", templ)
if err != nil {
return "", err
}
@@ -94,7 +117,6 @@ Guidelines:
4. Update your state appropriately
When making decisions:
- Use the "reply" tool to provide final responses
- Update your state using appropriate tools
- Plan complex tasks using the planning tool
- Consider both immediate and long-term goals
@@ -105,43 +127,4 @@ Remember:
- Keep track of your progress and state
- Be proactive in addressing potential issues
Available Tools:
{{range .Actions -}}
- {{.Name}}: {{.Description }}
{{ end }}
{{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}}
` + hudTemplate
const reSelfEvalTemplate = pickSelfTemplate
const pickActionTemplate = hudTemplate + `
Your only task is to analyze the conversation and determine a goal and the best tool to use, or just a final response if we have fullfilled the goal.
Guidelines:
1. Review the current state, what was done already and context
2. Consider available tools and their purposes
3. Plan your approach carefully
4. Explain your reasoning clearly
When choosing actions:
- Use "reply" or "answer" tools for direct responses
- Select appropriate tools for specific tasks
- Consider the impact of each action
- Plan for potential challenges
Decision Process:
1. Analyze the situation
2. Consider available options
3. Choose the best course of action
4. Explain your reasoning
5. Execute the chosen action
Available Tools:
{{range .Actions -}}
- {{.Name}}: {{.Description }}
{{ end }}
{{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}}`
const reEvalTemplate = pickActionTemplate
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"sync"
"time"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
)
+49
View File
@@ -0,0 +1,49 @@
package scheduler
import (
"context"
)
// TaskStore defines the interface for task persistence
type TaskStore interface {
// Create adds a new task
Create(task *Task) error
// Get retrieves a task by ID
Get(id string) (*Task, error)
// GetAll retrieves all tasks
GetAll() ([]*Task, error)
// GetDue retrieves tasks that are due for execution
GetDue() ([]*Task, error)
// GetByAgent retrieves all tasks for a specific agent
GetByAgent(agentName string) ([]*Task, error)
// Update updates an existing task
Update(task *Task) error
// Delete removes a task
Delete(id string) error
// LogRun records a task execution
LogRun(run *TaskRun) error
// GetRuns retrieves execution history for a task
GetRuns(taskID string, limit int) ([]*TaskRun, error)
// Close releases resources
Close() error
}
// AgentExecutor defines the interface for executing agent tasks
type AgentExecutor interface {
Execute(ctx context.Context, agentName string, prompt string) (*JobResult, error)
}
// JobResult represents the result of an agent execution
type JobResult struct {
Response string
Error error
}
+219
View File
@@ -0,0 +1,219 @@
package scheduler
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// JSONStore implements TaskStore using JSON file storage
type JSONStore struct {
filePath string
mu sync.RWMutex
data *storeData
}
type storeData struct {
Tasks []*Task `json:"tasks"`
TaskRuns []*TaskRun `json:"task_runs"`
}
// NewJSONStore creates a new JSON-based task store
func NewJSONStore(filePath string) (*JSONStore, error) {
store := &JSONStore{
filePath: filePath,
data: &storeData{
Tasks: make([]*Task, 0),
TaskRuns: make([]*TaskRun, 0),
},
}
if err := store.load(); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load store: %w", err)
}
// File doesn't exist, create it
if err := store.save(); err != nil {
return nil, fmt.Errorf("failed to create store file: %w", err)
}
}
return store, nil
}
// Create adds a new task
func (s *JSONStore) Create(task *Task) error {
s.mu.Lock()
defer s.mu.Unlock()
// Check for duplicate ID
for _, t := range s.data.Tasks {
if t.ID == task.ID {
return fmt.Errorf("task with ID %s already exists", task.ID)
}
}
s.data.Tasks = append(s.data.Tasks, task)
return s.save()
}
// Get retrieves a task by ID
func (s *JSONStore) Get(id string) (*Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, task := range s.data.Tasks {
if task.ID == id {
return task, nil
}
}
return nil, fmt.Errorf("task not found: %s", id)
}
// GetAll retrieves all tasks
func (s *JSONStore) GetAll() ([]*Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// Return a copy to prevent external modification
tasks := make([]*Task, len(s.data.Tasks))
copy(tasks, s.data.Tasks)
return tasks, nil
}
// GetDue retrieves tasks that are due for execution
func (s *JSONStore) GetDue() ([]*Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
now := time.Now()
dueTasks := make([]*Task, 0)
for _, task := range s.data.Tasks {
if task.Status == TaskStatusActive && now.After(task.NextRun) {
dueTasks = append(dueTasks, task)
}
}
return dueTasks, nil
}
// GetByAgent retrieves all tasks for a specific agent
func (s *JSONStore) GetByAgent(agentName string) ([]*Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
agentTasks := make([]*Task, 0)
for _, task := range s.data.Tasks {
if task.AgentName == agentName {
agentTasks = append(agentTasks, task)
}
}
return agentTasks, nil
}
// Update updates an existing task
func (s *JSONStore) Update(task *Task) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, t := range s.data.Tasks {
if t.ID == task.ID {
task.UpdatedAt = time.Now()
s.data.Tasks[i] = task
return s.save()
}
}
return fmt.Errorf("task not found: %s", task.ID)
}
// Delete removes a task
func (s *JSONStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, task := range s.data.Tasks {
if task.ID == id {
// Remove task from slice
s.data.Tasks = append(s.data.Tasks[:i], s.data.Tasks[i+1:]...)
return s.save()
}
}
return fmt.Errorf("task not found: %s", id)
}
// LogRun records a task execution
func (s *JSONStore) LogRun(run *TaskRun) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data.TaskRuns = append(s.data.TaskRuns, run)
return s.save()
}
// GetRuns retrieves execution history for a task
func (s *JSONStore) GetRuns(taskID string, limit int) ([]*TaskRun, error) {
s.mu.RLock()
defer s.mu.RUnlock()
runs := make([]*TaskRun, 0)
for i := len(s.data.TaskRuns) - 1; i >= 0 && len(runs) < limit; i-- {
if s.data.TaskRuns[i].TaskID == taskID {
runs = append(runs, s.data.TaskRuns[i])
}
}
return runs, nil
}
// Close releases resources (no-op for JSON store)
func (s *JSONStore) Close() error {
return nil
}
// load reads data from the JSON file
func (s *JSONStore) load() error {
file, err := os.ReadFile(s.filePath)
if err != nil {
return err
}
// Handle empty file
if len(file) == 0 {
return nil
}
if err := json.Unmarshal(file, s.data); err != nil {
return err
}
// Ensure slices are not nil after unmarshaling
if s.data.Tasks == nil {
s.data.Tasks = make([]*Task, 0)
}
if s.data.TaskRuns == nil {
s.data.TaskRuns = make([]*TaskRun, 0)
}
return nil
}
// save writes data to the JSON file
func (s *JSONStore) save() error {
data, err := json.MarshalIndent(s.data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal data: %w", err)
}
basePath := filepath.Dir(s.filePath)
os.MkdirAll(basePath, 0755)
return os.WriteFile(s.filePath, data, 0644)
}
+249
View File
@@ -0,0 +1,249 @@
package scheduler
import (
"context"
"fmt"
"sync"
"time"
"github.com/mudler/xlog"
)
// Scheduler manages scheduled tasks
type Scheduler struct {
store TaskStore
executor AgentExecutor
pollInterval time.Duration
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
mu sync.RWMutex
runningTasks map[string]context.CancelFunc
}
// NewScheduler creates a new scheduler with the given store and executor
func NewScheduler(store TaskStore, executor AgentExecutor, pollInterval time.Duration) *Scheduler {
return &Scheduler{
store: store,
executor: executor,
pollInterval: pollInterval,
runningTasks: make(map[string]context.CancelFunc),
}
}
// Start begins the scheduler's polling loop
func (s *Scheduler) Start() {
if s.ctx != nil {
xlog.Warn("Scheduler already started")
return
}
ctx, cancel := context.WithCancel(context.Background())
s.ctx = ctx
s.cancel = cancel
s.wg.Add(1)
go s.run()
xlog.Info("Task scheduler started", "poll_interval", s.pollInterval)
}
// Stop gracefully stops the scheduler
func (s *Scheduler) Stop() {
if s.cancel != nil {
s.cancel()
}
s.wg.Wait()
s.store.Close()
xlog.Info("Task scheduler stopped")
s.cancel = nil
s.ctx = nil
}
// run is the main polling loop
func (s *Scheduler) run() {
defer s.wg.Done()
ticker := time.NewTicker(s.pollInterval)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
s.processDueTasks()
}
}
}
// processDueTasks checks for and executes due tasks
func (s *Scheduler) processDueTasks() {
tasks, err := s.store.GetDue()
if err != nil {
xlog.Error("Failed to get due tasks", "error", err)
return
}
if len(tasks) > 0 {
xlog.Debug("Processing due tasks", "count", len(tasks))
}
for _, task := range tasks {
// Check if task is already running
s.mu.RLock()
_, running := s.runningTasks[task.ID]
s.mu.RUnlock()
if running {
xlog.Warn("Task already running, skipping", "task_id", task.ID)
continue
}
// Execute task in goroutine
s.wg.Add(1)
go s.executeTask(task)
}
}
// executeTask runs a single task
func (s *Scheduler) executeTask(task *Task) {
defer s.wg.Done()
taskCtx, cancel := context.WithCancel(s.ctx)
defer cancel()
// Register running task
s.mu.Lock()
s.runningTasks[task.ID] = cancel
s.mu.Unlock()
defer func() {
s.mu.Lock()
delete(s.runningTasks, task.ID)
s.mu.Unlock()
}()
xlog.Info("Executing task", "task_id", task.ID, "agent", task.AgentName, "prompt", task.Prompt)
startTime := time.Now()
run := NewTaskRun(task.ID)
// Execute the task
result, err := s.executor.Execute(taskCtx, task.AgentName, task.Prompt)
run.DurationMs = time.Since(startTime).Milliseconds()
if err != nil {
run.Status = "error"
run.Error = err.Error()
xlog.Error("Task execution failed", "task_id", task.ID, "error", err)
} else {
run.Status = "success"
if result != nil {
run.Result = result.Response
}
xlog.Info("Task executed successfully", "task_id", task.ID, "duration_ms", run.DurationMs)
}
// Log the run
if err := s.store.LogRun(run); err != nil {
xlog.Error("Failed to log task run", "task_id", task.ID, "error", err)
}
// Update task for next run
now := time.Now()
task.LastRun = &now
// For one-time tasks, mark as deleted
if task.ScheduleType == ScheduleTypeOnce {
if err := s.store.Delete(task.ID); err != nil {
xlog.Error("Failed to delete task", "task_id", task.ID, "error", err)
}
} else {
// Calculate next run
if err := task.CalculateNextRun(); err != nil {
xlog.Error("Failed to calculate next run", "task_id", task.ID, "error", err)
task.Status = TaskStatusPaused
}
}
if err := s.store.Update(task); err != nil {
xlog.Error("Failed to update task", "task_id", task.ID, "error", err)
}
}
// CRUD operations
// CreateTask adds a new task
func (s *Scheduler) CreateTask(task *Task) error {
return s.store.Create(task)
}
// GetTask retrieves a task by ID
func (s *Scheduler) GetTask(id string) (*Task, error) {
return s.store.Get(id)
}
// GetAllTasks retrieves all tasks
func (s *Scheduler) GetAllTasks() ([]*Task, error) {
return s.store.GetAll()
}
// GetTasksByAgent retrieves all tasks for a specific agent
func (s *Scheduler) GetTasksByAgent(agentName string) ([]*Task, error) {
return s.store.GetByAgent(agentName)
}
// UpdateTask updates an existing task
func (s *Scheduler) UpdateTask(task *Task) error {
return s.store.Update(task)
}
// DeleteTask removes a task
func (s *Scheduler) DeleteTask(id string) error {
return s.store.Delete(id)
}
// GetTaskRuns retrieves execution history for a task
func (s *Scheduler) GetTaskRuns(taskID string, limit int) ([]*TaskRun, error) {
return s.store.GetRuns(taskID, limit)
}
// PauseTask pauses a task
func (s *Scheduler) PauseTask(id string) error {
task, err := s.store.Get(id)
if err != nil {
return err
}
task.Status = TaskStatusPaused
return s.store.Update(task)
}
// ResumeTask resumes a paused task
func (s *Scheduler) ResumeTask(id string) error {
task, err := s.store.Get(id)
if err != nil {
return err
}
task.Status = TaskStatusActive
if err := task.CalculateNextRun(); err != nil {
return err
}
return s.store.Update(task)
}
// CancelRunningTask cancels a currently running task
func (s *Scheduler) CancelRunningTask(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
cancel, exists := s.runningTasks[id]
if !exists {
return fmt.Errorf("task not running: %s", id)
}
cancel()
return nil
}
+13
View File
@@ -0,0 +1,13 @@
package scheduler_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestScheduler(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Scheduler Suite")
}
+395
View File
@@ -0,0 +1,395 @@
package scheduler_test
import (
"context"
"errors"
"os"
"time"
"github.com/mudler/LocalAGI/core/scheduler"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// MockExecutor for testing
type MockExecutor struct {
executedTasks []string
shouldError bool
}
func (m *MockExecutor) Execute(ctx context.Context, agentName string, prompt string) (*scheduler.JobResult, error) {
m.executedTasks = append(m.executedTasks, agentName+":"+prompt)
if m.shouldError {
return nil, errors.New("mock execution error")
}
return &scheduler.JobResult{Response: "test response"}, nil
}
var _ = Describe("Scheduler", func() {
var (
tempFile string
store scheduler.TaskStore
executor *MockExecutor
sched *scheduler.Scheduler
)
BeforeEach(func() {
// Create temporary file for JSON store
f, err := os.CreateTemp("", "scheduler_test_*.json")
Expect(err).NotTo(HaveOccurred())
tempFile = f.Name()
f.Close()
store, err = scheduler.NewJSONStore(tempFile)
Expect(err).NotTo(HaveOccurred())
executor = &MockExecutor{}
sched = scheduler.NewScheduler(store, executor, 100*time.Millisecond)
sched.Start()
})
AfterEach(func() {
if sched != nil {
sched.Stop()
}
os.Remove(tempFile)
})
Describe("Task Creation", func() {
It("should create a valid task with cron schedule", func() {
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
Expect(err).NotTo(HaveOccurred())
Expect(task.ID).NotTo(BeEmpty())
Expect(task.AgentName).To(Equal("test-agent"))
Expect(task.Prompt).To(Equal("test prompt"))
Expect(task.ScheduleType).To(Equal(scheduler.ScheduleTypeCron))
Expect(task.Status).To(Equal(scheduler.TaskStatusActive))
Expect(task.NextRun).NotTo(BeZero())
})
It("should return error for invalid cron expression", func() {
_, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "invalid cron")
Expect(err).To(HaveOccurred())
})
It("should create a valid task with interval schedule", func() {
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeInterval, "3600000")
Expect(err).NotTo(HaveOccurred())
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(time.Hour), 5*time.Second))
})
It("should create a valid task with once schedule using duration", func() {
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "24h")
Expect(err).NotTo(HaveOccurred())
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(24*time.Hour), 5*time.Second))
})
It("should create a valid task with once schedule using day syntax", func() {
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "1d")
Expect(err).NotTo(HaveOccurred())
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(24*time.Hour), 5*time.Second))
})
It("should create a valid task with once schedule using combined day+time", func() {
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "2d12h30m")
Expect(err).NotTo(HaveOccurred())
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(2*24*time.Hour+12*time.Hour+30*time.Minute), 5*time.Second))
})
It("should return error for invalid once duration", func() {
_, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "invalid")
Expect(err).To(HaveOccurred())
})
})
Describe("Task IsDue", func() {
It("should return true for active task past due time", func() {
task := &scheduler.Task{
Status: scheduler.TaskStatusActive,
NextRun: time.Now().Add(-1 * time.Hour),
}
Expect(task.IsDue()).To(BeTrue())
})
It("should return false for active task not yet due", func() {
task := &scheduler.Task{
Status: scheduler.TaskStatusActive,
NextRun: time.Now().Add(1 * time.Hour),
}
Expect(task.IsDue()).To(BeFalse())
})
It("should return false for paused task even if past due", func() {
task := &scheduler.Task{
Status: scheduler.TaskStatusPaused,
NextRun: time.Now().Add(-1 * time.Hour),
}
Expect(task.IsDue()).To(BeFalse())
})
})
Describe("JSON Store", func() {
Context("CRUD operations", func() {
It("should create and retrieve a task", func() {
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
Expect(err).NotTo(HaveOccurred())
err = store.Create(task)
Expect(err).NotTo(HaveOccurred())
retrieved, err := store.Get(task.ID)
Expect(err).NotTo(HaveOccurred())
Expect(retrieved.ID).To(Equal(task.ID))
Expect(retrieved.AgentName).To(Equal(task.AgentName))
Expect(retrieved.Prompt).To(Equal(task.Prompt))
})
It("should update a task", func() {
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
store.Create(task)
task.Prompt = "updated prompt"
err := store.Update(task)
Expect(err).NotTo(HaveOccurred())
updated, err := store.Get(task.ID)
Expect(err).NotTo(HaveOccurred())
Expect(updated.Prompt).To(Equal("updated prompt"))
})
It("should delete a task", func() {
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
store.Create(task)
err := store.Delete(task.ID)
Expect(err).NotTo(HaveOccurred())
_, err = store.Get(task.ID)
Expect(err).To(HaveOccurred())
})
It("should return error when getting non-existent task", func() {
_, err := store.Get("non-existent-id")
Expect(err).To(HaveOccurred())
})
})
Context("Querying tasks", func() {
BeforeEach(func() {
// Create test tasks
// task1: once schedule with 0s delay => immediately due
task1, err := scheduler.NewTask("agent1", "prompt1", scheduler.ScheduleTypeOnce, "0s")
Expect(err).NotTo(HaveOccurred())
task1.NextRun = time.Now().Add(-1 * time.Hour) // force into the past
// task2: cron schedule => next run in the future, not due
task2, err := scheduler.NewTask("agent2", "prompt2", scheduler.ScheduleTypeCron, "0 0 1 1 *")
Expect(err).NotTo(HaveOccurred())
// task3: once schedule but paused => not due
task3, err := scheduler.NewTask("agent1", "prompt3", scheduler.ScheduleTypeOnce, "0s")
Expect(err).NotTo(HaveOccurred())
task3.Status = scheduler.TaskStatusPaused
Expect(store.Create(task1)).To(Succeed())
Expect(store.Create(task2)).To(Succeed())
Expect(store.Create(task3)).To(Succeed())
})
It("should get all tasks", func() {
tasks, err := store.GetAll()
Expect(err).NotTo(HaveOccurred())
Expect(tasks).To(HaveLen(3))
})
It("should get only due tasks", func() {
dueTasks, err := store.GetDue()
Expect(err).NotTo(HaveOccurred())
Expect(dueTasks).To(HaveLen(1))
Expect(dueTasks[0].AgentName).To(Equal("agent1"))
Expect(dueTasks[0].Prompt).To(Equal("prompt1"))
})
It("should get tasks by agent", func() {
agentTasks, err := store.GetByAgent("agent1")
Expect(err).NotTo(HaveOccurred())
Expect(agentTasks).To(HaveLen(2))
})
})
Context("Task runs", func() {
It("should log and retrieve task runs", func() {
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
store.Create(task)
run := scheduler.NewTaskRun(task.ID)
run.Status = "success"
run.Result = "test result"
run.DurationMs = 1000
err := store.LogRun(run)
Expect(err).NotTo(HaveOccurred())
runs, err := store.GetRuns(task.ID, 10)
Expect(err).NotTo(HaveOccurred())
Expect(runs).To(HaveLen(1))
Expect(runs[0].Status).To(Equal("success"))
Expect(runs[0].Result).To(Equal("test result"))
})
It("should limit returned runs", func() {
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
store.Create(task)
// Create 5 runs
for i := 0; i < 5; i++ {
run := scheduler.NewTaskRun(task.ID)
store.LogRun(run)
}
runs, err := store.GetRuns(task.ID, 3)
Expect(err).NotTo(HaveOccurred())
Expect(runs).To(HaveLen(3))
})
})
Context("Persistence", func() {
It("should persist data across store instances", func() {
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
store.Create(task)
store.Close()
// Create new store instance with same file
newStore, err := scheduler.NewJSONStore(tempFile)
Expect(err).NotTo(HaveOccurred())
defer newStore.Close()
retrieved, err := newStore.Get(task.ID)
Expect(err).NotTo(HaveOccurred())
Expect(retrieved.ID).To(Equal(task.ID))
})
})
})
Describe("Scheduler Execution", func() {
It("should execute a due task", func() {
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "0s")
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
err := sched.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
// Scheduler is already started in BeforeEach
Eventually(func() int {
return len(executor.executedTasks)
}, "2s", "100ms").Should(Equal(1))
Expect(executor.executedTasks[0]).To(Equal("test-agent:test prompt"))
// Verify task run was logged
runs, err := sched.GetTaskRuns(task.ID, 10)
Expect(err).NotTo(HaveOccurred())
Expect(runs).To(HaveLen(1))
Expect(runs[0].Status).To(Equal("success"))
// Verify one-time task was deleted
_, err = sched.GetTask(task.ID)
Expect(err).To(HaveOccurred())
})
It("should execute recurring tasks multiple times", func() {
task, _ := scheduler.NewTask("test-agent", "recurring", scheduler.ScheduleTypeInterval, "500")
task.NextRun = time.Now().Add(-1 * time.Second)
err := sched.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
// Scheduler is already started in BeforeEach
Eventually(func() int {
return len(executor.executedTasks)
}, "3s", "100ms").Should(BeNumerically(">=", 2))
// Verify task is still active
updatedTask, err := sched.GetTask(task.ID)
Expect(err).NotTo(HaveOccurred())
Expect(updatedTask.Status).To(Equal(scheduler.TaskStatusActive))
})
It("should handle task execution errors", func() {
executor.shouldError = true
task, _ := scheduler.NewTask("test-agent", "error task", scheduler.ScheduleTypeOnce, "0s")
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
sched.CreateTask(task)
// Scheduler is already started in BeforeEach
Eventually(func() int {
runs, _ := sched.GetTaskRuns(task.ID, 10)
return len(runs)
}, "2s", "100ms").Should(Equal(1))
runs, _ := sched.GetTaskRuns(task.ID, 10)
Expect(runs[0].Status).To(Equal("error"))
Expect(runs[0].Error).NotTo(BeEmpty())
})
It("should not execute paused tasks", func() {
task, _ := scheduler.NewTask("test-agent", "paused", scheduler.ScheduleTypeOnce, "0s")
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
task.Status = scheduler.TaskStatusPaused
sched.CreateTask(task)
// Scheduler is already started in BeforeEach
Consistently(func() int {
return len(executor.executedTasks)
}, "1s", "100ms").Should(Equal(0))
})
})
Describe("Task Management", func() {
It("should pause and resume a task", func() {
task, _ := scheduler.NewTask("test-agent", "test", scheduler.ScheduleTypeCron, "0 0 * * *")
sched.CreateTask(task)
err := sched.PauseTask(task.ID)
Expect(err).NotTo(HaveOccurred())
paused, _ := sched.GetTask(task.ID)
Expect(paused.Status).To(Equal(scheduler.TaskStatusPaused))
err = sched.ResumeTask(task.ID)
Expect(err).NotTo(HaveOccurred())
resumed, _ := sched.GetTask(task.ID)
Expect(resumed.Status).To(Equal(scheduler.TaskStatusActive))
Expect(resumed.NextRun).NotTo(BeZero())
})
It("should get tasks by agent", func() {
task1, err := scheduler.NewTask("agent1", "prompt1", scheduler.ScheduleTypeCron, "0 0 * * *")
Expect(err).NotTo(HaveOccurred())
task2, err := scheduler.NewTask("agent2", "prompt2", scheduler.ScheduleTypeCron, "0 0 * * *")
Expect(err).NotTo(HaveOccurred())
task3, err := scheduler.NewTask("agent1", "prompt3", scheduler.ScheduleTypeCron, "0 0 * * *")
Expect(err).NotTo(HaveOccurred())
Expect(sched.CreateTask(task1)).To(Succeed())
Expect(sched.CreateTask(task2)).To(Succeed())
Expect(sched.CreateTask(task3)).To(Succeed())
agent1Tasks, err := sched.GetTasksByAgent("agent1")
Expect(err).NotTo(HaveOccurred())
Expect(agent1Tasks).To(HaveLen(2))
})
It("should delete a task", func() {
task, _ := scheduler.NewTask("test-agent", "test", scheduler.ScheduleTypeCron, "0 0 * * *")
sched.CreateTask(task)
err := sched.DeleteTask(task.ID)
Expect(err).NotTo(HaveOccurred())
_, err = sched.GetTask(task.ID)
Expect(err).To(HaveOccurred())
})
})
})
+156
View File
@@ -0,0 +1,156 @@
package scheduler
import (
"fmt"
"regexp"
"strconv"
"time"
"github.com/google/uuid"
"github.com/robfig/cron/v3"
)
var dayPattern = regexp.MustCompile(`^(\d+)d(.*)$`)
// ParseDuration extends time.ParseDuration with support for days ("d").
// Examples: "1d" = 24h, "2d12h" = 60h, "30m", "2h30m".
func ParseDuration(s string) (time.Duration, error) {
if m := dayPattern.FindStringSubmatch(s); m != nil {
days, err := strconv.Atoi(m[1])
if err != nil {
return 0, fmt.Errorf("invalid duration: %s", s)
}
d := time.Duration(days) * 24 * time.Hour
if m[2] != "" {
rest, err := time.ParseDuration(m[2])
if err != nil {
return 0, fmt.Errorf("invalid duration: %w", err)
}
d += rest
}
return d, nil
}
return time.ParseDuration(s)
}
type TaskStatus string
const (
TaskStatusActive TaskStatus = "active"
TaskStatusPaused TaskStatus = "paused"
)
type ScheduleType string
const (
ScheduleTypeCron ScheduleType = "cron"
ScheduleTypeInterval ScheduleType = "interval"
ScheduleTypeOnce ScheduleType = "once"
)
// Task represents a scheduled task
type Task struct {
ID string `json:"id"`
AgentName string `json:"agent_name"`
Prompt string `json:"prompt"`
ScheduleType ScheduleType `json:"schedule_type"`
ScheduleValue string `json:"schedule_value"`
Status TaskStatus `json:"status"`
NextRun time.Time `json:"next_run"`
LastRun *time.Time `json:"last_run,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ContextMode string `json:"context_mode"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// TaskRun represents a single execution of a task
type TaskRun struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
RunAt time.Time `json:"run_at"`
DurationMs int64 `json:"duration_ms"`
Status string `json:"status"` // "success", "error", "timeout"
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// NewTask creates a new task with the given parameters
func NewTask(agentName, prompt string, scheduleType ScheduleType, scheduleValue string) (*Task, error) {
task := &Task{
ID: uuid.New().String(),
AgentName: agentName,
Prompt: prompt,
ScheduleType: scheduleType,
ScheduleValue: scheduleValue,
Status: TaskStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ContextMode: "agent",
Metadata: make(map[string]interface{}),
}
if err := task.CalculateNextRun(); err != nil {
return nil, err
}
return task, nil
}
// CalculateNextRun calculates the next run time based on schedule type
func (t *Task) CalculateNextRun() error {
now := time.Now()
switch t.ScheduleType {
case ScheduleTypeCron:
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
schedule, err := parser.Parse(t.ScheduleValue)
if err != nil {
return fmt.Errorf("invalid cron expression: %w", err)
}
t.NextRun = schedule.Next(now)
case ScheduleTypeInterval:
intervalMs, err := strconv.ParseInt(t.ScheduleValue, 10, 64)
if err != nil {
return fmt.Errorf("invalid interval: %w", err)
}
if intervalMs <= 0 {
return fmt.Errorf("invalid interval: %d", intervalMs)
}
if t.LastRun != nil {
t.NextRun = t.LastRun.Add(time.Duration(intervalMs) * time.Millisecond)
} else {
t.NextRun = now.Add(time.Duration(intervalMs) * time.Millisecond)
}
case ScheduleTypeOnce:
duration, err := ParseDuration(t.ScheduleValue)
if err != nil {
return fmt.Errorf("invalid duration: %w", err)
}
if duration < 0 {
return fmt.Errorf("duration must be positive: %s", t.ScheduleValue)
}
t.NextRun = now.Add(duration)
default:
return fmt.Errorf("unknown schedule type: %s", t.ScheduleType)
}
return nil
}
// IsDue checks if the task should be executed now
func (t *Task) IsDue() bool {
return t.Status == TaskStatusActive && time.Now().After(t.NextRun)
}
// NewTaskRun creates a new task run record
func NewTaskRun(taskID string) *TaskRun {
return &TaskRun{
ID: uuid.New().String(),
TaskID: taskID,
RunAt: time.Now(),
}
}
+13
View File
@@ -27,6 +27,8 @@ type (
Manager interface {
Send(message Envelope)
Handle(ctx *fiber.Ctx, cl Listener)
Register(cl Listener)
Unregister(id string)
Clients() []string
}
@@ -110,6 +112,17 @@ func (manager *broadcastManager) Send(message Envelope) {
manager.broadcast <- message
}
// Register adds a client to the broadcast list and sends message history.
func (manager *broadcastManager) Register(cl Listener) {
manager.register(cl)
manager.messageHistory.Send(cl)
}
// Unregister removes a client from the broadcast list and closes its channel.
func (manager *broadcastManager) Unregister(id string) {
manager.unregister(id)
}
// Handle sets up a new client and handles the connection.
func (manager *broadcastManager) Handle(c *fiber.Ctx, cl Listener) {
+241
View File
@@ -0,0 +1,241 @@
package state
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/mudler/LocalAGI/pkg/llm"
"github.com/mudler/LocalAGI/pkg/localrag"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
)
// KBCompactionClient is the interface used by compaction. It can be implemented by the HTTP RAG client adapter or by the in-process collection adapter.
type KBCompactionClient interface {
Collection() string
ListEntries() ([]string, error)
GetEntryContent(entry string) (content string, chunkCount int, err error)
Store(filePath string) error
DeleteEntry(entry string) error
}
// wrappedClientCompactionAdapter adapts *localrag.WrappedClient to KBCompactionClient.
type wrappedClientCompactionAdapter struct {
*localrag.WrappedClient
}
func (a *wrappedClientCompactionAdapter) ListEntries() ([]string, error) {
return a.Client.ListEntries(a.Collection())
}
func (a *wrappedClientCompactionAdapter) Store(filePath string) error {
_, err := a.Client.Store(a.Collection(), filePath)
return err
}
func (a *wrappedClientCompactionAdapter) DeleteEntry(entry string) error {
_, err := a.Client.DeleteEntry(a.Collection(), entry)
return err
}
// datePrefixRegex matches YYYY-MM-DD at the start of a filename (e.g. 2006-01-02-15-04-05-hash.txt).
var datePrefixRegex = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})`)
// summaryPrefix is the filename prefix for compaction summary entries; skip re-compacting these.
const summaryPrefix = "summary-"
// bucketKey returns the period bucket key for a date string (YYYY-MM-DD).
func bucketKey(dateStr, period string) (string, error) {
t, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return "", err
}
switch period {
case "daily":
return dateStr, nil
case "weekly":
year, week := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", year, week), nil
case "monthly":
return t.Format("2006-01"), nil
default:
return dateStr, nil
}
}
// dateFromFilename extracts YYYY-MM-DD from the start of a filename if present.
func dateFromFilename(filename string) (string, bool) {
base := filepath.Base(filename)
matches := datePrefixRegex.FindStringSubmatch(base)
if len(matches) < 2 {
return "", false
}
return matches[1], true
}
// groupEntriesByPeriod groups entry names by period bucket (daily/weekly/monthly). Skips summary-* and entries without a parseable date.
func groupEntriesByPeriod(entries []string, period string) map[string][]string {
groups := make(map[string][]string)
for _, entry := range entries {
if strings.HasPrefix(filepath.Base(entry), summaryPrefix) {
continue
}
dateStr, ok := dateFromFilename(entry)
if !ok {
continue
}
key, err := bucketKey(dateStr, period)
if err != nil {
xlog.Debug("compaction: skip entry, invalid date", "entry", entry, "error", err)
continue
}
groups[key] = append(groups[key], entry)
}
return groups
}
// summarizer summarizes text via the LLM.
type summarizer interface {
Summarize(ctx context.Context, content string) (string, error)
}
type openAISummarizer struct {
client *openai.Client
model string
}
func (s *openAISummarizer) Summarize(ctx context.Context, content string) (string, error) {
if content == "" {
return "", nil
}
resp, err := s.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: s.model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: "Summarize the following knowledge base entries into a concise summary. Preserve important facts and key points."},
{Role: openai.ChatMessageRoleUser, Content: content},
},
})
if err != nil {
return "", err
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("no completion choices")
}
return strings.TrimSpace(resp.Choices[0].Message.Content), nil
}
// RunCompaction runs one compaction pass: list entries, group by period, for each group fetch content, optionally summarize, store result, delete originals.
func RunCompaction(ctx context.Context, client KBCompactionClient, period string, summarize bool, apiURL, apiKey, model string) error {
collection := client.Collection()
entries, err := client.ListEntries()
if err != nil {
return fmt.Errorf("list entries: %w", err)
}
groups := groupEntriesByPeriod(entries, period)
if len(groups) == 0 {
xlog.Debug("compaction: no groups to compact", "collection", collection, "period", period)
return nil
}
var sum summarizer
if summarize && apiURL != "" && model != "" {
openAIClient := llm.NewClient(apiKey, apiURL, "120s")
sum = &openAISummarizer{client: openAIClient, model: model}
}
for key, groupEntries := range groups {
if len(groupEntries) == 0 {
continue
}
var combined strings.Builder
for _, entry := range groupEntries {
entryContent, _, err := client.GetEntryContent(entry)
if err != nil {
xlog.Warn("compaction: get entry content failed", "entry", entry, "error", err)
continue
}
if entryContent != "" {
combined.WriteString(entryContent)
combined.WriteString("\n\n")
}
}
content := strings.TrimSpace(combined.String())
if content == "" {
xlog.Debug("compaction: empty content for group", "key", key)
continue
}
if sum != nil {
summary, err := sum.Summarize(ctx, content)
if err != nil {
xlog.Warn("compaction: summarize failed", "key", key, "error", err)
continue
}
content = summary
}
// Store result as summary-<key>.txt
resultFilename := fmt.Sprintf("%s%s.txt", summaryPrefix, key)
tmpDir, err := os.MkdirTemp("", "localagi-compact")
if err != nil {
xlog.Warn("compaction: mkdir temp failed", "error", err)
continue
}
tmpPath := filepath.Join(tmpDir, resultFilename)
if err := os.WriteFile(tmpPath, []byte(content), 0644); err != nil {
os.RemoveAll(tmpDir)
xlog.Warn("compaction: write temp file failed", "error", err)
continue
}
if err := client.Store(tmpPath); err != nil {
os.RemoveAll(tmpDir)
xlog.Warn("compaction: store failed", "key", key, "error", err)
continue
}
os.RemoveAll(tmpDir)
for _, entry := range groupEntries {
if err := client.DeleteEntry(entry); err != nil {
xlog.Warn("compaction: delete entry failed", "entry", entry, "error", err)
}
}
xlog.Info("compaction: compacted group", "collection", collection, "period", period, "key", key, "entries", len(groupEntries))
}
return nil
}
// runCompactionTicker runs compaction on a schedule (daily/weekly/monthly). It stops when ctx is done.
func runCompactionTicker(ctx context.Context, client KBCompactionClient, config *AgentConfig, apiURL, apiKey, model string) {
// Run first compaction immediately on startup
if err := RunCompaction(ctx, client, config.KBCompactionInterval, config.KBCompactionSummarize, apiURL, apiKey, model); err != nil {
xlog.Warn("compaction ticker initial run failed", "collection", client.Collection(), "error", err)
}
interval := 24 * time.Hour
switch config.KBCompactionInterval {
case "weekly":
interval = 7 * 24 * time.Hour
case "monthly":
interval = 30 * 24 * time.Hour
default:
interval = 24 * time.Hour
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
xlog.Debug("compaction ticker stopped", "collection", client.Collection())
return
case <-ticker.C:
if err := RunCompaction(ctx, client, config.KBCompactionInterval, config.KBCompactionSummarize, apiURL, apiKey, model); err != nil {
xlog.Warn("compaction ticker failed", "collection", client.Collection(), "error", err)
}
}
}
}
+280 -41
View File
@@ -3,6 +3,7 @@ package state
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/mudler/LocalAGI/core/agent"
@@ -10,6 +11,21 @@ import (
"github.com/mudler/LocalAGI/pkg/config"
)
// parseIntField parses an integer field that may be received as either a number or a string
func parseIntField(value interface{}) int {
switch v := value.(type) {
case int:
return v
case float64:
return int(v)
case string:
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return 0
}
type ConnectorConfig struct {
Type string `json:"type"` // e.g. Slack
Config string `json:"config"`
@@ -43,40 +59,61 @@ type AgentConfig struct {
MCPServers []agent.MCPServer `json:"mcp_servers" form:"mcp_servers"`
MCPSTDIOServers []agent.MCPSTDIOServer `json:"mcp_stdio_servers" form:"mcp_stdio_servers"`
MCPPrepareScript string `json:"mcp_prepare_script" form:"mcp_prepare_script"`
MCPBoxURL string `json:"mcp_box_url" form:"mcp_box_url"`
Filters []FiltersConfig `json:"filters" form:"filters"`
Description string `json:"description" form:"description"`
Model string `json:"model" form:"model"`
MultimodalModel string `json:"multimodal_model" form:"multimodal_model"`
APIURL string `json:"api_url" form:"api_url"`
APIKey string `json:"api_key" form:"api_key"`
LocalRAGURL string `json:"local_rag_url" form:"local_rag_url"`
LocalRAGAPIKey string `json:"local_rag_api_key" form:"local_rag_api_key"`
LastMessageDuration string `json:"last_message_duration" form:"last_message_duration"`
Model string `json:"model" form:"model"`
MultimodalModel string `json:"multimodal_model" form:"multimodal_model"`
TranscriptionModel string `json:"transcription_model" form:"transcription_model"`
TranscriptionLanguage string `json:"transcription_language" form:"transcription_language"`
TTSModel string `json:"tts_model" form:"tts_model"`
APIURL string `json:"api_url" form:"api_url"`
APIKey string `json:"api_key" form:"api_key"`
LocalRAGURL string `json:"local_rag_url" form:"local_rag_url"`
LocalRAGAPIKey string `json:"local_rag_api_key" form:"local_rag_api_key"`
LastMessageDuration string `json:"last_message_duration" form:"last_message_duration"`
Name string `json:"name" form:"name"`
HUD bool `json:"hud" form:"hud"`
StandaloneJob bool `json:"standalone_job" form:"standalone_job"`
RandomIdentity bool `json:"random_identity" form:"random_identity"`
InitiateConversations bool `json:"initiate_conversations" form:"initiate_conversations"`
CanPlan bool `json:"enable_planning" form:"enable_planning"`
IdentityGuidance string `json:"identity_guidance" form:"identity_guidance"`
PeriodicRuns string `json:"periodic_runs" form:"periodic_runs"`
PermanentGoal string `json:"permanent_goal" form:"permanent_goal"`
EnableKnowledgeBase bool `json:"enable_kb" form:"enable_kb"`
EnableReasoning bool `json:"enable_reasoning" form:"enable_reasoning"`
KnowledgeBaseResults int `json:"kb_results" form:"kb_results"`
LoopDetectionSteps int `json:"loop_detection_steps" form:"loop_detection_steps"`
CanStopItself bool `json:"can_stop_itself" form:"can_stop_itself"`
SystemPrompt string `json:"system_prompt" form:"system_prompt"`
LongTermMemory bool `json:"long_term_memory" form:"long_term_memory"`
SummaryLongTermMemory bool `json:"summary_long_term_memory" form:"summary_long_term_memory"`
ParallelJobs int `json:"parallel_jobs" form:"parallel_jobs"`
StripThinkingTags bool `json:"strip_thinking_tags" form:"strip_thinking_tags"`
EnableEvaluation bool `json:"enable_evaluation" form:"enable_evaluation"`
MaxEvaluationLoops int `json:"max_evaluation_loops" form:"max_evaluation_loops"`
Name string `json:"name" form:"name"`
HUD bool `json:"hud" form:"hud"`
StandaloneJob bool `json:"standalone_job" form:"standalone_job"`
RandomIdentity bool `json:"random_identity" form:"random_identity"`
InitiateConversations bool `json:"initiate_conversations" form:"initiate_conversations"`
CanPlan bool `json:"enable_planning" form:"enable_planning"`
PlanReviewerModel string `json:"plan_reviewer_model" form:"plan_reviewer_model"`
DisableSinkState bool `json:"disable_sink_state" form:"disable_sink_state"`
IdentityGuidance string `json:"identity_guidance" form:"identity_guidance"`
PeriodicRuns string `json:"periodic_runs" form:"periodic_runs"`
SchedulerPollInterval string `json:"scheduler_poll_interval" form:"scheduler_poll_interval"`
SchedulerTaskTemplate string `json:"scheduler_task_template" form:"scheduler_task_template"`
PermanentGoal string `json:"permanent_goal" form:"permanent_goal"`
EnableKnowledgeBase bool `json:"enable_kb" form:"enable_kb"`
EnableKBCompaction bool `json:"enable_kb_compaction" form:"enable_kb_compaction"`
KBCompactionInterval string `json:"kb_compaction_interval" form:"kb_compaction_interval"`
KBCompactionSummarize bool `json:"kb_compaction_summarize" form:"kb_compaction_summarize"`
KBAutoSearch bool `json:"kb_auto_search" form:"kb_auto_search"`
KBAsTools bool `json:"kb_as_tools" form:"kb_as_tools"`
EnableReasoning bool `json:"enable_reasoning" form:"enable_reasoning"`
EnableForceReasoningTool bool `json:"enable_reasoning_tool" form:"enable_reasoning_tool"`
EnableGuidedTools bool `json:"enable_guided_tools" form:"enable_guided_tools"`
EnableSkills bool `json:"enable_skills" form:"enable_skills"`
KnowledgeBaseResults int `json:"kb_results" form:"kb_results"`
CanStopItself bool `json:"can_stop_itself" form:"can_stop_itself"`
SystemPrompt string `json:"system_prompt" form:"system_prompt"`
SkillsPrompt string `json:"skills_prompt" form:"skills_prompt"`
InnerMonologueTemplate string `json:"inner_monologue_template" form:"inner_monologue_template"`
LongTermMemory bool `json:"long_term_memory" form:"long_term_memory"`
SummaryLongTermMemory bool `json:"summary_long_term_memory" form:"summary_long_term_memory"`
ConversationStorageMode string `json:"conversation_storage_mode" form:"conversation_storage_mode"`
ParallelJobs int `json:"parallel_jobs" form:"parallel_jobs"`
CancelPreviousOnNewMessage *bool `json:"cancel_previous_on_new_message" form:"cancel_previous_on_new_message"`
StripThinkingTags bool `json:"strip_thinking_tags" form:"strip_thinking_tags"`
EnableEvaluation bool `json:"enable_evaluation" form:"enable_evaluation"`
MaxEvaluationLoops int `json:"max_evaluation_loops" form:"max_evaluation_loops"`
MaxAttempts int `json:"max_attempts" form:"max_attempts"`
LoopDetection int `json:"loop_detection" form:"loop_detection"`
EnableAutoCompaction bool `json:"enable_auto_compaction" form:"enable_auto_compaction"`
AutoCompactionThreshold int `json:"auto_compaction_threshold" form:"auto_compaction_threshold"`
}
type AgentConfigMeta struct {
@@ -146,6 +183,34 @@ func NewAgentConfigMeta(
DefaultValue: "",
Tags: config.Tags{Section: "ModelSettings"},
},
{
Name: "transcription_model",
Label: "Transcription Model",
Type: "text",
DefaultValue: "",
Tags: config.Tags{Section: "ModelSettings"},
},
{
Name: "transcription_language",
Label: "Transcription Language",
Type: "text",
DefaultValue: "",
Tags: config.Tags{Section: "ModelSettings"},
},
{
Name: "tts_model",
Label: "TTS Model",
Type: "text",
DefaultValue: "",
Tags: config.Tags{Section: "ModelSettings"},
},
{
Name: "plan_reviewer_model",
Label: "Plan Reviewer Model",
Type: "text",
DefaultValue: "",
Tags: config.Tags{Section: "ModelSettings"},
},
{
Name: "api_url",
Label: "API URL",
@@ -190,6 +255,31 @@ func NewAgentConfigMeta(
Step: 1,
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "enable_kb_compaction",
Label: "Enable KB Compaction",
Type: "checkbox",
DefaultValue: false,
HelpText: "Periodically group collection entries by date (daily/weekly/monthly), optionally summarize or concatenate, then store and remove originals",
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "kb_compaction_interval",
Label: "KB Compaction Interval",
Type: "text",
DefaultValue: "daily",
Placeholder: "daily, weekly, monthly",
HelpText: "Compaction window: daily, weekly, or monthly",
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "kb_compaction_summarize",
Label: "KB Compaction Summarize",
Type: "checkbox",
DefaultValue: true,
HelpText: "When enabled, summarize grouped content via LLM; when disabled, store concatenated content only (no LLM call)",
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "long_term_memory",
Label: "Long Term Memory",
@@ -204,6 +294,35 @@ func NewAgentConfigMeta(
DefaultValue: false,
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "kb_auto_search",
Label: "KB Auto Search",
Type: "checkbox",
DefaultValue: true,
HelpText: "Automatically search knowledge base when a user message is received",
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "kb_as_tools",
Label: "KB As Tools",
Type: "checkbox",
DefaultValue: false,
HelpText: "Inject knowledge base search and add actions as tools, allowing the agent to access its memory without manual configuration",
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "conversation_storage_mode",
Label: "Conversation Storage Mode",
Type: "select",
DefaultValue: "user_only",
Options: []config.FieldOption{
{Value: "user_only", Label: "User Messages Only"},
{Value: "user_and_assistant", Label: "User and Assistant Messages"},
{Value: "whole_conversation", Label: "Whole Conversation as Block"},
},
HelpText: "Controls what gets stored in the knowledge base: only user messages, user and assistant messages separately, or the entire conversation as a single block",
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "system_prompt",
Label: "System Prompt",
@@ -220,6 +339,30 @@ func NewAgentConfigMeta(
HelpText: "Long-term objective for the agent to pursue",
Tags: config.Tags{Section: "PromptsGoals"},
},
{
Name: "skills_prompt",
Label: "Skills Prompt",
Type: "textarea",
DefaultValue: "",
HelpText: "Optional instructions for using skills. Used when Enable Skills is on. If empty, default instructions are used.",
Tags: config.Tags{Section: "PromptsGoals"},
},
{
Name: "inner_monologue_template",
Label: "Inner Monologue Template",
Type: "textarea",
DefaultValue: "",
HelpText: "Prompt used for periodic/standalone runs when the agent evaluates what to do next. If empty, the default autonomous agent instructions are used.",
Tags: config.Tags{Section: "PromptsGoals"},
},
{
Name: "scheduler_task_template",
Label: "Scheduler Task Template",
Type: "textarea",
DefaultValue: "",
HelpText: "Template for scheduled/recurring tasks. Use {{.Task}} to reference the task. Example: \"Execute: {{.Task}}\". If empty, the default inner monologue template is used with the task injected.",
Tags: config.Tags{Section: "PromptsGoals"},
},
{
Name: "standalone_job",
Label: "Standalone Job",
@@ -244,6 +387,24 @@ func NewAgentConfigMeta(
HelpText: "Enable agent to create and execute plans",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "cancel_previous_on_new_message",
Label: "Cancel previous message on new message",
Type: "checkbox",
DefaultValue: true,
HelpText: "When a new message arrives for the same conversation, cancel the currently running job and start the new one. If disabled, new messages are queued.",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "loop_detection",
Label: "Loop Detection",
Type: "number",
DefaultValue: 5,
Min: 1,
Step: 1,
HelpText: "Number of messages to check for loop detection. If a message is the same as the previous message, the job is cancelled.",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "can_stop_itself",
Label: "Can Stop Itself",
@@ -261,21 +422,45 @@ func NewAgentConfigMeta(
HelpText: "Duration for scheduling periodic agent runs",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "scheduler_poll_interval",
Label: "Scheduler Poll Interval",
Type: "text",
DefaultValue: "30s",
Placeholder: "30s",
HelpText: "Duration for polling the scheduler for planned tasks",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "enable_reasoning",
Label: "Enable Reasoning",
Type: "checkbox",
DefaultValue: true,
DefaultValue: false,
HelpText: "Enable agent to explain its reasoning process",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "loop_detection_steps",
Label: "Max Loop Detection Steps",
Type: "number",
DefaultValue: 5,
Min: 1,
Step: 1,
Name: "enable_reasoning_tool",
Label: "Enable Reasoning for tools",
Type: "checkbox",
DefaultValue: true,
HelpText: "Enable agent to reason more on tools",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "enable_guided_tools",
Label: "Enable Guided Tools",
Type: "checkbox",
DefaultValue: false,
HelpText: "Filter tools through guidance using their descriptions; creates virtual guidelines when none exist",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "enable_skills",
Label: "Enable Skills",
Type: "checkbox",
DefaultValue: false,
HelpText: "Inject available skills into the agent and expose skill tools (list, read, search, resources) via MCP",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
@@ -288,21 +473,29 @@ func NewAgentConfigMeta(
HelpText: "Number of concurrent tasks that can run in parallel",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "disable_sink_state",
Label: "Disable Sink State",
Type: "checkbox",
DefaultValue: false,
HelpText: "Disable the sink state of the agent",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "mcp_stdio_servers",
Label: "MCP STDIO Servers",
Type: "textarea",
DefaultValue: "",
HelpText: "JSON configuration for MCP STDIO servers",
Tags: config.Tags{Section: "AdvancedSettings"},
Tags: config.Tags{Section: "MCP"},
},
{
Name: "mcp_prepare_script",
Label: "MCP Prepare Script",
Type: "textarea",
DefaultValue: "",
HelpText: "Script to prepare the MCP box",
Tags: config.Tags{Section: "AdvancedSettings"},
HelpText: "Script to prepare for running MCP servers",
Tags: config.Tags{Section: "MCP"},
},
{
Name: "strip_thinking_tags",
@@ -312,6 +505,24 @@ func NewAgentConfigMeta(
HelpText: "Remove content between <thinking></thinking> and <think></think> tags from agent responses",
Tags: config.Tags{Section: "ModelSettings"},
},
{
Name: "enable_auto_compaction",
Label: "Enable Auto Compaction",
Type: "checkbox",
DefaultValue: false,
HelpText: "Enable automatic conversation compaction when token threshold is reached",
Tags: config.Tags{Section: "ModelSettings"},
},
{
Name: "auto_compaction_threshold",
Label: "Auto Compaction Threshold (tokens)",
Type: "number",
DefaultValue: 4096,
Min: 1,
Step: 1,
HelpText: "Number of tokens to trigger automatic compaction",
Tags: config.Tags{Section: "ModelSettings"},
},
{
Name: "enable_evaluation",
Label: "Enable Evaluation",
@@ -330,6 +541,16 @@ func NewAgentConfigMeta(
HelpText: "Maximum number of evaluation loops to perform when addressing gaps in responses",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "max_attempts",
Label: "Max Attempts",
Type: "number",
DefaultValue: 1,
Min: 1,
Step: 1,
HelpText: "Number of attempts on failure before surfacing the error to the user (1 = no retries)",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "last_message_duration",
Label: "Last Message Duration",
@@ -373,6 +594,10 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
aux := &struct {
*Alias
MCPSTDIOServersConfig interface{} `json:"mcp_stdio_servers"`
MaxEvaluationLoops interface{} `json:"max_evaluation_loops"`
MaxAttempts interface{} `json:"max_attempts"`
ParallelJobs interface{} `json:"parallel_jobs"`
KnowledgeBaseResults interface{} `json:"kb_results"`
}{
Alias: (*Alias)(a),
}
@@ -381,6 +606,13 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
return err
}
// Parse integer fields that may come as strings
a.MaxEvaluationLoops = parseIntField(aux.MaxEvaluationLoops)
a.MaxAttempts = parseIntField(aux.MaxAttempts)
a.ParallelJobs = parseIntField(aux.ParallelJobs)
a.KnowledgeBaseResults = parseIntField(aux.KnowledgeBaseResults)
a.LoopDetection = parseIntField(aux.LoopDetection)
// Handle MCP STDIO servers configuration
if aux.MCPSTDIOServersConfig != nil {
switch v := aux.MCPSTDIOServersConfig.(type) {
@@ -399,7 +631,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
}
a.MCPSTDIOServers = make([]agent.MCPSTDIOServer, 0, len(mcpConfig.MCPServers))
for _, server := range mcpConfig.MCPServers {
for name, server := range mcpConfig.MCPServers {
// Convert env map to slice of "KEY=VALUE" strings
envSlice := make([]string, 0, len(server.Env))
for k, v := range server.Env {
@@ -407,6 +639,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
}
a.MCPSTDIOServers = append(a.MCPSTDIOServers, agent.MCPSTDIOServer{
Name: name,
Cmd: server.Command,
Args: server.Args,
Env: envSlice,
@@ -421,6 +654,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
return fmt.Errorf("invalid server configuration format")
}
name, _ := serverMap["name"].(string)
cmd, _ := serverMap["cmd"].(string)
args := make([]string, 0)
if argsInterface, ok := serverMap["args"].([]interface{}); ok {
@@ -441,6 +675,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
}
a.MCPSTDIOServers = append(a.MCPSTDIOServers, agent.MCPSTDIOServer{
Name: name,
Cmd: cmd,
Args: args,
Env: env,
@@ -489,7 +724,11 @@ func (a *AgentConfig) MarshalJSON() ([]byte, error) {
}
}
mcpConfig.MCPServers[fmt.Sprintf("server%d", i)] = struct {
key := server.Name
if key == "" {
key = fmt.Sprintf("server%d", i)
}
mcpConfig.MCPServers[key] = struct {
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
+428 -209
View File
@@ -2,7 +2,6 @@ package state
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
@@ -13,34 +12,68 @@ import (
"time"
. "github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/sse"
sseLib "github.com/mudler/LocalAGI/core/sse"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/llm"
"github.com/mudler/LocalAGI/pkg/localrag"
"github.com/mudler/LocalAGI/pkg/utils"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mudler/cogito"
"github.com/mudler/xlog"
)
// SkillsProvider supplies the skills dynamic prompt and MCP session when skills are enabled for an agent.
type SkillsProvider interface {
GetSkillsPrompt(config *AgentConfig) (DynamicPrompt, error)
GetMCPSession(ctx context.Context) (*mcp.ClientSession, error)
}
// RAGProvider returns a RAGDB and optional compaction client for a collection (e.g. agent name).
// effectiveRAGURL/Key are pool/agent defaults; implementation may use them (HTTP) or ignore them (embedded).
type RAGProvider func(collectionName, effectiveRAGURL, effectiveRAGKey string) (RAGDB, KBCompactionClient, bool)
// NewHTTPRAGProvider returns a RAGProvider that uses the LocalRAG HTTP API. When effective URL/key are empty, baseURL/baseKey are used.
func NewHTTPRAGProvider(baseURL, baseKey string) RAGProvider {
return func(collectionName, effectiveURL, effectiveKey string) (RAGDB, KBCompactionClient, bool) {
url := effectiveURL
if url == "" {
url = baseURL
}
key := effectiveKey
if key == "" {
key = baseKey
}
wc := localrag.NewWrappedClient(url, key, collectionName)
return wc, &wrappedClientCompactionAdapter{WrappedClient: wc}, true
}
}
type AgentPool struct {
sync.Mutex
file string
pooldir string
pool AgentPoolData
agents map[string]*Agent
managers map[string]sse.Manager
agentStatus map[string]*Status
apiURL, defaultModel, defaultMultimodalModel string
mcpBoxURL string
imageModel, localRAGAPI, localRAGKey, apiKey string
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action
connectors func(*AgentConfig) []Connector
dynamicPrompt func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []DynamicPrompt
filters func(*AgentConfig) types.JobFilters
timeout string
conversationLogs string
file string
pooldir string
pool AgentPoolData
agents map[string]*Agent
managers map[string]sseLib.Manager
agentStatus map[string]*Status
apiURL, defaultModel, defaultMultimodalModel, defaultTTSModel string
defaultTranscriptionModel, defaultTranscriptionLanguage string
apiKey string
ragProvider RAGProvider
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action
connectors func(*AgentConfig) []Connector
dynamicPrompt func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []DynamicPrompt
filters func(*AgentConfig) types.JobFilters
timeout string
conversationLogs string
skillsService SkillsProvider
}
// SetRAGProvider sets the single RAG provider (HTTP or embedded). Must be called after pool creation.
func (a *AgentPool) SetRAGProvider(fn RAGProvider) {
a.Lock()
defer a.Unlock()
a.ragProvider = fn
}
type Status struct {
@@ -74,14 +107,14 @@ func loadPoolFromFile(path string) (*AgentPoolData, error) {
}
func NewAgentPool(
defaultModel, defaultMultimodalModel, imageModel, apiURL, apiKey, directory, mcpBoxURL string,
LocalRAGAPI string,
defaultModel, defaultMultimodalModel, defaultTranscriptionModel, defaultTranscriptionLanguage, defaultTTSModel, apiURL, apiKey, directory string,
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action,
connectors func(*AgentConfig) []Connector,
promptBlocks func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []DynamicPrompt,
filters func(*AgentConfig) types.JobFilters,
timeout string,
withLogs bool,
skillsService SkillsProvider,
) (*AgentPool, error) {
// if file exists, try to load an existing pool.
// if file does not exist, create a new pool.
@@ -92,56 +125,67 @@ func NewAgentPool(
if withLogs {
conversationPath = filepath.Join(directory, "conversations")
}
if _, err := os.Stat(poolfile); err != nil {
// file does not exist, create a new pool
return &AgentPool{
file: poolfile,
pooldir: directory,
apiURL: apiURL,
defaultModel: defaultModel,
defaultMultimodalModel: defaultMultimodalModel,
mcpBoxURL: mcpBoxURL,
imageModel: imageModel,
localRAGAPI: LocalRAGAPI,
apiKey: apiKey,
agents: make(map[string]*Agent),
pool: make(map[string]AgentConfig),
agentStatus: make(map[string]*Status),
managers: make(map[string]sse.Manager),
connectors: connectors,
availableActions: availableActions,
dynamicPrompt: promptBlocks,
filters: filters,
timeout: timeout,
conversationLogs: conversationPath,
file: poolfile,
pooldir: directory,
apiURL: apiURL,
defaultModel: defaultModel,
defaultMultimodalModel: defaultMultimodalModel,
defaultTranscriptionModel: defaultTranscriptionModel,
defaultTranscriptionLanguage: defaultTranscriptionLanguage,
defaultTTSModel: defaultTTSModel,
apiKey: apiKey,
agents: make(map[string]*Agent),
pool: make(map[string]AgentConfig),
agentStatus: make(map[string]*Status),
managers: make(map[string]sseLib.Manager),
connectors: connectors,
availableActions: availableActions,
dynamicPrompt: promptBlocks,
filters: filters,
timeout: timeout,
conversationLogs: conversationPath,
skillsService: skillsService,
}, nil
}
poolData, err := loadPoolFromFile(poolfile)
if err != nil {
return nil, err
bakPath := poolfile + ".bak"
poolData, err = loadPoolFromFile(bakPath)
if err != nil {
xlog.Warn("Pool file invalid and backup missing or invalid, starting with empty pool", "poolfile", poolfile, "error", err)
poolData = &AgentPoolData{}
} else {
xlog.Info("Recovered pool from backup, repairing main file", "poolfile", poolfile)
if repairData, _ := json.MarshalIndent(poolData, "", " "); len(repairData) > 0 {
_ = os.WriteFile(poolfile, repairData, 0644)
}
}
}
return &AgentPool{
file: poolfile,
apiURL: apiURL,
pooldir: directory,
defaultModel: defaultModel,
defaultMultimodalModel: defaultMultimodalModel,
mcpBoxURL: mcpBoxURL,
imageModel: imageModel,
apiKey: apiKey,
agents: make(map[string]*Agent),
managers: make(map[string]sse.Manager),
agentStatus: map[string]*Status{},
pool: *poolData,
connectors: connectors,
localRAGAPI: LocalRAGAPI,
dynamicPrompt: promptBlocks,
filters: filters,
availableActions: availableActions,
timeout: timeout,
conversationLogs: conversationPath,
file: poolfile,
apiURL: apiURL,
pooldir: directory,
defaultModel: defaultModel,
defaultMultimodalModel: defaultMultimodalModel,
defaultTranscriptionModel: defaultTranscriptionModel,
defaultTranscriptionLanguage: defaultTranscriptionLanguage,
defaultTTSModel: defaultTTSModel,
apiKey: apiKey,
agents: make(map[string]*Agent),
managers: make(map[string]sseLib.Manager),
agentStatus: map[string]*Status{},
pool: *poolData,
connectors: connectors,
dynamicPrompt: promptBlocks,
filters: filters,
availableActions: availableActions,
timeout: timeout,
conversationLogs: conversationPath,
skillsService: skillsService,
}, nil
}
@@ -150,6 +194,14 @@ func replaceInvalidChars(s string) string {
return strings.ReplaceAll(s, " ", "_")
}
// StartAgentStandalone starts an agent without saving it to the pool registry.
// It is intended for running a single agent from the CLI without the web server.
func (a *AgentPool) StartAgentStandalone(name string, agentConfig *AgentConfig) error {
a.Lock()
defer a.Unlock()
return a.startAgentWithConfig(name, a.pooldir, agentConfig, nil)
}
// CreateAgent adds a new agent to the pool
// and starts it.
// It also saves the state to the file.
@@ -166,14 +218,7 @@ func (a *AgentPool) CreateAgent(name string, agentConfig *AgentConfig) error {
return err
}
go func(ac AgentConfig) {
// Create the agent avatar
if err := createAgentAvatar(a.apiURL, a.apiKey, a.defaultModel, a.imageModel, a.pooldir, ac); err != nil {
xlog.Error("Failed to create agent avatar", "error", err)
}
}(a.pool[name])
return a.startAgentWithConfig(name, agentConfig, nil)
return a.startAgentWithConfig(name, a.pooldir, agentConfig, nil)
}
func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
@@ -182,22 +227,22 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
oldAgent := a.agents[name]
var o *types.Observable
obs := oldAgent.Observer()
if obs != nil {
o = obs.NewObservable()
o.Name = "Restarting Agent"
o.Icon = "sync"
o.Creation = &types.Creation{}
obs.Update(*o)
var obs Observer
if oldAgent != nil {
obs = oldAgent.Observer()
if obs != nil {
o = obs.NewObservable()
o.Name = "Restarting Agent"
o.Icon = "sync"
o.Creation = &types.Creation{}
obs.Update(*o)
}
stateFile, characterFile := a.stateFiles(name)
os.Remove(stateFile)
os.Remove(characterFile)
oldAgent.Stop()
}
stateFile, characterFile := a.stateFiles(name)
os.Remove(stateFile)
os.Remove(characterFile)
oldAgent.Stop()
a.pool[name] = *agentConfig
delete(a.agents, name)
@@ -209,7 +254,7 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
return err
}
if err := a.startAgentWithConfig(name, agentConfig, obs); err != nil {
if err := a.startAgentWithConfig(name, a.pooldir, agentConfig, obs); err != nil {
if obs != nil {
o.Completion = &types.Completion{Error: err.Error()}
obs.Update(*o)
@@ -225,84 +270,6 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
return nil
}
func createAgentAvatar(APIURL, APIKey, model, imageModel, avatarDir string, agent AgentConfig) error {
client := llm.NewClient(APIKey, APIURL+"/v1", "10m")
if imageModel == "" {
return fmt.Errorf("image model not set")
}
if model == "" {
return fmt.Errorf("default model not set")
}
imagePath := filepath.Join(avatarDir, "avatars", fmt.Sprintf("%s.png", agent.Name))
if _, err := os.Stat(imagePath); err == nil {
// Image already exists
xlog.Debug("Avatar already exists", "path", imagePath)
return nil
}
var results struct {
ImagePrompt string `json:"image_prompt"`
}
err := llm.GenerateTypedJSONWithGuidance(
context.Background(),
llm.NewClient(APIKey, APIURL, "10m"),
"Generate a prompt that I can use to create a random avatar for the bot '"+agent.Name+"', the description of the bot is: "+agent.Description,
model,
jsonschema.Definition{
Type: jsonschema.Object,
Properties: map[string]jsonschema.Definition{
"image_prompt": {
Type: jsonschema.String,
Description: "The prompt to generate the image",
},
},
Required: []string{"image_prompt"},
}, &results)
if err != nil {
return fmt.Errorf("failed to generate image prompt: %w", err)
}
if results.ImagePrompt == "" {
xlog.Error("Failed to generate image prompt")
return fmt.Errorf("failed to generate image prompt")
}
req := openai.ImageRequest{
Prompt: results.ImagePrompt,
Model: imageModel,
Size: openai.CreateImageSize256x256,
ResponseFormat: openai.CreateImageResponseFormatB64JSON,
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
resp, err := client.CreateImage(ctx, req)
if err != nil {
return fmt.Errorf("failed to generate image: %w", err)
}
if len(resp.Data) == 0 {
return fmt.Errorf("failed to generate image")
}
imageJson := resp.Data[0].B64JSON
os.MkdirAll(filepath.Join(avatarDir, "avatars"), 0755)
// Save the image to the agent directory
imageData, err := base64.StdEncoding.DecodeString(imageJson)
if err != nil {
return err
}
return os.WriteFile(imagePath, imageData, 0644)
}
func (a *AgentPool) List() []string {
a.Lock()
defer a.Unlock()
@@ -324,58 +291,72 @@ func (a *AgentPool) GetStatusHistory(name string) *Status {
return a.agentStatus[name]
}
func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs Observer) error {
var manager sse.Manager
func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConfig, obs Observer) error {
var manager sseLib.Manager
if m, ok := a.managers[name]; ok {
manager = m
} else {
manager = sse.NewManager(5)
manager = sseLib.NewManager(5)
}
ctx := context.Background()
model := a.defaultModel
multimodalModel := a.defaultMultimodalModel
transcriptionModel := a.defaultTranscriptionModel
transcriptionLanguage := a.defaultTranscriptionLanguage
ttsModel := a.defaultTTSModel
if config.MultimodalModel != "" {
multimodalModel = config.MultimodalModel
}
if config.TranscriptionModel != "" {
transcriptionModel = config.TranscriptionModel
}
if config.TranscriptionLanguage != "" {
transcriptionLanguage = config.TranscriptionLanguage
}
if config.TTSModel != "" {
ttsModel = config.TTSModel
}
if config.Model != "" {
model = config.Model
} else {
config.Model = model
}
if config.MCPBoxURL != "" {
a.mcpBoxURL = config.MCPBoxURL
}
if config.PeriodicRuns == "" {
config.PeriodicRuns = "10m"
}
// XXX: Why do we update the pool config from an Agent's config?
if config.SchedulerPollInterval == "" {
config.SchedulerPollInterval = "30s"
}
// Use agent-specific config when set, otherwise pool defaults. Do not update pool from agent config.
effectiveAPIURL := a.apiURL
if config.APIURL != "" {
a.apiURL = config.APIURL
effectiveAPIURL = config.APIURL
} else {
config.APIURL = a.apiURL
}
effectiveAPIKey := a.apiKey
if config.APIKey != "" {
a.apiKey = config.APIKey
effectiveAPIKey = config.APIKey
} else {
config.APIKey = a.apiKey
}
if config.LocalRAGURL != "" {
a.localRAGAPI = config.LocalRAGURL
}
if config.LocalRAGAPIKey != "" {
a.localRAGKey = config.LocalRAGAPIKey
}
effectiveLocalRAGAPI := config.LocalRAGURL
effectiveLocalRAGKey := config.LocalRAGAPIKey
connectors := a.connectors(config)
promptBlocks := a.dynamicPrompt(config)(ctx, a)
if a.skillsService != nil && config.EnableSkills {
if prompt, err := a.skillsService.GetSkillsPrompt(config); err == nil && prompt != nil {
promptBlocks = append(promptBlocks, prompt)
}
}
actions := a.availableActions(config)(ctx, a)
filters := a.filters(config)
stateFile, characterFile := a.stateFiles(name)
@@ -399,7 +380,7 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
"Creating agent",
"name", name,
"model", model,
"api_url", a.apiURL,
"api_url", effectiveAPIURL,
"actions", actionsLog,
"connectors", connectorLog,
"filters", filtersLog,
@@ -415,14 +396,18 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
}
opts := []Option{
WithSchedulerStorePath(filepath.Join(pooldir, fmt.Sprintf("scheduler-%s.json", name))),
WithModel(model),
WithLLMAPIURL(a.apiURL),
WithLLMAPIURL(effectiveAPIURL),
WithContext(ctx),
WithMCPServers(config.MCPServers...),
WithTranscriptionModel(transcriptionModel),
WithTranscriptionLanguage(transcriptionLanguage),
WithTTSModel(ttsModel),
WithPeriodicRuns(config.PeriodicRuns),
WithSchedulerPollInterval(config.SchedulerPollInterval),
WithPermanentGoal(config.PermanentGoal),
WithMCPSTDIOServers(config.MCPSTDIOServers...),
WithMCPBoxURL(a.mcpBoxURL),
WithPrompts(promptBlocks...),
WithJobFilters(filters...),
WithMCPPrepareScript(config.MCPPrepareScript),
@@ -435,20 +420,23 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
),
WithStateFile(stateFile),
WithCharacterFile(characterFile),
WithLLMAPIKey(a.apiKey),
WithLLMAPIKey(effectiveAPIKey),
WithTimeout(a.timeout),
WithRAGDB(localrag.NewWrappedClient(a.localRAGAPI, a.localRAGKey, name)),
WithAgentReasoningCallback(func(state types.ActionCurrentState) bool {
var actionName types.ActionDefinitionName
if state.Action != nil {
actionName = state.Action.Definition().Name
}
xlog.Info(
"Agent is thinking",
"agent", name,
"reasoning", state.Reasoning,
"action", state.Action.Definition().Name,
"action", actionName,
"params", state.Params,
)
manager.Send(
sse.NewMessage(
sseLib.NewMessage(
fmt.Sprintf(`Thinking: %s`, utils.HTMLify(state.Reasoning)),
).WithEvent("status"),
)
@@ -461,6 +449,8 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
return true
}),
WithSystemPrompt(config.SystemPrompt),
WithInnerMonologueTemplate(config.InnerMonologueTemplate),
WithSchedulerTaskTemplate(config.SchedulerTaskTemplate),
WithMultimodalModel(multimodalModel),
WithLastMessageDuration(config.LastMessageDuration),
WithAgentResultCallback(func(state types.ActionState) {
@@ -475,16 +465,20 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
"Calling agent result callback",
)
var actionName types.ActionDefinitionName
if state.ActionCurrentState.Action != nil {
actionName = state.ActionCurrentState.Action.Definition().Name
}
text := fmt.Sprintf(`Reasoning: %s
Action taken: %+v
Parameters: %+v
Result: %s`,
state.Reasoning,
state.ActionCurrentState.Action.Definition().Name,
actionName,
state.ActionCurrentState.Params,
state.Result)
manager.Send(
sse.NewMessage(
sseLib.NewMessage(
utils.HTMLify(
text,
),
@@ -518,6 +512,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
opts = append(opts, EnableSummaryMemory)
}
if config.ConversationStorageMode != "" {
opts = append(opts, WithConversationStorageMode(ConversationStorageMode(config.ConversationStorageMode)))
}
if config.CanStopItself {
opts = append(opts, CanStopItself)
}
@@ -526,6 +524,14 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
opts = append(opts, EnablePlanning)
}
if config.PlanReviewerModel != "" {
opts = append(opts, WithPlanReviewerLLM(config.PlanReviewerModel))
}
if config.DisableSinkState {
opts = append(opts, DisableSinkState)
}
if config.InitiateConversations {
opts = append(opts, EnableInitiateConversations)
}
@@ -538,37 +544,125 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
}
}
if config.EnableKnowledgeBase {
opts = append(opts, EnableKnowledgeBase)
if a.skillsService != nil && config.EnableSkills {
if session, err := a.skillsService.GetMCPSession(ctx); err == nil && session != nil {
opts = append(opts, WithMCPSession(session))
}
}
var ragDB RAGDB
var compactionClient KBCompactionClient
if config.EnableKnowledgeBase && a.ragProvider != nil {
if db, comp, ok := a.ragProvider(name, effectiveLocalRAGAPI, effectiveLocalRAGKey); ok && db != nil {
ragDB = db
compactionClient = comp
}
}
if ragDB != nil {
opts = append(opts, WithRAGDB(ragDB), EnableKnowledgeBase)
kbAutoSearch := config.KBAutoSearch
if !config.KBAutoSearch && !config.KBAsTools {
kbAutoSearch = true
}
opts = append(opts, WithKBAutoSearch(kbAutoSearch))
if config.KBAsTools {
kbResults := config.KnowledgeBaseResults
if kbResults <= 0 {
kbResults = 5
}
searchAction, addAction := NewKBWrapperActions(ragDB, kbResults)
opts = append(opts, WithActions(searchAction, addAction))
}
}
if config.EnableReasoning {
opts = append(opts, EnableForceReasoning)
}
if config.EnableGuidedTools {
opts = append(opts, EnableGuidedTools)
}
if config.StripThinkingTags {
opts = append(opts, EnableStripThinkingTags)
}
if config.EnableAutoCompaction {
opts = append(opts, EnableAutoCompaction)
}
if config.AutoCompactionThreshold > 0 {
opts = append(opts, WithAutoCompactionThreshold(config.AutoCompactionThreshold))
}
if config.KnowledgeBaseResults > 0 {
opts = append(opts, EnableKnowledgeBaseWithResults(config.KnowledgeBaseResults))
}
if config.LoopDetectionSteps > 0 {
opts = append(opts, WithLoopDetectionSteps(config.LoopDetectionSteps))
}
if config.ParallelJobs > 0 {
opts = append(opts, WithParallelJobs(config.ParallelJobs))
}
if config.CancelPreviousOnNewMessage != nil {
opts = append(opts, WithCancelPreviousOnNewMessage(*config.CancelPreviousOnNewMessage))
} else {
opts = append(opts, WithCancelPreviousOnNewMessage(true))
}
if config.EnableEvaluation {
opts = append(opts, EnableEvaluation())
if config.MaxEvaluationLoops > 0 {
opts = append(opts, WithMaxEvaluationLoops(config.MaxEvaluationLoops))
}
}
if config.MaxEvaluationLoops > 0 {
opts = append(opts, WithMaxEvaluationLoops(config.MaxEvaluationLoops))
}
if config.MaxAttempts > 0 {
opts = append(opts, WithMaxAttempts(config.MaxAttempts))
}
if config.LoopDetection > 0 {
opts = append(opts, WithLoopDetection(config.LoopDetection))
}
if config.EnableForceReasoningTool {
opts = append(opts, EnableForceReasoningTool)
}
// Wire cogito streaming events into the SSE manager for live token delivery
opts = append(opts, WithStreamCallback(func(ev cogito.StreamEvent) {
switch ev.Type {
case cogito.StreamEventReasoning:
data, _ := json.Marshal(map[string]interface{}{
"type": "reasoning",
"content": ev.Content,
"timestamp": time.Now().Format(time.RFC3339),
})
manager.Send(sseLib.NewMessage(string(data)).WithEvent("stream_event"))
case cogito.StreamEventContent:
data, _ := json.Marshal(map[string]interface{}{
"type": "content",
"content": ev.Content,
"timestamp": time.Now().Format(time.RFC3339),
})
manager.Send(sseLib.NewMessage(string(data)).WithEvent("stream_event"))
case cogito.StreamEventToolCall:
data, _ := json.Marshal(map[string]interface{}{
"type": "tool_call",
"tool_name": ev.ToolName,
"tool_args": ev.ToolArgs,
"timestamp": time.Now().Format(time.RFC3339),
})
manager.Send(sseLib.NewMessage(string(data)).WithEvent("stream_event"))
case cogito.StreamEventDone:
data, _ := json.Marshal(map[string]interface{}{
"type": "done",
"timestamp": time.Now().Format(time.RFC3339),
})
manager.Send(sseLib.NewMessage(string(data)).WithEvent("stream_event"))
}
}))
xlog.Info("Starting agent", "name", name, "config", config)
agent, err := New(opts...)
@@ -585,6 +679,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
}
}()
if config.EnableKnowledgeBase && config.EnableKBCompaction && compactionClient != nil {
go runCompactionTicker(ctx, compactionClient, config, effectiveAPIURL, effectiveAPIKey, model)
}
xlog.Info("Starting connectors", "name", name, "config", config)
for _, c := range connectors {
@@ -594,7 +692,7 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
go func() {
for {
time.Sleep(1 * time.Second) // Send a message every seconds
manager.Send(sse.NewMessage(
manager.Send(sseLib.NewMessage(
utils.HTMLify(agent.State().String()),
).WithEvent("hud"))
}
@@ -613,7 +711,7 @@ func (a *AgentPool) StartAll() error {
if a.agents[name] != nil { // Agent already started
continue
}
if err := a.startAgentWithConfig(name, &config, nil); err != nil {
if err := a.startAgentWithConfig(name, a.pooldir, &config, nil); err != nil {
xlog.Error("Failed to start agent", "name", name, "error", err)
}
}
@@ -651,12 +749,121 @@ func (a *AgentPool) Start(name string) error {
return nil
}
if config, ok := a.pool[name]; ok {
return a.startAgentWithConfig(name, &config, nil)
return a.startAgentWithConfig(name, a.pooldir, &config, nil)
}
return fmt.Errorf("agent %s not found", name)
}
// CreateOnly creates the agent instance without calling Run().
// This is used in distributed mode where the agent is executed statelessly
// via AskDirect() — the persistent Run() loop is not needed.
func (a *AgentPool) CreateOnly(name string) error {
a.Lock()
defer a.Unlock()
if _, ok := a.agents[name]; ok {
return nil // already created
}
if config, ok := a.pool[name]; ok {
return a.createAgentWithoutRun(name, a.pooldir, &config)
}
return fmt.Errorf("agent %s not found", name)
}
// createAgentWithoutRun is like startAgentWithConfig but skips Run(), connectors, and HUD.
func (a *AgentPool) createAgentWithoutRun(name, pooldir string, config *AgentConfig) error {
var manager sseLib.Manager
if m, ok := a.managers[name]; ok {
manager = m
} else {
manager = sseLib.NewManager(5)
}
ctx := context.Background()
model := a.defaultModel
multimodalModel := a.defaultMultimodalModel
transcriptionModel := a.defaultTranscriptionModel
transcriptionLanguage := a.defaultTranscriptionLanguage
ttsModel := a.defaultTTSModel
if config.MultimodalModel != "" {
multimodalModel = config.MultimodalModel
}
if config.TranscriptionModel != "" {
transcriptionModel = config.TranscriptionModel
}
if config.TranscriptionLanguage != "" {
transcriptionLanguage = config.TranscriptionLanguage
}
if config.TTSModel != "" {
ttsModel = config.TTSModel
}
if config.Model != "" {
model = config.Model
} else {
config.Model = model
}
effectiveAPIURL := a.apiURL
if config.APIURL != "" {
effectiveAPIURL = config.APIURL
} else {
config.APIURL = a.apiURL
}
effectiveAPIKey := a.apiKey
if config.APIKey != "" {
effectiveAPIKey = config.APIKey
} else {
config.APIKey = a.apiKey
}
promptBlocks := a.dynamicPrompt(config)(ctx, a)
if a.skillsService != nil && config.EnableSkills {
if prompt, err := a.skillsService.GetSkillsPrompt(config); err == nil && prompt != nil {
promptBlocks = append(promptBlocks, prompt)
}
}
actions := a.availableActions(config)(ctx, a)
stateFile, characterFile := a.stateFiles(name)
obs := NewSSEObserver(name, manager)
opts := []Option{
WithSchedulerStorePath(filepath.Join(pooldir, fmt.Sprintf("scheduler-%s.json", name))),
WithModel(model),
WithLLMAPIURL(effectiveAPIURL),
WithContext(ctx),
WithTranscriptionModel(transcriptionModel),
WithTranscriptionLanguage(transcriptionLanguage),
WithTTSModel(ttsModel),
WithPrompts(promptBlocks...),
WithActions(actions...),
WithObserver(obs),
WithMultimodalModel(multimodalModel),
WithCharacterFile(characterFile),
WithStateFile(stateFile),
WithSystemPrompt(config.SystemPrompt),
}
if effectiveAPIKey != "" {
opts = append(opts, WithLLMAPIKey(effectiveAPIKey))
}
xlog.Info("Creating agent (no Run)", "name", name, "model", model, "api_url", effectiveAPIURL)
agent, err := New(opts...)
if err != nil {
return err
}
a.agents[name] = agent
a.managers[name] = manager
// Start the conversation consumer so ConversationAction doesn't deadlock.
// This is normally started by Run(), but we skip Run() in distributed mode.
agent.StartConversationConsumer()
xlog.Info("Agent created (no Run)", "name", name)
return nil
}
func (a *AgentPool) stateFiles(name string) (string, string) {
stateFile := filepath.Join(a.pooldir, fmt.Sprintf("%s.state.json", name))
characterFile := filepath.Join(a.pooldir, fmt.Sprintf("%s.character.json", name))
@@ -677,9 +884,6 @@ func (a *AgentPool) Remove(name string) error {
delete(a.agents, name)
delete(a.pool, name)
// remove avatar
os.Remove(filepath.Join(a.pooldir, "avatars", fmt.Sprintf("%s.png", name)))
if err := a.save(); err != nil {
return err
}
@@ -697,7 +901,21 @@ func (a *AgentPool) save() error {
if err != nil {
return err
}
return os.WriteFile(a.file, data, 0644)
tmpPath := a.file + ".tmp"
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
os.Remove(tmpPath)
return err
}
if err := os.Rename(tmpPath, a.file); err != nil {
os.Remove(tmpPath)
return err
}
bakPath := a.file + ".bak"
if err := os.WriteFile(bakPath, data, 0644); err != nil {
// best-effort; main file is already good
xlog.Warn("Failed to write pool backup", "path", bakPath, "error", err)
}
return nil
}
func (a *AgentPool) GetAgent(name string) *Agent {
@@ -726,8 +944,9 @@ func (a *AgentPool) GetConfig(name string) *AgentConfig {
return &agent
}
func (a *AgentPool) GetManager(name string) sse.Manager {
func (a *AgentPool) GetManager(name string) sseLib.Manager {
a.Lock()
defer a.Unlock()
return a.managers[name]
}
+35 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"github.com/mudler/cogito"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
@@ -88,11 +89,36 @@ func (a ActionDefinition) ToFunctionDefinition() *openai.FunctionDefinition {
}
}
type cogitoWrapper struct {
action Action
ctx context.Context
sharedState *AgentSharedState
}
func (c *cogitoWrapper) Tool() openai.Tool {
return openai.Tool{
Type: openai.ToolTypeFunction,
Function: c.action.Definition().ToFunctionDefinition(),
}
}
func (c *cogitoWrapper) Execute(args map[string]any) (string, any, error) {
ctx := c.ctx
if ctx == nil {
ctx = context.Background()
}
result, err := c.action.Run(ctx, c.sharedState, ActionParams(args))
if err != nil {
return "", nil, err
}
return result.Result, result, nil
}
// Actions is something the agent can do
type Action interface {
Run(ctx context.Context, sharedState *AgentSharedState, action ActionParams) (ActionResult, error)
Definition() ActionDefinition
Plannable() bool
}
// UserDefinedChecker interface to identify user-defined actions
@@ -162,6 +188,14 @@ func (a Actions) ToTools() []openai.Tool {
return tools
}
func (a Actions) ToCogitoTools(ctx context.Context, sharedState *AgentSharedState) []cogito.ToolDefinitionInterface {
tools := []cogito.ToolDefinitionInterface{}
for _, action := range a {
tools = append(tools, &cogitoWrapper{action: action, ctx: ctx, sharedState: sharedState})
}
return tools
}
func (a Actions) Find(name string) Action {
for _, action := range a {
if action.Definition().Name.Is(name) {
+27
View File
@@ -0,0 +1,27 @@
package types
import (
"github.com/sashabaranov/go-openai"
)
// ConversationMessage represents a message with associated metadata
// Used when the agent initiates new conversations to preserve context
// such as generated images, files, or URLs
type ConversationMessage struct {
Message openai.ChatCompletionMessage
Metadata map[string]interface{}
}
// NewConversationMessage creates a new ConversationMessage with the given message
func NewConversationMessage(msg openai.ChatCompletionMessage) *ConversationMessage {
return &ConversationMessage{
Message: msg,
Metadata: make(map[string]interface{}),
}
}
// WithMetadata adds metadata to the conversation message
func (c *ConversationMessage) WithMetadata(metadata map[string]interface{}) *ConversationMessage {
c.Metadata = metadata
return c
}
+11 -40
View File
@@ -5,9 +5,15 @@ import (
"log"
"github.com/google/uuid"
"github.com/mudler/cogito"
"github.com/sashabaranov/go-openai"
)
// MetadataKeyConversationID is the job metadata key for per-conversation identity.
// When set (e.g. "slack:CHANNEL_ID", "telegram:CHAT_ID"), the agent may cancel the
// currently running job for that conversation before enqueueing a new one.
const MetadataKeyConversationID = "conversation_id"
// Job is a request to the agent to do something
type Job struct {
// The job is a request to the agent to do something
@@ -20,19 +26,15 @@ type Job struct {
UUID string
Metadata map[string]interface{}
DoneFilter bool
// Tools available for this job
BuiltinTools []ActionDefinition // Built-in tools like web search
UserTools []ActionDefinition // User-defined function tools
ToolChoice string
pastActions []*ActionRequest
nextAction *Action
nextActionParams *ActionParams
nextActionReasoning string
context context.Context
cancel context.CancelFunc
context context.Context
fragment *cogito.Fragment
cancel context.CancelFunc
Obs *Observable
}
@@ -80,7 +82,7 @@ func WithResultCallback(f func(ActionState)) JobOption {
}
}
func WithMetadata(metadata map[string]interface{}) JobOption {
func WithMetadata(metadata map[string]any) JobOption {
return func(j *Job) {
j.Metadata = metadata
}
@@ -108,37 +110,6 @@ func (j *Job) CallbackWithResult(stateResult ActionState) {
j.ResultCallback(stateResult)
}
func (j *Job) SetNextAction(action *Action, params *ActionParams, reasoning string) {
j.nextAction = action
j.nextActionParams = params
j.nextActionReasoning = reasoning
}
func (j *Job) AddPastAction(action Action, params *ActionParams) {
j.pastActions = append(j.pastActions, &ActionRequest{
Action: action,
Params: params,
})
}
func (j *Job) GetPastActions() []*ActionRequest {
return j.pastActions
}
func (j *Job) GetNextAction() (*Action, *ActionParams, string) {
return j.nextAction, j.nextActionParams, j.nextActionReasoning
}
func (j *Job) HasNextAction() bool {
return j.nextAction != nil
}
func (j *Job) ResetNextAction() {
j.nextAction = nil
j.nextActionParams = nil
j.nextActionReasoning = ""
}
func WithTextImage(text, image string) JobOption {
return func(j *Job) {
j.ConversationHistory = append(j.ConversationHistory, openai.ChatCompletionMessage{
+2 -2
View File
@@ -1,7 +1,7 @@
package types
import (
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
)
@@ -25,7 +25,7 @@ type Completion struct {
Conversation []openai.ChatCompletionMessage `json:"conversation,omitempty"`
ActionResult string `json:"action_result,omitempty"`
AgentState *AgentInternalState `json:"agent_state,omitempty"`
FilterResult *FilterResult `json:"filter_result,omitempty"`
FilterResult *FilterResult `json:"filter_result,omitempty"`
}
type Observable struct {
+11 -4
View File
@@ -1,8 +1,10 @@
package types
import (
"context"
"sync"
"github.com/mudler/cogito"
"github.com/sashabaranov/go-openai"
)
@@ -11,6 +13,7 @@ type JobResult struct {
sync.Mutex
// The result of a job
State []ActionState
Plans []cogito.PlanStatus
Conversation []openai.ChatCompletionMessage
Finalizers []func([]openai.ChatCompletionMessage)
@@ -28,7 +31,7 @@ func (j *JobResult) SetResult(text ActionState) {
j.State = append(j.State, text)
}
// SetResult sets the result of a job
// Finish marks the job as done and closes the ready channel.
func (j *JobResult) Finish(e error) {
j.Lock()
j.Error = e
@@ -59,9 +62,13 @@ func (j *JobResult) SetResponse(response string) {
}
// WaitResult waits for the result of a job
func (j *JobResult) WaitResult() *JobResult {
<-j.ready
func (j *JobResult) WaitResult(ctx context.Context) (*JobResult, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-j.ready:
}
j.Lock()
defer j.Unlock()
return j
return j, nil
}
+27 -8
View File
@@ -5,8 +5,19 @@ import (
"time"
"github.com/mudler/LocalAGI/core/conversations"
"github.com/mudler/LocalAGI/core/scheduler"
)
// Forward declaration to avoid circular import
type TaskScheduler interface {
CreateTask(task *scheduler.Task) error
GetAllTasks() ([]*scheduler.Task, error)
GetTask(id string) (*scheduler.Task, error)
DeleteTask(id string) error
PauseTask(id string) error
ResumeTask(id string) error
}
// State is the structure
// that is used to keep track of the current state
// and the Agent's short memory that it can update
@@ -29,17 +40,26 @@ const (
DefaultLastMessageDuration = 5 * time.Minute
)
type ReminderActionResponse struct {
Message string `json:"message"`
CronExpr string `json:"cron_expr"` // Cron expression for scheduling
LastRun time.Time `json:"last_run"` // Last time this reminder was triggered
NextRun time.Time `json:"next_run"` // Next scheduled run time
IsRecurring bool `json:"is_recurring"` // Whether this is a recurring reminder
// RecurringReminderParams are the parameters the LLM provides for set_recurring_reminder.
type RecurringReminderParams struct {
Message string `json:"message"`
CronExpr string `json:"cron_expr"`
}
// OneTimeReminderParams are the parameters the LLM provides for set_onetime_reminder.
type OneTimeReminderParams struct {
Message string `json:"message"`
Delay string `json:"delay"` // Go duration format with day support: "30m", "2h", "1d", "1d12h"
}
// ReminderActionResponse is kept for backward compatibility.
// Deprecated: use RecurringReminderParams or OneTimeReminderParams.
type ReminderActionResponse = RecurringReminderParams
type AgentSharedState struct {
ConversationTracker *conversations.ConversationTracker[string] `json:"conversation_tracker"`
Reminders []ReminderActionResponse `json:"reminders"`
Scheduler TaskScheduler `json:"-"` // Not serialized, set at runtime
AgentName string `json:"agent_name"`
}
func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
@@ -48,7 +68,6 @@ func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
}
return &AgentSharedState{
ConversationTracker: conversations.NewConversationTracker[string](lastMessageDuration),
Reminders: make([]ReminderActionResponse, 0),
}
}
+2 -12
View File
@@ -11,26 +11,16 @@ services:
- /dev/dri
- /dev/kfd
mcpbox:
postgres:
extends:
file: docker-compose.yaml
service: mcpbox
service: postgres
dind:
extends:
file: docker-compose.yaml
service: dind
localrecall:
extends:
file: docker-compose.yaml
service: localrecall
localrecall-healthcheck:
extends:
file: docker-compose.yaml
service: localrecall-healthcheck
localagi:
extends:
file: docker-compose.yaml
+3 -13
View File
@@ -6,32 +6,22 @@ services:
environment:
- LOCALAI_SINGLE_ACTIVE_BACKEND=true
- DEBUG=true
image: localai/localai:master-gpu-intel-f32
image: localai/localai:master-gpu-intel
devices:
# On a system with integrated GPU and an Arc 770, this is the Arc 770
- /dev/dri/card1
- /dev/dri/renderD129
mcpbox:
postgres:
extends:
file: docker-compose.yaml
service: mcpbox
service: postgres
dind:
extends:
file: docker-compose.yaml
service: dind
localrecall:
extends:
file: docker-compose.yaml
service: localrecall
localrecall-healthcheck:
extends:
file: docker-compose.yaml
service: localrecall-healthcheck
localagi:
extends:
file: docker-compose.yaml
+4 -14
View File
@@ -6,7 +6,7 @@ services:
environment:
- LOCALAI_SINGLE_ACTIVE_BACKEND=true
- DEBUG=true
image: localai/localai:master-gpu-nvidia-cuda12
image: localai/localai:master-gpu-nvidia-cuda-12
# For images with python backends, use:
# image: localai/localai:master-cublas-cuda12-ffmpeg
deploy:
@@ -17,26 +17,16 @@ services:
count: 1
capabilities: [gpu]
mcpbox:
postgres:
extends:
file: docker-compose.yaml
service: mcpbox
service: postgres
dind:
extends:
file: docker-compose.yaml
service: dind
localrecall:
extends:
file: docker-compose.yaml
service: localrecall
localrecall-healthcheck:
extends:
file: docker-compose.yaml
service: localrecall-healthcheck
localagi:
extends:
file: docker-compose.yaml
+38 -49
View File
@@ -8,8 +8,7 @@ services:
image: localai/localai:master
command:
- ${MODEL_NAME:-gemma-3-4b-it-qat}
- ${MULTIMODAL_MODEL:-moondream2-20250414}
- ${IMAGE_MODEL:-sd-1.5-ggml}
- ${MULTIMODAL_MODEL:-gemma-3-4b-it-qat}
- granite-embedding-107m-multilingual
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/readyz"]
@@ -22,30 +21,25 @@ services:
- DEBUG=true
#- LOCALAI_API_KEY=sk-1234567890
volumes:
- ./volumes/models:/models
- ./volumes/backends:/backends
- ./volumes/images:/tmp/generated/images
- models:/models
- backends:/backends
- images:/tmp/generated/images
localrecall:
image: quay.io/mudler/localrecall:main
ports:
- 8080
postgres:
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.2}-postgresql
environment:
- COLLECTION_DB_PATH=/db
- EMBEDDING_MODEL=granite-embedding-107m-multilingual
- FILE_ASSETS=/assets
- OPENAI_API_KEY=sk-1234567890
- OPENAI_BASE_URL=http://localai:8080
- POSTGRES_DB=localrecall
- POSTGRES_USER=localrecall
- POSTGRES_PASSWORD=localrecall
ports:
- 5432:5432
volumes:
- ./volumes/localrag/db:/db
- ./volumes/localrag/assets/:/assets
localrecall-healthcheck:
depends_on:
localrecall:
condition: service_started
image: busybox
command: ["sh", "-c", "until wget -q -O - http://localrecall:8080 > /dev/null 2>&1; do echo 'Waiting for localrecall...'; sleep 1; done; echo 'localrecall is up!'"]
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U localrecall"]
interval: 10s
timeout: 5s
retries: 5
sshbox:
build:
@@ -61,30 +55,14 @@ services:
dind:
condition: service_healthy
mcpbox:
build:
context: .
dockerfile: Dockerfile.mcpbox
ports:
- "8080"
volumes:
- ./volumes/mcpbox:/app/data
environment:
- DOCKER_HOST=tcp://dind:2375
depends_on:
dind:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/processes"]
interval: 30s
timeout: 10s
retries: 3
dind:
image: docker:dind
privileged: true
command: ["dockerd", "-H", "tcp://0.0.0.0:2375", "-H", "unix:///var/run/docker.sock"]
environment:
- DOCKER_TLS_CERTDIR=""
expose:
- 2375
healthcheck:
test: ["CMD", "docker", "info"]
interval: 10s
@@ -95,9 +73,9 @@ services:
depends_on:
localai:
condition: service_healthy
localrecall-healthcheck:
condition: service_completed_successfully
mcpbox:
postgres:
condition: service_healthy
dind:
condition: service_healthy
build:
context: .
@@ -108,16 +86,27 @@ services:
environment:
- LOCALAGI_MODEL=${MODEL_NAME:-gemma-3-4b-it-qat}
- LOCALAGI_MULTIMODAL_MODEL=${MULTIMODAL_MODEL:-moondream2-20250414}
- LOCALAGI_IMAGE_MODEL=${IMAGE_MODEL:-sd-1.5-ggml}
- LOCALAGI_LLM_API_URL=http://localai:8080
#- LOCALAGI_LLM_API_KEY=sk-1234567890
- LOCALAGI_LOCALRAG_URL=http://localrecall:8080
- LOCALAGI_STATE_DIR=/pool
# Knowledge base (collections) with PostgreSQL by default
- VECTOR_ENGINE=postgres
- DATABASE_URL=postgresql://localrecall:localrecall@postgres:5432/localrecall?sslmode=disable
- EMBEDDING_MODEL=granite-embedding-107m-multilingual
- LOCALAGI_TIMEOUT=5m
- LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
- LOCALAGI_MCPBOX_URL=http://mcpbox:8080
- LOCALAGI_SSHBOX_URL=root:root@sshbox:22
- DOCKER_HOST=tcp://dind:2375
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./volumes/localagi/:/pool
- localagi_pool:/pool
# Optional: mount a host directory for skills (replaces the default state-dir/skills path)
# - ./skills:/pool/skills
volumes:
postgres_data:
models:
backends:
images:
localagi_pool:
+56
View File
@@ -0,0 +1,56 @@
package custom_actions
import (
"encoding/json"
)
type Params struct {
Message string `json:"message"` // field name
}
func Run(config map[string]interface{}) (string, map[string]interface{}, error) {
p := Params{}
b, err := json.Marshal(config)
if err != nil {
return "", map[string]interface{}{}, err
}
if err := json.Unmarshal(b, &p); err != nil {
return "", map[string]interface{}{}, err
}
return "Hello, " + p.Message + "!", map[string]interface{}{}, nil
}
func Description() string {
return "Send a message to the user"
}
func Definition() map[string][]string {
return map[string][]string{
"message": { // field name
"string", // type
"The message to send", // description
},
}
}
func RequiredFields() []string {
return []string{"message"} // field name
}
var config string
func Init(configuration string) error {
// Do something with the configuration that was passed-by
config = configuration
return nil
}
// DynamicPrompt
func Render() (string, string, error) {
return "Hello, " + config + "!", "", nil
}
func Role() string {
return "system" // Role for the dynamic prompt
}
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from RealtimeSTT import AudioToTextRecorder
def process_text(text):
print(text)
if __name__ == '__main__':
recorder = AudioToTextRecorder(wake_words="jarvis")
while True:
recorder.text(process_text)
+100 -54
View File
@@ -1,92 +1,144 @@
module github.com/mudler/LocalAGI
go 1.24
toolchain go1.24.2
go 1.26.0
require (
github.com/Masterminds/sprig/v3 v3.3.0
github.com/blevesearch/bleve/v2 v2.5.7
github.com/bwmarrin/discordgo v0.29.0
github.com/chasefleming/elem-go v0.30.0
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2
github.com/donseba/go-htmx v1.12.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/eritikass/githubmarkdownconvertergo v0.1.10
github.com/go-telegram/bot v1.17.0
github.com/gofiber/fiber/v2 v2.52.8
github.com/gofiber/fiber/v2 v2.52.11
github.com/gofiber/template/html/v2 v2.1.3
github.com/google/go-github/v69 v69.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/metoro-io/mcp-golang v0.16.0
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.37.0
github.com/jung-kurt/gofpdf v1.16.2
github.com/modelcontextprotocol/go-sdk v1.2.0
github.com/mudler/cogito v0.9.5-0.20260315222927-63abdec7189b
github.com/mudler/localrecall v0.6.3-0.20260618142827-d0073dd5dc32
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49
github.com/mudler/xlog v0.0.5
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/philippgille/chromem-go v0.7.0
github.com/robfig/cron/v3 v3.0.1
github.com/sashabaranov/go-openai v1.40.3
github.com/sashabaranov/go-openai v1.41.2
github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64
github.com/tmc/langchaingo v0.1.13
github.com/tmc/langchaingo v0.1.14
github.com/traefik/yaegi v0.16.1
github.com/valyala/fasthttp v1.62.0
golang.org/x/crypto v0.40.0
github.com/valyala/fasthttp v1.68.0
golang.org/x/crypto v0.50.0
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056
maunium.net/go/mautrix v0.17.0
mvdan.cc/xurls/v2 v2.6.0
)
require github.com/JohannesKaufmann/dom v0.2.0 // indirect
require (
dario.cat/mergo v1.0.2 // indirect
github.com/JohannesKaufmann/dom v0.2.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/blevesearch/bleve_index_api v1.2.11 // indirect
github.com/blevesearch/geo v0.2.4 // indirect
github.com/blevesearch/go-faiss v1.0.26 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.1.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.2.8 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/utils v1.1.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jolestar/go-commons-pool/v2 v2.1.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klippa-app/go-pdfium v1.19.2 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.20.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.3
github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/antchfx/htmlquery v1.3.4 // indirect
github.com/antchfx/xmlquery v1.4.4 // indirect
github.com/antchfx/xpath v1.3.4 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/emersion/go-imap/v2 v2.0.0-beta.5
github.com/emersion/go-message v0.18.2
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.10.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gocolly/colly v1.2.0 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/utils v1.1.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/kennygrant/sanitize v1.2.4 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkoukk/tiktoken-go v0.1.7 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.31.0 // indirect
@@ -97,21 +149,15 @@ require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
go.mau.fi/util v0.3.0 // indirect
go.starlark.net v0.0.0-20250417143717-f57e51f710eb // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
maunium.net/go/maulogger/v2 v2.4.1 // indirect
)
+331 -113
View File
@@ -1,13 +1,32 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ=
github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.3 h1:r3fokGFRDk/8pHmwLwJ8zsX4qiqfS1/1TZm2BH8ueY8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.3/go.mod h1:HtsP+1Fchp4dVvaiIsLHAl/yqL3H1YLwqLC9kNwqQEg=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0 h1:C0/TerKdQX9Y9pbYi1EsLr5LDNANsqunyI/btpyfCg8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0/go.mod h1:OLaKh+giepO8j7teevrNwiy/fwf8LXgoc9g7rwaE1jk=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ=
github.com/antchfx/htmlquery v1.3.4/go.mod h1:K9os0BwIEmLAvTqaNSua8tXLWRWZpocZIH73OzWQbwM=
github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg=
@@ -15,32 +34,93 @@ github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fus
github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.3.4 h1:1ixrW1VnXd4HurCj7qnqnR0jo14g8JMe20Fshg1Vgz4=
github.com/antchfx/xpath v1.3.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI=
github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/chasefleming/elem-go v0.30.0 h1:BlhV1ekv1RbFiM8XZUQeln1Ikb4D+bu2eDO4agREvok=
github.com/chasefleming/elem-go v0.30.0/go.mod h1:hz73qILBIKnTgOujnSMtEj20/epI+f6vg71RUilJAA4=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2 h1:flLYmnQFZNo04x2NPehMbf30m7Pli57xwZ0NFqR/hb0=
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2/go.mod h1:NtWqRzAp/1tw+twkW8uuBenEVVYndEAZACWU3F3xdoQ=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/donseba/go-htmx v1.12.0 h1:7tESER0uxaqsuGMv3yP3pK1drfBUXM6apG4H7/3+IgE=
github.com/donseba/go-htmx v1.12.0/go.mod h1:8PTAYvNKf8+QYis+DpAsggKz+sa2qljtMgvdAeNBh5s=
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emersion/go-imap/v2 v2.0.0-beta.5 h1:H3858DNmBuXyMK1++YrQIRdpKE1MwBc+ywBtg3n+0wA=
github.com/emersion/go-imap/v2 v2.0.0-beta.5/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
@@ -49,24 +129,38 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/eritikass/githubmarkdownconvertergo v0.1.10 h1:mL93ADvYMOeT15DcGtK9AaFFc+RcWcy6kQBC6yS/5f4=
github.com/eritikass/githubmarkdownconvertergo v0.1.10/go.mod h1:BdpHs6imOtzE5KorbUtKa6bZ0ZBh1yFcrTTAL8FwDKY=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-telegram/bot v1.17.0 h1:Hs0kGxSj97QFqOQP0zxduY/4tSx8QDzvNI9uVRS+zmY=
@@ -75,19 +169,23 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI=
github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o=
github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE=
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
@@ -95,6 +193,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -107,33 +207,59 @@ github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMM
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 h1:gD0vax+4I+mAj+jEChEf25Ia07Jq7kYOFO5PPhAxFl4=
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jolestar/go-commons-pool/v2 v2.1.2 h1:E+XGo58F23t7HtZiC/W6jzO2Ux2IccSH/yx4nD+J1CM=
github.com/jolestar/go-commons-pool/v2 v2.1.2/go.mod h1:r4NYccrkS5UqP1YQI1COyTZ9UjPJAAGTUxzcsK1kqhY=
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klippa-app/go-pdfium v1.19.2 h1:Gc/OT7wVO7xStNlDR5o/Qz0T/tsVtODsh7I1vOJXIKU=
github.com/klippa-app/go-pdfium v1.19.2/go.mod h1:X+AMQDw/TXTsgiY2vEGA7oYlQTmjyqmlt6pm6aoIDa0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -144,65 +270,127 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/metoro-io/mcp-golang v0.16.0 h1:7NrP8Hca4IDLipPitZaTClzmN8uQcQWX8IsziXU813Y=
github.com/metoro-io/mcp-golang v0.16.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/mudler/cogito v0.9.5-0.20260315222927-63abdec7189b h1:A74T2Lauvg61KodYqsjTYDY05kPLcW+efVZjd23dghU=
github.com/mudler/cogito v0.9.5-0.20260315222927-63abdec7189b/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
github.com/mudler/localrecall v0.6.3-0.20260618142827-d0073dd5dc32 h1:RP4BVGTHHpJIrGAwqRD3Wq1wmURmc1SxhwacnIWgI+g=
github.com/mudler/localrecall v0.6.3-0.20260618142827-d0073dd5dc32/go.mod h1:28k5n19raUrkuwXkacdNsBlj8yuSnGhpT16tu+2+4dU=
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49 h1:dAF1ALXqqapRZo80x56BIBBcPrPbRNerbd66rdyO8J4=
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49/go.mod h1:z3yFhcL9bSykmmh6xgGu0hyoItd4CnxgtWMEWw8uFJU=
github.com/mudler/xlog v0.0.5 h1:2unBuVC5rNGhCC86UaA94TElWFml80NL5XLK+kAmNuU=
github.com/mudler/xlog v0.0.5/go.mod h1:39f5vcd05Qd6GWKM8IjyHNQ7AmOx3ZM0YfhfIGhC18U=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 h1:2vmb32OdDhjZf2ETGDlr9n8RYXx7c+jXPxMiPbwnA+8=
github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4/go.mod h1:2JQx4jDHmWrbABvpOayg/+OTU6ehN0IyK2EHzceXpJo=
github.com/philippgille/chromem-go v0.7.0 h1:4jfvfyKymjKNfGxBUhHUcj1kp7B17NL/I1P+vGh1RvY=
github.com/philippgille/chromem-go v0.7.0/go.mod h1:hTd+wGEm/fFPQl7ilfCwQXkgEUxceYh86iIdoKMolPo=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/sashabaranov/go-openai v1.40.3 h1:PkOw0SK34wrvYVOuXF1HZzuTBRh992qRZHil4kG3eYE=
github.com/sashabaranov/go-openai v1.40.3/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sebdah/goldie/v2 v2.5.5 h1:rx1mwF95RxZ3/83sdS4Yp7t2C5TCokvWP4TBRbAyEWY=
github.com/sebdah/goldie/v2 v2.5.5/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5E=
github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw=
github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64 h1:l/T7dYuJEQZOwVOpjIXr1180aM9PZL/d1MnMVIxefX4=
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64/go.mod h1:Q1NAJOuRdQCqN/VIWdnaaEhV8LpeO2rtlBP7/iDJNII=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -215,52 +403,71 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tmc/langchaingo v0.1.13 h1:rcpMWBIi2y3B90XxfE4Ao8dhCQPVDMaNPnN5cGB1CaA=
github.com/tmc/langchaingo v0.1.13/go.mod h1:vpQ5NOIhpzxDfTZK9B6tf2GM/MoaHewPWM5KXXGh7hg=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/tmc/langchaingo v0.1.14 h1:o1qWBPigAIuFvrG6cjTFo0cZPFEZ47ZqpOYMjM15yZc=
github.com/tmc/langchaingo v0.1.14/go.mod h1:aKKYXYoqhIDEv7WKdpnnCLRaqXic69cX9MnDUk72378=
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0=
github.com/valyala/fasthttp v1.62.0/go.mod h1:FCINgr4GKdKqV8Q0xv8b+UxPV+H/O5nNFo3D+r54Htg=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo=
github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
go.mau.fi/util v0.3.0 h1:Lt3lbRXP6ZBqTINK0EieRWor3zEwwwrDT14Z5N8RUCs=
go.mau.fi/util v0.3.0/go.mod h1:9dGsBCCbZJstx16YgnVMVi3O2bOizELoKpugLD4FoGs=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.starlark.net v0.0.0-20250417143717-f57e51f710eb h1:zOg9DxxrorEmgGUr5UPdCEwKqiqG0MlZciuCuA3XiDE=
go.starlark.net v0.0.0-20250417143717-f57e51f710eb/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
@@ -268,8 +475,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -277,11 +486,16 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -291,8 +505,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -302,8 +516,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -315,30 +529,35 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 h1:6YFJoB+0fUH6X3xU/G2tQqCYg+PkGtnZ5nMR5rpw72g=
@@ -349,6 +568,5 @@ maunium.net/go/mautrix v0.17.0 h1:scc1qlUbzPn+wc+3eAPquyD+3gZwwy/hBANBm+iGKK8=
maunium.net/go/mautrix v0.17.0/go.mod h1:j+puTEQCEydlVxhJ/dQP5chfa26TdvBO7X6F3Ataav8=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
+2 -98
View File
@@ -1,105 +1,9 @@
package main
import (
"log"
"os"
"path/filepath"
"strings"
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/services"
"github.com/mudler/LocalAGI/webui"
"github.com/mudler/LocalAGI/cmd"
)
var baseModel = os.Getenv("LOCALAGI_MODEL")
var multimodalModel = os.Getenv("LOCALAGI_MULTIMODAL_MODEL")
var apiURL = os.Getenv("LOCALAGI_LLM_API_URL")
var apiKey = os.Getenv("LOCALAGI_LLM_API_KEY")
var timeout = os.Getenv("LOCALAGI_TIMEOUT")
var stateDir = os.Getenv("LOCALAGI_STATE_DIR")
var localRAG = os.Getenv("LOCALAGI_LOCALRAG_URL")
var withLogs = os.Getenv("LOCALAGI_ENABLE_CONVERSATIONS_LOGGING") == "true"
var apiKeysEnv = os.Getenv("LOCALAGI_API_KEYS")
var imageModel = os.Getenv("LOCALAGI_IMAGE_MODEL")
var conversationDuration = os.Getenv("LOCALAGI_CONVERSATION_DURATION")
var localOperatorBaseURL = os.Getenv("LOCALOPERATOR_BASE_URL")
var mcpboxURL = os.Getenv("LOCALAGI_MCPBOX_URL")
var customActionsDir = os.Getenv("LOCALAGI_CUSTOM_ACTIONS_DIR")
var sshBoxURL = os.Getenv("LOCALAGI_SSHBOX_URL")
func init() {
if baseModel == "" {
panic("LOCALAGI_MODEL not set")
}
if apiURL == "" {
panic("LOCALAGI_LLM_API_URL not set")
}
if timeout == "" {
timeout = "5m"
}
if stateDir == "" {
cwd, err := os.Getwd()
if err != nil {
panic(err)
}
stateDir = filepath.Join(cwd, "pool")
}
}
func main() {
// make sure state dir exists
os.MkdirAll(stateDir, 0755)
apiKeys := []string{}
if apiKeysEnv != "" {
apiKeys = strings.Split(apiKeysEnv, ",")
}
// Create the agent pool
pool, err := state.NewAgentPool(
baseModel,
multimodalModel,
imageModel,
apiURL,
apiKey,
stateDir,
mcpboxURL,
localRAG,
services.Actions(map[string]string{
services.ActionConfigBrowserAgentRunner: localOperatorBaseURL,
services.ActionConfigDeepResearchRunner: localOperatorBaseURL,
services.ActionConfigSSHBoxURL: sshBoxURL,
services.ConfigStateDir: stateDir,
}, customActionsDir),
services.Connectors,
services.DynamicPrompts(map[string]string{
services.ConfigStateDir: stateDir,
}),
services.Filters,
timeout,
withLogs,
)
if err != nil {
panic(err)
}
// Create the application
app := webui.NewApp(
webui.WithPool(pool),
webui.WithConversationStoreduration(conversationDuration),
webui.WithApiKeys(apiKeys...),
webui.WithLLMAPIUrl(apiURL),
webui.WithLLMAPIKey(apiKey),
webui.WithLLMModel(baseModel),
webui.WithStateDir(stateDir),
)
// Start the agents
if err := pool.StartAll(); err != nil {
panic(err)
}
// Start the web server
log.Fatal(app.Listen(":3000"))
cmd.Execute()
}
+7 -7
View File
@@ -8,13 +8,13 @@ import (
// AgentConfig represents the configuration for an agent
type AgentConfig struct {
Name string `json:"name"`
Actions []string `json:"actions,omitempty"`
Connectors []string `json:"connectors,omitempty"`
PromptBlocks []string `json:"prompt_blocks,omitempty"`
InitialPrompt string `json:"initial_prompt,omitempty"`
Parallel bool `json:"parallel,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
Name string `json:"name"`
Actions []string `json:"actions,omitempty"`
Connectors []string `json:"connectors,omitempty"`
PromptBlocks []string `json:"prompt_blocks,omitempty"`
InitialPrompt string `json:"initial_prompt,omitempty"`
Parallel bool `json:"parallel,omitempty"`
EnableReasoning bool `json:"enable_reasoning,omitempty"`
}
// AgentStatus represents the status of an agent
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"encoding/json"
"fmt"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
-149
View File
@@ -1,149 +0,0 @@
package localoperator
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type Client struct {
baseURL string
httpClient *http.Client
}
func NewClient(baseURL string, timeout ...time.Duration) *Client {
defaultTimeout := 30 * time.Second
if len(timeout) > 0 {
defaultTimeout = timeout[0]
}
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: defaultTimeout,
},
}
}
type AgentRequest struct {
Goal string `json:"goal"`
MaxAttempts int `json:"max_attempts,omitempty"`
MaxNoActionAttempts int `json:"max_no_action_attempts,omitempty"`
}
type DesktopAgentRequest struct {
AgentRequest
DesktopURL string `json:"desktop_url"`
}
type DeepResearchRequest struct {
Topic string `json:"topic"`
MaxCycles int `json:"max_cycles,omitempty"`
MaxNoActionAttempts int `json:"max_no_action_attempts,omitempty"`
MaxResults int `json:"max_results,omitempty"`
}
// Response types
type StateDescription struct {
CurrentURL string `json:"current_url"`
PageTitle string `json:"page_title"`
PageContentDescription string `json:"page_content_description"`
Screenshot string `json:"screenshot"`
ScreenshotMimeType string `json:"screenshot_mime_type"`
}
type StateHistory struct {
States []StateDescription `json:"states"`
}
type DesktopStateDescription struct {
ScreenContent string `json:"screen_content"`
ScreenshotPath string `json:"screenshot_path"`
}
type DesktopStateHistory struct {
States []DesktopStateDescription `json:"states"`
}
type SearchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
}
type ResearchResult struct {
Topic string `json:"topic"`
Summary string `json:"summary"`
Sources []SearchResult `json:"sources"`
KnowledgeGaps []string `json:"knowledge_gaps"`
SearchQueries []string `json:"search_queries"`
ResearchCycles int `json:"research_cycles"`
CompletionTime time.Duration `json:"completion_time"`
}
func (c *Client) RunBrowserAgent(req AgentRequest) (*StateHistory, error) {
return post[*StateHistory](c.httpClient, c.baseURL+"/api/browser/run", req)
}
func (c *Client) RunDesktopAgent(req DesktopAgentRequest) (*DesktopStateHistory, error) {
return post[*DesktopStateHistory](c.httpClient, c.baseURL+"/api/desktop/run", req)
}
func (c *Client) RunDeepResearch(req DeepResearchRequest) (*ResearchResult, error) {
return post[*ResearchResult](c.httpClient, c.baseURL+"/api/deep-research/run", req)
}
func (c *Client) Readyz() (string, error) {
return c.get("/readyz")
}
func (c *Client) Healthz() (string, error) {
return c.get("/healthz")
}
func (c *Client) get(path string) (string, error) {
resp, err := c.httpClient.Get(c.baseURL + path)
if err != nil {
return "", fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
return resp.Status, nil
}
func post[T any](client *http.Client, url string, body interface{}) (T, error) {
var result T
jsonBody, err := json.Marshal(body)
if err != nil {
return result, fmt.Errorf("failed to marshal request body: %w", err)
}
fmt.Println("Sending request", "url", url, "body", string(jsonBody))
resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonBody))
if err != nil {
return result, fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
fmt.Println("Response", "status", resp.StatusCode)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return result, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return result, fmt.Errorf("failed to decode response: %w", err)
}
return result, nil
}
+303 -59
View File
@@ -11,12 +11,14 @@ import (
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
)
var _ agent.RAGDB = &WrappedClient{}
@@ -26,7 +28,8 @@ type WrappedClient struct {
collection string
}
func NewWrappedClient(baseURL, apiKey, collection string) *WrappedClient {
func NewWrappedClient(baseURL, apiKey, c string) *WrappedClient {
collection := strings.TrimSpace(strings.ToLower(c))
wc := &WrappedClient{
Client: NewClient(baseURL, apiKey),
collection: collection,
@@ -37,6 +40,11 @@ func NewWrappedClient(baseURL, apiKey, collection string) *WrappedClient {
return wc
}
// Collection returns the collection name for this client.
func (c *WrappedClient) Collection() string {
return c.collection
}
func (c *WrappedClient) Count() int {
entries, err := c.ListEntries(c.collection)
if err != nil {
@@ -85,7 +93,39 @@ func (c *WrappedClient) Store(s string) error {
}
defer os.Remove(f)
return c.Client.Store(c.collection, f)
_, err = c.Client.Store(c.collection, f)
return err
}
// GetEntryContent returns the full file content (no chunk overlap) and the number of chunks for the entry.
func (c *WrappedClient) GetEntryContent(entry string) (content string, chunkCount int, err error) {
return c.Client.GetEntryContent(c.collection, entry)
}
// apiResponse is the standardized LocalRecall API response wrapper (since 3f73ff3a).
type apiResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Error *apiError `json:"error,omitempty"`
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
// parseAPIError reads the response body and returns an error from the API response or a generic message.
func parseAPIError(resp *http.Response, body []byte, fallback string) error {
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err == nil && wrap.Error != nil {
if wrap.Error.Details != "" {
return fmt.Errorf("%s: %s", wrap.Error.Message, wrap.Error.Details)
}
return errors.New(wrap.Error.Message)
}
return fmt.Errorf("%s: %s", fallback, string(body))
}
// Result represents a single result from a query.
@@ -101,6 +141,13 @@ type Result struct {
Similarity float32
}
// EntryChunk represents a single chunk (legacy; GetEntryContent now returns full file content).
type EntryChunk struct {
ID string `json:"id"`
Content string `json:"content"`
Metadata map[string]string `json:"metadata"`
}
// Client is a client for the RAG API
type Client struct {
BaseURL string
@@ -151,7 +198,8 @@ func (c *Client) CreateCollection(name string) error {
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return errors.New("failed to create collection")
body, _ := io.ReadAll(resp.Body)
return parseAPIError(resp, body, "failed to create collection")
}
return nil
@@ -174,17 +222,30 @@ func (c *Client) ListCollections() ([]string, error) {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("failed to list collections")
}
var collections []string
err = json.NewDecoder(resp.Body).Decode(&collections)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return collections, nil
if resp.StatusCode != http.StatusOK {
return nil, parseAPIError(resp, body, "failed to list collections")
}
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
if wrap.Error != nil {
return nil, errors.New(wrap.Error.Message)
}
return nil, fmt.Errorf("invalid response: %w", err)
}
var data struct {
Collections []string `json:"collections"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return nil, err
}
return data.Collections, nil
}
// ListEntries lists all entries in a collection
@@ -204,17 +265,79 @@ func (c *Client) ListEntries(collection string) ([]string, error) {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("failed to list entries")
}
var entries []string
err = json.NewDecoder(resp.Body).Decode(&entries)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return entries, nil
if resp.StatusCode != http.StatusOK {
return nil, parseAPIError(resp, body, "failed to list entries")
}
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
if wrap.Error != nil {
return nil, errors.New(wrap.Error.Message)
}
return nil, fmt.Errorf("invalid response: %w", err)
}
var data struct {
Entries []string `json:"entries"`
Keys []string `json:"keys"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return nil, err
}
if len(data.Keys) > 0 {
return data.Keys, nil
}
return data.Entries, nil
}
// GetEntryContent returns the full file content (no chunk overlap) and the number of chunks for the entry.
func (c *Client) GetEntryContent(collection, entry string) (content string, chunkCount int, err error) {
entryEscaped := url.PathEscape(entry)
reqURL := fmt.Sprintf("%s/api/collections/%s/entries/%s", c.BaseURL, collection, entryEscaped)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return "", 0, err
}
c.addAuthHeader(req)
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", 0, err
}
if resp.StatusCode != http.StatusOK {
return "", 0, parseAPIError(resp, body, "failed to get entry content")
}
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
if wrap.Error != nil {
return "", 0, errors.New(wrap.Error.Message)
}
return "", 0, fmt.Errorf("invalid response: %w", err)
}
var data struct {
Content string `json:"content"`
ChunkCount int `json:"chunk_count"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return "", 0, err
}
return data.Content, data.ChunkCount, nil
}
// DeleteEntry deletes an entry in a collection
@@ -244,19 +367,30 @@ func (c *Client) DeleteEntry(collection, entry string) ([]string, error) {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyResult := new(bytes.Buffer)
bodyResult.ReadFrom(resp.Body)
return nil, errors.New("failed to delete entry: " + bodyResult.String())
}
var results []string
err = json.NewDecoder(resp.Body).Decode(&results)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return results, nil
if resp.StatusCode != http.StatusOK {
return nil, parseAPIError(resp, body, "failed to delete entry")
}
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
if wrap.Error != nil {
return nil, errors.New(wrap.Error.Message)
}
return nil, fmt.Errorf("invalid response: %w", err)
}
var data struct {
RemainingEntries []string `json:"remaining_entries"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return nil, err
}
return data.RemainingEntries, nil
}
// Search searches a collection
@@ -287,17 +421,30 @@ func (c *Client) Search(collection, query string, maxResults int) ([]Result, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("failed to search collection")
}
var results []Result
err = json.NewDecoder(resp.Body).Decode(&results)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return results, nil
if resp.StatusCode != http.StatusOK {
return nil, parseAPIError(resp, body, "failed to search collection")
}
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
if wrap.Error != nil {
return nil, errors.New(wrap.Error.Message)
}
return nil, fmt.Errorf("invalid response: %w", err)
}
var data struct {
Results []Result `json:"results"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return nil, err
}
return data.Results, nil
}
// Reset resets a collection
@@ -318,21 +465,20 @@ func (c *Client) Reset(collection string) error {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b := new(bytes.Buffer)
b.ReadFrom(resp.Body)
return errors.New("failed to reset collection: " + b.String())
body, _ := io.ReadAll(resp.Body)
return parseAPIError(resp, body, "failed to reset collection")
}
return nil
}
// Store uploads a file to a collection
func (c *Client) Store(collection, filePath string) error {
// Store uploads a file to a collection and returns the assigned entry key.
func (c *Client) Store(collection, filePath string) (string, error) {
url := fmt.Sprintf("%s/api/collections/%s/upload", c.BaseURL, collection)
file, err := os.Open(filePath)
if err != nil {
return err
return "", err
}
defer file.Close()
@@ -341,22 +487,22 @@ func (c *Client) Store(collection, filePath string) error {
part, err := writer.CreateFormFile("file", file.Name())
if err != nil {
return err
return "", err
}
_, err = io.Copy(part, file)
if err != nil {
return err
return "", err
}
err = writer.Close()
if err != nil {
return err
return "", err
}
req, err := http.NewRequest(http.MethodPost, url, body)
if err != nil {
return err
return "", err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
c.addAuthHeader(req)
@@ -364,26 +510,124 @@ func (c *Client) Store(collection, filePath string) error {
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b := new(bytes.Buffer)
b.ReadFrom(resp.Body)
type response struct {
Error string `json:"error"`
}
var r response
err = json.Unmarshal(b.Bytes(), &r)
if err == nil {
return errors.New("failed to upload file: " + r.Error)
}
return errors.New("failed to upload file")
body, _ := io.ReadAll(resp.Body)
return "", parseAPIError(resp, body, "failed to upload file")
}
var result struct {
Status string `json:"status"`
Filename string `json:"filename"`
Key string `json:"key"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", nil // upload succeeded, can't parse key
}
return result.Key, nil
}
// SourceInfo represents an external source for a collection (LocalRecall API contract).
type SourceInfo struct {
URL string `json:"url"`
UpdateInterval int `json:"update_interval"` // minutes
LastUpdate string `json:"last_update"` // RFC3339
}
// AddSource registers an external source for a collection.
func (c *Client) AddSource(collection, url string, updateIntervalMinutes int) error {
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
var body struct {
URL string `json:"url"`
UpdateInterval int `json:"update_interval"`
}
body.URL = url
body.UpdateInterval = updateIntervalMinutes
if body.UpdateInterval < 1 {
body.UpdateInterval = 60
}
payload, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewBuffer(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.addAuthHeader(req)
resp, err := (&http.Client{}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return parseAPIError(resp, b, "failed to add source")
}
return nil
}
// RemoveSource removes an external source from a collection.
func (c *Client) RemoveSource(collection, url string) error {
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
payload, err := json.Marshal(map[string]string{"url": url})
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodDelete, reqURL, bytes.NewBuffer(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
c.addAuthHeader(req)
resp, err := (&http.Client{}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return parseAPIError(resp, b, "failed to remove source")
}
return nil
}
// ListSources returns external sources for a collection.
func (c *Client) ListSources(collection string) ([]SourceInfo, error) {
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
c.addAuthHeader(req)
resp, err := (&http.Client{}).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, parseAPIError(resp, body, "failed to list sources")
}
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
if wrap.Error != nil {
return nil, errors.New(wrap.Error.Message)
}
return nil, fmt.Errorf("invalid response: %w", err)
}
var data struct {
Sources []SourceInfo `json:"sources"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return nil, err
}
return data.Sources, nil
}
-325
View File
@@ -1,325 +0,0 @@
package stdio
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sync"
"time"
"github.com/gorilla/websocket"
)
// Client implements the transport.Interface for stdio processes
type Client struct {
baseURL string
processes map[string]*Process
groups map[string][]string
mu sync.RWMutex
}
// NewClient creates a new stdio transport client
func NewClient(baseURL string) *Client {
return &Client{
baseURL: baseURL,
processes: make(map[string]*Process),
groups: make(map[string][]string),
}
}
// CreateProcess starts a new process in a group
func (c *Client) CreateProcess(ctx context.Context, command string, args []string, env []string, groupID string) (*Process, error) {
log.Printf("Creating process: command=%s, args=%v, groupID=%s", command, args, groupID)
req := struct {
Command string `json:"command"`
Args []string `json:"args"`
Env []string `json:"env"`
GroupID string `json:"group_id"`
}{
Command: command,
Args: args,
Env: env,
GroupID: groupID,
}
reqBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
url := fmt.Sprintf("%s/processes", c.baseURL)
log.Printf("Sending POST request to %s", url)
resp, err := http.Post(url, "application/json", bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to start process: %w", err)
}
defer resp.Body.Close()
log.Printf("Received response with status: %d", resp.StatusCode)
var result struct {
ID string `json:"id"`
}
body, _ := io.ReadAll(resp.Body)
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w. body: %s", err, string(body))
}
log.Printf("Successfully created process with ID: %s", result.ID)
process := &Process{
ID: result.ID,
GroupID: groupID,
CreatedAt: time.Now(),
}
c.mu.Lock()
c.processes[process.ID] = process
if groupID != "" {
c.groups[groupID] = append(c.groups[groupID], process.ID)
}
c.mu.Unlock()
return process, nil
}
// GetProcess returns a process by ID
func (c *Client) GetProcess(id string) (*Process, error) {
c.mu.RLock()
process, exists := c.processes[id]
c.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("process not found: %s", id)
}
return process, nil
}
// GetGroupProcesses returns all processes in a group
func (c *Client) GetGroupProcesses(groupID string) ([]*Process, error) {
c.mu.RLock()
processIDs, exists := c.groups[groupID]
if !exists {
c.mu.RUnlock()
return nil, fmt.Errorf("group not found: %s", groupID)
}
processes := make([]*Process, 0, len(processIDs))
for _, pid := range processIDs {
if process, exists := c.processes[pid]; exists {
processes = append(processes, process)
}
}
c.mu.RUnlock()
return processes, nil
}
// StopProcess stops a single process
func (c *Client) StopProcess(id string) error {
c.mu.Lock()
process, exists := c.processes[id]
if !exists {
c.mu.Unlock()
return fmt.Errorf("process not found: %s", id)
}
// Remove from group if it exists
if process.GroupID != "" {
groupProcesses := c.groups[process.GroupID]
for i, pid := range groupProcesses {
if pid == id {
c.groups[process.GroupID] = append(groupProcesses[:i], groupProcesses[i+1:]...)
break
}
}
if len(c.groups[process.GroupID]) == 0 {
delete(c.groups, process.GroupID)
}
}
delete(c.processes, id)
c.mu.Unlock()
req, err := http.NewRequest(
"DELETE",
fmt.Sprintf("%s/processes/%s", c.baseURL, id),
nil,
)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to stop process: %w", err)
}
resp.Body.Close()
return nil
}
// StopGroup stops all processes in a group
func (c *Client) StopGroup(groupID string) error {
c.mu.Lock()
processIDs, exists := c.groups[groupID]
if !exists {
c.mu.Unlock()
return fmt.Errorf("group not found: %s", groupID)
}
c.mu.Unlock()
for _, pid := range processIDs {
if err := c.StopProcess(pid); err != nil {
return fmt.Errorf("failed to stop process %s in group %s: %w", pid, groupID, err)
}
}
return nil
}
// ListGroups returns all group IDs
func (c *Client) ListGroups() []string {
c.mu.RLock()
defer c.mu.RUnlock()
groups := make([]string, 0, len(c.groups))
for groupID := range c.groups {
groups = append(groups, groupID)
}
return groups
}
// GetProcessIO returns io.Reader and io.Writer for a process
func (c *Client) GetProcessIO(id string) (io.Reader, io.Writer, error) {
log.Printf("Getting IO for process: %s", id)
process, err := c.GetProcess(id)
if err != nil {
return nil, nil, err
}
// Parse the base URL to get the host
baseURL, err := url.Parse(c.baseURL)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse base URL: %w", err)
}
// Connect to WebSocket
u := url.URL{
Scheme: "ws",
Host: baseURL.Host,
Path: fmt.Sprintf("/ws/%s", process.ID),
}
log.Printf("Connecting to WebSocket at: %s", u.String())
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to WebSocket: %w", err)
}
log.Printf("Successfully connected to WebSocket for process: %s", id)
// Create reader and writer
reader := &websocketReader{conn: conn}
writer := &websocketWriter{conn: conn}
return reader, writer, nil
}
// websocketReader implements io.Reader for WebSocket
type websocketReader struct {
conn *websocket.Conn
}
func (r *websocketReader) Read(p []byte) (n int, err error) {
_, message, err := r.conn.ReadMessage()
if err != nil {
return 0, err
}
n = copy(p, message)
return n, nil
}
// websocketWriter implements io.Writer for WebSocket
type websocketWriter struct {
conn *websocket.Conn
}
func (w *websocketWriter) Write(p []byte) (n int, err error) {
// Use BinaryMessage type for better compatibility
err = w.conn.WriteMessage(websocket.BinaryMessage, p)
if err != nil {
return 0, fmt.Errorf("failed to write WebSocket message: %w", err)
}
return len(p), nil
}
// Close closes all connections and stops all processes
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
// Stop all processes
for id := range c.processes {
if err := c.StopProcess(id); err != nil {
return fmt.Errorf("failed to stop process %s: %w", id, err)
}
}
return nil
}
// RunProcess executes a command and returns its output
func (c *Client) RunProcess(ctx context.Context, command string, args []string, env []string) (string, error) {
log.Printf("Running one-time process: command=%s, args=%v", command, args)
req := struct {
Command string `json:"command"`
Args []string `json:"args"`
Env []string `json:"env"`
}{
Command: command,
Args: args,
Env: env,
}
reqBody, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
url := fmt.Sprintf("%s/run", c.baseURL)
log.Printf("Sending POST request to %s", url)
resp, err := http.Post(url, "application/json", bytes.NewReader(reqBody))
if err != nil {
return "", fmt.Errorf("failed to execute process: %w", err)
}
defer resp.Body.Close()
log.Printf("Received response with status: %d", resp.StatusCode)
var result struct {
Output string `json:"output"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("failed to decode response: %w. body: %s", err, string(body))
}
log.Printf("Successfully executed process with output length: %d", len(result.Output))
return result.Output, nil
}
-28
View File
@@ -1,28 +0,0 @@
package stdio
import (
"os"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestSTDIOTransport(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "STDIOTransport test suite")
}
var baseURL string
func init() {
baseURL = os.Getenv("LOCALAGI_MCPBOX_URL")
if baseURL == "" {
baseURL = "http://localhost:8080"
}
}
var _ = AfterSuite(func() {
client := NewClient(baseURL)
client.StopGroup("test-group")
})
-235
View File
@@ -1,235 +0,0 @@
package stdio
import (
"context"
"time"
mcp "github.com/metoro-io/mcp-golang"
"github.com/metoro-io/mcp-golang/transport/stdio"
"github.com/mudler/LocalAGI/pkg/xlog"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Client", func() {
var (
client *Client
)
BeforeEach(func() {
client = NewClient(baseURL)
})
AfterEach(func() {
if client != nil {
Expect(client.Close()).To(Succeed())
}
})
Context("Process Management", func() {
It("should create and stop a process", func() {
ctx := context.Background()
// Use a command that doesn't exit immediately
process, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Hello, World!'; sleep 10"}, []string{}, "test-group")
Expect(err).NotTo(HaveOccurred())
Expect(process).NotTo(BeNil())
Expect(process.ID).NotTo(BeEmpty())
// Get process IO
reader, writer, err := client.GetProcessIO(process.ID)
Expect(err).NotTo(HaveOccurred())
Expect(reader).NotTo(BeNil())
Expect(writer).NotTo(BeNil())
// Write to process
_, err = writer.Write([]byte("test input\n"))
Expect(err).NotTo(HaveOccurred())
// Read from process with timeout
buf := make([]byte, 1024)
readDone := make(chan struct{})
var readErr error
var readN int
go func() {
readN, readErr = reader.Read(buf)
close(readDone)
}()
// Wait for read with timeout
select {
case <-readDone:
Expect(readErr).NotTo(HaveOccurred())
Expect(readN).To(BeNumerically(">", 0))
Expect(string(buf[:readN])).To(ContainSubstring("Hello, World!"))
case <-time.After(5 * time.Second):
Fail("Timeout waiting for process output")
}
// Stop the process
err = client.StopProcess(process.ID)
Expect(err).NotTo(HaveOccurred())
})
It("should manage process groups", func() {
ctx := context.Background()
groupID := "test-group"
// Create multiple processes in the same group
process1, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Process 1'; sleep 1"}, []string{}, groupID)
Expect(err).NotTo(HaveOccurred())
Expect(process1).NotTo(BeNil())
process2, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Process 2'; sleep 1"}, []string{}, groupID)
Expect(err).NotTo(HaveOccurred())
Expect(process2).NotTo(BeNil())
// Get group processes
processes, err := client.GetGroupProcesses(groupID)
Expect(err).NotTo(HaveOccurred())
Expect(processes).To(HaveLen(2))
// List groups
groups := client.ListGroups()
Expect(groups).To(ContainElement(groupID))
// Stop the group
err = client.StopGroup(groupID)
Expect(err).NotTo(HaveOccurred())
})
It("should run a one-time process", func() {
ctx := context.Background()
output, err := client.RunProcess(ctx, "echo", []string{"One-time process"}, []string{})
Expect(err).NotTo(HaveOccurred())
Expect(output).To(ContainSubstring("One-time process"))
})
It("should handle process with environment variables", func() {
ctx := context.Background()
env := []string{"TEST_VAR=test_value"}
process, err := client.CreateProcess(ctx, "sh", []string{"-c", "env | grep TEST_VAR; sleep 1"}, env, "test-group")
Expect(err).NotTo(HaveOccurred())
Expect(process).NotTo(BeNil())
// Get process IO
reader, _, err := client.GetProcessIO(process.ID)
Expect(err).NotTo(HaveOccurred())
// Read environment variables with timeout
buf := make([]byte, 1024)
readDone := make(chan struct{})
var readErr error
var readN int
go func() {
readN, readErr = reader.Read(buf)
close(readDone)
}()
// Wait for read with timeout
select {
case <-readDone:
Expect(readErr).NotTo(HaveOccurred())
Expect(readN).To(BeNumerically(">", 0))
Expect(string(buf[:readN])).To(ContainSubstring("TEST_VAR=test_value"))
case <-time.After(5 * time.Second):
Fail("Timeout waiting for process output")
}
// Stop the process
err = client.StopProcess(process.ID)
Expect(err).NotTo(HaveOccurred())
})
It("should handle long-running processes", func() {
ctx := context.Background()
process, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Starting long process'; sleep 5"}, []string{}, "test-group")
Expect(err).NotTo(HaveOccurred())
Expect(process).NotTo(BeNil())
// Get process IO
reader, _, err := client.GetProcessIO(process.ID)
Expect(err).NotTo(HaveOccurred())
// Read initial output
buf := make([]byte, 1024)
readDone := make(chan struct{})
var readErr error
var readN int
go func() {
readN, readErr = reader.Read(buf)
close(readDone)
}()
// Wait for read with timeout
select {
case <-readDone:
Expect(readErr).NotTo(HaveOccurred())
Expect(readN).To(BeNumerically(">", 0))
Expect(string(buf[:readN])).To(ContainSubstring("Starting long process"))
case <-time.After(5 * time.Second):
Fail("Timeout waiting for process output")
}
// Wait a bit to ensure process is running
time.Sleep(time.Second)
// Stop the process
err = client.StopProcess(process.ID)
Expect(err).NotTo(HaveOccurred())
})
It("MCP", func() {
ctx := context.Background()
process, err := client.CreateProcess(ctx,
"docker", []string{"run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"},
[]string{"GITHUB_PERSONAL_ACCESS_TOKEN=test"}, "test-group")
Expect(err).NotTo(HaveOccurred())
Expect(process).NotTo(BeNil())
Expect(process.ID).NotTo(BeEmpty())
defer client.StopProcess(process.ID)
// MCP client
read, writer, err := client.GetProcessIO(process.ID)
Expect(err).NotTo(HaveOccurred())
Expect(read).NotTo(BeNil())
Expect(writer).NotTo(BeNil())
transport := stdio.NewStdioServerTransportWithIO(read, writer)
// Create a new client
mcpClient := mcp.NewClient(transport)
// Initialize the client
response, e := mcpClient.Initialize(ctx)
Expect(e).NotTo(HaveOccurred())
Expect(response).NotTo(BeNil())
Expect(mcpClient.Ping(ctx)).To(Succeed())
xlog.Debug("Client initialized: %v", response.Instructions)
alltools := []mcp.ToolRetType{}
var cursor *string
for {
tools, err := mcpClient.ListTools(ctx, cursor)
Expect(err).NotTo(HaveOccurred())
Expect(tools).NotTo(BeNil())
Expect(tools.Tools).NotTo(BeEmpty())
alltools = append(alltools, tools.Tools...)
if tools.NextCursor == nil {
break // No more pages
}
cursor = tools.NextCursor
}
for _, tool := range alltools {
xlog.Debug("Tool: %v", tool)
}
})
})
})
-473
View File
@@ -1,473 +0,0 @@
package stdio
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/mudler/LocalAGI/pkg/xlog"
)
// Process represents a running process with its stdio streams
type Process struct {
ID string
GroupID string
Cmd *exec.Cmd
Stdin io.WriteCloser
Stdout io.ReadCloser
Stderr io.ReadCloser
CreatedAt time.Time
}
// Server handles process management and stdio streaming
type Server struct {
processes map[string]*Process
groups map[string][]string // maps group ID to process IDs
mu sync.RWMutex
upgrader websocket.Upgrader
}
// NewServer creates a new stdio server
func NewServer() *Server {
return &Server{
processes: make(map[string]*Process),
groups: make(map[string][]string),
upgrader: websocket.Upgrader{},
}
}
// StartProcess starts a new process and returns its ID
func (s *Server) StartProcess(ctx context.Context, command string, args []string, env []string, groupID string) (string, error) {
xlog.Debug("Starting process", "command", command, "args", args, "groupID", groupID)
cmd := exec.CommandContext(ctx, command, args...)
if len(env) > 0 {
cmd.Env = append(os.Environ(), env...)
xlog.Debug("Process environment", "env", cmd.Env)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return "", fmt.Errorf("failed to create stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", fmt.Errorf("failed to create stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return "", fmt.Errorf("failed to create stderr pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("failed to start process: %w", err)
}
process := &Process{
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
GroupID: groupID,
Cmd: cmd,
Stdin: stdin,
Stdout: stdout,
Stderr: stderr,
CreatedAt: time.Now(),
}
s.mu.Lock()
s.processes[process.ID] = process
if groupID != "" {
s.groups[groupID] = append(s.groups[groupID], process.ID)
}
s.mu.Unlock()
xlog.Debug("Successfully started process", "id", process.ID, "pid", cmd.Process.Pid)
return process.ID, nil
}
// StopProcess stops a running process
func (s *Server) StopProcess(id string) error {
s.mu.Lock()
process, exists := s.processes[id]
if !exists {
s.mu.Unlock()
return fmt.Errorf("process not found: %s", id)
}
xlog.Debug("Stopping process", "processID", id, "pid", process.Cmd.Process.Pid)
// Remove from group if it exists
if process.GroupID != "" {
groupProcesses := s.groups[process.GroupID]
for i, pid := range groupProcesses {
if pid == id {
s.groups[process.GroupID] = append(groupProcesses[:i], groupProcesses[i+1:]...)
break
}
}
if len(s.groups[process.GroupID]) == 0 {
delete(s.groups, process.GroupID)
}
}
delete(s.processes, id)
s.mu.Unlock()
if err := process.Cmd.Process.Kill(); err != nil {
xlog.Debug("Failed to kill process", "processID", id, "pid", process.Cmd.Process.Pid, "error", err)
return fmt.Errorf("failed to kill process: %w", err)
}
xlog.Debug("Successfully killed process", "processID", id, "pid", process.Cmd.Process.Pid)
return nil
}
// StopGroup stops all processes in a group
func (s *Server) StopGroup(groupID string) error {
s.mu.Lock()
processIDs, exists := s.groups[groupID]
if !exists {
s.mu.Unlock()
return fmt.Errorf("group not found: %s", groupID)
}
s.mu.Unlock()
for _, pid := range processIDs {
if err := s.StopProcess(pid); err != nil {
return fmt.Errorf("failed to stop process %s in group %s: %w", pid, groupID, err)
}
}
return nil
}
// GetGroupProcesses returns all processes in a group
func (s *Server) GetGroupProcesses(groupID string) ([]*Process, error) {
s.mu.RLock()
processIDs, exists := s.groups[groupID]
if !exists {
s.mu.RUnlock()
return nil, fmt.Errorf("group not found: %s", groupID)
}
processes := make([]*Process, 0, len(processIDs))
for _, pid := range processIDs {
if process, exists := s.processes[pid]; exists {
processes = append(processes, process)
}
}
s.mu.RUnlock()
return processes, nil
}
// ListGroups returns all group IDs
func (s *Server) ListGroups() []string {
s.mu.RLock()
defer s.mu.RUnlock()
groups := make([]string, 0, len(s.groups))
for groupID := range s.groups {
groups = append(groups, groupID)
}
return groups
}
// GetProcess returns a process by ID
func (s *Server) GetProcess(id string) (*Process, error) {
s.mu.RLock()
process, exists := s.processes[id]
s.mu.RUnlock()
if !exists {
return nil, fmt.Errorf("process not found: %s", id)
}
return process, nil
}
// ListProcesses returns all running processes
func (s *Server) ListProcesses() []*Process {
s.mu.RLock()
defer s.mu.RUnlock()
processes := make([]*Process, 0, len(s.processes))
for _, p := range s.processes {
processes = append(processes, p)
}
return processes
}
// RunProcess executes a command and returns its output
func (s *Server) RunProcess(ctx context.Context, command string, args []string, env []string) (string, error) {
cmd := exec.CommandContext(ctx, command, args...)
if len(env) > 0 {
cmd.Env = append(os.Environ(), env...)
}
output, err := cmd.CombinedOutput()
if err != nil {
return string(output), fmt.Errorf("process failed: %w", err)
}
return string(output), nil
}
// Start starts the HTTP server
func (s *Server) Start(addr string) error {
http.HandleFunc("/processes", s.handleProcesses)
http.HandleFunc("/processes/", s.handleProcess)
http.HandleFunc("/ws/", s.handleWebSocket)
http.HandleFunc("/groups", s.handleGroups)
http.HandleFunc("/groups/", s.handleGroup)
http.HandleFunc("/run", s.handleRun)
return http.ListenAndServe(addr, nil)
}
func (s *Server) handleProcesses(w http.ResponseWriter, r *http.Request) {
log.Printf("Handling /processes request: method=%s", r.Method)
switch r.Method {
case http.MethodGet:
processes := s.ListProcesses()
json.NewEncoder(w).Encode(processes)
case http.MethodPost:
var req struct {
Command string `json:"command"`
Args []string `json:"args"`
Env []string `json:"env"`
GroupID string `json:"group_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
id, err := s.StartProcess(context.Background(), req.Command, req.Args, req.Env, req.GroupID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"id": id})
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleProcess(w http.ResponseWriter, r *http.Request) {
id := r.URL.Path[len("/processes/"):]
switch r.Method {
case http.MethodGet:
process, err := s.GetProcess(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(process)
case http.MethodDelete:
if err := s.StopProcess(id); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
id := r.URL.Path[len("/ws/"):]
xlog.Debug("Handling WebSocket connection", "processID", id)
process, err := s.GetProcess(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
if process.Cmd.ProcessState != nil && process.Cmd.ProcessState.Exited() {
xlog.Debug("Process already exited", "processID", id)
http.Error(w, "Process already exited", http.StatusGone)
return
}
xlog.Debug("Process is running", "processID", id, "pid", process.Cmd.Process.Pid)
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer conn.Close()
xlog.Debug("WebSocket connection established", "processID", id)
// Create a done channel to signal process completion
done := make(chan struct{})
// Handle stdin
go func() {
defer func() {
select {
case <-done:
xlog.Debug("Process stdin handler done", "processID", id)
default:
xlog.Debug("WebSocket stdin connection closed", "processID", id)
}
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
xlog.Debug("WebSocket stdin unexpected error", "processID", id, "error", err)
}
return
}
xlog.Debug("Received message", "processID", id, "message", string(message))
if _, err := process.Stdin.Write(message); err != nil {
if err != io.EOF {
xlog.Debug("WebSocket stdin write error", "processID", id, "error", err)
}
return
}
xlog.Debug("Message sent to process", "processID", id, "message", string(message))
}
}()
// Handle stdout and stderr
go func() {
defer func() {
select {
case <-done:
xlog.Debug("Process output handler done", "processID", id)
default:
xlog.Debug("WebSocket output connection closed", "processID", id)
}
}()
// Create a buffer for reading
buf := make([]byte, 4096)
reader := io.MultiReader(process.Stdout, process.Stderr)
for {
n, err := reader.Read(buf)
if err != nil {
if err != io.EOF {
xlog.Debug("Read error", "processID", id, "error", err)
}
return
}
if n > 0 {
xlog.Debug("Sending message", "processID", id, "size", n)
if err := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
xlog.Debug("WebSocket output write error", "processID", id, "error", err)
}
return
}
xlog.Debug("Message sent to client", "processID", id, "size", n)
}
}
}()
// Wait for process to exit
xlog.Debug("Waiting for process to exit", "processID", id)
err = process.Cmd.Wait()
close(done) // Signal that the process is done
if err != nil {
xlog.Debug("Process exited with error",
"processID", id,
"pid", process.Cmd.Process.Pid,
"error", err)
} else {
xlog.Debug("Process exited successfully",
"processID", id,
"pid", process.Cmd.Process.Pid)
}
}
// Add new handlers for group management
func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
groups := s.ListGroups()
json.NewEncoder(w).Encode(groups)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleGroup(w http.ResponseWriter, r *http.Request) {
groupID := r.URL.Path[len("/groups/"):]
switch r.Method {
case http.MethodGet:
processes, err := s.GetGroupProcesses(groupID)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(processes)
case http.MethodDelete:
if err := s.StopGroup(groupID); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
log.Printf("Handling /run request")
var req struct {
Command string `json:"command"`
Args []string `json:"args"`
Env []string `json:"env"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("Executing one-time process: command=%s, args=%v", req.Command, req.Args)
output, err := s.RunProcess(r.Context(), req.Command, req.Args, req.Env)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
log.Printf("One-time process completed with output length: %d", len(output))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"output": output,
})
}
-71
View File
@@ -1,71 +0,0 @@
package xlog
import (
"context"
"log/slog"
"os"
"runtime"
)
var logger *slog.Logger
func init() {
var level = slog.LevelDebug
switch os.Getenv("LOG_LEVEL") {
case "info":
level = slog.LevelInfo
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
case "debug":
level = slog.LevelDebug
}
var opts = &slog.HandlerOptions{
Level: level,
}
var handler slog.Handler
if os.Getenv("LOG_FORMAT") == "json" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
logger = slog.New(handler)
}
func _log(level slog.Level, msg string, args ...any) {
_, f, l, _ := runtime.Caller(2)
group := slog.Group(
"source",
slog.Attr{
Key: "file",
Value: slog.AnyValue(f),
},
slog.Attr{
Key: "L",
Value: slog.AnyValue(l),
},
)
args = append(args, group)
logger.Log(context.Background(), level, msg, args...)
}
func Info(msg string, args ...any) {
_log(slog.LevelInfo, msg, args...)
}
func Debug(msg string, args ...any) {
_log(slog.LevelDebug, msg, args...)
}
func Error(msg string, args ...any) {
_log(slog.LevelError, msg, args...)
}
func Warn(msg string, args ...any) {
_log(slog.LevelWarn, msg, args...)
}
+331 -234
View File
@@ -6,12 +6,14 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/mudler/LocalAGI/core/action"
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/mudler/LocalAGI/services/actions"
)
@@ -20,8 +22,6 @@ const (
// Actions
ActionSearch = "search"
ActionCustom = "custom"
ActionBrowserAgentRunner = "browser-agent-runner"
ActionDeepResearchRunner = "deep-research-runner"
ActionGithubIssueLabeler = "github-issue-labeler"
ActionGithubIssueOpener = "github-issue-opener"
ActionGithubIssueEditor = "github-issue-editor"
@@ -45,16 +45,29 @@ const (
ActionTwitterPost = "twitter-post"
ActionSendMail = "send-mail"
ActionGenerateImage = "generate_image"
ActionGenerateSong = "generate_song"
ActionGeneratePDF = "generate_pdf"
ActionCounter = "counter"
ActionCallAgents = "call_agents"
ActionShellcommand = "shell-command"
ActionSendTelegramMessage = "send-telegram-message"
ActionSetReminder = "set_reminder"
ActionSetRecurringReminder = "set_recurring_reminder"
ActionSetOneTimeReminder = "set_onetime_reminder"
ActionListReminders = "list_reminders"
ActionRemoveReminder = "remove_reminder"
ActionAddToMemory = "add_to_memory"
ActionListMemory = "list_memory"
ActionRemoveFromMemory = "remove_from_memory"
ActionSearchMemory = "search_memory"
ActionPiKVMPowerControl = "pikvm_power_control"
ActionWebhook = "webhook"
)
const (
nameField = "name"
descriptionField = "description"
configurationField = "configuration"
)
var AvailableActions = []string{
@@ -69,8 +82,6 @@ var AvailableActions = []string{
ActionGithubGetAllContent,
ActionGithubRepositorySearchFiles,
ActionGithubRepositoryListFiles,
ActionBrowserAgentRunner,
ActionDeepResearchRunner,
ActionGithubRepositoryCreateOrUpdate,
ActionGithubIssueReader,
ActionGithubIssueCommenter,
@@ -84,6 +95,8 @@ var AvailableActions = []string{
ActionWikipedia,
ActionSendMail,
ActionGenerateImage,
ActionGenerateSong,
ActionGeneratePDF,
ActionTwitterPost,
ActionCounter,
ActionCallAgents,
@@ -95,22 +108,275 @@ var AvailableActions = []string{
ActionAddToMemory,
ActionListMemory,
ActionRemoveFromMemory,
ActionSearchMemory,
ActionPiKVMPowerControl,
ActionWebhook,
}
var DefaultActions = []config.FieldGroup{
{
Name: "search",
Label: "Search",
Fields: actions.SearchConfigMeta(),
},
{
Name: "generate_image",
Label: "Generate Image",
Fields: actions.GenImageConfigMeta(),
},
{
Name: "generate_song",
Label: "Generate Song",
Fields: actions.GenSongConfigMeta(),
},
{
Name: "generate_pdf",
Label: "Generate PDF",
Fields: actions.GenPDFConfigMeta(),
},
{
Name: "add_to_memory",
Label: "Add to Memory",
Fields: actions.AddToMemoryConfigMeta(),
},
{
Name: "list_memory",
Label: "List Memory",
Fields: actions.ListMemoryConfigMeta(),
},
{
Name: "remove_from_memory",
Label: "Remove from Memory",
Fields: actions.RemoveFromMemoryConfigMeta(),
},
{
Name: "search_memory",
Label: "Search Memory",
Fields: actions.SearchMemoryConfigMeta(),
},
{
Name: "github-issue-labeler",
Label: "GitHub Issue Labeler",
Fields: actions.GithubIssueLabelerConfigMeta(),
},
{
Name: "github-issue-opener",
Label: "GitHub Issue Opener",
Fields: actions.GithubIssueOpenerConfigMeta(),
},
{
Name: "github-issue-editor",
Label: "GitHub Issue Editor",
Fields: actions.GithubIssueEditorConfigMeta(),
},
{
Name: "github-issue-closer",
Label: "GitHub Issue Closer",
Fields: actions.GithubIssueCloserConfigMeta(),
},
{
Name: "github-issue-commenter",
Label: "GitHub Issue Commenter",
Fields: actions.GithubIssueCommenterConfigMeta(),
},
{
Name: "github-issue-reader",
Label: "GitHub Issue Reader",
Fields: actions.GithubIssueReaderConfigMeta(),
},
{
Name: "github-issue-searcher",
Label: "GitHub Issue Search",
Fields: actions.GithubIssueSearchConfigMeta(),
},
{
Name: "github-repository-get-content",
Label: "GitHub Repository Get Content",
Fields: actions.GithubRepositoryGetContentConfigMeta(),
},
{
Name: "github-get-all-repository-content",
Label: "GitHub Get All Repository Content",
Fields: actions.GithubRepositoryGetAllContentConfigMeta(),
},
{
Name: "github-repository-search-files",
Label: "GitHub Repository Search Files",
Fields: actions.GithubRepositorySearchFilesConfigMeta(),
},
{
Name: "github-repository-list-files",
Label: "GitHub Repository List Files",
Fields: actions.GithubRepositoryListFilesConfigMeta(),
},
{
Name: "github-repository-create-or-update-content",
Label: "GitHub Repository Create/Update Content",
Fields: actions.GithubRepositoryCreateOrUpdateContentConfigMeta(),
},
{
Name: "github-readme",
Label: "GitHub Repository README",
Fields: actions.GithubRepositoryREADMEConfigMeta(),
},
{
Name: "github-pr-reader",
Label: "GitHub PR Reader",
Fields: actions.GithubPRReaderConfigMeta(),
},
{
Name: "github-pr-commenter",
Label: "GitHub PR Commenter",
Fields: actions.GithubPRCommenterConfigMeta(),
},
{
Name: "github-pr-reviewer",
Label: "GitHub PR Reviewer",
Fields: actions.GithubPRReviewerConfigMeta(),
},
{
Name: "github-pr-creator",
Label: "GitHub PR Creator",
Fields: actions.GithubPRCreatorConfigMeta(),
},
{
Name: "twitter-post",
Label: "Twitter Post",
Fields: actions.TwitterPostConfigMeta(),
},
{
Name: "send-mail",
Label: "Send Mail",
Fields: actions.SendMailConfigMeta(),
},
{
Name: "shell-command",
Label: "Shell Command",
Fields: actions.ShellConfigMeta(),
},
{
Name: "custom",
Label: "Custom",
Fields: action.CustomConfigMeta(),
},
{
Name: "scraper",
Label: "Scraper",
Fields: []config.Field{},
},
{
Name: "wikipedia",
Label: "Wikipedia",
Fields: []config.Field{},
},
{
Name: "browse",
Label: "Browse",
Fields: []config.Field{},
},
{
Name: "counter",
Label: "Counter",
Fields: []config.Field{},
},
{
Name: "call_agents",
Label: "Call Agents",
Fields: actions.CallAgentConfigMeta(),
},
{
Name: "send-telegram-message",
Label: "Send Telegram Message",
Fields: actions.SendTelegramMessageConfigMeta(),
},
{
Name: "set_recurring_reminder",
Label: "Set Recurring Reminder",
Fields: []config.Field{},
},
{
Name: "set_onetime_reminder",
Label: "Set One-Time Reminder",
Fields: []config.Field{},
},
{
Name: "list_reminders",
Label: "List Reminders",
Fields: []config.Field{},
},
{
Name: "remove_reminder",
Label: "Remove Reminder",
Fields: []config.Field{},
},
{
Name: "pikvm_power_control",
Label: "PiKVM Power Control",
Fields: actions.PiKVMConfigMeta(),
},
{
Name: "webhook",
Label: "Webhook",
Fields: actions.WebhookConfigMeta(),
},
}
const (
ActionConfigBrowserAgentRunner = "browser-agent-runner-base-url"
ActionConfigDeepResearchRunner = "deep-research-runner-base-url"
ActionConfigSSHBoxURL = "sshbox-url"
ConfigStateDir = "state-dir"
ActionConfigSSHBoxURL = "sshbox-url"
ConfigStateDir = "state-dir"
CustomActionsDir = "custom-actions-dir"
)
func Actions(actionsConfigs map[string]string, customActionsDir string) func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action {
func customActions(customActionsDir string, existingActionConfigs map[string]map[string]string) (allActions []types.Action) {
files, err := os.ReadDir(customActionsDir)
if err != nil {
xlog.Error("Error reading custom actions directory", "error", err)
return allActions
}
for _, file := range files {
if filepath.Ext(file.Name()) != ".go" {
continue
}
content, err := os.ReadFile(filepath.Join(customActionsDir, file.Name()))
if err != nil {
xlog.Error("Error reading custom action file", "error", err, "file", file.Name())
continue
}
actionName := strings.TrimSuffix(file.Name(), ".go")
actionConfig := map[string]string{
"name": actionName,
"description": "",
"code": string(content),
"unsafe": "false",
}
if c, exists := existingActionConfigs[actionName]; exists {
// We allow the user to customize name and description
actionConfig[descriptionField] = c[descriptionField]
actionConfig[nameField] = c[nameField]
actionConfig[configurationField] = c[configurationField]
}
a, err := Action(ActionCustom, "", actionConfig, nil, map[string]string{})
if err != nil {
xlog.Error("Error creating custom action", "error", err, "file", file.Name())
continue
}
allActions = append(allActions, a)
}
return
}
func Actions(actionsConfigs map[string]string) func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action {
return func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []types.Action {
return func(ctx context.Context, pool *state.AgentPool) []types.Action {
allActions := []types.Action{}
agentName := a.Name
existingActionConfigs := map[string]map[string]string{}
for _, a := range a.Actions {
var config map[string]string
if err := json.Unmarshal([]byte(a.Config), &config); err != nil {
@@ -118,6 +384,8 @@ func Actions(actionsConfigs map[string]string, customActionsDir string) func(a *
continue
}
existingActionConfigs[a.Name] = config
a, err := Action(a.Name, agentName, config, pool, actionsConfigs)
if err != nil {
continue
@@ -126,33 +394,8 @@ func Actions(actionsConfigs map[string]string, customActionsDir string) func(a *
}
// Now we will scan a directory for custom actions
if customActionsDir != "" {
files, err := os.ReadDir(customActionsDir)
if err != nil {
xlog.Error("Error reading custom actions directory", "error", err)
return allActions
}
for _, file := range files {
if filepath.Ext(file.Name()) != ".go" {
continue
}
content, err := os.ReadFile(filepath.Join(customActionsDir, file.Name()))
if err != nil {
xlog.Error("Error reading custom action file", "error", err, "file", file.Name())
continue
}
a, err := Action(ActionCustom, agentName, map[string]string{
"content": string(content),
}, pool, actionsConfigs)
if err != nil {
xlog.Error("Error creating custom action", "error", err, "file", file.Name())
continue
}
allActions = append(allActions, a)
}
if actionsConfigs[CustomActionsDir] != "" {
allActions = append(allActions, customActions(actionsConfigs[CustomActionsDir], existingActionConfigs)...)
}
return allActions
@@ -169,13 +412,17 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
config = map[string]string{}
}
memoryFilePath := memoryPath(agentName, actionsConfigs)
memoryIdxPath := memoryIndexPath(agentName, actionsConfigs)
switch name {
case ActionCustom:
a, err = action.NewCustom(config, "")
case ActionGenerateImage:
a = actions.NewGenImage(config)
case ActionGenerateSong:
a = actions.NewGenSong(config)
case ActionGeneratePDF:
a = actions.NewGenPDF(config)
case ActionSearch:
a = actions.NewSearch(config)
case ActionGithubIssueLabeler:
@@ -188,10 +435,6 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
a = actions.NewGithubIssueCloser(config)
case ActionGithubIssueSearcher:
a = actions.NewGithubIssueSearch(config)
case ActionBrowserAgentRunner:
a = actions.NewBrowserAgentRunner(config, actionsConfigs[ActionConfigBrowserAgentRunner])
case ActionDeepResearchRunner:
a = actions.NewDeepResearchRunner(config, actionsConfigs[ActionConfigDeepResearchRunner])
case ActionGithubIssueReader:
a = actions.NewGithubIssueReader(config)
case ActionGithubPRReader:
@@ -224,6 +467,8 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
a = actions.NewBrowse(config)
case ActionSendMail:
a = actions.NewSendMail(config)
case ActionWebhook:
a = actions.NewWebhook(config)
case ActionTwitterPost:
a = actions.NewPostTweet(config)
case ActionCounter:
@@ -234,18 +479,24 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
a = actions.NewShell(config, actionsConfigs[ActionConfigSSHBoxURL])
case ActionSendTelegramMessage:
a = actions.NewSendTelegramMessageRunner(config)
case ActionSetReminder:
a = action.NewReminder()
case ActionSetRecurringReminder:
a = action.NewRecurringReminder()
case ActionSetOneTimeReminder:
a = action.NewOneTimeReminder()
case ActionListReminders:
a = action.NewListReminders()
case ActionRemoveReminder:
a = action.NewRemoveReminder()
case ActionAddToMemory:
a, _, _ = actions.NewMemoryActions(memoryFilePath, config)
a, _, _, _ = actions.NewMemoryActions(memoryIdxPath, config)
case ActionListMemory:
_, a, _ = actions.NewMemoryActions(memoryFilePath, config)
_, a, _, _ = actions.NewMemoryActions(memoryIdxPath, config)
case ActionRemoveFromMemory:
_, _, a = actions.NewMemoryActions(memoryFilePath, config)
_, _, a, _ = actions.NewMemoryActions(memoryIdxPath, config)
case ActionSearchMemory:
_, _, _, a = actions.NewMemoryActions(memoryIdxPath, config)
case ActionPiKVMPowerControl:
a = actions.NewPiKVMAction(config)
default:
xlog.Error("Action not found", "name", name)
return nil, fmt.Errorf("Action not found")
@@ -258,192 +509,38 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
return a, nil
}
func ActionsConfigMeta() []config.FieldGroup {
return []config.FieldGroup{
{
Name: "search",
Label: "Search",
Fields: actions.SearchConfigMeta(),
},
{
Name: "browser-agent-runner",
Label: "Browser Agent Runner",
Fields: actions.BrowserAgentRunnerConfigMeta(),
},
{
Name: "deep-research-runner",
Label: "Deep Research Runner",
Fields: actions.DeepResearchRunnerConfigMeta(),
},
{
Name: "generate_image",
Label: "Generate Image",
Fields: actions.GenImageConfigMeta(),
},
{
Name: "add_to_memory",
Label: "Add to Memory",
Fields: actions.AddToMemoryConfigMeta(),
},
{
Name: "list_memory",
Label: "List Memory",
Fields: actions.ListMemoryConfigMeta(),
},
{
Name: "remove_from_memory",
Label: "Remove from Memory",
Fields: actions.RemoveFromMemoryConfigMeta(),
},
{
Name: "github-issue-labeler",
Label: "GitHub Issue Labeler",
Fields: actions.GithubIssueLabelerConfigMeta(),
},
{
Name: "github-issue-opener",
Label: "GitHub Issue Opener",
Fields: actions.GithubIssueOpenerConfigMeta(),
},
{
Name: "github-issue-editor",
Label: "GitHub Issue Editor",
Fields: actions.GithubIssueEditorConfigMeta(),
},
{
Name: "github-issue-closer",
Label: "GitHub Issue Closer",
Fields: actions.GithubIssueCloserConfigMeta(),
},
{
Name: "github-issue-commenter",
Label: "GitHub Issue Commenter",
Fields: actions.GithubIssueCommenterConfigMeta(),
},
{
Name: "github-issue-reader",
Label: "GitHub Issue Reader",
Fields: actions.GithubIssueReaderConfigMeta(),
},
{
Name: "github-issue-searcher",
Label: "GitHub Issue Search",
Fields: actions.GithubIssueSearchConfigMeta(),
},
{
Name: "github-repository-get-content",
Label: "GitHub Repository Get Content",
Fields: actions.GithubRepositoryGetContentConfigMeta(),
},
{
Name: "github-get-all-repository-content",
Label: "GitHub Get All Repository Content",
Fields: actions.GithubRepositoryGetAllContentConfigMeta(),
},
{
Name: "github-repository-search-files",
Label: "GitHub Repository Search Files",
Fields: actions.GithubRepositorySearchFilesConfigMeta(),
},
{
Name: "github-repository-list-files",
Label: "GitHub Repository List Files",
Fields: actions.GithubRepositoryListFilesConfigMeta(),
},
{
Name: "github-repository-create-or-update-content",
Label: "GitHub Repository Create/Update Content",
Fields: actions.GithubRepositoryCreateOrUpdateContentConfigMeta(),
},
{
Name: "github-readme",
Label: "GitHub Repository README",
Fields: actions.GithubRepositoryREADMEConfigMeta(),
},
{
Name: "github-pr-reader",
Label: "GitHub PR Reader",
Fields: actions.GithubPRReaderConfigMeta(),
},
{
Name: "github-pr-commenter",
Label: "GitHub PR Commenter",
Fields: actions.GithubPRCommenterConfigMeta(),
},
{
Name: "github-pr-reviewer",
Label: "GitHub PR Reviewer",
Fields: actions.GithubPRReviewerConfigMeta(),
},
{
Name: "github-pr-creator",
Label: "GitHub PR Creator",
Fields: actions.GithubPRCreatorConfigMeta(),
},
{
Name: "twitter-post",
Label: "Twitter Post",
Fields: actions.TwitterPostConfigMeta(),
},
{
Name: "send-mail",
Label: "Send Mail",
Fields: actions.SendMailConfigMeta(),
},
{
Name: "shell-command",
Label: "Shell Command",
Fields: actions.ShellConfigMeta(),
},
{
Name: "custom",
Label: "Custom",
Fields: action.CustomConfigMeta(),
},
{
Name: "scraper",
Label: "Scraper",
Fields: []config.Field{},
},
{
Name: "wikipedia",
Label: "Wikipedia",
Fields: []config.Field{},
},
{
Name: "browse",
Label: "Browse",
Fields: []config.Field{},
},
{
Name: "counter",
Label: "Counter",
Fields: []config.Field{},
},
{
Name: "call_agents",
Label: "Call Agents",
Fields: actions.CallAgentConfigMeta(),
},
{
Name: "send-telegram-message",
Label: "Send Telegram Message",
Fields: actions.SendTelegramMessageConfigMeta(),
},
{
Name: "set_reminder",
Label: "Set Reminder",
Fields: []config.Field{},
},
{
Name: "list_reminders",
Label: "List Reminders",
Fields: []config.Field{},
},
{
Name: "remove_reminder",
Label: "Remove Reminder",
Fields: []config.Field{},
},
func ActionsConfigMeta(customActionDir string) []config.FieldGroup {
all := slices.Clone(DefaultActions)
if customActionDir != "" {
actions := customActions(customActionDir, map[string]map[string]string{})
for _, a := range actions {
all = append(all, config.FieldGroup{
Name: a.Definition().Name.String(),
Label: a.Definition().Name.String(),
Fields: []config.Field{
{
Name: nameField,
Label: "Name",
Type: config.FieldTypeText,
HelpText: "Name of the custom action",
},
{
Name: descriptionField,
Label: "Description",
Type: config.FieldTypeTextarea,
HelpText: "Description of the custom action",
},
{
Name: configurationField,
Label: "Configuration",
Type: config.FieldTypeTextarea,
HelpText: "Configuration of the custom action",
},
},
})
}
}
return all
}
-140
View File
@@ -1,140 +0,0 @@
package actions
import (
"context"
"fmt"
"time"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
api "github.com/mudler/LocalAGI/pkg/localoperator"
"github.com/sashabaranov/go-openai/jsonschema"
)
const (
MetadataBrowserAgentHistory = "browser_agent_history"
)
type BrowserAgentRunner struct {
baseURL, customActionName string
client *api.Client
}
func NewBrowserAgentRunner(config map[string]string, defaultURL string) *BrowserAgentRunner {
if config["baseURL"] == "" {
config["baseURL"] = defaultURL
}
timeout := "15m"
if config["timeout"] != "" {
timeout = config["timeout"]
}
duration, err := time.ParseDuration(timeout)
if err != nil {
// If parsing fails, use default 15 minutes
duration = 15 * time.Minute
}
client := api.NewClient(config["baseURL"], duration)
return &BrowserAgentRunner{
client: client,
baseURL: config["baseURL"],
customActionName: config["customActionName"],
}
}
func (b *BrowserAgentRunner) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := api.AgentRequest{}
err := params.Unmarshal(&result)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to unmarshal params: %w", err)
}
req := api.AgentRequest{
Goal: result.Goal,
MaxAttempts: result.MaxAttempts,
MaxNoActionAttempts: result.MaxNoActionAttempts,
}
stateHistory, err := b.client.RunBrowserAgent(req)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to run browser agent: %w", err)
}
// Format the state history into a readable string
var historyStr string
// for i, state := range stateHistory.States {
// historyStr += fmt.Sprintf("State %d:\n", i+1)
// historyStr += fmt.Sprintf(" URL: %s\n", state.CurrentURL)
// historyStr += fmt.Sprintf(" Title: %s\n", state.PageTitle)
// historyStr += fmt.Sprintf(" Description: %s\n\n", state.PageContentDescription)
// }
historyStr += fmt.Sprintf(" URL: %s\n", stateHistory.States[len(stateHistory.States)-1].CurrentURL)
historyStr += fmt.Sprintf(" Title: %s\n", stateHistory.States[len(stateHistory.States)-1].PageTitle)
historyStr += fmt.Sprintf(" Description: %s\n\n", stateHistory.States[len(stateHistory.States)-1].PageContentDescription)
return types.ActionResult{
Result: fmt.Sprintf("Browser agent completed successfully. History:\n%s", historyStr),
Metadata: map[string]interface{}{MetadataBrowserAgentHistory: stateHistory},
}, nil
}
func (b *BrowserAgentRunner) Definition() types.ActionDefinition {
actionName := "run_browser_agent"
if b.customActionName != "" {
actionName = b.customActionName
}
description := "Run a browser agent to achieve a specific goal, for example: 'Go to https://www.google.com and search for 'LocalAI', and tell me what's on the first page'"
return types.ActionDefinition{
Name: types.ActionDefinitionName(actionName),
Description: description,
Properties: map[string]jsonschema.Definition{
"goal": {
Type: jsonschema.String,
Description: "The goal for the browser agent to achieve",
},
"max_attempts": {
Type: jsonschema.Number,
Description: "Maximum number of attempts the agent can make (optional)",
},
"max_no_action_attempts": {
Type: jsonschema.Number,
Description: "Maximum number of attempts without taking an action (optional)",
},
},
Required: []string{"goal"},
}
}
func (a *BrowserAgentRunner) Plannable() bool {
return true
}
// BrowserAgentRunnerConfigMeta returns the metadata for Browser Agent Runner action configuration fields
func BrowserAgentRunnerConfigMeta() []config.Field {
return []config.Field{
{
Name: "baseURL",
Label: "Base URL",
Type: config.FieldTypeText,
Required: false,
HelpText: "Base URL of the LocalOperator API",
},
{
Name: "customActionName",
Label: "Custom Action Name",
Type: config.FieldTypeText,
HelpText: "Custom name for this action",
},
{
Name: "timeout",
Label: "Client Timeout",
Type: config.FieldTypeText,
Required: false,
HelpText: "Client timeout duration (e.g. '15m', '1h'). Defaults to '15m' if not specified.",
},
}
}
-148
View File
@@ -1,148 +0,0 @@
package actions
import (
"context"
"fmt"
"time"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
api "github.com/mudler/LocalAGI/pkg/localoperator"
"github.com/sashabaranov/go-openai/jsonschema"
)
const (
MetadataDeepResearchResult = "deep_research_result"
)
type DeepResearchRunner struct {
baseURL, customActionName string
client *api.Client
}
func NewDeepResearchRunner(config map[string]string, defaultURL string) *DeepResearchRunner {
if config["baseURL"] == "" {
config["baseURL"] = defaultURL
}
timeout := "15m"
if config["timeout"] != "" {
timeout = config["timeout"]
}
duration, err := time.ParseDuration(timeout)
if err != nil {
// If parsing fails, use default 15 minutes
duration = 15 * time.Minute
}
client := api.NewClient(config["baseURL"], duration)
return &DeepResearchRunner{
client: client,
baseURL: config["baseURL"],
customActionName: config["customActionName"],
}
}
func (d *DeepResearchRunner) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := api.DeepResearchRequest{}
err := params.Unmarshal(&result)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to unmarshal params: %w", err)
}
req := api.DeepResearchRequest{
Topic: result.Topic,
MaxCycles: result.MaxCycles,
MaxNoActionAttempts: result.MaxNoActionAttempts,
MaxResults: result.MaxResults,
}
researchResult, err := d.client.RunDeepResearch(req)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to run deep research: %w", err)
}
// Format the research result into a readable string
var resultStr string
resultStr += "Deep research result\n"
resultStr += fmt.Sprintf("Topic: %s\n", researchResult.Topic)
resultStr += fmt.Sprintf("Summary: %s\n", researchResult.Summary)
resultStr += fmt.Sprintf("Research Cycles: %d\n", researchResult.ResearchCycles)
resultStr += fmt.Sprintf("Completion Time: %s\n\n", researchResult.CompletionTime)
if len(researchResult.Sources) > 0 {
resultStr += "Sources:\n"
for _, source := range researchResult.Sources {
resultStr += fmt.Sprintf("- %s (%s)\n %s\n", source.Title, source.URL, source.Description)
}
}
return types.ActionResult{
Result: fmt.Sprintf("Deep research completed successfully.\n%s", resultStr),
Metadata: map[string]interface{}{MetadataDeepResearchResult: researchResult},
}, nil
}
func (d *DeepResearchRunner) Definition() types.ActionDefinition {
actionName := "run_deep_research"
if d.customActionName != "" {
actionName = d.customActionName
}
description := "Run a deep research on a specific topic, gathering information from multiple sources and providing a comprehensive summary"
return types.ActionDefinition{
Name: types.ActionDefinitionName(actionName),
Description: description,
Properties: map[string]jsonschema.Definition{
"topic": {
Type: jsonschema.String,
Description: "The topic to research",
},
"max_cycles": {
Type: jsonschema.Number,
Description: "Maximum number of research cycles to perform (optional)",
},
"max_no_action_attempts": {
Type: jsonschema.Number,
Description: "Maximum number of attempts without taking an action (optional)",
},
"max_results": {
Type: jsonschema.Number,
Description: "Maximum number of results to collect (optional)",
},
},
Required: []string{"topic"},
}
}
func (d *DeepResearchRunner) Plannable() bool {
return true
}
// DeepResearchRunnerConfigMeta returns the metadata for Deep Research Runner action configuration fields
func DeepResearchRunnerConfigMeta() []config.Field {
return []config.Field{
{
Name: "baseURL",
Label: "Base URL",
Type: config.FieldTypeText,
Required: false,
HelpText: "Base URL of the LocalOperator API",
},
{
Name: "customActionName",
Label: "Custom Action Name",
Type: config.FieldTypeText,
HelpText: "Custom name for this action",
},
{
Name: "timeout",
Label: "Client Timeout",
Type: config.FieldTypeText,
Required: false,
HelpText: "Client timeout duration (e.g. '15m', '1h'). Defaults to '15m' if not specified.",
},
}
}
+165
View File
@@ -0,0 +1,165 @@
package actions
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
"github.com/jung-kurt/gofpdf"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
const (
MetadataPDFs = "pdf_paths"
)
// NewGenPDF creates a new PDF generation action
func NewGenPDF(config map[string]string) *GenPDFAction {
a := &GenPDFAction{
outputDir: config["outputDir"],
cleanOnStart: config["cleanOnStart"] == "true" || config["cleanOnStart"] == "1",
}
if a.outputDir != "" {
if err := os.MkdirAll(a.outputDir, 0755); err != nil {
xlog.Error("Failed to create output directory", "path", a.outputDir, "error", err)
}
if a.cleanOnStart {
entries, err := os.ReadDir(a.outputDir)
if err == nil {
for _, e := range entries {
_ = os.Remove(filepath.Join(a.outputDir, e.Name()))
}
}
}
}
return a
}
type GenPDFAction struct {
outputDir string
cleanOnStart bool
}
func (a *GenPDFAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Title string `json:"title"`
Content string `json:"content"`
Filename string `json:"filename"`
}{}
if err := params.Unmarshal(&result); err != nil {
return types.ActionResult{}, err
}
if result.Content == "" {
return types.ActionResult{}, fmt.Errorf("content is required")
}
if a.outputDir == "" {
return types.ActionResult{}, fmt.Errorf("outputDir is required for generate_pdf (configure the action with an output directory)")
}
// Generate filename if not provided
filename := result.Filename
if filename == "" {
filename = fmt.Sprintf("document_%d", time.Now().UnixNano())
}
// Clean filename to prevent path traversal
filename = filepath.Base(filename)
// Ensure filename has .pdf extension
if !strings.HasSuffix(strings.ToLower(filename), ".pdf") {
filename = filename + ".pdf"
}
// Create PDF
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
tr := pdf.UnicodeTranslatorFromDescriptor("")
// Add title if provided
if result.Title != "" {
pdf.SetFont("Arial", "B", 16)
pdf.MultiCell(0, 10, tr(result.Title), "", "", false)
pdf.Ln(5)
}
// Add content: parse as markdown and render, or fall back to plain text
pdf.SetFont("Arial", "", 12)
p := parser.NewWithExtensions(parser.CommonExtensions)
doc := p.Parse([]byte(result.Content))
if doc != nil && ast.GetFirstChild(doc) != nil {
renderMarkdownToPDF(pdf, tr, doc)
} else {
pdf.MultiCell(0, 10, tr(result.Content), "", "", false)
}
// Save PDF
savedPath := filepath.Join(a.outputDir, filename)
if err := pdf.OutputFileAndClose(savedPath); err != nil {
return types.ActionResult{}, fmt.Errorf("failed to save PDF: %w", err)
}
return types.ActionResult{
Result: fmt.Sprintf("PDF generated and saved to: %s", savedPath),
Metadata: map[string]interface{}{
MetadataPDFs: []string{savedPath},
},
}, nil
}
func (a *GenPDFAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: "generate_pdf",
Description: "Generate a PDF document from text content. The PDF is saved locally and can be sent to the user by connectors.",
Properties: map[string]jsonschema.Definition{
"title": {
Type: jsonschema.String,
Description: "Title of the PDF document",
},
"content": {
Type: jsonschema.String,
Description: "Text or Markdown content to include in the PDF (headings, bold, lists, code blocks, etc. are rendered)",
},
"filename": {
Type: jsonschema.String,
Description: "Optional custom filename (extension is optional - .pdf will be automatically added if missing)",
},
},
Required: []string{"content"},
}
}
func (a *GenPDFAction) Plannable() bool {
return true
}
// GenPDFConfigMeta returns the metadata for GenPDF action configuration fields.
func GenPDFConfigMeta() []config.Field {
return []config.Field{
{
Name: "outputDir",
Label: "Output directory",
Type: config.FieldTypeText,
Required: true,
HelpText: "Directory where generated PDF files are saved",
},
{
Name: "cleanOnStart",
Label: "Clean output directory on start",
Type: config.FieldTypeCheckbox,
DefaultValue: false,
HelpText: "If enabled, clear the output directory when the action is loaded",
},
}
}
+412
View File
@@ -0,0 +1,412 @@
package actions
import (
"fmt"
"strings"
"github.com/gomarkdown/markdown/ast"
"github.com/jung-kurt/gofpdf"
)
const (
pdfLineHeight = 6.0
pdfBlockMargin = 4.0
)
// renderMarkdownToPDF walks the markdown AST and renders it to the PDF using tr for all text.
func renderMarkdownToPDF(pdf *gofpdf.Fpdf, tr func(string) string, doc ast.Node) {
for child := ast.GetFirstChild(doc); child != nil; child = ast.GetNextNode(child) {
renderBlock(pdf, tr, child)
}
}
func renderBlock(pdf *gofpdf.Fpdf, tr func(string) string, node ast.Node) {
switch n := node.(type) {
case *ast.Document:
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
renderBlock(pdf, tr, child)
}
case *ast.Heading:
level := n.Level
if level > 6 {
level = 6
}
size := float64(22 - level*2)
if size < 12 {
size = 12
}
pdf.SetFont("Arial", "B", size)
writeInlineContent(pdf, tr, n)
pdf.Ln(pdfLineHeight + pdfBlockMargin)
pdf.SetFont("Arial", "", 12)
case *ast.Paragraph:
writeInlineContent(pdf, tr, n)
pdf.Ln(pdfLineHeight + pdfBlockMargin)
case *ast.List:
listType := n.ListFlags
ordered := (listType & ast.ListTypeOrdered) != 0
start := n.Start
if start <= 0 {
start = 1
}
itemNum := 0
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
if item, ok := child.(*ast.ListItem); ok {
itemNum++
var bullet string
if ordered {
bullet = tr(fmt.Sprintf("%d. ", start+itemNum-1))
} else {
bullet = tr("• ")
}
pdf.SetFont("Arial", "", 12)
pdf.CellFormat(8, pdfLineHeight, bullet, "", 0, "", false, 0, "")
for inner := ast.GetFirstChild(item); inner != nil; inner = ast.GetNextNode(inner) {
renderBlock(pdf, tr, inner)
}
}
}
pdf.Ln(pdfBlockMargin)
case *ast.ListItem:
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
renderBlock(pdf, tr, child)
}
case *ast.CodeBlock:
pdf.SetFont("Courier", "", 10)
lit := n.Literal
if lit == nil {
lit = n.Content
}
if len(lit) > 0 {
pdf.MultiCell(0, pdfLineHeight-1, tr(string(lit)), "", "", false)
}
pdf.SetFont("Arial", "", 12)
pdf.Ln(pdfBlockMargin)
case *ast.BlockQuote:
left, _, _, _ := pdf.GetMargins()
saveLeft := left
pdf.SetLeftMargin(saveLeft + 4)
pdf.SetX(saveLeft + 4)
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
renderBlock(pdf, tr, child)
}
pdf.SetLeftMargin(saveLeft)
pdf.Ln(pdfBlockMargin)
case *ast.HorizontalRule:
pdf.Ln(pdfBlockMargin)
pdf.Line(pdf.GetX(), pdf.GetY(), pdf.GetX()+190, pdf.GetY())
pdf.Ln(pdfBlockMargin)
case *ast.Table:
renderTable(pdf, tr, n)
pdf.Ln(pdfBlockMargin)
case *ast.MathBlock:
pdf.SetFont("Courier", "", 10)
lit := n.Literal
if lit == nil {
lit = n.Content
}
if len(lit) > 0 {
pdf.MultiCell(0, pdfLineHeight-1, tr(string(lit)), "", "", false)
}
pdf.SetFont("Arial", "", 12)
pdf.Ln(pdfBlockMargin)
case *ast.HTMLBlock:
lit := n.Literal
if lit == nil {
lit = n.Content
}
if len(lit) > 0 {
pdf.SetFont("Courier", "", 9)
pdf.MultiCell(0, pdfLineHeight-1, tr(string(lit)), "", "", false)
pdf.SetFont("Arial", "", 12)
}
pdf.Ln(pdfBlockMargin)
case *ast.Aside:
left, _, _, _ := pdf.GetMargins()
saveLeft := left
pdf.SetLeftMargin(saveLeft + 4)
pdf.SetX(saveLeft + 4)
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
renderBlock(pdf, tr, child)
}
pdf.SetLeftMargin(saveLeft)
pdf.Ln(pdfBlockMargin)
default:
// Unknown block: try to render as inline content (e.g. paragraph-like)
if ast.GetFirstChild(node) != nil {
writeInlineContent(pdf, tr, node)
pdf.Ln(pdfLineHeight + pdfBlockMargin)
}
}
}
const (
pdfTableLineHt = 7.0
pdfTableHeaderR = 72
pdfTableHeaderG = 72
pdfTableHeaderB = 72
pdfTableBorderR = 200
pdfTableBorderG = 200
pdfTableBorderB = 200
pdfTableStripR = 248
pdfTableStripG = 248
pdfTableStripB = 248
)
// renderTable draws a markdown table. Table contains TableHeader and TableBody, each with TableRows of TableCells.
func renderTable(pdf *gofpdf.Fpdf, tr func(string) string, table *ast.Table) {
left, _, right, _ := pdf.GetMargins()
pageW := 210.0
tblW := pageW - left - right
// Collect all rows: header rows first, then body (and footer if any)
var rows [][]string
var numCols int
for section := ast.GetFirstChild(table); section != nil; section = ast.GetNextNode(section) {
for rowNode := ast.GetFirstChild(section); rowNode != nil; rowNode = ast.GetNextNode(rowNode) {
row, ok := rowNode.(*ast.TableRow)
if !ok {
continue
}
var cells []string
for c := ast.GetFirstChild(row); c != nil; c = ast.GetNextNode(c) {
if cell, ok := c.(*ast.TableCell); ok {
cells = append(cells, tr(getCellText(cell)))
}
}
if len(cells) > 0 {
rows = append(rows, cells)
if len(cells) > numCols {
numCols = len(cells)
}
}
}
}
if numCols == 0 {
return
}
colW := tblW / float64(numCols)
lineHt := pdfTableLineHt
// Save current colors and set light gray borders for the table
saveDrawR, saveDrawG, saveDrawB := pdf.GetDrawColor()
saveFillR, saveFillG, saveFillB := pdf.GetFillColor()
saveTextR, saveTextG, saveTextB := pdf.GetTextColor()
pdf.SetDrawColor(pdfTableBorderR, pdfTableBorderG, pdfTableBorderB)
for i, row := range rows {
isHeader := i == 0
lastRow := i == len(rows) - 1
// Header: dark gray background, white text, bold
if isHeader {
pdf.SetFont("Arial", "B", 12)
pdf.SetFillColor(pdfTableHeaderR, pdfTableHeaderG, pdfTableHeaderB)
pdf.SetTextColor(255, 255, 255)
} else {
pdf.SetFont("Arial", "", 12)
pdf.SetTextColor(0, 0, 0)
if i%2 == 1 {
pdf.SetFillColor(pdfTableStripR, pdfTableStripG, pdfTableStripB)
} else {
pdf.SetFillColor(255, 255, 255)
}
}
border := "LTR"
if lastRow {
border = "LTRB"
}
fill := true
for j, cellText := range row {
w := colW
if j == numCols-1 {
w = 0
}
pdf.CellFormat(w, lineHt, cellText, border, 0, "L", fill, 0, "")
}
pdf.Ln(lineHt)
}
// Restore colors and font
pdf.SetDrawColor(saveDrawR, saveDrawG, saveDrawB)
pdf.SetFillColor(saveFillR, saveFillG, saveFillB)
pdf.SetTextColor(saveTextR, saveTextG, saveTextB)
pdf.SetFont("Arial", "", 12)
}
// getInlineText returns plain text from an inline container (e.g. Image alt text).
func getInlineText(node ast.Node) string {
var b []byte
for child := ast.GetFirstChild(node); child != nil; child = ast.GetNextNode(child) {
if leaf, ok := child.(*ast.Leaf); ok && len(leaf.Literal) > 0 {
b = append(b, leaf.Literal...)
} else if text, ok := child.(*ast.Text); ok {
lit := text.Literal
if lit == nil {
lit = text.Content
}
if len(lit) > 0 {
b = append(b, lit...)
}
} else {
b = append(b, getInlineText(child)...)
}
}
return string(b)
}
// getCellText returns plain text from a table cell (walks Paragraph/Text and Leaf nodes).
func getCellText(node ast.Node) string {
var b []byte
for child := ast.GetFirstChild(node); child != nil; child = ast.GetNextNode(child) {
if leaf, ok := child.(*ast.Leaf); ok && len(leaf.Literal) > 0 {
b = append(b, leaf.Literal...)
} else if text, ok := child.(*ast.Text); ok {
lit := text.Literal
if lit == nil {
lit = text.Content
}
if len(lit) > 0 {
b = append(b, lit...)
}
} else {
b = append(b, getCellText(child)...)
}
}
return string(b)
}
// writeInlineContent outputs inline content (text, strong, emph, code) with correct font changes.
func writeInlineContent(pdf *gofpdf.Fpdf, tr func(string) string, node ast.Node) {
lineHt := pdfLineHeight
left, _, right, _ := pdf.GetMargins()
pageW := 210.0 // A4 mm
maxW := pageW - left - right
for child := ast.GetFirstChild(node); child != nil; child = ast.GetNextNode(child) {
writeInline(pdf, tr, child, lineHt, maxW)
}
}
func writeInline(pdf *gofpdf.Fpdf, tr func(string) string, node ast.Node, lineHt, maxW float64) {
switch n := node.(type) {
case *ast.Text:
lit := n.Literal
if lit == nil {
lit = n.Content
}
if len(lit) > 0 {
cellWrap(pdf, tr(string(lit)), lineHt, maxW)
}
case *ast.Strong:
pdf.SetFont("Arial", "B", 12)
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
writeInline(pdf, tr, c, lineHt, maxW)
}
pdf.SetFont("Arial", "", 12)
case *ast.Emph:
pdf.SetFont("Arial", "I", 12)
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
writeInline(pdf, tr, c, lineHt, maxW)
}
pdf.SetFont("Arial", "", 12)
case *ast.Code:
lit := n.Literal
if lit == nil {
lit = n.Content
}
if len(lit) > 0 {
pdf.SetFont("Courier", "", 11)
cellWrap(pdf, tr(string(lit)), lineHt, maxW)
pdf.SetFont("Arial", "", 12)
}
case *ast.Link:
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
writeInline(pdf, tr, c, lineHt, maxW)
}
if len(n.Destination) > 0 {
pdf.SetFont("Arial", "I", 10)
cellWrap(pdf, tr(" ("+string(n.Destination)+")"), lineHt, maxW)
pdf.SetFont("Arial", "", 12)
}
case *ast.Image:
alt := getInlineText(n)
if alt != "" {
cellWrap(pdf, tr(alt), lineHt, maxW)
}
if len(n.Destination) > 0 {
pdf.SetFont("Arial", "I", 10)
cellWrap(pdf, tr(" [Image: "+string(n.Destination)+"]"), lineHt, maxW)
pdf.SetFont("Arial", "", 12)
}
case *ast.Del:
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
writeInline(pdf, tr, c, lineHt, maxW)
}
case *ast.Subscript:
pdf.SetFont("Arial", "", 9)
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
writeInline(pdf, tr, c, lineHt, maxW)
}
pdf.SetFont("Arial", "", 12)
case *ast.Superscript:
pdf.SetFont("Arial", "", 9)
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
writeInline(pdf, tr, c, lineHt, maxW)
}
pdf.SetFont("Arial", "", 12)
case *ast.Math:
lit := n.Literal
if lit == nil {
lit = n.Content
}
if len(lit) > 0 {
pdf.SetFont("Courier", "", 10)
cellWrap(pdf, tr(string(lit)), lineHt, maxW)
pdf.SetFont("Arial", "", 12)
}
case *ast.Hardbreak:
pdf.Ln(lineHt)
case *ast.Softbreak:
pdf.Ln(lineHt)
default:
if leaf, ok := node.(*ast.Leaf); ok && len(leaf.Literal) > 0 {
cellWrap(pdf, tr(string(leaf.Literal)), lineHt, maxW)
}
}
}
// cellWrap outputs text with word-wrap: splits on spaces and starts a new line when the next word would overflow.
func cellWrap(pdf *gofpdf.Fpdf, s string, lineHt, maxW float64) {
left, _, _, _ := pdf.GetMargins()
words := strings.Fields(s)
for i, word := range words {
wordW := pdf.GetStringWidth(word)
spaceW := 0.0
if i > 0 {
spaceW = pdf.GetStringWidth(" ")
}
x := pdf.GetX()
// If this word (and preceding space) would overflow, start a new line first.
if i > 0 {
if x+spaceW+wordW > maxW && x > left {
pdf.Ln(lineHt)
x = pdf.GetX()
} else {
pdf.CellFormat(spaceW, lineHt, " ", "", 0, "", false, 0, "")
x = pdf.GetX()
}
} else if wordW > 0 && x+wordW > maxW && x > left {
pdf.Ln(lineHt)
x = pdf.GetX()
}
// Single word longer than line width: use MultiCell so it wraps.
if wordW > maxW-left {
pdf.MultiCell(0, lineHt, word, "", "", false)
} else {
if x+wordW > maxW && x > left {
pdf.Ln(lineHt)
}
pdf.CellFormat(wordW, lineHt, word, "", 0, "", false, 0, "")
}
}
}
+212
View File
@@ -0,0 +1,212 @@
package actions_test
import (
"context"
"os"
"path/filepath"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/services/actions"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GenPDFAction", func() {
var (
tmpDir string
action *actions.GenPDFAction
ctx context.Context
sharedState *types.AgentSharedState
)
BeforeEach(func() {
var err error
tmpDir, err = os.MkdirTemp("", "genpdf_test_*")
Expect(err).ToNot(HaveOccurred())
action = actions.NewGenPDF(map[string]string{
"outputDir": tmpDir,
})
ctx = context.Background()
sharedState = &types.AgentSharedState{}
})
AfterEach(func() {
os.RemoveAll(tmpDir)
})
It("generates PDF with title and content", func() {
result, err := action.Run(ctx, sharedState, types.ActionParams{
"title": "Test Document",
"content": "This is test content for the PDF.",
})
Expect(err).ToNot(HaveOccurred())
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
Expect(result.Metadata).To(HaveKey(actions.MetadataPDFs))
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(paths).To(HaveLen(1))
Expect(paths[0]).To(BeAnExistingFile())
})
It("requires content parameter", func() {
_, err := action.Run(ctx, sharedState, types.ActionParams{
"title": "Test",
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("content is required"))
})
It("uses custom filename when provided", func() {
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": "Test content",
"filename": "custom_name",
})
Expect(err).ToNot(HaveOccurred())
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(filepath.Base(paths[0])).To(Equal("custom_name.pdf"))
})
It("generates PDF with content only (no title)", func() {
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": "Just some content without a title.",
})
Expect(err).ToNot(HaveOccurred())
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(paths).To(HaveLen(1))
Expect(paths[0]).To(BeAnExistingFile())
})
It("automatically adds .pdf extension if missing", func() {
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": "Test content",
"filename": "my_document",
})
Expect(err).ToNot(HaveOccurred())
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(filepath.Base(paths[0])).To(Equal("my_document.pdf"))
})
It("does not double-add .pdf extension", func() {
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": "Test content",
"filename": "document.pdf",
})
Expect(err).ToNot(HaveOccurred())
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(filepath.Base(paths[0])).To(Equal("document.pdf"))
})
It("requires outputDir to be configured", func() {
actionNoDir := actions.NewGenPDF(map[string]string{})
_, err := actionNoDir.Run(ctx, sharedState, types.ActionParams{
"content": "Test content",
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("outputDir is required"))
})
It("cleans output directory on start if cleanOnStart is enabled", func() {
// Create a test file in the directory
testFile := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("test"), 0644)
Expect(err).ToNot(HaveOccurred())
Expect(testFile).To(BeAnExistingFile())
// Create a new action with cleanOnStart enabled
_ = actions.NewGenPDF(map[string]string{
"outputDir": tmpDir,
"cleanOnStart": "true",
})
// The test file should be deleted
Expect(testFile).ToNot(BeAnExistingFile())
})
It("does not clean output directory if cleanOnStart is disabled", func() {
// Create a test file in the directory
testFile := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("test"), 0644)
Expect(err).ToNot(HaveOccurred())
Expect(testFile).To(BeAnExistingFile())
// Create a new action with cleanOnStart disabled (default)
_ = actions.NewGenPDF(map[string]string{
"outputDir": tmpDir,
})
// The test file should still exist
Expect(testFile).To(BeAnExistingFile())
})
It("prevents path traversal in filename", func() {
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": "Test content",
"filename": "../../../etc/passwd",
})
Expect(err).ToNot(HaveOccurred())
paths := result.Metadata[actions.MetadataPDFs].([]string)
// Should only use the base filename, not the path
Expect(filepath.Base(paths[0])).To(Equal("passwd.pdf"))
// Should be in the tmpDir, not in /etc
Expect(filepath.Dir(paths[0])).To(Equal(tmpDir))
})
It("generates PDF with markdown content and renders structure", func() {
content := "# Section\n\n**Bold** and *italic* and `code`.\n\n- Item one\n- Item two"
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": content,
})
Expect(err).ToNot(HaveOccurred())
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(paths).To(HaveLen(1))
Expect(paths[0]).To(BeAnExistingFile())
info, err := os.Stat(paths[0])
Expect(err).ToNot(HaveOccurred())
Expect(info.Size()).To(BeNumerically(">", 0))
})
It("generates PDF with special characters", func() {
content := "Café, \"quotes\", 23"
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": content,
})
Expect(err).ToNot(HaveOccurred())
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(paths).To(HaveLen(1))
Expect(paths[0]).To(BeAnExistingFile())
info, err := os.Stat(paths[0])
Expect(err).ToNot(HaveOccurred())
Expect(info.Size()).To(BeNumerically(">", 0))
})
It("generates PDF with markdown table", func() {
content := "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |"
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": content,
})
Expect(err).ToNot(HaveOccurred())
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(paths).To(HaveLen(1))
Expect(paths[0]).To(BeAnExistingFile())
info, err := os.Stat(paths[0])
Expect(err).ToNot(HaveOccurred())
Expect(info.Size()).To(BeNumerically(">", 0))
})
})
+302
View File
@@ -0,0 +1,302 @@
package actions
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/dhowden/tag"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/sashabaranov/go-openai/jsonschema"
)
const (
MetadataSongs = "songs_paths"
)
// audioExtensionFromContentType returns a file extension for common audio MIME types.
// It strips parameters (e.g. "audio/flac; rate=44100" -> "flac").
func audioExtensionFromContentType(contentType string) string {
mediaType, _, _ := strings.Cut(strings.TrimSpace(contentType), ";")
mediaType = strings.TrimSpace(strings.ToLower(mediaType))
switch mediaType {
case "audio/flac":
return "flac"
case "audio/mpeg", "audio/mp3":
return "mp3"
case "audio/wav", "audio/wave", "audio/x-wav":
return "wav"
case "audio/ogg":
return "ogg"
case "audio/webm":
return "webm"
default:
return ""
}
}
// audioExtensionFromTag uses github.com/dhowden/tag to identify format from audio bytes.
// Identify works on raw audio (e.g. FLAC without vorbis comments) and returns FileType.
func audioExtensionFromTag(data []byte) string {
if len(data) < 11 {
return ""
}
r := bytes.NewReader(data)
_, fileType, err := tag.Identify(r)
if err != nil || fileType == tag.UnknownFileType {
return ""
}
switch fileType {
case tag.FLAC:
return "flac"
case tag.MP3:
return "mp3"
case tag.OGG:
return "ogg"
case tag.M4A:
return "m4a"
case tag.M4B:
return "m4b"
case tag.M4P:
return "m4p"
case tag.ALAC:
return "m4a"
case tag.DSF:
return "dsf"
default:
return ""
}
}
// soundRequest matches LocalAI /sound endpoint (ACE-Step advanced mode) request body.
// See: https://localai.io/features/text-to-audio/
type soundRequest struct {
Model string `json:"model"`
Caption string `json:"caption"`
Lyrics string `json:"lyrics,omitempty"`
BPM *int `json:"bpm,omitempty"`
Keyscale string `json:"keyscale,omitempty"`
Language string `json:"language,omitempty"`
DurationSeconds *float64 `json:"duration_seconds,omitempty"`
}
func NewGenSong(config map[string]string) *GenSongAction {
model := config["model"]
if model == "" {
model = "ace-step-turbo"
}
a := &GenSongAction{
apiURL: strings.TrimSuffix(config["apiURL"], "/"),
apiKey: config["apiKey"],
outputDir: config["outputDir"],
model: model,
cleanOnStart: config["cleanOnStart"] == "true" || config["cleanOnStart"] == "1",
}
if a.outputDir != "" {
if err := os.MkdirAll(a.outputDir, 0755); err != nil {
// log but continue; Run will fail with a clear error when saving
_ = err
}
if a.cleanOnStart {
entries, err := os.ReadDir(a.outputDir)
if err == nil {
for _, e := range entries {
_ = os.Remove(filepath.Join(a.outputDir, e.Name()))
}
}
}
}
return a
}
type GenSongAction struct {
apiURL string
apiKey string
outputDir string
model string
cleanOnStart bool
}
func (a *GenSongAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Caption string `json:"caption"`
Lyrics string `json:"lyrics"`
BPM *int `json:"bpm"`
Keyscale string `json:"keyscale"`
Language string `json:"language"`
Duration *float64 `json:"duration_seconds"`
Model string `json:"model"`
}{}
if err := params.Unmarshal(&result); err != nil {
return types.ActionResult{}, err
}
if result.Caption == "" {
return types.ActionResult{}, fmt.Errorf("caption is required")
}
if a.outputDir == "" {
return types.ActionResult{}, fmt.Errorf("outputDir is required for generate_song (configure the action with an output directory)")
}
reqBody := soundRequest{
Model: a.model,
Caption: result.Caption,
Lyrics: result.Lyrics,
Keyscale: result.Keyscale,
Language: result.Language,
DurationSeconds: result.Duration,
BPM: result.BPM,
}
body, err := json.Marshal(reqBody)
if err != nil {
return types.ActionResult{}, err
}
url := a.apiURL + "/v1/sound-generation"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return types.ActionResult{}, err
}
req.Header.Set("Content-Type", "application/json")
if a.apiKey != "" {
req.Header.Set("xi-api-key", a.apiKey)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return types.ActionResult{Result: "Failed to generate song: " + err.Error()}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(resp.Body)
return types.ActionResult{}, fmt.Errorf("sound endpoint failed: %s: %s", resp.Status, string(msg))
}
audioBytes, err := io.ReadAll(resp.Body)
if err != nil {
return types.ActionResult{}, err
}
if len(audioBytes) == 0 {
return types.ActionResult{}, fmt.Errorf("no audio data returned")
}
ext := audioExtensionFromContentType(resp.Header.Get("Content-Type"))
if ext == "" {
ext = audioExtensionFromTag(audioBytes)
}
if ext == "" {
ext = "flac" // default when unknown (e.g. ACE-Step)
}
filename := fmt.Sprintf("song_%d.%s", time.Now().UnixNano(), ext)
savedPath := filepath.Join(a.outputDir, filename)
if err := os.WriteFile(savedPath, audioBytes, 0644); err != nil {
return types.ActionResult{}, fmt.Errorf("failed to save song: %w", err)
}
return types.ActionResult{
Result: fmt.Sprintf("The song was generated and saved to: %s", savedPath),
Metadata: map[string]interface{}{
MetadataSongs: []string{savedPath},
},
}, nil
}
func (a *GenSongAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: "generate_song",
Description: "Generate a song or music track using LocalAI /sound endpoint (ACE-Step advanced mode). Uses caption, optional lyrics, BPM, key scale, language and duration. The file is saved locally and can be sent to the user by connectors.",
Properties: map[string]jsonschema.Definition{
"caption": {
Type: jsonschema.String,
Description: "Description of the song or music to generate (e.g. 'A funky Japanese disco track').",
},
"lyrics": {
Type: jsonschema.String,
Description: "Lyrics or structure (e.g. '[Verse 1]\\n...').",
},
"bpm": {
Type: jsonschema.Integer,
Description: "Beats per minute (e.g. 120). Optional.",
},
"keyscale": {
Type: jsonschema.String,
Description: "Key and scale (e.g. 'Ab major'). Optional.",
},
"language": {
Type: jsonschema.String,
Description: "Language code for vocals (e.g. 'ja', 'en'). Optional.",
},
"duration_seconds": {
Type: jsonschema.Number,
Description: "Duration of the generated audio in seconds (e.g. 225). Optional.",
},
"model": {
Type: jsonschema.String,
Description: "Model name (e.g. ace-step-turbo). Optional; uses action config default if omitted.",
},
},
Required: []string{"caption"},
}
}
func (a *GenSongAction) Plannable() bool {
return true
}
// GenSongConfigMeta returns the metadata for GenSong action configuration fields.
func GenSongConfigMeta() []config.Field {
return []config.Field{
{
Name: "apiURL",
Label: "API URL",
Type: config.FieldTypeText,
Required: true,
DefaultValue: "http://localhost:8080",
HelpText: "LocalAI base URL (e.g. http://localhost:8080) for /sound endpoint",
},
{
Name: "model",
Label: "Model",
Type: config.FieldTypeText,
Required: false,
DefaultValue: "ace-step-turbo",
HelpText: "Default model for sound generation (e.g. ace-step-turbo)",
},
{
Name: "apiKey",
Label: "API Key",
Type: config.FieldTypeText,
Required: false,
HelpText: "Optional API key if the endpoint requires authentication",
},
{
Name: "outputDir",
Label: "Output directory",
Type: config.FieldTypeText,
Required: true,
HelpText: "Directory where generated song files are saved (required for connectors to send files)",
},
{
Name: "cleanOnStart",
Label: "Clean output directory on start",
Type: config.FieldTypeCheckbox,
DefaultValue: false,
HelpText: "If enabled, clear the output directory when the action is loaded",
},
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
@@ -9,7 +9,7 @@ import (
"github.com/google/go-github/v69/github"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
+284 -103
View File
@@ -2,22 +2,29 @@ package actions
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"io"
"github.com/blevesearch/bleve/v2"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/sashabaranov/go-openai/jsonschema"
)
// Remove global const and mutex, and add them as fields to a struct
// indexCache avoids opening the same Bleve index path multiple times, which would
// deadlock (Bleve uses file locks; a second Open() on the same path blocks).
var (
indexCache = map[string]bleve.Index{}
indexCacheMu sync.Mutex
)
type MemoryActions struct {
filePath string
index bleve.Index
indexPath string
customName string
customDescription string
}
@@ -25,138 +32,269 @@ type MemoryActions struct {
type AddToMemoryAction struct{ *MemoryActions }
type ListMemoryAction struct{ *MemoryActions }
type RemoveFromMemoryAction struct{ *MemoryActions }
type SearchMemoryAction struct{ *MemoryActions }
// NewMemoryActions returns the three actions, using the provided filePath and config
func NewMemoryActions(filePath string, config map[string]string) (*AddToMemoryAction, *ListMemoryAction, *RemoveFromMemoryAction) {
ma := &MemoryActions{filePath: filePath}
// MemoryEntry matches the MCP memory structure (Bleve-backed).
type MemoryEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
// NewMemoryActions returns the four memory actions (Add, List, Remove, Search) using a Bleve index at indexPath.
func NewMemoryActions(indexPath string, config map[string]string) (*AddToMemoryAction, *ListMemoryAction, *RemoveFromMemoryAction, *SearchMemoryAction) {
ma := &MemoryActions{indexPath: indexPath}
if config != nil {
ma.customName = config["custom_name"]
ma.customDescription = config["custom_description"]
}
return &AddToMemoryAction{ma}, &ListMemoryAction{ma}, &RemoveFromMemoryAction{ma}
}
type addToMemoryParams struct {
Item string `json:"item"`
}
type removeFromMemoryParams struct {
Index *int `json:"index,omitempty"`
Value string `json:"value,omitempty"`
}
func (m *MemoryActions) readMemory() ([]string, error) {
f, err := os.Open(m.filePath)
idx, err := openOrCreateBleveIndex(indexPath)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, err
// Allow lazy init: index will be nil and operations will return this error
ma.index = nil
} else {
ma.index = idx
}
defer f.Close()
var items []string
if err := json.NewDecoder(f).Decode(&items); err != nil {
if err == io.EOF {
return []string{}, nil
}
return nil, err
}
return items, nil
return &AddToMemoryAction{ma}, &ListMemoryAction{ma}, &RemoveFromMemoryAction{ma}, &SearchMemoryAction{ma}
}
func (m *MemoryActions) writeMemory(items []string) error {
f, err := os.Create(m.filePath)
func openOrCreateBleveIndex(indexPath string) (bleve.Index, error) {
indexCacheMu.Lock()
if idx, ok := indexCache[indexPath]; ok {
indexCacheMu.Unlock()
return idx, nil
}
indexCacheMu.Unlock()
var idx bleve.Index
var err error
if _, statErr := os.Stat(indexPath); statErr == nil {
idx, err = bleve.Open(indexPath)
} else {
os.MkdirAll(filepath.Dir(indexPath), 0755)
mapping := bleve.NewIndexMapping()
entryMapping := bleve.NewDocumentMapping()
nameFieldMapping := bleve.NewTextFieldMapping()
nameFieldMapping.Analyzer = "standard"
nameFieldMapping.Store = true
entryMapping.AddFieldMappingsAt("name", nameFieldMapping)
contentFieldMapping := bleve.NewTextFieldMapping()
contentFieldMapping.Analyzer = "standard"
contentFieldMapping.Store = true
entryMapping.AddFieldMappingsAt("content", contentFieldMapping)
dateFieldMapping := bleve.NewDateTimeFieldMapping()
dateFieldMapping.Store = true
entryMapping.AddFieldMappingsAt("created_at", dateFieldMapping)
mapping.AddDocumentMapping("_default", entryMapping)
idx, err = bleve.New(indexPath, mapping)
}
if err != nil {
return nil, err
}
indexCacheMu.Lock()
indexCache[indexPath] = idx
indexCacheMu.Unlock()
return idx, nil
}
func (m *MemoryActions) ensureIndex() error {
if m.index != nil {
return nil
}
idx, err := openOrCreateBleveIndex(m.indexPath)
if err != nil {
return err
}
defer f.Close()
return json.NewEncoder(f).Encode(items)
m.index = idx
return nil
}
func generateID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
type addToMemoryParams struct {
Name string `json:"name"`
Content string `json:"content"`
}
type removeFromMemoryParams struct {
ID string `json:"id"`
}
type searchMemoryParams struct {
Query string `json:"query"`
}
func (a *AddToMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if err := a.ensureIndex(); err != nil {
return types.ActionResult{}, err
}
var req addToMemoryParams
if err := params.Unmarshal(&req); err != nil {
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
}
if req.Item == "" {
return types.ActionResult{}, fmt.Errorf("item cannot be empty")
if req.Name == "" && req.Content == "" {
return types.ActionResult{}, fmt.Errorf("name or content cannot both be empty")
}
items, err := a.readMemory()
if err != nil {
return types.ActionResult{}, err
entry := MemoryEntry{
ID: generateID(),
Name: req.Name,
Content: req.Content,
CreatedAt: time.Now(),
}
items = append(items, req.Item)
if err := a.writeMemory(items); err != nil {
return types.ActionResult{}, err
if err := a.index.Index(entry.ID, entry); err != nil {
return types.ActionResult{}, fmt.Errorf("failed to index memory entry: %w", err)
}
return types.ActionResult{
Result: fmt.Sprintf("Added item to memory: %s", req.Item),
Metadata: map[string]any{"item": req.Item, "count": len(items)},
Result: fmt.Sprintf("Added memory entry: id=%s name=%q", entry.ID, entry.Name),
Metadata: map[string]any{"id": entry.ID, "name": entry.Name, "content": entry.Content, "created_at": entry.CreatedAt},
}, nil
}
func (a *ListMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
items, err := a.readMemory()
if err != nil {
if err := a.ensureIndex(); err != nil {
return types.ActionResult{}, err
}
query := bleve.NewMatchAllQuery()
searchRequest := bleve.NewSearchRequest(query)
searchRequest.Size = 10000
searchRequest.Fields = []string{"name", "created_at"}
searchRequest.SortBy([]string{"-created_at"})
outputResult := "Number of items in memory: " + strconv.Itoa(len(items)) + "\n"
for i, item := range items {
outputResult += fmt.Sprintf("%d) %s\n", i, item)
searchResult, err := a.index.Search(searchRequest)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to search index: %w", err)
}
type listEntry struct {
Name string
CreatedAt time.Time
}
entries := make([]listEntry, 0, len(searchResult.Hits))
for _, hit := range searchResult.Hits {
e := listEntry{}
if v, ok := hit.Fields["name"].(string); ok {
e.Name = v
}
if v, ok := hit.Fields["created_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, v); err == nil {
e.CreatedAt = t
}
} else if v, ok := hit.Fields["created_at"].(time.Time); ok {
e.CreatedAt = v
}
entries = append(entries, e)
}
outputResult := "Number of items in memory: " + strconv.Itoa(len(entries)) + "\n"
for i, e := range entries {
createdStr := e.CreatedAt.Format(time.RFC3339)
outputResult += fmt.Sprintf("%d) %s (created_at: %s)\n", i, e.Name, createdStr)
}
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.Name
}
return types.ActionResult{
Result: outputResult,
Metadata: map[string]any{"items": items},
Metadata: map[string]any{"names": names, "entries": entries, "count": len(entries)},
}, nil
}
func (a *RemoveFromMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if err := a.ensureIndex(); err != nil {
return types.ActionResult{}, err
}
var req removeFromMemoryParams
if err := params.Unmarshal(&req); err != nil {
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
}
items, err := a.readMemory()
if req.ID == "" {
return types.ActionResult{}, fmt.Errorf("id is required to remove a memory entry")
}
doc, err := a.index.Document(req.ID)
if err != nil {
return types.ActionResult{}, err
return types.ActionResult{}, fmt.Errorf("failed to check document: %w", err)
}
var removed string
if req.Index != nil {
idx := *req.Index
if idx < 0 || idx >= len(items) {
return types.ActionResult{}, fmt.Errorf("index out of range")
}
removed = items[idx]
items = append(items[:idx], items[idx+1:]...)
} else if req.Value != "" {
found := false
for i, v := range items {
if v == req.Value {
removed = v
items = append(items[:i], items[i+1:]...)
found = true
break
}
}
if !found {
return types.ActionResult{}, fmt.Errorf("value not found in memory")
}
} else {
return types.ActionResult{}, fmt.Errorf("must provide index or value to remove")
if doc == nil {
return types.ActionResult{}, fmt.Errorf("memory entry with ID %q not found", req.ID)
}
if err := a.writeMemory(items); err != nil {
return types.ActionResult{}, err
if err := a.index.Delete(req.ID); err != nil {
return types.ActionResult{}, fmt.Errorf("failed to delete memory entry: %w", err)
}
return types.ActionResult{
Result: fmt.Sprintf("Removed item from memory: %s", removed),
Metadata: map[string]any{"removed": removed, "count": len(items)},
Result: fmt.Sprintf("Removed memory entry with ID %q", req.ID),
Metadata: map[string]any{"removed_id": req.ID},
}, nil
}
func (a *SearchMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if err := a.ensureIndex(); err != nil {
return types.ActionResult{}, err
}
var req searchMemoryParams
if err := params.Unmarshal(&req); err != nil {
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
}
if req.Query == "" {
return types.ActionResult{}, fmt.Errorf("query cannot be empty")
}
nameQuery := bleve.NewMatchQuery(req.Query)
nameQuery.SetField("name")
contentQuery := bleve.NewMatchQuery(req.Query)
contentQuery.SetField("content")
disjunctionQuery := bleve.NewDisjunctionQuery(nameQuery, contentQuery)
searchRequest := bleve.NewSearchRequest(disjunctionQuery)
searchRequest.Size = 100
searchRequest.Fields = []string{"name", "content", "created_at"}
searchResult, err := a.index.Search(searchRequest)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to search index: %w", err)
}
results := make([]MemoryEntry, 0, len(searchResult.Hits))
for _, hit := range searchResult.Hits {
e := MemoryEntry{ID: hit.ID}
if v, ok := hit.Fields["name"].(string); ok {
e.Name = v
}
if v, ok := hit.Fields["content"].(string); ok {
e.Content = v
}
if v, ok := hit.Fields["created_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, v); err == nil {
e.CreatedAt = t
}
} else if v, ok := hit.Fields["created_at"].(time.Time); ok {
e.CreatedAt = v
}
results = append(results, e)
}
outputResult := fmt.Sprintf("Query: %q — %d result(s)\n", req.Query, len(results))
for i, e := range results {
outputResult += fmt.Sprintf("%d) [%s] %s — %s\n", i, e.ID, e.Name, e.Content)
}
return types.ActionResult{
Result: outputResult,
Metadata: map[string]any{"query": req.Query, "results": results, "count": len(results)},
}, nil
}
func (a *AddToMemoryAction) Definition() types.ActionDefinition {
name := "add_to_memory"
description := "Add a string item to memory (stored in a JSON file)."
description := "Add a new entry to memory storage (name and/or content). Stored in a Bleve index."
if a.customName != "" {
name = a.customName
}
@@ -167,18 +305,22 @@ func (a *AddToMemoryAction) Definition() types.ActionDefinition {
Name: types.ActionDefinitionName(name),
Description: description,
Properties: map[string]jsonschema.Definition{
"item": {
"name": {
Type: jsonschema.String,
Description: "The string item to add to memory.",
Description: "The name/title of the memory entry.",
},
"content": {
Type: jsonschema.String,
Description: "The content to store in memory.",
},
},
Required: []string{"item"},
Required: []string{},
}
}
func (a *ListMemoryAction) Definition() types.ActionDefinition {
name := "list_memory"
description := "List all items currently stored in memory."
description := "List all memory entry names."
if a.customName != "" {
name = a.customName
}
@@ -195,7 +337,7 @@ func (a *ListMemoryAction) Definition() types.ActionDefinition {
func (a *RemoveFromMemoryAction) Definition() types.ActionDefinition {
name := "remove_from_memory"
description := "Remove an item from memory by index or value."
description := "Remove a memory entry by ID."
if a.customName != "" {
name = a.customName
}
@@ -206,22 +348,41 @@ func (a *RemoveFromMemoryAction) Definition() types.ActionDefinition {
Name: types.ActionDefinitionName(name),
Description: description,
Properties: map[string]jsonschema.Definition{
"index": {
Type: jsonschema.Integer,
Description: "The index of the item to remove (optional, 0-based)",
},
"value": {
"id": {
Type: jsonschema.String,
Description: "The value of the item to remove (optional)",
Description: "The ID of the memory entry to remove.",
},
},
Required: []string{},
Required: []string{"id"},
}
}
func (a *AddToMemoryAction) Plannable() bool { return true }
func (a *ListMemoryAction) Plannable() bool { return true }
func (a *RemoveFromMemoryAction) Plannable() bool { return true }
func (a *SearchMemoryAction) Definition() types.ActionDefinition {
name := "search_memory"
description := "Search memory entries by name and content using full-text search."
if a.customName != "" {
name = a.customName
}
if a.customDescription != "" {
description = a.customDescription
}
return types.ActionDefinition{
Name: types.ActionDefinitionName(name),
Description: description,
Properties: map[string]jsonschema.Definition{
"query": {
Type: jsonschema.String,
Description: "The search query to find matching memory entries.",
},
},
Required: []string{"query"},
}
}
func (a *AddToMemoryAction) Plannable() bool { return true }
func (a *ListMemoryAction) Plannable() bool { return true }
func (a *RemoveFromMemoryAction) Plannable() bool { return true }
func (a *SearchMemoryAction) Plannable() bool { return true }
// AddToMemoryConfigMeta returns the metadata for AddToMemory action configuration fields
func AddToMemoryConfigMeta() []config.Field {
@@ -238,7 +399,7 @@ func AddToMemoryConfigMeta() []config.Field {
Label: "Custom Description",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom description for the action (optional, defaults to 'Add a string item to memory (stored in a JSON file).')",
HelpText: "Custom description for the action (optional)",
},
}
}
@@ -258,7 +419,7 @@ func ListMemoryConfigMeta() []config.Field {
Label: "Custom Description",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom description for the action (optional, defaults to 'List all items currently stored in memory.')",
HelpText: "Custom description for the action (optional)",
},
}
}
@@ -278,7 +439,27 @@ func RemoveFromMemoryConfigMeta() []config.Field {
Label: "Custom Description",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom description for the action (optional, defaults to 'Remove an item from memory by index or value.')",
HelpText: "Custom description for the action (optional)",
},
}
}
// SearchMemoryConfigMeta returns the metadata for SearchMemory action configuration fields
func SearchMemoryConfigMeta() []config.Field {
return []config.Field{
{
Name: "custom_name",
Label: "Custom Name",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom name for the action (optional, defaults to 'search_memory')",
},
{
Name: "custom_description",
Label: "Custom Description",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom description for the action (optional)",
},
}
}
+51 -43
View File
@@ -3,6 +3,7 @@ package actions_test
import (
"context"
"os"
"path/filepath"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/services/actions"
@@ -12,72 +13,79 @@ import (
var _ = Describe("MemoryActions", func() {
var (
tmpFile string
tmpDir string
indexPath string
aAdd *actions.AddToMemoryAction
aList *actions.ListMemoryAction
aRemove *actions.RemoveFromMemoryAction
aSearch *actions.SearchMemoryAction
)
BeforeEach(func() {
f, err := os.CreateTemp("", "memory_test_*.json")
var err error
tmpDir, err = os.MkdirTemp("", "memory_test_*")
Expect(err).ToNot(HaveOccurred())
tmpFile = f.Name()
f.Close()
aAdd, aList, aRemove = actions.NewMemoryActions(tmpFile, map[string]string{})
indexPath = filepath.Join(tmpDir, "memory.bleve")
aAdd, aList, aRemove, aSearch = actions.NewMemoryActions(indexPath, map[string]string{})
})
AfterEach(func() {
os.Remove(tmpFile)
os.RemoveAll(tmpDir)
})
It("adds and lists items", func() {
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
It("adds and lists entries by name", func() {
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
Expect(err).ToNot(HaveOccurred())
_, err = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "bar"})
_, err = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "baz", "content": "qux"})
Expect(err).ToNot(HaveOccurred())
res, err := aList.Run(context.TODO(), nil, types.ActionParams{})
Expect(err).ToNot(HaveOccurred())
Expect(res.Metadata["items"]).To(ContainElements("foo", "bar"))
Expect(res.Metadata["names"]).To(ContainElements("foo", "baz"))
Expect(res.Metadata["count"]).To(Equal(2))
})
It("removes by index", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "bar"})
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"index": 0})
It("removes by id", func() {
addRes, _ := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
id, ok := addRes.Metadata["id"].(string)
Expect(ok).To(BeTrue())
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "baz", "content": "qux"})
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"id": id})
Expect(err).ToNot(HaveOccurred())
res, _ := aList.Run(context.TODO(), nil, types.ActionParams{})
Expect(res.Metadata["items"]).To(ConsistOf("bar"))
Expect(res.Metadata["names"]).To(ConsistOf("baz"))
})
It("removes by value", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "bar"})
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"value": "bar"})
Expect(err).ToNot(HaveOccurred())
res, _ := aList.Run(context.TODO(), nil, types.ActionParams{})
Expect(res.Metadata["items"]).To(ConsistOf("foo"))
})
It("returns error for out of range index", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"index": 2})
Expect(err).To(HaveOccurred())
})
It("returns error for value not found", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"value": "bar"})
Expect(err).To(HaveOccurred())
})
It("returns error for empty item", func() {
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"item": ""})
Expect(err).To(HaveOccurred())
})
It("returns error if neither index nor value provided", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
It("returns error for missing id on remove", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{})
Expect(err).To(HaveOccurred())
})
It("returns error for unknown id on remove", func() {
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"id": "nonexistent"})
Expect(err).To(HaveOccurred())
})
It("returns error for empty name and content on add", func() {
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "", "content": ""})
Expect(err).To(HaveOccurred())
})
It("search returns matching entries", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "meeting", "content": "discussed project X"})
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "lunch", "content": "ate pizza"})
res, err := aSearch.Run(context.TODO(), nil, types.ActionParams{"query": "project"})
Expect(err).ToNot(HaveOccurred())
Expect(res.Metadata["count"]).To(Equal(1))
results, ok := res.Metadata["results"].([]actions.MemoryEntry)
Expect(ok).To(BeTrue())
Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("meeting"))
Expect(results[0].Content).To(Equal("discussed project X"))
})
It("search returns error for empty query", func() {
_, err := aSearch.Run(context.TODO(), nil, types.ActionParams{"query": ""})
Expect(err).To(HaveOccurred())
})
})
+199
View File
@@ -0,0 +1,199 @@
package actions
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/sashabaranov/go-openai/jsonschema"
)
func NewPiKVMAction(config map[string]string) *PiKVMAction {
return &PiKVMAction{
hostname: config["hostname"],
username: config["username"],
password: config["password"],
customName: config["custom_name"],
customDescription: config["custom_description"],
insecure: config["insecure"] == "true",
}
}
type PiKVMAction struct {
hostname string
username string
password string
customName string
customDescription string
insecure bool
}
type pikvmPowerParams struct {
Action string `json:"action"`
}
func (a *PiKVMAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
var req pikvmPowerParams
if err := params.Unmarshal(&req); err != nil {
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
}
// Validate action parameter
validActions := map[string]bool{
"on": true,
"off": true,
"off_hard": true,
"reset_hard": true,
}
if !validActions[req.Action] {
return types.ActionResult{}, fmt.Errorf("invalid action: %s. Valid actions are: on, off, off_hard, reset_hard", req.Action)
}
// Check if required config is provided
if a.hostname == "" {
return types.ActionResult{}, fmt.Errorf("hostname is required in action configuration")
}
if a.username == "" {
return types.ActionResult{}, fmt.Errorf("username is required in action configuration")
}
if a.password == "" {
return types.ActionResult{}, fmt.Errorf("password is required in action configuration")
}
// Build the API URL
apiURL := fmt.Sprintf("https://%s/api/atx/power", a.hostname)
insecure := false
if a.insecure {
insecure = true
}
// Create HTTP client with basic auth
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
}
client := &http.Client{Transport: tr}
reqHTTP, err := http.NewRequestWithContext(ctx, "POST", apiURL, nil)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to create HTTP request: %w", err)
}
// Set basic authentication
reqHTTP.SetBasicAuth(a.username, a.password)
// Add query parameters
q := reqHTTP.URL.Query()
q.Add("action", req.Action)
reqHTTP.URL.RawQuery = q.Encode()
// Make the request
resp, err := client.Do(reqHTTP)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to make HTTP request to PiKVM: %w", err)
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK {
return types.ActionResult{}, fmt.Errorf("PiKVM API returned status %d: %s", resp.StatusCode, resp.Status)
}
// Determine action description for user-friendly response
actionDesc := map[string]string{
"on": "power on",
"off": "power off",
"off_hard": "hard power off",
"reset_hard": "hard reset",
}
result := fmt.Sprintf("Successfully sent %s command to PiKVM at %s", actionDesc[req.Action], a.hostname)
return types.ActionResult{
Result: result,
Metadata: map[string]any{
"action": req.Action,
"hostname": a.hostname,
"status": "success",
},
}, nil
}
func (a *PiKVMAction) Definition() types.ActionDefinition {
name := "pikvm_power_control"
description := "Control power state of a PiKVM device using ATX power management."
if a.customName != "" {
name = a.customName
}
if a.customDescription != "" {
description = a.customDescription
}
return types.ActionDefinition{
Name: types.ActionDefinitionName(name),
Description: description,
Properties: map[string]jsonschema.Definition{
"action": {
Type: jsonschema.String,
Description: "The power action to perform on the PiKVM device.",
Enum: []string{"on", "off", "off_hard", "reset_hard"},
},
},
Required: []string{"action"},
}
}
func (a *PiKVMAction) Plannable() bool {
return true
}
// PiKVMConfigMeta returns the metadata for PiKVM action configuration fields
func PiKVMConfigMeta() []config.Field {
return []config.Field{
{
Name: "hostname",
Label: "PiKVM Hostname",
Type: config.FieldTypeText,
Required: true,
HelpText: "The hostname or IP address of the PiKVM device (e.g., pikvm.local or 192.168.1.100)",
},
{
Name: "username",
Label: "Username",
Type: config.FieldTypeText,
Required: true,
HelpText: "Username for PiKVM authentication (usually 'admin')",
},
{
Name: "password",
Label: "Password",
Type: config.FieldTypeText,
Required: true,
HelpText: "Password for PiKVM authentication",
},
{
Name: "custom_name",
Label: "Custom Action Name",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom name for this action (optional, defaults to 'pikvm_power_control')",
},
{
Name: "custom_description",
Label: "Custom Description",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom description for this action (optional)",
},
{
Name: "insecure",
Label: "Insecure",
Type: config.FieldTypeCheckbox,
Required: false,
HelpText: "Skip certificate verification (optional)",
},
}
}
+256
View File
@@ -0,0 +1,256 @@
// Package actions contains action implementations used by LocalAGI.
// This file implements the "webhook" action which can send an HTTP request
// to an external service with a configurable method, content type, and payload.
package actions
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/sashabaranov/go-openai/jsonschema"
)
// NewWebhook constructs a WebhookAction using provided configuration values:
// - url: Destination endpoint for the HTTP request (required).
// - method: HTTP method to use (GET, POST, PUT, DELETE, ...). Defaults to POST.
// - contentType: Value for the Content-Type header (e.g., application/json).
// - payloadTemplate: Optional template for the request body; the runtime parameter
// "payload" (if provided) will replace the "{{payload}}" placeholder inside this template.
func NewWebhook(cfg map[string]string) *WebhookAction {
wa := &WebhookAction{
url: strings.TrimSpace(cfg["url"]),
method: strings.ToUpper(strings.TrimSpace(cfg["method"])),
contentType: strings.TrimSpace(cfg["contentType"]),
payloadTemplate: cfg["payloadTemplate"],
}
// Optional custom overrides
if cfg != nil {
wa.customName = cfg["custom_name"]
wa.customDescription = cfg["custom_description"]
wa.customPayloadDescription = cfg["custom_payload_description"]
}
if wa.method == "" {
wa.method = http.MethodPost
}
return wa
}
// WebhookAction holds the static configuration for the webhook.
// These values come from the action configuration (UI/agent config),
// while the runtime parameter only carries the dynamic payload.
// - url: Target endpoint for the request.
// - method: HTTP method to use. Defaults to POST if not provided.
// - contentType: Sets the Content-Type header when a body is sent.
// - payloadTemplate: Optional template used to build the request body; occurrences
// of "{{payload}}" get replaced with the runtime payload string.
// If no placeholder is present, the template is used as-is.
// For GET requests the body is omitted regardless of payload.
//
// Note: This action does not follow redirects!
type WebhookAction struct {
url string
method string
contentType string
payloadTemplate string
customName string
customDescription string
customPayloadDescription string
}
// Run executes the webhook call.
// It reads the runtime parameter "payload" (optional), merges it into the
// configured payloadTemplate (if any), constructs an HTTP request using the
// configured URL, method and content type, and then returns a summary with the
// response status and body (truncated to 4KiB for safety).
func (a *WebhookAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
// Runtime parameters: only payload
type input struct {
Payload string `json:"payload"`
}
var in input
if err := params.Unmarshal(&in); err != nil {
return types.ActionResult{}, err
}
// Validate essential configuration. The URL must be provided via the
// action configuration (not via runtime parameters).
if a.url == "" {
return types.ActionResult{}, fmt.Errorf("configuration.url is required")
}
method := a.method
// Build the request body based on template and payload:
// - If a payloadTemplate is provided, replace occurrences of "{{payload}}"
// with the runtime payload value.
// - If the template does not contain the placeholder but is provided, we use
// the template as-is (common for static JSON bodies prepared at config time).
// - If no template is configured, we send the runtime payload as-is.
// - For GET requests the body is omitted regardless of payload.
var payload string
if a.payloadTemplate != "" {
payload = strings.ReplaceAll(a.payloadTemplate, "{{payload}}", in.Payload)
if payload == a.payloadTemplate && in.Payload != "" {
// If no placeholder found, fallback to template or payload alone
payload = a.payloadTemplate
}
} else {
payload = in.Payload
}
var body io.Reader
if method != http.MethodGet && payload != "" {
body = bytes.NewBufferString(payload)
}
// Create the HTTP request bound to the provided context so that cancellation
// or timeouts from the caller propagate to the outbound call.
req, err := http.NewRequestWithContext(ctx, method, a.url, body)
if err != nil {
return types.ActionResult{}, err
}
// Set Content-Type header if configured. For GET requests this header is
// typically ignored by servers as there is no body.
if a.contentType != "" {
req.Header.Set("Content-Type", a.contentType)
}
// Use a new http.Client with default settings. Consider configuring timeouts
// at the caller level via the context, or wiring a custom client if needed.
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return types.ActionResult{}, err
}
defer resp.Body.Close()
// Read and safely truncate the response body to avoid flooding the agent's
// context with very large payloads. Errors on ReadAll are ignored here as
// we already have the status code.
respBytes, _ := io.ReadAll(resp.Body)
respBody := string(respBytes)
if len(respBody) > 4096 {
respBody = respBody[:4096] + "... (truncated)"
}
return types.ActionResult{
// Return the response body as the result.
// If the response body is empty, use the status text as the result (e.g. "OK" for status code 200).
Result: func() string {
if respBody == "" {
return http.StatusText(resp.StatusCode)
}
return respBody
}(),
// Include the response status code in the metadata.
Metadata: map[string]interface{}{
"statusCode": resp.StatusCode,
},
}, nil
}
// Definition returns the action schema exposed to the planner/runtime.
// Only the runtime parameter "payload" is accepted; all connection details
// are configured statically via the action configuration UI.
func (a *WebhookAction) Definition() types.ActionDefinition {
name := "webhook"
description := "Send an HTTP request to a configured URL/method/content-type. Accepts a runtime payload parameter optionally inserted into the configured payload template."
if a.customName != "" {
name = a.customName
}
if a.customDescription != "" {
description = a.customDescription
}
payloadDesc := "Payload/body to send with the request at runtime. If a payloadTemplate is configured, '{{payload}}' will be replaced by this value."
if a.customPayloadDescription != "" {
payloadDesc = a.customPayloadDescription
}
return types.ActionDefinition{
Name: types.ActionDefinitionName(name),
Description: description,
Properties: map[string]jsonschema.Definition{
"payload": {
Type: jsonschema.String,
Description: payloadDesc,
},
},
}
}
// Plannable indicates the action can be suggested/used by planners without
// requiring hidden context; inputs are straightforward and safe.
func (a *WebhookAction) Plannable() bool { return true }
// WebhookConfigMeta returns the metadata for Webhook action configuration fields:
// - url: The endpoint to send requests to (required).
// - method: One of GET/POST/PUT/DELETE. Defaults to POST.
// - contentType: Common content types selectable from a dropdown.
// - payloadTemplate: Optional body template. At runtime, "{{payload}}" is
// replaced by the provided payload parameter. If missing, the template is used
// as-is; for GET, no body is sent regardless.
func WebhookConfigMeta() []config.Field {
return []config.Field{
{
Name: "custom_name",
Label: "Custom Name",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom name for the action (optional, defaults to 'webhook')",
},
{
Name: "custom_description",
Label: "Custom Description",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom description for the action (optional)",
},
{
Name: "custom_payload_description",
Label: "Custom Payload Description",
Type: config.FieldTypeText,
Required: false,
HelpText: "Override the payload parameter description shown in the UI/schema (optional).",
},
{
Name: "url",
Label: "URL",
Type: config.FieldTypeText,
Required: true,
HelpText: "Destination URL for the webhook",
},
{
Name: "method",
Label: "HTTP Method",
Type: config.FieldTypeSelect,
Options: []config.FieldOption{{Value: http.MethodGet, Label: "GET"}, {Value: http.MethodPost, Label: "POST"}, {Value: http.MethodPut, Label: "PUT"}, {Value: http.MethodDelete, Label: "DELETE"}},
DefaultValue: http.MethodPost,
Required: true,
HelpText: "HTTP method to use",
},
{
Name: "contentType",
Label: "Content Type",
Type: config.FieldTypeSelect,
Options: []config.FieldOption{
{Value: "application/json", Label: "application/json"},
{Value: "text/plain", Label: "text/plain"},
{Value: "application/x-www-form-urlencoded", Label: "application/x-www-form-urlencoded"},
},
Required: true,
HelpText: "Content-Type header to send",
},
{
Name: "payloadTemplate",
Label: "Payload Template",
Type: config.FieldTypeTextarea,
HelpText: "Optional template used to craft the request body. Use '{{payload}}' as placeholder for the runtime payload.",
},
}
}
+23 -1
View File
@@ -3,8 +3,9 @@ package services
import (
"fmt"
"os"
"path/filepath"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
)
func memoryPath(agentName string, actionsConfigs map[string]string) string {
@@ -26,3 +27,24 @@ func memoryPath(agentName string, actionsConfigs map[string]string) string {
return memoryFilePath
}
// memoryIndexPath returns the directory path for the Bleve index (used by memory actions).
func memoryIndexPath(agentName string, actionsConfigs map[string]string) string {
indexPath := "memory.bleve"
if actionsConfigs != nil {
if stateDir, ok := actionsConfigs[ConfigStateDir]; ok && stateDir != "" {
memoryDir := fmt.Sprintf("%s/memory", stateDir)
if err := os.MkdirAll(memoryDir, 0755); err != nil {
xlog.Error("Error creating memory directory", "error", err)
return indexPath
}
indexPath = filepath.Join(memoryDir, agentName+".bleve")
} else {
indexPath = agentName + ".memory.bleve"
}
}
if dir := filepath.Dir(indexPath); dir != "." {
os.MkdirAll(dir, 0755)
}
return indexPath
}
+1 -1
View File
@@ -4,8 +4,8 @@ import (
"encoding/json"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/services/connectors"
"github.com/mudler/xlog"
"github.com/mudler/LocalAGI/core/state"
)
+101
View File
@@ -0,0 +1,101 @@
package common
import (
"fmt"
"strings"
"github.com/mudler/LocalAGI/core/types"
)
const (
// MaxParamsLen is the maximum length for params in "Calling tool X with parameters: ..."
MaxParamsLen = 400
// MaxResultLen is the maximum length for result in "Result of X: ..."
MaxResultLen = 500
)
// StatusAccumulator holds accumulated status lines for a job's placeholder message.
// Callers (connectors) are responsible for mutex and for clearing when the job ends.
type StatusAccumulator struct {
lines []string
}
// NewStatusAccumulator returns a new accumulator.
func NewStatusAccumulator() *StatusAccumulator {
return &StatusAccumulator{lines: nil}
}
// AppendReasoning appends a "Current thought: ..." line when reasoning is non-empty.
func (a *StatusAccumulator) AppendReasoning(reasoning string) {
if reasoning == "" {
return
}
a.lines = append(a.lines, "Current thought process:\n"+reasoning)
}
// AppendToolCall appends a "Calling tool X with parameters: ..." line (params truncated).
func (a *StatusAccumulator) AppendToolCall(actionName string, params string) {
if actionName == "" {
actionName = "Tool"
}
truncated := Truncate(params, MaxParamsLen)
a.lines = append(a.lines, fmt.Sprintf("Calling tool `%s` with parameters: %s", actionName, truncated))
}
// AppendToolResult appends a "Result of X: ..." line (result truncated).
func (a *StatusAccumulator) AppendToolResult(actionName string, result string) {
if actionName == "" {
actionName = "Tool"
}
truncated := Truncate(result, MaxResultLen)
a.lines = append(a.lines, fmt.Sprintf("Result of `%s`: %s", actionName, truncated))
}
// BuildMessage returns thinkingPrefix + "\n\n" + joined lines, truncated to maxTotalLen if needed.
// If over the limit, the message is truncated from the start (oldest content dropped) so the latest lines stay visible.
func (a *StatusAccumulator) BuildMessage(thinkingPrefix string, maxTotalLen int) string {
if len(a.lines) == 0 {
return thinkingPrefix
}
body := strings.Join(a.lines, "\n\n")
full := thinkingPrefix + "\n\n" + body
if maxTotalLen <= 0 || len(full) <= maxTotalLen {
return full
}
// Keep prefix and truncate from the start of the body
available := maxTotalLen - len(thinkingPrefix) - 2 // 2 for "\n\n"
if available <= 0 {
return Truncate(full, maxTotalLen)
}
if len(body) <= available {
return full
}
// Drop oldest lines until we fit
for i := 0; i < len(a.lines); i++ {
trimmed := strings.Join(a.lines[i:], "\n\n")
if len(trimmed) <= available {
return thinkingPrefix + "\n\n" + trimmed
}
}
// Single line too long
return thinkingPrefix + "\n\n" + Truncate(body, available)
}
// Truncate returns s truncated to maxLen with "..." suffix if truncated.
func Truncate(s string, maxLen int) string {
if maxLen <= 0 || len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
// ActionDisplayName returns the action's display name for status messages, or "Tool" if nil.
func ActionDisplayName(action types.Action) string {
if action == nil {
return "Tool"
}
return action.Definition().Name.String()
}
+5 -5
View File
@@ -9,7 +9,7 @@ import (
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
)
@@ -85,11 +85,11 @@ func (d *Discord) Start(a *agent.Agent) {
if d.defaultChannel != "" {
// handle new conversations
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
xlog.Debug("Subscriber(discord)", "message", ccm.Content)
a.AddSubscriber(func(ccm *types.ConversationMessage) {
xlog.Debug("Subscriber(discord)", "message", ccm.Message.Content)
// Send the message to the default channel
_, err := dg.ChannelMessageSend(d.defaultChannel, ccm.Content)
_, err := dg.ChannelMessageSend(d.defaultChannel, ccm.Message.Content)
if err != nil {
xlog.Error(fmt.Sprintf("Error sending message: %v", err))
}
@@ -97,7 +97,7 @@ func (d *Discord) Start(a *agent.Agent) {
a.SharedState().ConversationTracker.AddMessage(
fmt.Sprintf("discord:%s", d.defaultChannel),
openai.ChatCompletionMessage{
Content: ccm.Content,
Content: ccm.Message.Content,
Role: "assistant",
},
)
+5 -5
View File
@@ -22,7 +22,7 @@ import (
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
)
@@ -377,14 +377,14 @@ func (e *Email) Start(a *agent.Agent) {
go func() {
if e.defaultEmail != "" {
// handle new conversations
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
xlog.Debug("Subscriber(email)", "message", ccm.Content)
a.AddSubscriber(func(ccm *types.ConversationMessage) {
xlog.Debug("Subscriber(email)", "message", ccm.Message.Content)
// Send the message to the default email
e.sendMail(
e.defaultEmail,
"Message from LocalAGI",
ccm.Content,
ccm.Message.Content,
"",
"",
[]string{e.defaultEmail},
@@ -394,7 +394,7 @@ func (e *Email) Start(a *agent.Agent) {
a.SharedState().ConversationTracker.AddMessage(
fmt.Sprintf("email:%s", e.defaultEmail),
openai.ChatCompletionMessage{
Content: ccm.Content,
Content: ccm.Message.Content,
Role: "assistant",
},
)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
)
+5 -5
View File
@@ -8,8 +8,8 @@ import (
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/services/actions"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
irc "github.com/thoj/go-ircevent"
)
@@ -73,12 +73,12 @@ func (i *IRC) Start(a *agent.Agent) {
if i.channel != "" {
// handle new conversations
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
xlog.Debug("Subscriber(irc)", "message", ccm.Content)
a.AddSubscriber(func(ccm *types.ConversationMessage) {
xlog.Debug("Subscriber(irc)", "message", ccm.Message.Content)
// Split the response into multiple messages if it's too long
maxLength := 400 // Safe limit for most IRC servers
response := ccm.Content
response := ccm.Message.Content
// Handle multiline responses
lines := strings.Split(response, "\n")
@@ -109,7 +109,7 @@ func (i *IRC) Start(a *agent.Agent) {
a.SharedState().ConversationTracker.AddMessage(
fmt.Sprintf("irc:%s", i.channel),
openai.ChatCompletionMessage{
Content: ccm.Content,
Content: ccm.Message.Content,
Role: "assistant",
},
)
+8 -29
View File
@@ -10,7 +10,7 @@ import (
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
@@ -93,25 +93,6 @@ func (m *Matrix) AgentReasoningCallback() func(state types.ActionCurrentState) b
}
}
// cancelActiveJobForRoom cancels any active job for the given room
func (m *Matrix) cancelActiveJobForRoom(roomID string) {
m.activeJobsMutex.RLock()
ctxs, exists := m.activeJobs[roomID]
m.activeJobsMutex.RUnlock()
if exists {
xlog.Info(fmt.Sprintf("Cancelling active job for room: %s", roomID))
// Mark the job as inactive
m.activeJobsMutex.Lock()
for _, c := range ctxs {
c.Cancel()
}
delete(m.activeJobs, roomID)
m.activeJobsMutex.Unlock()
}
}
func (m *Matrix) handleRoomMessage(a *agent.Agent, evt *event.Event) {
if m.roomID != evt.RoomID.String() && m.roomMode { // If we have a roomID and it's not the same as the event room
// Skip messages from other rooms
@@ -136,9 +117,6 @@ func (m *Matrix) handleRoomMessage(a *agent.Agent, evt *event.Event) {
return
}
// Cancel any active job for this room before starting a new one
m.cancelActiveJobForRoom(evt.RoomID.String())
currentConv := a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("matrix:%s", evt.RoomID.String()))
message := evt.Content.AsMessage().Body
@@ -159,9 +137,10 @@ func (m *Matrix) handleRoomMessage(a *agent.Agent, evt *event.Event) {
agentOptions = append(agentOptions, types.WithConversationHistory(currentConv))
// Add room to metadata for tracking
// Add room and conversation_id for tracking and cancel-previous-on-new-message
metadata := map[string]any{
"room": evt.RoomID.String(),
"room": evt.RoomID.String(),
types.MetadataKeyConversationID: "matrix:" + evt.RoomID.String(),
}
agentOptions = append(agentOptions, types.WithMetadata(metadata))
@@ -225,16 +204,16 @@ func (m *Matrix) Start(a *agent.Agent) {
if m.roomID != "" {
// handle new conversations
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
xlog.Debug("Subscriber(matrix)", "message", ccm.Content)
_, err := m.client.SendText(context.Background(), id.RoomID(m.roomID), ccm.Content)
a.AddSubscriber(func(ccm *types.ConversationMessage) {
xlog.Debug("Subscriber(matrix)", "message", ccm.Message.Content)
_, err := m.client.SendText(context.Background(), id.RoomID(m.roomID), ccm.Message.Content)
if err != nil {
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
}
a.SharedState().ConversationTracker.AddMessage(
fmt.Sprintf("matrix:%s", m.roomID),
openai.ChatCompletionMessage{
Content: ccm.Content,
Content: ccm.Message.Content,
Role: "assistant",
},
)
+309 -128
View File
@@ -4,20 +4,23 @@ import (
"bytes"
"encoding/base64"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/localoperator"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/pkg/xstrings"
"github.com/mudler/LocalAGI/services/actions"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/services/connectors/common"
"github.com/slack-go/slack/socketmode"
@@ -36,6 +39,7 @@ type Slack struct {
// To track placeholder messages
placeholders map[string]string // map[jobUUID]messageTS
placeholderMutex sync.RWMutex
jobStatus map[string]*common.StatusAccumulator // map[jobUUID]accumulator
apiClient *slack.Client
// Track active jobs for cancellation
@@ -53,15 +57,39 @@ func NewSlack(config map[string]string) *Slack {
channelID: config["channelID"],
channelMode: config["channelMode"] == "true",
placeholders: make(map[string]string),
jobStatus: make(map[string]*common.StatusAccumulator),
activeJobs: make(map[string][]*types.Job),
}
}
func (t *Slack) AgentResultCallback() func(state types.ActionState) {
return func(state types.ActionState) {
// Mark the job as completed when we get the final result
if state.ActionCurrentState.Job != nil && state.ActionCurrentState.Job.Metadata != nil {
if channel, ok := state.ActionCurrentState.Job.Metadata["channel"].(string); ok && channel != "" {
// Update placeholder with tool result if still in progress
job := state.ActionCurrentState.Job
if job != nil && job.Metadata != nil {
if channel, ok := job.Metadata["channel"].(string); ok && channel != "" {
t.placeholderMutex.Lock()
msgTs, exists := t.placeholders[job.UUID]
if exists && msgTs != "" && t.apiClient != nil {
acc, ok := t.jobStatus[job.UUID]
if !ok {
acc = common.NewStatusAccumulator()
t.jobStatus[job.UUID] = acc
}
acc.AppendToolResult(common.ActionDisplayName(state.Action), state.Result)
thought := acc.BuildMessage(thinkingMessage, 3000)
t.placeholderMutex.Unlock()
_, _, _, err := t.apiClient.UpdateMessage(
channel,
msgTs,
slack.MsgOptionText(githubmarkdownconvertergo.Slack(thought), false),
)
if err != nil {
xlog.Error(fmt.Sprintf("Error updating tool result message: %v", err))
}
t.placeholderMutex.Lock()
}
t.placeholderMutex.Unlock()
t.activeJobsMutex.Lock()
delete(t.activeJobs, channel)
t.activeJobsMutex.Unlock()
@@ -73,7 +101,7 @@ func (t *Slack) AgentResultCallback() func(state types.ActionState) {
func (t *Slack) AgentReasoningCallback() func(state types.ActionCurrentState) bool {
return func(state types.ActionCurrentState) bool {
// Check if we have a placeholder message for this job
t.placeholderMutex.RLock()
t.placeholderMutex.Lock()
msgTs, exists := t.placeholders[state.Job.UUID]
channel := ""
if state.Job.Metadata != nil {
@@ -81,18 +109,31 @@ func (t *Slack) AgentReasoningCallback() func(state types.ActionCurrentState) bo
channel = ch
}
}
t.placeholderMutex.RUnlock()
if !exists || msgTs == "" || channel == "" || t.apiClient == nil {
return true // Skip if we don't have a message to update
t.placeholderMutex.Unlock()
return true
}
thought := thinkingMessage + "\n\n"
// Update when we have reasoning or a tool call to show
if state.Reasoning == "" && state.Action == nil {
t.placeholderMutex.Unlock()
return true
}
acc, ok := t.jobStatus[state.Job.UUID]
if !ok {
acc = common.NewStatusAccumulator()
t.jobStatus[state.Job.UUID] = acc
}
if state.Reasoning != "" {
thought += "Current thought process:\n" + state.Reasoning
acc.AppendReasoning(state.Reasoning)
}
if state.Action != nil {
acc.AppendToolCall(common.ActionDisplayName(state.Action), state.Params.String())
}
thought := acc.BuildMessage(thinkingMessage, 3000)
t.placeholderMutex.Unlock()
// Update the placeholder message with the current reasoning
_, _, _, err := t.apiClient.UpdateMessage(
channel,
msgTs,
@@ -105,25 +146,6 @@ func (t *Slack) AgentReasoningCallback() func(state types.ActionCurrentState) bo
}
}
// cancelActiveJobForChannel cancels any active job for the given channel
func (t *Slack) cancelActiveJobForChannel(channelID string) {
t.activeJobsMutex.RLock()
ctxs, exists := t.activeJobs[channelID]
t.activeJobsMutex.RUnlock()
if exists {
xlog.Info(fmt.Sprintf("Cancelling active job for channel: %s", channelID))
// Mark the job as inactive
t.activeJobsMutex.Lock()
for _, c := range ctxs {
c.Cancel()
}
delete(t.activeJobs, channelID)
t.activeJobsMutex.Unlock()
}
}
func cleanUpUsernameFromMessage(message string, b *slack.AuthTestResponse) string {
cleaned := strings.ReplaceAll(message, "<@"+b.UserID+">", "")
cleaned = strings.ReplaceAll(cleaned, "<@"+b.BotID+">", "")
@@ -149,63 +171,193 @@ func replaceUserIDsWithNamesInMessage(api *slack.Client, message string) string
return message
}
func generateAttachmentsFromJobResponse(j *types.JobResult, api *slack.Client, channelID, ts string) (attachments []slack.Attachment) {
for _, state := range j.State {
// coming from the browser agent
if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
if historyStruct, ok := history.(*localoperator.StateHistory); ok {
state := historyStruct.States[len(historyStruct.States)-1]
// Decode base64 screenshot and upload to Slack
if state.Screenshot != "" {
screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
if err != nil {
xlog.Error(fmt.Sprintf("Error decoding screenshot: %v", err))
continue
}
// attachmentsFromMetadataOnly returns link/image attachments from metadata (no file uploads).
// Used when posting a message so we can include URLs/images in the same post.
func attachmentsFromMetadataOnly(metadata map[string]interface{}) (attachments []slack.Attachment) {
if metadata == nil {
return nil
}
if urls, exists := metadata[actions.MetadataUrls]; exists {
for _, url := range xstrings.UniqueSlice(stringSliceFromMetadata(urls)) {
attachments = append(attachments, slack.Attachment{
Title: "URL",
TitleLink: url,
Text: url,
})
}
}
if imagesUrls, exists := metadata[actions.MetadataImages]; exists {
for _, url := range xstrings.UniqueSlice(stringSliceFromMetadata(imagesUrls)) {
attachments = append(attachments, slack.Attachment{
Title: "Image",
TitleLink: url,
ImageURL: url,
})
}
}
return attachments
}
data := string(screenshotData)
// Upload the file to Slack
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
Reader: bytes.NewReader(screenshotData),
FileSize: len(data),
ThreadTimestamp: ts,
Channel: channelID,
Filename: "screenshot.png",
InitialComment: "Browser Agent Screenshot",
})
if err != nil {
xlog.Error(fmt.Sprintf("Error uploading screenshot: %v", err))
continue
}
}
// stringSliceFromMetadata converts a metadata value to []string, supporting both
// []string and []interface{} (e.g. from JSON). Returns nil if the value is not a supported slice type.
func stringSliceFromMetadata(v interface{}) []string {
if v == nil {
return nil
}
switch v := v.(type) {
case string:
return []string{v}
case []string:
return v
case []interface{}:
out := make([]string, 0, len(v))
for _, e := range v {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
return nil
}
// coming from the search action
if urls, exists := state.Metadata[actions.MetadataUrls]; exists {
for _, url := range xstrings.UniqueSlice(urls.([]string)) {
attachment := slack.Attachment{
Title: "URL",
TitleLink: url,
Text: url,
}
attachments = append(attachments, attachment)
// uploadFilesFromMetadata uploads song and PDF files from metadata to the given thread.
// Paths must be local filesystem paths; URLs will be skipped with a clear log.
// Call after posting a message so threadTs is the message timestamp.
func uploadFilesFromMetadata(metadata map[string]interface{}, api *slack.Client, channelID, threadTs string) {
if metadata == nil {
return
}
isURL := func(p string) bool {
return strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://")
}
if songPaths, exists := metadata[actions.MetadataSongs]; exists {
sl := stringSliceFromMetadata(songPaths)
for _, path := range xstrings.UniqueSlice(sl) {
if isURL(path) {
xlog.Error("Slack upload skipped: song path is a URL, need local path", "path", path)
continue
}
}
// coming from the gen image actions
if imagesUrls, exists := state.Metadata[actions.MetadataImages]; exists {
for _, url := range xstrings.UniqueSlice(imagesUrls.([]string)) {
attachment := slack.Attachment{
Title: "Image",
TitleLink: url,
ImageURL: url,
}
attachments = append(attachments, attachment)
data, err := os.ReadFile(path)
if err != nil {
xlog.Error("Error reading song file", "path", path, "error", err)
continue
}
filename := filepath.Base(path)
if filename == "" || filename == "." {
filename = "audio"
}
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
Reader: bytes.NewReader(data),
FileSize: len(data),
ThreadTimestamp: threadTs,
Channel: channelID,
Filename: filename,
Title: filename,
InitialComment: "Generated song",
})
if err != nil {
xlog.Error("Slack UploadFileV2 failed for song", "error", err, "path", path)
}
}
}
return
if pdfPaths, exists := metadata[actions.MetadataPDFs]; exists {
sl := stringSliceFromMetadata(pdfPaths)
for _, path := range xstrings.UniqueSlice(sl) {
if isURL(path) {
xlog.Error("Slack upload skipped: PDF path is a URL, need local path", "path", path)
continue
}
data, err := os.ReadFile(path)
if err != nil {
xlog.Error("Error reading PDF file", "path", path, "error", err)
continue
}
filename := filepath.Base(path)
if filename == "" || filename == "." {
filename = "document.pdf"
}
xlog.Debug("Uploading PDF from metadata to Slack thread", "filename", filename, "path", path)
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
Reader: bytes.NewReader(data),
FileSize: len(data),
ThreadTimestamp: threadTs,
Channel: channelID,
Filename: filename,
Title: filename,
InitialComment: "Generated PDF document",
})
if err != nil {
xlog.Error("Slack UploadFileV2 failed for PDF", "error", err, "path", path)
}
}
}
// Handle generated images (download from URL and upload as file, so temporary URLs like DALL-E are preserved)
if imageUrls, exists := metadata[actions.MetadataImages]; exists {
sl := stringSliceFromMetadata(imageUrls)
for _, imgURL := range xstrings.UniqueSlice(sl) {
resp, err := http.Get(imgURL)
if err != nil {
xlog.Error("Error downloading image for Slack upload", "url", imgURL, "error", err)
continue
}
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
xlog.Error("Error reading image body for Slack upload", "url", imgURL, "error", err)
continue
}
if len(data) == 0 {
xlog.Error("Empty image body for Slack upload", "url", imgURL)
continue
}
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
Reader: bytes.NewReader(data),
FileSize: len(data),
ThreadTimestamp: threadTs,
Channel: channelID,
Filename: "image.png",
Title: "Generated image",
InitialComment: "Generated image",
})
if err != nil {
xlog.Error("Slack UploadFileV2 failed for image", "error", err, "url", imgURL)
}
}
}
}
// attachmentsAndUploadsFromMetadata returns link/image attachments and uploads files (songs, PDFs)
// from a metadata map. Used both by JobResult.State and by ConversationMessage.Metadata
// (e.g. when newconversation/send_message is used so metadata is passed without going through State).
func attachmentsAndUploadsFromMetadata(metadata map[string]interface{}, api *slack.Client, channelID, threadTs string) (attachments []slack.Attachment) {
attachments = attachmentsFromMetadataOnly(metadata)
uploadFilesFromMetadata(metadata, api, channelID, threadTs)
return attachments
}
// attachmentsFromJobResponseOnly returns link/image attachments from job response without uploading files.
// Use with uploadJobResultFiles so uploads happen once per reply.
func attachmentsFromJobResponseOnly(j *types.JobResult) []slack.Attachment {
if j == nil {
return nil
}
var out []slack.Attachment
for _, state := range j.State {
out = append(out, attachmentsFromMetadataOnly(state.Metadata)...)
}
return out
}
// uploadJobResultFiles uploads all song/PDF files from job result states to the given thread once.
func uploadJobResultFiles(res *types.JobResult, api *slack.Client, channelID, threadTs string) {
if res == nil {
return
}
for _, state := range res.State {
uploadFilesFromMetadata(state.Metadata, api, channelID, threadTs)
}
}
// ImageData represents a single image with its metadata
@@ -380,9 +532,6 @@ func (t *Slack) handleChannelMessage(
return
}
// Cancel any active job for this channel before starting a new one
t.cancelActiveJobForChannel(ev.Channel)
currentConv := a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("slack:%s", t.channelID))
message := replaceUserIDsWithNamesInMessage(api, cleanUpUsernameFromMessage(ev.Text, b))
@@ -411,9 +560,10 @@ func (t *Slack) handleChannelMessage(
agentOptions = append(agentOptions, types.WithConversationHistory(currentConv))
// Add channel to metadata for tracking
// Add channel and conversation_id for tracking and cancel-previous-on-new-message
metadata := map[string]interface{}{
"channel": ev.Channel,
"channel": ev.Channel,
types.MetadataKeyConversationID: "slack:" + ev.Channel,
}
agentOptions = append(agentOptions, types.WithMetadata(metadata))
@@ -461,50 +611,61 @@ func (t *Slack) handleChannelMessage(
xlog.Debug("After adding message to conversation tracker", "conversation", a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("slack:%s", t.channelID)))
//res.Response = githubmarkdownconvertergo.Slack(res.Response)
replyWithPostMessage(res.Response, api, ev, postMessageParams, res)
convertedResponse := githubmarkdownconvertergo.Slack(res.Response)
replyWithPostMessage(convertedResponse, api, ev, postMessageParams, res)
}()
}
func replyWithPostMessage(finalResponse string, api *slack.Client, ev *slackevents.MessageEvent, postMessageParams slack.PostMessageParameters, res *types.JobResult) {
attachments := attachmentsFromJobResponseOnly(res)
if len(finalResponse) > 4000 {
// split response in multiple messages, and update the first
// Split response into multiple messages; post first to get thread ts, upload files once, then post rest in thread
messages := xstrings.SplitParagraph(finalResponse, 3000)
for _, message := range messages {
_, _, err := api.PostMessage(ev.Channel,
var firstTs string
for i, message := range messages {
opts := []slack.MsgOption{
slack.MsgOptionLinkNames(true),
slack.MsgOptionEnableLinkUnfurl(),
slack.MsgOptionText(message, true),
slack.MsgOptionText(message, false),
slack.MsgOptionPostMessageParameters(postMessageParams),
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, "")...),
)
slack.MsgOptionAttachments(attachments...),
}
if i > 0 && firstTs != "" {
opts = append(opts, slack.MsgOptionTS(firstTs))
}
_, ts, err := api.PostMessage(ev.Channel, opts...)
if err != nil {
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
xlog.Error("Error posting message", "error", err)
continue
}
if i == 0 {
firstTs = ts
uploadJobResultFiles(res, api, ev.Channel, firstTs)
}
}
} else {
_, _, err := api.PostMessage(ev.Channel,
_, ts, err := api.PostMessage(ev.Channel,
slack.MsgOptionLinkNames(true),
slack.MsgOptionEnableLinkUnfurl(),
slack.MsgOptionText(res.Response, true),
slack.MsgOptionText(finalResponse, false),
slack.MsgOptionPostMessageParameters(postMessageParams),
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, "")...),
// slack.MsgOptionTS(ts),
slack.MsgOptionAttachments(attachments...),
)
if err != nil {
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
xlog.Error("Error posting message", "error", err)
return
}
uploadJobResultFiles(res, api, ev.Channel, ts)
}
}
func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackevents.AppMentionEvent, msgTs string, ts string, res *types.JobResult) {
func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackevents.AppMentionEvent, msgTs string, ts string, postMessageParams slack.PostMessageParameters, res *types.JobResult) {
attachments := attachmentsFromJobResponseOnly(res)
// Use the thread root timestamp (ts), not the placeholder reply timestamp (msgTs).
// Slack API: "Never use a reply's ts value; use its parent instead."
uploadJobResultFiles(res, api, ev.Channel, ts)
if len(finalResponse) > 3000 {
// split response in multiple messages, and update the first
messages := xstrings.SplitParagraph(finalResponse, 3000)
_, _, _, err := api.UpdateMessage(
@@ -512,11 +673,12 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
msgTs,
slack.MsgOptionLinkNames(true),
slack.MsgOptionEnableLinkUnfurl(),
slack.MsgOptionText(messages[0], true),
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, msgTs)...),
slack.MsgOptionText(messages[0], false),
slack.MsgOptionPostMessageParameters(postMessageParams),
slack.MsgOptionAttachments(attachments...),
)
if err != nil {
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
xlog.Error("Error updating final message", "error", err)
}
for i, message := range messages {
@@ -526,11 +688,12 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
_, _, err = api.PostMessage(ev.Channel,
slack.MsgOptionLinkNames(true),
slack.MsgOptionEnableLinkUnfurl(),
slack.MsgOptionText(message, true),
slack.MsgOptionText(message, false),
slack.MsgOptionPostMessageParameters(postMessageParams),
slack.MsgOptionTS(ts),
)
if err != nil {
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
xlog.Error("Error posting message", "error", err)
}
}
} else {
@@ -539,11 +702,12 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
msgTs,
slack.MsgOptionLinkNames(true),
slack.MsgOptionEnableLinkUnfurl(),
slack.MsgOptionText(finalResponse, true),
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, msgTs)...),
slack.MsgOptionText(finalResponse, false),
slack.MsgOptionPostMessageParameters(postMessageParams),
slack.MsgOptionAttachments(attachments...),
)
if err != nil {
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
xlog.Error("Error updating final message", "error", err)
}
}
}
@@ -675,9 +839,10 @@ func (t *Slack) handleMention(
}
}
// Add channel to job metadata for use in callbacks
// Add channel and conversation_id for callbacks and cancel-previous-on-new-message
metadata := map[string]interface{}{
"channel": ev.Channel,
"channel": ev.Channel,
types.MetadataKeyConversationID: "slack:" + ev.Channel,
}
// Call the agent with the conversation history
@@ -687,9 +852,9 @@ func (t *Slack) handleMention(
types.WithMetadata(metadata),
)
if res.Response == "" {
if res == nil || res.Response == "" {
xlog.Debug(fmt.Sprintf("Empty response from agent"))
replyToUpdateMessage("there was an internal error. try again!", api, ev, msgTs, ts, res)
replyToUpdateMessage("there was an internal error. try again!", api, ev, msgTs, ts, postMessageParams, res)
// _, _, err := api.DeleteMessage(ev.Channel, msgTs)
// if err != nil {
@@ -700,20 +865,24 @@ func (t *Slack) handleMention(
// get user id
user, err := api.GetUserInfo(ev.User)
displayName := ev.User
if err != nil {
xlog.Error(fmt.Sprintf("Error getting user info: %v", err))
} else if user != nil {
displayName = user.Name
}
// Format the final response
//finalResponse := githubmarkdownconvertergo.Slack(res.Response)
finalResponse := fmt.Sprintf("@%s %s", user.Name, res.Response)
// Format the final response (convert GitHub markdown to Slack mrkdwn)
convertedResponse := githubmarkdownconvertergo.Slack(res.Response)
finalResponse := fmt.Sprintf("@%s %s", displayName, convertedResponse)
xlog.Debug("Send final response to slack", "response", finalResponse)
replyToUpdateMessage(finalResponse, api, ev, msgTs, ts, res)
replyToUpdateMessage(finalResponse, api, ev, msgTs, ts, postMessageParams, res)
// Clean up the placeholder map
// Clean up the placeholder map and job status
t.placeholderMutex.Lock()
delete(t.placeholders, jobUUID)
delete(t.jobStatus, jobUUID)
t.placeholderMutex.Unlock()
}()
}
@@ -734,22 +903,34 @@ func (t *Slack) Start(a *agent.Agent) {
if t.channelID != "" {
xlog.Debug(fmt.Sprintf("Listening for messages in channel %s", t.channelID))
// handle new conversations
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
xlog.Debug("Subscriber(slack)", "message", ccm.Content)
_, _, err := api.PostMessage(t.channelID,
// handle new conversations (e.g. send_message / newconversation action)
// Preserve metadata (PDFs, songs, images, URLs) so attachments are not lost
a.AddSubscriber(func(ccm *types.ConversationMessage) {
xlog.Debug("Subscriber(slack)", "message", ccm.Message.Content)
convertedContent := githubmarkdownconvertergo.Slack(ccm.Message.Content)
attachments := attachmentsFromMetadataOnly(ccm.Metadata)
channelID, ts, err := api.PostMessage(t.channelID,
slack.MsgOptionLinkNames(true),
slack.MsgOptionEnableLinkUnfurl(),
slack.MsgOptionText(ccm.Content, true),
slack.MsgOptionText(convertedContent, false),
slack.MsgOptionPostMessageParameters(postMessageParams),
slack.MsgOptionAttachments(attachments...),
)
if err != nil {
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
}
// Always upload files (PDFs, songs) when metadata is present—to the same thread if post succeeded, else to channel
if ccm.Metadata != nil {
ch, threadTs := t.channelID, ""
if err == nil {
ch, threadTs = channelID, ts
}
uploadFilesFromMetadata(ccm.Metadata, api, ch, threadTs)
}
a.SharedState().ConversationTracker.AddMessage(
fmt.Sprintf("slack:%s", t.channelID),
openai.ChatCompletionMessage{
Content: ccm.Content,
Content: ccm.Message.Content,
Role: "assistant",
},
)
+429 -116
View File
@@ -10,7 +10,9 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
@@ -20,10 +22,10 @@ import (
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/localoperator"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/services/connectors/common"
"github.com/mudler/LocalAGI/pkg/xstrings"
"github.com/mudler/LocalAGI/services/actions"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
)
@@ -40,6 +42,7 @@ type Telegram struct {
// To track placeholder messages
placeholders map[string]int // map[jobUUID]messageID
placeholderMutex sync.RWMutex
jobStatus map[string]*common.StatusAccumulator // map[jobUUID]accumulator
// Track active jobs for cancellation
activeJobs map[int64][]*types.Job // map[chatID]bool to track if a chat has active processing
@@ -56,14 +59,24 @@ func (t *Telegram) isBotMentioned(message string, botUsername string) bool {
}
func (t *Telegram) chatFromMessage(update *models.Update) (openai.ChatCompletionMessage, error) {
if len(update.Message.Photo) == 0 {
return openai.ChatCompletionMessage{
Content: update.Message.Text,
Role: "user",
}, nil
// Handle audio messages
if update.Message.Voice != nil || update.Message.Audio != nil {
return t.handleAudioMessage(update)
}
// Handle photo messages
if len(update.Message.Photo) > 0 {
return t.handlePhotoMessage(update)
}
// Handle text messages
return openai.ChatCompletionMessage{
Content: update.Message.Text,
Role: "user",
}, nil
}
func (t *Telegram) handlePhotoMessage(update *models.Update) (openai.ChatCompletionMessage, error) {
xlog.Debug("Image", "found image")
// Get the largest photo
photo := update.Message.Photo[len(update.Message.Photo)-1]
@@ -77,44 +90,122 @@ func (t *Telegram) chatFromMessage(update *models.Update) (openai.ChatCompletion
})
if err != nil {
xlog.Error("Error getting file", "error", err)
} else {
// Construct the full URL for downloading the file
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", t.Token, file.FilePath)
// Download the file content
resp, err := http.Get(fileURL)
if err != nil {
xlog.Error("Error downloading file", "error", err)
} else {
defer resp.Body.Close()
imageBytes, err := io.ReadAll(resp.Body)
if err != nil {
xlog.Error("Error reading image", "error", err)
} else {
// Encode to base64
imgBase64 := base64.StdEncoding.EncodeToString(imageBytes)
xlog.Debug("Image", "sending encoded image")
// Add to conversation as multi-content message
return openai.ChatCompletionMessage{
Role: "user",
MultiContent: []openai.ChatMessagePart{
{
Text: update.Message.Caption,
Type: openai.ChatMessagePartTypeText,
},
{
Type: openai.ChatMessagePartTypeImageURL,
ImageURL: &openai.ChatMessageImageURL{
URL: fmt.Sprintf("data:image/jpeg;base64,%s", imgBase64),
},
},
},
}, nil
}
}
return openai.ChatCompletionMessage{}, err
}
return openai.ChatCompletionMessage{}, errors.New("no image found")
// Construct the full URL for downloading the file
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", t.Token, file.FilePath)
// Download the file content
resp, err := http.Get(fileURL)
if err != nil {
xlog.Error("Error downloading file", "error", err)
return openai.ChatCompletionMessage{}, err
}
defer resp.Body.Close()
imageBytes, err := io.ReadAll(resp.Body)
if err != nil {
xlog.Error("Error reading image", "error", err)
return openai.ChatCompletionMessage{}, err
}
// Encode to base64
imgBase64 := base64.StdEncoding.EncodeToString(imageBytes)
xlog.Debug("Image", "sending encoded image")
// Add to conversation as multi-content message
return openai.ChatCompletionMessage{
Role: "user",
MultiContent: []openai.ChatMessagePart{
{
Text: update.Message.Caption,
Type: openai.ChatMessagePartTypeText,
},
{
Type: openai.ChatMessagePartTypeImageURL,
ImageURL: &openai.ChatMessageImageURL{
URL: fmt.Sprintf("data:image/jpeg;base64,%s", imgBase64),
},
},
},
}, nil
}
func (t *Telegram) handleAudioMessage(update *models.Update) (openai.ChatCompletionMessage, error) {
var fileID string
var audioType string
if update.Message.Voice != nil {
fileID = update.Message.Voice.FileID
audioType = "voice"
} else if update.Message.Audio != nil {
fileID = update.Message.Audio.FileID
audioType = "audio"
}
xlog.Debug("Audio message received", "type", audioType, "fileID", fileID)
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Download the audio file
file, err := t.bot.GetFile(ctx, &bot.GetFileParams{
FileID: fileID,
})
if err != nil {
xlog.Error("Error getting audio file", "error", err)
return openai.ChatCompletionMessage{}, err
}
// Construct the full URL for downloading the file
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", t.Token, file.FilePath)
// Download the file content
resp, err := http.Get(fileURL)
if err != nil {
xlog.Error("Error downloading audio file", "error", err)
return openai.ChatCompletionMessage{}, err
}
defer resp.Body.Close()
audioBytes, err := io.ReadAll(resp.Body)
if err != nil {
xlog.Error("Error reading audio file", "error", err)
return openai.ChatCompletionMessage{}, err
}
// Create a temporary file for transcription
tempFile, err := os.CreateTemp("", "telegram_audio_*.ogg")
if err != nil {
xlog.Error("Error creating temp file", "error", err)
return openai.ChatCompletionMessage{}, err
}
defer os.Remove(tempFile.Name())
// Write audio data to temp file
if _, err := tempFile.Write(audioBytes); err != nil {
tempFile.Close()
xlog.Error("Error writing audio to temp file", "error", err)
return openai.ChatCompletionMessage{}, err
}
tempFile.Close()
// Transcribe the audio using the agent's Transcribe method
transcription, err := t.agent.Transcribe(ctx, tempFile.Name())
if err != nil {
xlog.Error("Error transcribing audio", "error", err)
return openai.ChatCompletionMessage{
Content: fmt.Sprintf("I received an audio message but couldn't transcribe it: %v", err),
Role: "user",
}, nil
}
xlog.Debug("Audio transcribed successfully", "transcription", transcription)
return openai.ChatCompletionMessage{
Content: transcription,
Role: "user",
}, nil
}
// handleGroupMessage handles messages in group chats
@@ -143,9 +234,6 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
return
}
// Cancel any active job for this chat before starting a new one
t.cancelActiveJobForChat(update.Message.Chat.ID)
// Clean up the message by removing bot mentions
message := strings.ReplaceAll(update.Message.Text, "@"+botInfo.Username, "")
update.Message.Text = strings.TrimSpace(message)
@@ -171,9 +259,15 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
t.placeholders[jobUUID] = msg.ID
t.placeholderMutex.Unlock()
// Add chat ID to metadata for tracking
// Add chat ID and conversation_id for tracking and cancel-previous-on-new-message
metadata := map[string]interface{}{
"chatID": update.Message.Chat.ID,
types.MetadataKeyConversationID: fmt.Sprintf("telegram:%d", update.Message.Chat.ID),
}
// Track if the original message was audio for TTS response
if update.Message.Voice != nil || update.Message.Audio != nil {
metadata["originalMessageType"] = "audio"
}
chatMessage, err := t.chatFromMessage(update)
@@ -212,9 +306,10 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
}
t.activeJobsMutex.Unlock()
// Clean up the placeholder map
// Clean up the placeholder map and job status
t.placeholderMutex.Lock()
delete(t.placeholders, jobUUID)
delete(t.jobStatus, jobUUID)
t.placeholderMutex.Unlock()
}()
@@ -251,6 +346,34 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
xlog.Error("Error handling multimedia content", "error", err)
}
// Check if original message was audio and generate TTS response
if metadata["originalMessageType"] == "audio" && res.Response != "" {
xlog.Debug("Original message was audio, generating TTS response")
audioData, err := t.agent.TTS(ctx, res.Response)
if err != nil {
xlog.Error("Error generating TTS", "error", err)
} else {
// Send audio response
err = sendAudioToTelegram(ctx, t.bot, update.Message.Chat.ID, audioData, res.Response)
if err != nil {
xlog.Error("Error sending audio response", "error", err)
} else {
xlog.Debug("Audio response sent successfully")
// Remove the thinking placeholder message before returning
_, err := t.bot.DeleteMessage(ctx, &bot.DeleteMessageParams{
ChatID: update.Message.Chat.ID,
MessageID: msg.ID,
})
if err != nil {
xlog.Error("Error deleting thinking placeholder", "error", err)
}
// Don't send text response if audio was sent successfully
return
}
}
}
// Update the message with the final response
formattedResponse := formatResponseWithURLs(res.Response, urls)
@@ -301,21 +424,48 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
func (t *Telegram) AgentResultCallback() func(state types.ActionState) {
return func(state types.ActionState) {
// Mark the job as completed when we get the final result
if state.ActionCurrentState.Job != nil && state.ActionCurrentState.Job.Metadata != nil {
if chatID, ok := state.ActionCurrentState.Job.Metadata["chatID"].(int64); ok && chatID != 0 {
t.activeJobsMutex.Lock()
delete(t.activeJobs, chatID)
t.activeJobsMutex.Unlock()
}
job := state.ActionCurrentState.Job
if job == nil || job.Metadata == nil {
return
}
chatID, ok := job.Metadata["chatID"].(int64)
if !ok || chatID == 0 {
return
}
// Update placeholder with tool result if still in progress
t.placeholderMutex.Lock()
msgID, exists := t.placeholders[job.UUID]
if exists && msgID != 0 && t.bot != nil {
acc, ok := t.jobStatus[job.UUID]
if !ok {
acc = common.NewStatusAccumulator()
t.jobStatus[job.UUID] = acc
}
acc.AppendToolResult(common.ActionDisplayName(state.Action), state.Result)
thought := acc.BuildMessage(telegramThinkingMessage, telegramMaxMessageLength)
t.placeholderMutex.Unlock()
_, err := t.bot.EditMessageText(t.agent.Context(), &bot.EditMessageTextParams{
ChatID: chatID,
MessageID: msgID,
Text: thought,
})
if err != nil {
xlog.Error("Error updating tool result message", "error", err)
}
t.placeholderMutex.Lock()
}
t.placeholderMutex.Unlock()
t.activeJobsMutex.Lock()
delete(t.activeJobs, chatID)
t.activeJobsMutex.Unlock()
}
}
func (t *Telegram) AgentReasoningCallback() func(state types.ActionCurrentState) bool {
return func(state types.ActionCurrentState) bool {
// Check if we have a placeholder message for this job
t.placeholderMutex.RLock()
t.placeholderMutex.Lock()
msgID, exists := t.placeholders[state.Job.UUID]
chatID := int64(0)
if state.Job.Metadata != nil {
@@ -323,18 +473,30 @@ func (t *Telegram) AgentReasoningCallback() func(state types.ActionCurrentState)
chatID = ch
}
}
t.placeholderMutex.RUnlock()
if !exists || msgID == 0 || chatID == 0 || t.bot == nil {
return true // Skip if we don't have a message to update
t.placeholderMutex.Unlock()
return true
}
thought := telegramThinkingMessage + "\n\n"
if state.Reasoning == "" && state.Action == nil {
t.placeholderMutex.Unlock()
return true
}
acc, ok := t.jobStatus[state.Job.UUID]
if !ok {
acc = common.NewStatusAccumulator()
t.jobStatus[state.Job.UUID] = acc
}
if state.Reasoning != "" {
thought += "Current thought process:\n" + state.Reasoning
acc.AppendReasoning(state.Reasoning)
}
if state.Action != nil {
acc.AppendToolCall(common.ActionDisplayName(state.Action), state.Params.String())
}
thought := acc.BuildMessage(telegramThinkingMessage, telegramMaxMessageLength)
t.placeholderMutex.Unlock()
// Update the placeholder message with the current reasoning
_, err := t.bot.EditMessageText(t.agent.Context(), &bot.EditMessageTextParams{
ChatID: chatID,
MessageID: msgID,
@@ -347,25 +509,6 @@ func (t *Telegram) AgentReasoningCallback() func(state types.ActionCurrentState)
}
}
// cancelActiveJobForChat cancels any active job for the given chat
func (t *Telegram) cancelActiveJobForChat(chatID int64) {
t.activeJobsMutex.RLock()
ctxs, exists := t.activeJobs[chatID]
t.activeJobsMutex.RUnlock()
if exists {
xlog.Info("Cancelling active job for chat", "chatID", chatID)
// Mark the job as inactive
t.activeJobsMutex.Lock()
for _, c := range ctxs {
c.Cancel()
}
delete(t.activeJobs, chatID)
t.activeJobsMutex.Unlock()
}
}
// sendImageToTelegram downloads and sends an image to Telegram
func sendImageToTelegram(ctx context.Context, b *bot.Bot, chatID int64, url string) error {
resp, err := http.Get(url)
@@ -396,6 +539,48 @@ func sendImageToTelegram(ctx context.Context, b *bot.Bot, chatID int64, url stri
return nil
}
// sendAudioToTelegram sends audio data to Telegram
func sendAudioToTelegram(ctx context.Context, b *bot.Bot, chatID int64, audioData []byte, caption string) error {
// Send audio with caption
_, err := b.SendVoice(ctx, &bot.SendVoiceParams{
ChatID: chatID,
Voice: &models.InputFileUpload{
Filename: "response.mp3",
Data: bytes.NewReader(audioData),
},
Caption: caption,
})
if err != nil {
return fmt.Errorf("error sending audio: %w", err)
}
return nil
}
// sendSongToTelegram reads a song file from path and sends it to Telegram as audio.
func sendSongToTelegram(ctx context.Context, b *bot.Bot, chatID int64, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("error reading song file: %w", err)
}
filename := filepath.Base(path)
if filename == "" || filename == "." {
filename = "audio"
}
_, err = b.SendAudio(ctx, &bot.SendAudioParams{
ChatID: chatID,
Audio: &models.InputFileUpload{
Filename: filename,
Data: bytes.NewReader(data),
},
Caption: "Generated song",
})
if err != nil {
return fmt.Errorf("error sending audio: %w", err)
}
return nil
}
// handleMultimediaContent processes and sends multimedia content from the agent's response
func (t *Telegram) handleMultimediaContent(ctx context.Context, chatID int64, res *types.JobResult) ([]string, error) {
var urls []string
@@ -416,33 +601,72 @@ func (t *Telegram) handleMultimediaContent(ctx context.Context, chatID int64, re
}
}
// Handle browser agent screenshots
if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
if historyStruct, ok := history.(*localoperator.StateHistory); ok {
state := historyStruct.States[len(historyStruct.States)-1]
if state.Screenshot != "" {
// Decode base64 screenshot
screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
if err != nil {
xlog.Error("Error decoding screenshot", "error", err)
continue
}
// Send screenshot with caption
_, err = t.bot.SendPhoto(ctx, &bot.SendPhotoParams{
ChatID: chatID,
Photo: &models.InputFileUpload{
Filename: "screenshot.png",
Data: bytes.NewReader(screenshotData),
},
Caption: "Browser Agent Screenshot",
})
if err != nil {
xlog.Error("Error sending screenshot", "error", err)
}
// Handle songs from generate_song action (local file paths)
if songPaths, exists := state.Metadata[actions.MetadataSongs]; exists {
for _, path := range xstrings.UniqueSlice(songPaths.([]string)) {
xlog.Debug("Sending song", "path", path)
if err := sendSongToTelegram(ctx, t.bot, chatID, path); err != nil {
xlog.Error("Error sending song", "error", err)
}
}
}
// Handle PDFs from generate_pdf action (local file paths)
if pdfPaths, exists := state.Metadata[actions.MetadataPDFs]; exists {
for _, path := range xstrings.UniqueSlice(pdfPaths.([]string)) {
data, err := os.ReadFile(path)
if err != nil {
xlog.Error("Error reading PDF file", "path", path, "error", err)
continue
}
filename := filepath.Base(path)
if filename == "" || filename == "." {
filename = "document.pdf"
}
xlog.Debug("Sending PDF document", "filename", filename, "size", len(data))
_, err = t.bot.SendDocument(ctx, &bot.SendDocumentParams{
ChatID: chatID,
Document: &models.InputFileUpload{
Filename: filename,
Data: bytes.NewReader(data),
},
Caption: "Generated PDF",
})
if err != nil {
xlog.Error("Error sending PDF", "error", err)
}
}
}
// Handle browser agent screenshots
// if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
// if historyStruct, ok := history.(*localoperator.StateHistory); ok {
// state := historyStruct.States[len(historyStruct.States)-1]
// if state.Screenshot != "" {
// // Decode base64 screenshot
// screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
// if err != nil {
// xlog.Error("Error decoding screenshot", "error", err)
// continue
// }
// // Send screenshot with caption
// _, err = t.bot.SendPhoto(ctx, &bot.SendPhotoParams{
// ChatID: chatID,
// Photo: &models.InputFileUpload{
// Filename: "screenshot.png",
// Data: bytes.NewReader(screenshotData),
// },
// Caption: "Browser Agent Screenshot",
// })
// if err != nil {
// xlog.Error("Error sending screenshot", "error", err)
// }
// }
// }
// }
}
return urls, nil
@@ -499,9 +723,6 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
return
}
// Cancel any active job for this chat before starting a new one
t.cancelActiveJobForChat(update.Message.Chat.ID)
currentConv := a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("telegram:%d", update.Message.From.ID))
message, err := t.chatFromMessage(update)
@@ -535,9 +756,15 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
t.placeholders[jobUUID] = msg.ID
t.placeholderMutex.Unlock()
// Add chat ID to metadata for tracking
// Add chat ID and conversation_id for tracking and cancel-previous-on-new-message
metadata := map[string]interface{}{
"chatID": update.Message.Chat.ID,
types.MetadataKeyConversationID: fmt.Sprintf("telegram:%d", update.Message.Chat.ID),
}
// Track if the original message was audio for TTS response
if update.Message.Voice != nil || update.Message.Audio != nil {
metadata["originalMessageType"] = "audio"
}
// Create a new job with the conversation history and metadata
@@ -564,9 +791,10 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
}
t.activeJobsMutex.Unlock()
// Clean up the placeholder map
// Clean up the placeholder map and job status
t.placeholderMutex.Lock()
delete(t.placeholders, jobUUID)
delete(t.jobStatus, jobUUID)
t.placeholderMutex.Unlock()
}()
@@ -603,6 +831,33 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
xlog.Error("Error handling multimedia content", "error", err)
}
// Check if original message was audio and generate TTS response
if metadata["originalMessageType"] == "audio" && res.Response != "" {
xlog.Debug("Original message was audio, generating TTS response")
audioData, err := t.agent.TTS(ctx, res.Response)
if err != nil {
xlog.Error("Error generating TTS", "error", err)
} else {
// Send audio response
err = sendAudioToTelegram(ctx, t.bot, update.Message.Chat.ID, audioData, res.Response)
if err != nil {
xlog.Error("Error sending audio response", "error", err)
} else {
xlog.Debug("Audio response sent successfully")
// Remove the thinking placeholder message before returning
_, err := t.bot.DeleteMessage(ctx, &bot.DeleteMessageParams{
ChatID: update.Message.Chat.ID,
MessageID: msg.ID,
})
if err != nil {
xlog.Error("Error deleting thinking placeholder", "error", err)
}
// Don't send text response if audio was sent successfully
return
}
}
}
// Update the message with the final response
formattedResponse := formatResponseWithURLs(res.Response, urls)
@@ -684,11 +939,68 @@ func (t *Telegram) Start(a *agent.Agent) {
if t.channelID != "" {
// handle new conversations
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
xlog.Debug("Subscriber(telegram)", "message", ccm.Content)
a.AddSubscriber(func(ccm *types.ConversationMessage) {
xlog.Debug("Subscriber(telegram)", "message", ccm.Message.Content)
// First, handle any multimedia content from metadata
if ccm.Metadata != nil {
// Handle images from gen image actions
if imagesUrls, exists := ccm.Metadata[actions.MetadataImages]; exists {
for _, url := range xstrings.UniqueSlice(imagesUrls.([]string)) {
xlog.Debug("Sending photo from new conversation", "url", url)
chatID, _ := strconv.ParseInt(t.channelID, 10, 64)
if err := sendImageToTelegram(ctx, t.bot, chatID, url); err != nil {
xlog.Error("Error handling image", "error", err)
}
}
}
// Handle songs from generate_song action (local file paths)
if songPaths, exists := ccm.Metadata[actions.MetadataSongs]; exists {
for _, path := range xstrings.UniqueSlice(songPaths.([]string)) {
xlog.Debug("Sending song from new conversation", "path", path)
chatID, _ := strconv.ParseInt(t.channelID, 10, 64)
if err := sendSongToTelegram(ctx, t.bot, chatID, path); err != nil {
xlog.Error("Error sending song", "error", err)
}
}
}
// Handle PDFs from generate_pdf action (local file paths)
if pdfPaths, exists := ccm.Metadata[actions.MetadataPDFs]; exists {
for _, path := range xstrings.UniqueSlice(pdfPaths.([]string)) {
data, err := os.ReadFile(path)
if err != nil {
xlog.Error("Error reading PDF file", "path", path, "error", err)
continue
}
filename := filepath.Base(path)
if filename == "" || filename == "." {
filename = "document.pdf"
}
xlog.Debug("Sending PDF document from new conversation", "filename", filename, "size", len(data))
chatID, _ := strconv.ParseInt(t.channelID, 10, 64)
_, err = t.bot.SendDocument(ctx, &bot.SendDocumentParams{
ChatID: chatID,
Document: &models.InputFileUpload{
Filename: filename,
Data: bytes.NewReader(data),
},
Caption: "Generated PDF",
})
if err != nil {
xlog.Error("Error sending PDF", "error", err)
}
}
}
}
// Then send the text message
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: t.channelID,
Text: ccm.Content,
Text: ccm.Message.Content,
})
if err != nil {
xlog.Error("Error sending message", "error", err)
@@ -698,7 +1010,7 @@ func (t *Telegram) Start(a *agent.Agent) {
t.agent.SharedState().ConversationTracker.AddMessage(
fmt.Sprintf("telegram:%s", t.channelID),
openai.ChatCompletionMessage{
Content: ccm.Content,
Content: ccm.Message.Content,
Role: "assistant",
},
)
@@ -724,6 +1036,7 @@ func NewTelegramConnector(config map[string]string) (*Telegram, error) {
Token: token,
admins: admins,
placeholders: make(map[string]int),
jobStatus: make(map[string]*common.StatusAccumulator),
activeJobs: make(map[int64][]*types.Job),
channelID: config["channel_id"],
groupMode: config["group_mode"] == "true",
+1 -1
View File
@@ -9,8 +9,8 @@ import (
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/services/connectors/twitter"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
)
+1 -1
View File
@@ -4,8 +4,8 @@ import (
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/services/filters"
"github.com/mudler/xlog"
)
func Filters(a *state.AgentConfig) types.JobFilters {
+99 -7
View File
@@ -3,11 +3,14 @@ package services
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/services/actions"
"github.com/mudler/LocalAGI/services/prompts"
"github.com/mudler/xlog"
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/state"
@@ -23,26 +26,108 @@ var AvailableBlockPrompts = []string{
DynamicPromptMemory,
}
func DynamicPromptsConfigMeta() []config.FieldGroup {
return []config.FieldGroup{
type dynamicPrompt struct {
agent.DynamicPrompt
Name string
}
func dynamicPrompts(customDirectory string, existingConfigs map[string]map[string]string) (allPrompts []dynamicPrompt) {
files, err := os.ReadDir(customDirectory)
if err != nil {
xlog.Error("Error reading custom actions directory", "error", err)
return
}
for _, file := range files {
if filepath.Ext(file.Name()) != ".go" {
continue
}
content, err := os.ReadFile(filepath.Join(customDirectory, file.Name()))
if err != nil {
xlog.Error("Error reading custom action file", "error", err, "file", file.Name())
continue
}
dynamicPromptName := strings.TrimSuffix(file.Name(), ".go")
dynamicPromptConfig := map[string]string{
"name": dynamicPromptName,
"code": string(content),
}
if c, exists := existingConfigs[dynamicPromptName]; exists {
dynamicPromptConfig["configuration"] = c["configuration"]
}
a, err := prompts.NewDynamicCustomPrompt(dynamicPromptConfig, "")
if err != nil {
xlog.Error("Error creating custom dynamic prompt", "error", err, "file", file.Name())
continue
}
if !a.CanRender() {
continue
}
allPrompts = append(allPrompts, dynamicPrompt{
DynamicPrompt: a,
Name: dynamicPromptName,
})
}
return
}
func DynamicPromptsConfigMeta(customDirectory string) []config.FieldGroup {
defaultDynamicPrompts := []config.FieldGroup{
prompts.NewDynamicPromptConfigMeta(),
prompts.NewMemoryPromptConfigMeta(),
}
if customDirectory != "" {
prompts := dynamicPrompts(customDirectory, map[string]map[string]string{})
for _, p := range prompts {
defaultDynamicPrompts = append(defaultDynamicPrompts, config.FieldGroup{
Name: p.Name,
Label: p.Name,
Fields: []config.Field{
{
Name: "configuration",
Label: "Configuration",
Type: config.FieldTypeTextarea,
HelpText: "Configuration for the custom prompt",
},
},
})
}
}
return defaultDynamicPrompts
}
func DynamicPrompts(dynamicConfig map[string]string) func(*state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []agent.DynamicPrompt {
return func(a *state.AgentConfig) func(ctx context.Context, pool *state.AgentPool) []agent.DynamicPrompt {
return func(ctx context.Context, pool *state.AgentPool) []agent.DynamicPrompt {
customDirectory := dynamicConfig[CustomActionsDir]
memoryFilePath := memoryPath(a.Name, dynamicConfig)
promptblocks := []agent.DynamicPrompt{}
existingDynamicPromptsConfigs := map[string]map[string]string{}
for _, c := range a.DynamicPrompts {
var config map[string]string
if err := json.Unmarshal([]byte(c.Config), &config); err != nil {
xlog.Info("Error unmarshalling connector config", err)
continue
}
existingDynamicPromptsConfigs[c.Type] = config
}
dynamicPromptsFound := dynamicPrompts(customDirectory, existingDynamicPromptsConfigs)
memoryIdxPath := memoryIndexPath(a.Name, dynamicConfig)
promptblocks := []agent.DynamicPrompt{}
for _, c := range a.DynamicPrompts {
config := existingDynamicPromptsConfigs[c.Type]
switch c.Type {
case DynamicPromptCustom:
prompt, err := prompts.NewDynamicCustomPrompt(config, "")
@@ -52,11 +137,18 @@ func DynamicPrompts(dynamicConfig map[string]string) func(*state.AgentConfig) fu
}
promptblocks = append(promptblocks, prompt)
case DynamicPromptMemory:
_, memory, _ := actions.NewMemoryActions(memoryFilePath, dynamicConfig)
_, memory, _, _ := actions.NewMemoryActions(memoryIdxPath, dynamicConfig)
promptblocks = append(promptblocks,
prompts.NewMemoryPrompt(config, memory),
)
default:
// Check if we have configured a custom dynamic prompt coming from a directory
for _, p := range dynamicPromptsFound {
if p.Name == c.Type {
promptblocks = append(promptblocks, p.DynamicPrompt)
}
}
}
}
return promptblocks
+32 -4
View File
@@ -2,12 +2,14 @@ package prompts
import (
"fmt"
"os"
"regexp"
"strings"
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/xlog"
"github.com/traefik/yaegi/interp"
"github.com/traefik/yaegi/stdlib"
)
@@ -42,12 +44,13 @@ func (a *DynamicCustomPrompt) callInit() error {
v, err := a.i.Eval(fmt.Sprintf("%s.Init", a.config["name"]))
if err != nil {
return err
xlog.Warn("No init function found for custom prompt", "error", err, "action", a.config["name"])
return nil
}
run := v.Interface().(func() error)
run := v.Interface().(func(string) error)
return run()
return run(a.config["configuration"])
}
func NewDynamicPromptConfigMeta() config.FieldGroup {
@@ -78,6 +81,12 @@ func NewDynamicPromptConfigMeta() config.FieldGroup {
Required: false,
HelpText: "Enable if the code needs to use unsafe Go features",
},
{
Name: "configuration",
Label: "Configuration",
Type: config.FieldTypeTextarea,
HelpText: "Configuration for the custom prompt",
},
},
}
}
@@ -88,6 +97,7 @@ func (a *DynamicCustomPrompt) initializeInterpreter() error {
i := interp.New(interp.Options{
GoPath: a.goPkgPath,
Unrestricted: unsafe,
Env: os.Environ(),
})
if err := i.Use(stdlib.Symbols); err != nil {
return err
@@ -97,6 +107,15 @@ func (a *DynamicCustomPrompt) initializeInterpreter() error {
a.config["name"] = "custom"
}
// let's find first if there is already a package declarated in the code
// the user might want to specify it to not break syntax with IDEs
re := regexp.MustCompile("package (\\w+)")
packageName := re.FindStringSubmatch(a.config["code"])
if len(packageName) > 1 {
// remove it from the code, normalize to `name`
a.config["code"] = re.ReplaceAllString(a.config["code"], "")
}
_, err := i.Eval(fmt.Sprintf("package %s\n%s", a.config["name"], a.config["code"]))
if err != nil {
return err
@@ -108,6 +127,15 @@ func (a *DynamicCustomPrompt) initializeInterpreter() error {
return nil
}
func (a *DynamicCustomPrompt) CanRender() bool {
_, err := a.i.Eval(fmt.Sprintf("%s.Render", a.config["name"]))
if err != nil {
return false
}
return true
}
func (a *DynamicCustomPrompt) Render(c *agent.Agent) (types.PromptResult, error) {
v, err := a.i.Eval(fmt.Sprintf("%s.Render", a.config["name"]))
if err != nil {
+106
View File
@@ -0,0 +1,106 @@
package skills
import (
"bytes"
"fmt"
"strings"
"text/template"
"github.com/Masterminds/sprig/v3"
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
skilldomain "github.com/mudler/skillserver/pkg/domain"
)
const defaultSkillsIntro = "You can use the following skills to help with the task.\nTo request the skill, you need to use the `request_skill` tool. The skill name is the name of the skill you want to use.\n"
// defaultSkillsTemplate is the default template that mimics the current XML behavior
const defaultSkillsTemplate = defaultSkillsIntro + `<available_skills>
{{range .Skills}}
<skill>
<name>{{escapeXML .Name}}</name>
<description>{{escapeXML .Description}}</description>
</skill>
{{end}}
</available_skills>`
// Skill is a local representation of a skill for template rendering
type Skill struct {
Name string
Description string
ID string
}
// skillsPrompt implements agent.DynamicPrompt and injects the available skills XML block
type skillsPrompt struct {
listSkills func() ([]skilldomain.Skill, error)
customTemplate string
}
// NewSkillsPrompt returns a DynamicPrompt that renders the list of available skills.
// If customTemplate is non-empty, it is used as a template with {{.Skills}} slice.
// Otherwise, the default template is used (mimics current XML behavior).
func NewSkillsPrompt(listSkills func() ([]skilldomain.Skill, error), customTemplate string) agent.DynamicPrompt {
return &skillsPrompt{listSkills: listSkills, customTemplate: customTemplate}
}
func (p *skillsPrompt) Render(a *agent.Agent) (types.PromptResult, error) {
skills, err := p.listSkills()
if err != nil {
return types.PromptResult{}, err
}
// Convert skilldomain.Skill to local Skill type for template rendering
localSkills := make([]Skill, len(skills))
for i, s := range skills {
desc := ""
if s.Metadata != nil && s.Metadata.Description != "" {
desc = s.Metadata.Description
}
localSkills[i] = Skill{
Name: s.ID,
Description: desc,
ID: s.ID,
}
}
// Use custom template or default
templ := p.customTemplate
if templ == "" {
templ = defaultSkillsTemplate
}
// Parse and execute the template
tmpl, err := template.New("skillsPrompt").Funcs(template.FuncMap{
"escapeXML": escapeXML,
}).Funcs(sprig.FuncMap()).Parse(templ)
if err != nil {
return types.PromptResult{}, fmt.Errorf("failed to parse skills template: %w", err)
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, struct {
Skills []Skill
}{
Skills: localSkills,
})
if err != nil {
return types.PromptResult{}, fmt.Errorf("failed to execute skills template: %w", err)
}
return types.PromptResult{Content: buf.String()}, nil
}
func (p *skillsPrompt) Role() string {
return "system"
}
func escapeXML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}

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